From 5dec107ad196911a8035ba78055b90e2028e5039 Mon Sep 17 00:00:00 2001 From: WhiteMoon319 <3287047638@qq.com> Date: Fri, 21 Aug 2026 14:25:39 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Live=20Updates=20=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E8=AE=BE=E7=BD=AE=EF=BC=88=E5=9F=BA?= =?UTF-8?q?=E7=A1=80=E5=8A=9F=E8=83=BD+=E6=97=A5=E5=BF=97+=E9=A2=9C?= =?UTF-8?q?=E8=89=B2+=E5=9B=BE=E6=A0=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + .../com/aliothmoon/maameow/constant/Routes.kt | 1 + .../data/preferences/AppSettingsManager.kt | 124 +++++++ .../maameow/domain/models/AppSettings.kt | 24 ++ .../domain/service/TaskExecutionService.kt | 91 +++-- .../notification/TrackerIconDecoder.kt | 45 +++ .../presentation/navigation/AppNavigation.kt | 4 + .../view/settings/LiveUpdateSettingsView.kt | 328 ++++++++++++++++++ .../view/settings/SettingsView.kt | 8 + app/src/main/res/drawable/ic_tracker_dot.xml | 9 + app/src/main/res/values-en/strings.xml | 34 ++ app/src/main/res/values/strings.xml | 34 ++ gradle/libs.versions.toml | 4 + 13 files changed, 687 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt create mode 100644 app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt create mode 100644 app/src/main/res/drawable/ic_tracker_dot.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a09d4cea..937642dfe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -238,6 +238,7 @@ dependencies { implementation(libs.sonner) implementation(libs.timber) implementation(libs.okhttp) + implementation(libs.androidsvg) implementation(libs.angus.mail) implementation(libs.angus.activation) implementation(libs.jakarta.activation.api) diff --git a/app/src/main/java/com/aliothmoon/maameow/constant/Routes.kt b/app/src/main/java/com/aliothmoon/maameow/constant/Routes.kt index e78a373e4..343749832 100644 --- a/app/src/main/java/com/aliothmoon/maameow/constant/Routes.kt +++ b/app/src/main/java/com/aliothmoon/maameow/constant/Routes.kt @@ -12,5 +12,6 @@ object Routes { const val SCHEDULE_EDIT = "schedule_edit/{strategyId}" const val SCHEDULE_TRIGGER_LOG = "schedule_trigger_log" const val NOTIFICATION = "notification" + const val LIVE_UPDATE = "live_update" const val TASK_OVERRIDE_EDITOR = "task_override_editor" } diff --git a/app/src/main/java/com/aliothmoon/maameow/data/preferences/AppSettingsManager.kt b/app/src/main/java/com/aliothmoon/maameow/data/preferences/AppSettingsManager.kt index 0224da848..6f956eaf3 100644 --- a/app/src/main/java/com/aliothmoon/maameow/data/preferences/AppSettingsManager.kt +++ b/app/src/main/java/com/aliothmoon/maameow/data/preferences/AppSettingsManager.kt @@ -454,6 +454,130 @@ class AppSettingsManager( } } + // Live Updates 通知自定义 + enum class LiveUpdateChipContent(@param:androidx.annotation.StringRes val labelRes: Int) { + BOTH(R.string.live_update_chip_both), + PROGRESS(R.string.live_update_chip_progress), + TASK(R.string.live_update_chip_task), + LOG(R.string.live_update_chip_log), + NONE(R.string.live_update_chip_none), + } + + enum class LiveUpdateColorScheme(@param:androidx.annotation.StringRes val labelRes: Int) { + DEFAULT(R.string.live_update_color_default), + BLUE(R.string.live_update_color_blue), + GREEN(R.string.live_update_color_green), + ORANGE(R.string.live_update_color_orange), + PURPLE(R.string.live_update_color_purple), + PINK(R.string.live_update_color_pink), + TEAL(R.string.live_update_color_teal), + CUSTOM(R.string.live_update_color_custom), + } + + val liveUpdateCustomColor: StateFlow = settings + .map { it.liveUpdateCustomColor } + .distinctUntilChanged() + .stateIn( + scope, SharingStarted.Eagerly, + initialSettings.liveUpdateCustomColor + ) + + val liveUpdateEnabled: StateFlow = settings + .map { it.liveUpdateEnabled.toBooleanStrictOrNull() ?: true } + .distinctUntilChanged() + .stateIn( + scope, SharingStarted.Eagerly, + initialSettings.liveUpdateEnabled.toBooleanStrictOrNull() ?: true + ) + + val liveUpdateChipContent: StateFlow = settings + .map { + runCatching { LiveUpdateChipContent.valueOf(it.liveUpdateChipContent) } + .getOrDefault(LiveUpdateChipContent.BOTH) + } + .distinctUntilChanged() + .stateIn( + scope, SharingStarted.Eagerly, + runCatching { LiveUpdateChipContent.valueOf(initialSettings.liveUpdateChipContent) } + .getOrDefault(LiveUpdateChipContent.BOTH) + ) + + val liveUpdateColorScheme: StateFlow = settings + .map { + runCatching { LiveUpdateColorScheme.valueOf(it.liveUpdateColorScheme) } + .getOrDefault(LiveUpdateColorScheme.DEFAULT) + } + .distinctUntilChanged() + .stateIn( + scope, SharingStarted.Eagerly, + runCatching { LiveUpdateColorScheme.valueOf(initialSettings.liveUpdateColorScheme) } + .getOrDefault(LiveUpdateColorScheme.DEFAULT) + ) + + suspend fun setLiveUpdateEnabled(enabled: Boolean) { + with(AppSettingsSchema) { + context.dataStore.edit { it[liveUpdateEnabled] = enabled.toString() } + } + } + + suspend fun setLiveUpdateChipContent(content: LiveUpdateChipContent) { + with(AppSettingsSchema) { + context.dataStore.edit { it[liveUpdateChipContent] = content.name } + } + } + + suspend fun setLiveUpdateColorScheme(scheme: LiveUpdateColorScheme) { + with(AppSettingsSchema) { + context.dataStore.edit { it[liveUpdateColorScheme] = scheme.name } + } + } + + suspend fun setLiveUpdateCustomColor(color: String) { + with(AppSettingsSchema) { + context.dataStore.edit { it[liveUpdateCustomColor] = color } + } + } + + // Live Updates 进度条追踪图标 + enum class LiveUpdateTrackerIcon(@param:androidx.annotation.StringRes val labelRes: Int) { + DEFAULT(R.string.live_update_icon_default), + LOGO(R.string.live_update_icon_logo), + DOT(R.string.live_update_icon_dot), + CUSTOM(R.string.live_update_icon_custom), + } + + val liveUpdateTrackerIcon: StateFlow = settings + .map { + runCatching { LiveUpdateTrackerIcon.valueOf(it.liveUpdateTrackerIcon) } + .getOrDefault(LiveUpdateTrackerIcon.DEFAULT) + } + .distinctUntilChanged() + .stateIn( + scope, SharingStarted.Eagerly, + runCatching { LiveUpdateTrackerIcon.valueOf(initialSettings.liveUpdateTrackerIcon) } + .getOrDefault(LiveUpdateTrackerIcon.DEFAULT) + ) + + suspend fun setLiveUpdateTrackerIcon(icon: LiveUpdateTrackerIcon) { + with(AppSettingsSchema) { + context.dataStore.edit { it[liveUpdateTrackerIcon] = icon.name } + } + } + + val liveUpdateCustomTrackerPath: StateFlow = settings + .map { it.liveUpdateCustomTrackerPath } + .distinctUntilChanged() + .stateIn( + scope, SharingStarted.Eagerly, + initialSettings.liveUpdateCustomTrackerPath + ) + + suspend fun setLiveUpdateCustomTrackerPath(path: String) { + with(AppSettingsSchema) { + context.dataStore.edit { it[liveUpdateCustomTrackerPath] = path } + } + } + // 后台虚拟屏分辨率 val backgroundResolution: StateFlow = settings .map { diff --git a/app/src/main/java/com/aliothmoon/maameow/domain/models/AppSettings.kt b/app/src/main/java/com/aliothmoon/maameow/domain/models/AppSettings.kt index 6b1f5b959..f0204e015 100644 --- a/app/src/main/java/com/aliothmoon/maameow/domain/models/AppSettings.kt +++ b/app/src/main/java/com/aliothmoon/maameow/domain/models/AppSettings.kt @@ -70,6 +70,30 @@ data class AppSettings( @PrefKey(default = "DEFAULT") val eventNotificationLevel: String = "DEFAULT", + /** Live Updates 通知是否启用(Android 16+ promoted ongoing / ProgressStyle 展示)。 */ + @PrefKey(default = "true") + val liveUpdateEnabled: String = "true", + + /** Live Updates 状态栏 chip 短关键文本内容:both=进度+任务名 / progress=仅进度 / task=仅任务名 / none=不显示。 */ + @PrefKey(default = "both") + val liveUpdateChipContent: String = "both", + + /** Live Updates 进度条颜色方案:default/blue/green/orange/purple/pink/teal/custom。 */ + @PrefKey(default = "default") + val liveUpdateColorScheme: String = "default", + + /** Live Updates 自定义主色 HEX(如 "#2196F3"),仅 liveUpdateColorScheme=custom 时使用。 */ + @PrefKey(default = "") + val liveUpdateCustomColor: String = "", + + /** Live Updates 进度条追踪图标:default=菱形 / logo=MAA 图标 / dot=圆点 / custom=自定义图片。 */ + @PrefKey(default = "default") + val liveUpdateTrackerIcon: String = "default", + + /** Live Updates 自定义追踪图标文件路径,仅 liveUpdateTrackerIcon=custom 时使用。 */ + @PrefKey(default = "") + val liveUpdateCustomTrackerPath: String = "", + @PrefKey(default = "P720") val backgroundResolution: String = "P720", diff --git a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt index bb4fdc020..4e7d935e8 100644 --- a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt +++ b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt @@ -21,6 +21,8 @@ import com.aliothmoon.maameow.domain.state.MaaExecutionState import com.aliothmoon.maameow.maa.callback.TaskChainStatusTracker import com.aliothmoon.maameow.maa.callback.TaskRunInfo import com.aliothmoon.maameow.maa.callback.TaskRunStatus +import com.aliothmoon.maameow.data.preferences.AppSettingsManager +import com.aliothmoon.maameow.notification.TrackerIconDecoder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -48,6 +50,12 @@ class TaskExecutionService : Service() { private const val PROGRESS_COLOR_ACTIVE = 0xFF2196F3.toInt() private const val PROGRESS_COLOR_PENDING = 0xFF9E9E9E.toInt() private const val PROGRESS_COLOR_ERROR = 0xFFD32F2F.toInt() + private const val PROGRESS_COLOR_BLUE = 0xFF2196F3.toInt() + private const val PROGRESS_COLOR_GREEN = 0xFF4CAF50.toInt() + private const val PROGRESS_COLOR_ORANGE = 0xFFFF9800.toInt() + private const val PROGRESS_COLOR_PURPLE = 0xFF9C27B0.toInt() + private const val PROGRESS_COLOR_PINK = 0xFFE91E63.toInt() + private const val PROGRESS_COLOR_TEAL = 0xFF009688.toInt() private val VISIBLE_TASK_TITLE_RES = mapOf( "Fight" to R.string.maa_fight, @@ -75,6 +83,7 @@ class TaskExecutionService : Service() { private val compositionService: MaaCompositionService by inject() private val sessionLogger: MaaSessionLogger by inject() private val taskChainStatusTracker: TaskChainStatusTracker by inject() + private val appSettingsManager: AppSettingsManager by inject() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var progressJob: Job? = null @@ -257,15 +266,34 @@ class TaskExecutionService : Service() { val contentText = buildContentText(statusText, progressInfo) val activeName = activeTaskName(snapshot) val title = activeName ?: getString(R.string.notification_task_running_title) + val shortCritical = buildShortCriticalText(statusText, progressInfo, activeName) return buildCompatProgressNotification( title = title, contentText = contentText, progressInfo = progressInfo, - activeTaskName = activeName, + shortCritical = shortCritical, ) } + private fun buildShortCriticalText( + statusText: String, + progressInfo: TaskProgressInfo, + activeTaskName: String?, + ): String? = when (appSettingsManager.liveUpdateChipContent.value) { + AppSettingsManager.LiveUpdateChipContent.BOTH -> when { + progressInfo.progressLabel != null && activeTaskName != null -> + "${progressInfo.progressLabel} $activeTaskName" + progressInfo.progressLabel != null -> progressInfo.progressLabel + activeTaskName != null -> activeTaskName + else -> null + } + AppSettingsManager.LiveUpdateChipContent.PROGRESS -> progressInfo.progressLabel + AppSettingsManager.LiveUpdateChipContent.TASK -> activeTaskName + AppSettingsManager.LiveUpdateChipContent.LOG -> statusText + AppSettingsManager.LiveUpdateChipContent.NONE -> null + } + private fun defaultStatusText(state: MaaExecutionState): String = when (state) { MaaExecutionState.STARTING -> getString(R.string.notification_task_starting) MaaExecutionState.STOPPING -> getString(R.string.notification_task_stopping) @@ -274,17 +302,34 @@ class TaskExecutionService : Service() { MaaExecutionState.ERROR -> getString(R.string.notification_task_error) } + private fun trackerIcon(): IconCompat { + val iconRes = when (appSettingsManager.liveUpdateTrackerIcon.value) { + AppSettingsManager.LiveUpdateTrackerIcon.LOGO -> R.drawable.ic_maa_logo + AppSettingsManager.LiveUpdateTrackerIcon.DOT -> R.drawable.ic_tracker_dot + AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM -> { + val path = appSettingsManager.liveUpdateCustomTrackerPath.value + TrackerIconDecoder.decode(path)?.let { bitmap -> + return IconCompat.createWithBitmap(bitmap) + } + // fallback + R.drawable.ic_progress_tracker + } + AppSettingsManager.LiveUpdateTrackerIcon.DEFAULT -> R.drawable.ic_progress_tracker + } + return IconCompat.createWithResource(this, iconRes) + } + private fun buildCompatProgressNotification( title: String, contentText: String, progressInfo: TaskProgressInfo, - activeTaskName: String?, + shortCritical: String?, ): Notification { val style = NotificationCompat.ProgressStyle() .setStyledByProgress(true) .setProgressIndeterminate(progressInfo.totalCount == 0) .setProgressTrackerIcon( - IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) + trackerIcon() ) if (progressInfo.totalCount > 0) { @@ -298,15 +343,6 @@ class TaskExecutionService : Service() { ) } - val shortCritical = when { - progressInfo.progressLabel != null && activeTaskName != null -> - "${progressInfo.progressLabel} $activeTaskName" - - progressInfo.progressLabel != null -> progressInfo.progressLabel - activeTaskName != null -> activeTaskName - else -> null - } - return NotificationCompat.Builder(this, TASK_CHANNEL_ID) .setSmallIcon(R.drawable.ic_maa_logo) .setColor(progressInfo.barColor) @@ -315,8 +351,7 @@ class TaskExecutionService : Service() { .setStyle(style) .setContentIntent(buildContentIntent()) .setOngoing(true) - .setRequestPromotedOngoing(canRequestPromotedOngoing()) - .setSilent(true) + .setRequestPromotedOngoing(canRequestPromotedOngoing()) .setSilent(true) .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_PROGRESS) .apply { @@ -372,12 +407,27 @@ class TaskExecutionService : Service() { else -> 0 }.coerceIn(0, PROGRESS_STYLE_MAX) - val barColor = when { - taskErrorIndex != null || snapshot.state == MaaExecutionState.ERROR -> - PROGRESS_COLOR_ERROR - - snapshot.state == MaaExecutionState.IDLE -> PROGRESS_COLOR_COMPLETED - else -> PROGRESS_COLOR_ACTIVE + val barColor = when (appSettingsManager.liveUpdateColorScheme.value) { + AppSettingsManager.LiveUpdateColorScheme.DEFAULT -> when { + taskErrorIndex != null || snapshot.state == MaaExecutionState.ERROR -> + PROGRESS_COLOR_ERROR + snapshot.state == MaaExecutionState.IDLE -> PROGRESS_COLOR_COMPLETED + else -> PROGRESS_COLOR_ACTIVE + } + AppSettingsManager.LiveUpdateColorScheme.BLUE -> PROGRESS_COLOR_BLUE + AppSettingsManager.LiveUpdateColorScheme.GREEN -> PROGRESS_COLOR_GREEN + AppSettingsManager.LiveUpdateColorScheme.ORANGE -> PROGRESS_COLOR_ORANGE + AppSettingsManager.LiveUpdateColorScheme.PURPLE -> PROGRESS_COLOR_PURPLE + AppSettingsManager.LiveUpdateColorScheme.PINK -> PROGRESS_COLOR_PINK + AppSettingsManager.LiveUpdateColorScheme.TEAL -> PROGRESS_COLOR_TEAL + AppSettingsManager.LiveUpdateColorScheme.CUSTOM -> { + val hex = appSettingsManager.liveUpdateCustomColor.value + if (hex.isNotEmpty()) { + try { + android.graphics.Color.parseColor(hex) + } catch (_: Exception) { PROGRESS_COLOR_BLUE } + } else PROGRESS_COLOR_BLUE + } } return TaskProgressInfo( @@ -406,6 +456,7 @@ class TaskExecutionService : Service() { } private fun canRequestPromotedOngoing(): Boolean { + if (!appSettingsManager.liveUpdateEnabled.value) return false return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { ContextCompat.checkSelfPermission( this, diff --git a/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt b/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt new file mode 100644 index 000000000..2b7c6e5e9 --- /dev/null +++ b/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt @@ -0,0 +1,45 @@ +package com.aliothmoon.maameow.notification + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import com.caverock.androidsvg.SVG +import java.io.FileInputStream + +/** + * 自定义进度条图标解码:先按像素格式(PNG/JPG/WebP/GIF/BMP)用 BitmapFactory 解码, + * 失败时尝试用 AndroidSVG 渲染矢量图(SVG / 内嵌 SVG 的 XML),统一缩放后返回位图。 + * 设置页预览与 TaskExecutionService 共用,保证所见即所得。 + */ +object TrackerIconDecoder { + + fun decode(path: String, targetSize: Int = 72): Bitmap? { + if (path.isEmpty()) return null + + // 像素格式优先 + BitmapFactory.decodeFile(path)?.let { bm -> + return scale(bm, targetSize) + } + + // 矢量格式:AndroidSVG 解析 SVG/XML 内容 + return runCatching { + val svg = SVG.getFromInputStream(FileInputStream(path)) + val bitmap = Bitmap.createBitmap(targetSize, targetSize, Bitmap.Config.ARGB_8888) + svg.renderToCanvas( + Canvas(bitmap), + android.graphics.RectF(0f, 0f, targetSize.toFloat(), targetSize.toFloat()) + ) + bitmap + }.getOrNull() + } + + private fun scale(source: Bitmap, targetSize: Int): Bitmap { + if (source.width <= targetSize && source.height <= targetSize) return source + val ratio = targetSize.toFloat() / maxOf(source.width, source.height) + val w = (source.width * ratio).toInt().coerceAtLeast(1) + val h = (source.height * ratio).toInt().coerceAtLeast(1) + val scaled = Bitmap.createScaledBitmap(source, w, h, true) + if (scaled !== source) source.recycle() + return scaled + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aliothmoon/maameow/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/aliothmoon/maameow/presentation/navigation/AppNavigation.kt index d22aaa229..3425b3117 100644 --- a/app/src/main/java/com/aliothmoon/maameow/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/aliothmoon/maameow/presentation/navigation/AppNavigation.kt @@ -44,6 +44,7 @@ import com.aliothmoon.maameow.presentation.view.notification.NotificationSetting import com.aliothmoon.maameow.presentation.view.settings.AchievementDebugView import com.aliothmoon.maameow.presentation.view.settings.AchievementView import com.aliothmoon.maameow.presentation.view.settings.ErrorLogView +import com.aliothmoon.maameow.presentation.view.settings.LiveUpdateSettingsView import com.aliothmoon.maameow.presentation.view.settings.LogHistoryView import com.aliothmoon.maameow.presentation.view.settings.TaskOverrideEditorView import com.aliothmoon.maameow.presentation.viewmodel.AppEventsViewModel @@ -177,6 +178,9 @@ fun AppNavigation( composable(Routes.NOTIFICATION) { NotificationSettingsView(navController = navController) } + composable(Routes.LIVE_UPDATE) { + LiveUpdateSettingsView(navController = navController) + } composable(Routes.ACHIEVEMENT) { AchievementView(navController = navController) } diff --git a/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt new file mode 100644 index 000000000..5b1a78df5 --- /dev/null +++ b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt @@ -0,0 +1,328 @@ +package com.aliothmoon.maameow.presentation.view.settings + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import android.net.Uri +import androidx.compose.foundation.Image +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 +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.aliothmoon.maameow.R +import com.aliothmoon.maameow.data.preferences.AppSettingsManager +import com.aliothmoon.maameow.notification.TrackerIconDecoder +import com.aliothmoon.maameow.presentation.components.ListItemDivider +import com.aliothmoon.maameow.presentation.components.SectionHeader +import com.aliothmoon.maameow.presentation.components.SelectableChipGroup +import com.aliothmoon.maameow.presentation.components.SettingRow +import com.aliothmoon.maameow.presentation.components.SettingsGroupCard +import com.aliothmoon.maameow.presentation.components.TopAppBar +import com.aliothmoon.maameow.theme.MaaDesignTokens +import java.io.File +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import timber.log.Timber + +// 色板预设色值,与 TaskExecutionService 颜色常量匹配 +private val ColorSwatches = listOf( + "#2196F3" to Color(0xFF2196F3), // Blue + "#4CAF50" to Color(0xFF4CAF50), // Green + "#FF9800" to Color(0xFFFF9800), // Orange + "#9C27B0" to Color(0xFF9C27B0), // Purple + "#E91E63" to Color(0xFFE91E63), // Pink + "#009688" to Color(0xFF009688), // Teal + "#F44336" to Color(0xFFF44336), // Red + "#607D8B" to Color(0xFF607D8B), // Blue Grey + "#795548" to Color(0xFF795548), // Brown + "#FFC107" to Color(0xFFFFC107), // Amber + "#00BCD4" to Color(0xFF00BCD4), // Cyan + "#8BC34A" to Color(0xFF8BC34A), // Light Green +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun LiveUpdateSettingsView(navController: NavController) { + val appSettingsManager: AppSettingsManager = koinInject() + val enabled by appSettingsManager.liveUpdateEnabled.collectAsStateWithLifecycle() + val chipContent by appSettingsManager.liveUpdateChipContent.collectAsStateWithLifecycle() + val colorScheme by appSettingsManager.liveUpdateColorScheme.collectAsStateWithLifecycle() + val customColor by appSettingsManager.liveUpdateCustomColor.collectAsStateWithLifecycle() + val trackerIcon by appSettingsManager.liveUpdateTrackerIcon.collectAsStateWithLifecycle() + val customTrackerPath by appSettingsManager.liveUpdateCustomTrackerPath.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + val contentColor = MaterialTheme.colorScheme.onSurface + val context = LocalContext.current + + // 自定义图标选择:从文件选择器取图片(PNG/JPG/WebP 等),复制到内部存储后持久化路径 + val customTrackerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + coroutineScope.launch { + try { + val targetDir = File(context.filesDir, "live_update") + targetDir.mkdirs() + // 保留原始文件扩展名(BitmapFactory 按内容解码,jpg/webp 均可) + val displayName = context.contentResolver + .query(uri, null, null, null, null) + ?.use { cursor -> + val idx = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null + } + val fileName = displayName + ?.takeIf { it.isNotBlank() } + ?.let { name -> + val dot = name.lastIndexOf('.') + if (dot > 0) name.substring(dot).lowercase() else null + } + ?.takeIf { ext -> ext.length in 2..5 && ext.all { it.isLetterOrDigit() || it == '.' } } + ?: ".png" + val target = File(targetDir, "tracker_icon$fileName") + context.contentResolver.openInputStream(uri)?.use { input -> + target.outputStream().use { output -> + input.copyTo(output) + } + } + appSettingsManager.setLiveUpdateCustomTrackerPath(target.absolutePath) + } catch (e: Exception) { + Timber.e(e, "copy tracker icon failed") + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = stringResource(R.string.live_update_settings_title), + navigationIcon = Icons.AutoMirrored.Filled.ArrowBack, + onNavigationClick = { navController.navigateUp() }) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(top = paddingValues.calculateTopPadding()), + contentPadding = PaddingValues( + horizontal = MaaDesignTokens.Spacing.listHorizontal, + vertical = MaaDesignTokens.Spacing.sm + ) + ) { + item { + SectionHeader(stringResource(R.string.live_update_section_general)) + SettingsGroupCard { + SettingRow( + title = stringResource(R.string.live_update_enable_title), + description = stringResource(R.string.live_update_enable_desc), + titleColor = contentColor, + trailing = { + Switch( + checked = enabled, + onCheckedChange = { value -> + coroutineScope.launch { + appSettingsManager.setLiveUpdateEnabled(value) + } + } + ) + }, + ) + } + } + + item { + SectionHeader(stringResource(R.string.live_update_section_display)) + SettingsGroupCard { + Column(modifier = Modifier.fillMaxWidth()) { + SelectableChipGroup( + label = stringResource(R.string.live_update_chip_label), + selectedValue = chipContent, + options = AppSettingsManager.LiveUpdateChipContent.entries.map { + it to stringResource(it.labelRes) + }, + onSelected = { value -> + if (value != chipContent) { + coroutineScope.launch { + appSettingsManager.setLiveUpdateChipContent(value) + } + } + }, + ) + } + ListItemDivider() + Column(modifier = Modifier.fillMaxWidth()) { + SelectableChipGroup( + label = stringResource(R.string.live_update_color_label), + selectedValue = colorScheme, + options = AppSettingsManager.LiveUpdateColorScheme.entries.map { + it to stringResource(it.labelRes) + }, + onSelected = { value -> + if (value != colorScheme) { + coroutineScope.launch { + appSettingsManager.setLiveUpdateColorScheme(value) + } + } + }, + ) + // 自定义颜色色板:仅在 CUSTOM 方案时显示 + if (colorScheme == AppSettingsManager.LiveUpdateColorScheme.CUSTOM) { + Text( + text = stringResource(R.string.live_update_color_custom_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + top = MaaDesignTokens.Spacing.md, + bottom = MaaDesignTokens.Spacing.xs + ) + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.fillMaxWidth() + ) { + ColorSwatches.forEach { (hex, color) -> + val isSelected = customColor.equals(hex, ignoreCase = true) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(color) + .then( + if (isSelected) Modifier.border( + 2.5.dp, + MaterialTheme.colorScheme.onSurface, + CircleShape + ) else Modifier + ) + .clickable { + coroutineScope.launch { + appSettingsManager.setLiveUpdateCustomColor(hex) + } + } + ) + } + } + } + } + ListItemDivider() + Column(modifier = Modifier.fillMaxWidth()) { + SelectableChipGroup( + label = stringResource(R.string.live_update_icon_label), + selectedValue = trackerIcon, + options = AppSettingsManager.LiveUpdateTrackerIcon.entries.map { + it to stringResource(it.labelRes) + }, + onSelected = { value -> + if (value != trackerIcon) { + coroutineScope.launch { + appSettingsManager.setLiveUpdateTrackerIcon(value) + } + } + }, + ) + // 自定义图片:选择 PNG、预览当前已选图片、支持移除 + if (trackerIcon == AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM) { + Text( + text = stringResource(R.string.live_update_icon_custom_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + top = MaaDesignTokens.Spacing.md, + bottom = MaaDesignTokens.Spacing.xs + ) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + val iconBitmap = if (customTrackerPath.isNotEmpty()) { + TrackerIconDecoder.decode(customTrackerPath, targetSize = 72) + } else null + if (iconBitmap != null) { + Image( + bitmap = iconBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + androidx.compose.foundation.layout.Spacer( + modifier = Modifier.size(MaaDesignTokens.Spacing.md) + ) + } + OutlinedButton( + onClick = { customTrackerLauncher.launch("*/*") }, + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.live_update_icon_pick) + ) + } + if (customTrackerPath.isNotEmpty()) { + OutlinedButton( + onClick = { + coroutineScope.launch { + appSettingsManager.setLiveUpdateCustomTrackerPath("") + } + }, + modifier = Modifier.padding(start = MaaDesignTokens.Spacing.xs) + ) { + Text( + text = stringResource(R.string.live_update_icon_clear) + ) + } + } + } + } + } + } + } + + item { + Text( + text = stringResource(R.string.live_update_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding( + start = MaaDesignTokens.Spacing.listHorizontal, + top = MaaDesignTokens.Spacing.xs + ) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/SettingsView.kt b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/SettingsView.kt index 6fe71e480..03f506398 100644 --- a/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/SettingsView.kt +++ b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/SettingsView.kt @@ -911,6 +911,14 @@ fun SettingsView( ) { navController.navigate(Routes.NOTIFICATION) } + ListItemDivider() + SettingClickItem( + title = stringResource(R.string.settings_live_update_title), + description = stringResource(R.string.settings_live_update_desc), + contentColor = contentColor + ) { + navController.navigate(Routes.LIVE_UPDATE) + } } } } diff --git a/app/src/main/res/drawable/ic_tracker_dot.xml b/app/src/main/res/drawable/ic_tracker_dot.xml new file mode 100644 index 000000000..b1680b580 --- /dev/null +++ b/app/src/main/res/drawable/ic_tracker_dot.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 03b331715..1fb56868b 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -134,6 +134,8 @@ Notifications Notification Settings Manage internal and external notification channels + Live Updates Customization + Customize the task progress Live Update style Data Management Export Configuration @@ -1365,6 +1367,38 @@ Pop-up alerts This is a test notification Send test notification + + + Live Updates Customization + General + Display + Enable Live Updates + Show task progress as live updates on supported devices + Status bar content + Progress + task + Progress only + Task only + Latest log + None + Progress bar color + Default (semantic) + Blue + Green + Orange + Purple + Pink + Teal + Custom + Pick a color + Progress icon + Diamond + MAA logo + Dot + Custom image + Select an image or vector file: PNG / JPG / WebP / GIF / SVG / XML supported + Pick image + Clear + Note: Live Updates require Android 16+ with system support; disabling falls back to the normal notification. Long status text may be truncated by the system. \"Latest log\" shows the current execution log line. External notifications Send notification when complete Send notification when task fails diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27321c7f2..ef05346e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -331,6 +331,8 @@ 通知 通知设置 管理内部和外部通知渠道 + Live Updates 自定义 + 自定义任务进度实时通知的显示样式 数据管理 @@ -1421,6 +1423,38 @@ 弹出提示 这是一条测试通知 发送测试通知 + + + Live Updates 自定义 + 通用 + 显示 + 启用 Live Updates + 在支持的设备上以实时动态样式展示任务进度 + 状态栏显示内容 + 进度 + 任务名 + 仅进度 + 仅任务名 + 最新日志 + 不显示 + 进度条颜色 + 默认(状态语义色) + 蓝色 + 绿色 + 橙色 + 紫色 + 粉色 + 青色 + 自定义 + 选择颜色 + 进度条图标 + 菱形 + MAA 图标 + 圆点 + 自定义图片 + 选择图片或矢量文件作为进度条图标:支持 PNG / JPG / WebP / GIF / SVG / XML 等 + 选择图片 + 清除 + 提示:Live Updates 需要 Android 16+ 且系统支持;关闭后通知将退化为普通样式。状态栏内容过长时系统会按空间截断。\"最新日志\"将显示当前执行的最新日志行。 外部通知 任务完成时通知 任务出错时通知 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b41a9cf73..5645b0727 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,9 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +# SVG rendering +androidsvg = "1.4" + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -109,6 +112,7 @@ jakarta-activation-api = { group = "jakarta.activation", name = "jakarta.activat compose-markdown = { group = "com.github.jeziellago", name = "compose-markdown", version.ref = "composeMarkdown" } reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +androidsvg = { group = "com.caverock", name = "androidsvg", version.ref = "androidsvg" } From 06beae69a29d9072633195d241c93cde48971c69 Mon Sep 17 00:00:00 2001 From: WhiteMoon319 <3287047638@qq.com> Date: Fri, 21 Aug 2026 14:25:49 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BB=A3=E7=A0=81=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8E=E6=8E=92=E7=89=88=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 7 项代码问题:链式调用缩进、小图缩放、采样解码、预览缓存、未用 import、文件末尾换行、trackerIcon 统一出口 - 重做 Live Updates 设置页排版:开关/内容/颜色/图标分卡片展示,颜色与图标可视化 - 图标默认项更名合成玉 --- .../domain/service/TaskExecutionService.kt | 25 +- .../notification/TrackerIconDecoder.kt | 28 +- .../view/settings/LiveUpdateSettingsView.kt | 415 +++++++++++++----- app/src/main/res/drawable/ic_tracker_dot.xml | 2 +- app/src/main/res/values-en/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- 6 files changed, 337 insertions(+), 137 deletions(-) diff --git a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt index 4e7d935e8..bf9e6c78d 100644 --- a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt +++ b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt @@ -302,21 +302,15 @@ class TaskExecutionService : Service() { MaaExecutionState.ERROR -> getString(R.string.notification_task_error) } - private fun trackerIcon(): IconCompat { - val iconRes = when (appSettingsManager.liveUpdateTrackerIcon.value) { - AppSettingsManager.LiveUpdateTrackerIcon.LOGO -> R.drawable.ic_maa_logo - AppSettingsManager.LiveUpdateTrackerIcon.DOT -> R.drawable.ic_tracker_dot - AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM -> { - val path = appSettingsManager.liveUpdateCustomTrackerPath.value - TrackerIconDecoder.decode(path)?.let { bitmap -> - return IconCompat.createWithBitmap(bitmap) - } - // fallback - R.drawable.ic_progress_tracker - } - AppSettingsManager.LiveUpdateTrackerIcon.DEFAULT -> R.drawable.ic_progress_tracker + private fun trackerIcon(): IconCompat = when (appSettingsManager.liveUpdateTrackerIcon.value) { + AppSettingsManager.LiveUpdateTrackerIcon.LOGO -> IconCompat.createWithResource(this, R.drawable.ic_maa_logo) + AppSettingsManager.LiveUpdateTrackerIcon.DOT -> IconCompat.createWithResource(this, R.drawable.ic_tracker_dot) + AppSettingsManager.LiveUpdateTrackerIcon.DEFAULT -> IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) + AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM -> { + val path = appSettingsManager.liveUpdateCustomTrackerPath.value + TrackerIconDecoder.decode(path)?.let { IconCompat.createWithBitmap(it) } + ?: IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) } - return IconCompat.createWithResource(this, iconRes) } private fun buildCompatProgressNotification( @@ -351,7 +345,8 @@ class TaskExecutionService : Service() { .setStyle(style) .setContentIntent(buildContentIntent()) .setOngoing(true) - .setRequestPromotedOngoing(canRequestPromotedOngoing()) .setSilent(true) + .setRequestPromotedOngoing(canRequestPromotedOngoing()) + .setSilent(true) .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_PROGRESS) .apply { diff --git a/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt b/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt index 2b7c6e5e9..52032c81a 100644 --- a/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt +++ b/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt @@ -16,8 +16,8 @@ object TrackerIconDecoder { fun decode(path: String, targetSize: Int = 72): Bitmap? { if (path.isEmpty()) return null - // 像素格式优先 - BitmapFactory.decodeFile(path)?.let { bm -> + // 像素格式:先读尺寸计算采样率,再解码以降低峰值内存 + decodeBitmapWithSample(path, targetSize)?.let { bm -> return scale(bm, targetSize) } @@ -33,8 +33,28 @@ object TrackerIconDecoder { }.getOrNull() } + /** + * 先用 inJustDecodeBounds 读取原始尺寸,计算 inSampleSize 使解码后 + * 的宽高不超过 targetSize×2,再用采样率解码以减少峰值内存。 + */ + private fun decodeBitmapWithSample(path: String, targetSize: Int): Bitmap? { + val opts = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(path, opts) + val (origW, origH) = opts.outWidth to opts.outHeight + if (origW <= 0 || origH <= 0) return null + + val sample = maxOf(1, origW / (targetSize * 2), origH / (targetSize * 2)) + return BitmapFactory.decodeFile( + path, + BitmapFactory.Options().apply { inSampleSize = sample } + ) + } + + /** 统一缩放到 targetSize,保持宽高比。小图也放大,确保 IconCompat 显示尺寸一致。 */ private fun scale(source: Bitmap, targetSize: Int): Bitmap { - if (source.width <= targetSize && source.height <= targetSize) return source + if (source.width == targetSize && source.height == targetSize) return source val ratio = targetSize.toFloat() / maxOf(source.width, source.height) val w = (source.width * ratio).toInt().coerceAtLeast(1) val h = (source.height * ratio).toInt().coerceAtLeast(1) @@ -42,4 +62,4 @@ object TrackerIconDecoder { if (scaled !== source) source.recycle() return scaled } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt index 5b1a78df5..ce2f94b2b 100644 --- a/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt +++ b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt @@ -16,20 +16,24 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -37,6 +41,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap 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.unit.dp @@ -45,7 +50,6 @@ import androidx.navigation.NavController import com.aliothmoon.maameow.R import com.aliothmoon.maameow.data.preferences.AppSettingsManager import com.aliothmoon.maameow.notification.TrackerIconDecoder -import com.aliothmoon.maameow.presentation.components.ListItemDivider import com.aliothmoon.maameow.presentation.components.SectionHeader import com.aliothmoon.maameow.presentation.components.SelectableChipGroup import com.aliothmoon.maameow.presentation.components.SettingRow @@ -57,20 +61,40 @@ import kotlinx.coroutines.launch import org.koin.compose.koinInject import timber.log.Timber -// 色板预设色值,与 TaskExecutionService 颜色常量匹配 +// 颜色方案 → 代表色(用于色块预览) +private val SchemeColor: Map = mapOf( + AppSettingsManager.LiveUpdateColorScheme.DEFAULT to Color(0xFF4CAF50), + AppSettingsManager.LiveUpdateColorScheme.BLUE to Color(0xFF2196F3), + AppSettingsManager.LiveUpdateColorScheme.GREEN to Color(0xFF4CAF50), + AppSettingsManager.LiveUpdateColorScheme.ORANGE to Color(0xFFFF9800), + AppSettingsManager.LiveUpdateColorScheme.PURPLE to Color(0xFF9C27B0), + AppSettingsManager.LiveUpdateColorScheme.PINK to Color(0xFFE91E63), + AppSettingsManager.LiveUpdateColorScheme.TEAL to Color(0xFF009688), + AppSettingsManager.LiveUpdateColorScheme.CUSTOM to Color(0xFF9E9E9E), +) + +// 图标方案 → 预览图标资源 ID(CUSTOM 无内置预览) +private val TrackerIconPreview: Map = mapOf( + AppSettingsManager.LiveUpdateTrackerIcon.DEFAULT to R.drawable.ic_progress_tracker, + AppSettingsManager.LiveUpdateTrackerIcon.LOGO to R.drawable.ic_maa_logo, + AppSettingsManager.LiveUpdateTrackerIcon.DOT to R.drawable.ic_tracker_dot, + AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM to null, +) + +// 自定义色板 private val ColorSwatches = listOf( - "#2196F3" to Color(0xFF2196F3), // Blue - "#4CAF50" to Color(0xFF4CAF50), // Green - "#FF9800" to Color(0xFFFF9800), // Orange - "#9C27B0" to Color(0xFF9C27B0), // Purple - "#E91E63" to Color(0xFFE91E63), // Pink - "#009688" to Color(0xFF009688), // Teal - "#F44336" to Color(0xFFF44336), // Red - "#607D8B" to Color(0xFF607D8B), // Blue Grey - "#795548" to Color(0xFF795548), // Brown - "#FFC107" to Color(0xFFFFC107), // Amber - "#00BCD4" to Color(0xFF00BCD4), // Cyan - "#8BC34A" to Color(0xFF8BC34A), // Light Green + "#2196F3" to Color(0xFF2196F3), + "#4CAF50" to Color(0xFF4CAF50), + "#FF9800" to Color(0xFFFF9800), + "#9C27B0" to Color(0xFF9C27B0), + "#E91E63" to Color(0xFFE91E63), + "#009688" to Color(0xFF009688), + "#F44336" to Color(0xFFF44336), + "#607D8B" to Color(0xFF607D8B), + "#795548" to Color(0xFF795548), + "#FFC107" to Color(0xFFFFC107), + "#00BCD4" to Color(0xFF00BCD4), + "#8BC34A" to Color(0xFF8BC34A), ) @OptIn(ExperimentalLayoutApi::class) @@ -87,7 +111,6 @@ fun LiveUpdateSettingsView(navController: NavController) { val contentColor = MaterialTheme.colorScheme.onSurface val context = LocalContext.current - // 自定义图标选择:从文件选择器取图片(PNG/JPG/WebP 等),复制到内部存储后持久化路径 val customTrackerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri: Uri? -> @@ -96,7 +119,6 @@ fun LiveUpdateSettingsView(navController: NavController) { try { val targetDir = File(context.filesDir, "live_update") targetDir.mkdirs() - // 保留原始文件扩展名(BitmapFactory 按内容解码,jpg/webp 均可) val displayName = context.contentResolver .query(uri, null, null, null, null) ?.use { cursor -> @@ -141,6 +163,7 @@ fun LiveUpdateSettingsView(navController: NavController) { vertical = MaaDesignTokens.Spacing.sm ) ) { + // ── 启用开关 ── item { SectionHeader(stringResource(R.string.live_update_section_general)) SettingsGroupCard { @@ -162,69 +185,161 @@ fun LiveUpdateSettingsView(navController: NavController) { } } + // ── 状态栏显示内容 ── item { SectionHeader(stringResource(R.string.live_update_section_display)) SettingsGroupCard { - Column(modifier = Modifier.fillMaxWidth()) { - SelectableChipGroup( - label = stringResource(R.string.live_update_chip_label), - selectedValue = chipContent, - options = AppSettingsManager.LiveUpdateChipContent.entries.map { - it to stringResource(it.labelRes) - }, - onSelected = { value -> - if (value != chipContent) { - coroutineScope.launch { - appSettingsManager.setLiveUpdateChipContent(value) - } - } - }, + Text( + text = stringResource(R.string.live_update_chip_label), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = MaaDesignTokens.Spacing.lg, + top = MaaDesignTokens.Spacing.lg, + end = MaaDesignTokens.Spacing.lg, + bottom = MaaDesignTokens.Spacing.xs, ) - } - ListItemDivider() + ) + SelectableChipGroup( + label = "", + selectedValue = chipContent, + options = AppSettingsManager.LiveUpdateChipContent.entries.map { + it to stringResource(it.labelRes) + }, + onSelected = { value -> + if (value != chipContent) { + coroutineScope.launch { + appSettingsManager.setLiveUpdateChipContent(value) + } + } + }, + modifier = Modifier.padding(horizontal = MaaDesignTokens.Spacing.lg) + ) + // 底部留白 + Box(modifier = Modifier.height(MaaDesignTokens.Spacing.md)) + } + } + + // ── 进度条颜色(视觉色块 + 标签) ── + item { + SectionHeader(stringResource(R.string.live_update_color_label)) + SettingsGroupCard { Column(modifier = Modifier.fillMaxWidth()) { - SelectableChipGroup( - label = stringResource(R.string.live_update_color_label), - selectedValue = colorScheme, - options = AppSettingsManager.LiveUpdateColorScheme.entries.map { - it to stringResource(it.labelRes) - }, - onSelected = { value -> - if (value != colorScheme) { - coroutineScope.launch { - appSettingsManager.setLiveUpdateColorScheme(value) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding( + start = MaaDesignTokens.Spacing.lg, + top = MaaDesignTokens.Spacing.lg, + end = MaaDesignTokens.Spacing.lg, + bottom = if (colorScheme == AppSettingsManager.LiveUpdateColorScheme.CUSTOM) MaaDesignTokens.Spacing.xs + else MaaDesignTokens.Spacing.md, + ) + ) { + AppSettingsManager.LiveUpdateColorScheme.entries.forEach { scheme -> + val selected = scheme == colorScheme + val color = SchemeColor[scheme] ?: Color.Gray + val label = stringResource(scheme.labelRes) + + Surface( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { + if (scheme != colorScheme) { + coroutineScope.launch { + appSettingsManager.setLiveUpdateColorScheme(scheme) + } + } + }, + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) { + if (scheme == AppSettingsManager.LiveUpdateColorScheme.DEFAULT) { + // 默认语义色:三色小条 + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 6.dp) + ) { + listOf(0xFF4CAF50, 0xFF2196F3, 0xFFD32F2F).forEach { c -> + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(Color(c)) + ) + } + } + } else { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(color) + .then( + if (selected && scheme == AppSettingsManager.LiveUpdateColorScheme.CUSTOM) + Modifier.border(1.5.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + else Modifier + ) + ) + if (scheme == AppSettingsManager.LiveUpdateColorScheme.CUSTOM) { + // 自定义:无颜色色块,仅用灰色圆 + 文字 + } + } + Box(modifier = Modifier.width(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + ) } } - }, - ) - // 自定义颜色色板:仅在 CUSTOM 方案时显示 + } + } + + // 自定义色板 if (colorScheme == AppSettingsManager.LiveUpdateColorScheme.CUSTOM) { Text( text = stringResource(R.string.live_update_color_custom_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding( - top = MaaDesignTokens.Spacing.md, - bottom = MaaDesignTokens.Spacing.xs + start = MaaDesignTokens.Spacing.lg, + end = MaaDesignTokens.Spacing.lg, + bottom = MaaDesignTokens.Spacing.xs, ) ) FlowRow( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding( + start = MaaDesignTokens.Spacing.lg, + end = MaaDesignTokens.Spacing.lg, + bottom = MaaDesignTokens.Spacing.md, + ) ) { ColorSwatches.forEach { (hex, color) -> val isSelected = customColor.equals(hex, ignoreCase = true) Box( modifier = Modifier - .size(32.dp) + .size(28.dp) .clip(CircleShape) .background(color) .then( if (isSelected) Modifier.border( - 2.5.dp, - MaterialTheme.colorScheme.onSurface, - CircleShape + 2.5.dp, MaterialTheme.colorScheme.onSurface, CircleShape ) else Modifier ) .clickable { @@ -237,74 +352,142 @@ fun LiveUpdateSettingsView(navController: NavController) { } } } - ListItemDivider() - Column(modifier = Modifier.fillMaxWidth()) { - SelectableChipGroup( - label = stringResource(R.string.live_update_icon_label), - selectedValue = trackerIcon, - options = AppSettingsManager.LiveUpdateTrackerIcon.entries.map { - it to stringResource(it.labelRes) - }, - onSelected = { value -> - if (value != trackerIcon) { - coroutineScope.launch { - appSettingsManager.setLiveUpdateTrackerIcon(value) + } + } + + // ── 进度条图标(图标预览 + 标签) ── + item { + SectionHeader(stringResource(R.string.live_update_icon_label)) + SettingsGroupCard { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding( + start = MaaDesignTokens.Spacing.lg, + top = MaaDesignTokens.Spacing.lg, + end = MaaDesignTokens.Spacing.lg, + bottom = if (trackerIcon == AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM) MaaDesignTokens.Spacing.xs + else MaaDesignTokens.Spacing.md, + ) + ) { + AppSettingsManager.LiveUpdateTrackerIcon.entries.forEach { icon -> + val selected = icon == trackerIcon + val label = stringResource(icon.labelRes) + val previewId = TrackerIconPreview[icon] + + Surface( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { + if (icon != trackerIcon) { + coroutineScope.launch { + appSettingsManager.setLiveUpdateTrackerIcon(icon) + } + } + }, + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp) + ) { + if (previewId != null) { + Image( + painter = painterResource(previewId), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + Box(modifier = Modifier.width(6.dp)) + } else { + // CUSTOM:用灰色圆 + 加号示意 + Box( + modifier = Modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Box(modifier = Modifier.width(6.dp)) } + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + ) } - }, + } + } + } + + // 自定义图片选择器 + if (trackerIcon == AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM) { + Text( + text = stringResource(R.string.live_update_icon_custom_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding( + start = MaaDesignTokens.Spacing.lg, + end = MaaDesignTokens.Spacing.lg, + bottom = MaaDesignTokens.Spacing.xs, + ) ) - // 自定义图片:选择 PNG、预览当前已选图片、支持移除 - if (trackerIcon == AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM) { - Text( - text = stringResource(R.string.live_update_icon_custom_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - top = MaaDesignTokens.Spacing.md, - bottom = MaaDesignTokens.Spacing.xs + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding( + start = MaaDesignTokens.Spacing.lg, + end = MaaDesignTokens.Spacing.lg, + bottom = MaaDesignTokens.Spacing.md, ) - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - val iconBitmap = if (customTrackerPath.isNotEmpty()) { + ) { + val iconBitmap = remember(customTrackerPath) { + if (customTrackerPath.isNotEmpty()) TrackerIconDecoder.decode(customTrackerPath, targetSize = 72) - } else null - if (iconBitmap != null) { - Image( - bitmap = iconBitmap.asImageBitmap(), - contentDescription = null, - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - ) - androidx.compose.foundation.layout.Spacer( - modifier = Modifier.size(MaaDesignTokens.Spacing.md) - ) - } + else null + } + if (iconBitmap != null) { + Image( + bitmap = iconBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + Box(modifier = Modifier.size(MaaDesignTokens.Spacing.md)) + } + OutlinedButton( + onClick = { customTrackerLauncher.launch("*/*") }, + modifier = Modifier.weight(1f) + ) { + Text(text = stringResource(R.string.live_update_icon_pick)) + } + if (customTrackerPath.isNotEmpty()) { OutlinedButton( - onClick = { customTrackerLauncher.launch("*/*") }, - modifier = Modifier.weight(1f) + onClick = { + coroutineScope.launch { + appSettingsManager.setLiveUpdateCustomTrackerPath("") + } + }, + modifier = Modifier.padding(start = MaaDesignTokens.Spacing.xs) ) { - Text( - text = stringResource(R.string.live_update_icon_pick) - ) - } - if (customTrackerPath.isNotEmpty()) { - OutlinedButton( - onClick = { - coroutineScope.launch { - appSettingsManager.setLiveUpdateCustomTrackerPath("") - } - }, - modifier = Modifier.padding(start = MaaDesignTokens.Spacing.xs) - ) { - Text( - text = stringResource(R.string.live_update_icon_clear) - ) - } + Text(text = stringResource(R.string.live_update_icon_clear)) } } } @@ -312,6 +495,7 @@ fun LiveUpdateSettingsView(navController: NavController) { } } + // ── 提示 ── item { Text( text = stringResource(R.string.live_update_hint), @@ -319,7 +503,8 @@ fun LiveUpdateSettingsView(navController: NavController) { color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.padding( start = MaaDesignTokens.Spacing.listHorizontal, - top = MaaDesignTokens.Spacing.xs + top = MaaDesignTokens.Spacing.xs, + bottom = MaaDesignTokens.Spacing.xl, ) ) } diff --git a/app/src/main/res/drawable/ic_tracker_dot.xml b/app/src/main/res/drawable/ic_tracker_dot.xml index b1680b580..2885d9ae4 100644 --- a/app/src/main/res/drawable/ic_tracker_dot.xml +++ b/app/src/main/res/drawable/ic_tracker_dot.xml @@ -6,4 +6,4 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 1fb56868b..bf8fd55b9 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1391,7 +1391,7 @@ Custom Pick a color Progress icon - Diamond + Orundum MAA logo Dot Custom image diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef05346e0..7e4979d81 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1447,7 +1447,7 @@ 自定义 选择颜色 进度条图标 - 菱形 + 合成玉 MAA 图标 圆点 自定义图片 From 3920ded34d646686702ce58f4db5ecc167371736 Mon Sep 17 00:00:00 2001 From: WhiteMoon319 <3287047638@qq.com> Date: Fri, 21 Aug 2026 14:25:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=9B=9B=E9=A1=B9=20Review=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态栏内容 NONE 显式 setShortCriticalText("") 确保 chip 无文本 - TrackerIconDecoder SVG 输入流用 .use 关闭,异常也确保释放 fd - ConfigBackupManager 导出清空本地图标路径并回退 CUSTOM 到 DEFAULT;导入丢弃路径并归一化 - 自定义图标缓存:主线程只读缓存,每秒刷新零 IO;首次/变更时 Dispatchers.IO 异步解码并主动刷新通知;防并发重复解码与过期结果;服务销毁取消协程 --- .../data/preferences/ConfigBackupManager.kt | 13 ++++- .../domain/service/TaskExecutionService.kt | 51 +++++++++++++++++-- .../notification/TrackerIconDecoder.kt | 16 +++--- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt b/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt index 0b8242960..06ba782bb 100644 --- a/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt +++ b/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt @@ -85,20 +85,29 @@ class ConfigBackupManager( /** * 导出时剥离设备本地字段:CDK 与解锁 PIN 属敏感信息; - * 自定义背景的开关与令牌对应本机 filesDir 下的图片文件,在其他设备上不存在。 + * 自定义背景的开关与令牌对应本机 filesDir 下的图片文件,在其他设备上不存在; + * 自定义图标路径同样指向本机 filesDir,跨设备无效,一并清除。 */ private fun AppSettings.sanitized() = copy( mirrorChyanCdk = "", wakeCredential = "", customBackgroundEnabled = "false", customBackgroundToken = "", + liveUpdateCustomTrackerPath = "", + // 自定义图标路径已清空,回退到内置方案 + liveUpdateTrackerIcon = if (this.liveUpdateTrackerIcon == "custom") "default" else this.liveUpdateTrackerIcon, ) /** * 导入时对已废弃或非法的旧值做归一化,避免后续读取时违反非空约束。 + * 自定义图标路径指向本机 filesDir,来自其他设备的备份必须丢弃, + * 若此时图标类型为 CUSTOM 则回退到内置方案。 */ private fun AppSettings.normalizedForImport() = copy( - shizukuLaunchPackage = shizukuLaunchPackage.ifBlank { OFFICIAL_SHIZUKU_PACKAGE } + shizukuLaunchPackage = shizukuLaunchPackage.ifBlank { OFFICIAL_SHIZUKU_PACKAGE }, + liveUpdateCustomTrackerPath = "", + liveUpdateTrackerIcon = if (this.liveUpdateTrackerIcon == "custom" && this.liveUpdateCustomTrackerPath.isBlank()) + "default" else this.liveUpdateTrackerIcon, ) /** diff --git a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt index bf9e6c78d..590c89e51 100644 --- a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt +++ b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo +import android.graphics.Bitmap import android.os.Build import android.os.IBinder import android.os.SystemClock @@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import timber.log.Timber @@ -88,6 +90,10 @@ class TaskExecutionService : Service() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var progressJob: Job? = null + // 自定义图标缓存:key = "custom|$path",value = 解码后的 Bitmap(null 表示解码失败/无效) + private val trackerIconCache = mutableMapOf() + private var trackerIconDecodeJob: Job? = null + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { @@ -291,7 +297,7 @@ class TaskExecutionService : Service() { AppSettingsManager.LiveUpdateChipContent.PROGRESS -> progressInfo.progressLabel AppSettingsManager.LiveUpdateChipContent.TASK -> activeTaskName AppSettingsManager.LiveUpdateChipContent.LOG -> statusText - AppSettingsManager.LiveUpdateChipContent.NONE -> null + AppSettingsManager.LiveUpdateChipContent.NONE -> "" } private fun defaultStatusText(state: MaaExecutionState): String = when (state) { @@ -308,8 +314,47 @@ class TaskExecutionService : Service() { AppSettingsManager.LiveUpdateTrackerIcon.DEFAULT -> IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) AppSettingsManager.LiveUpdateTrackerIcon.CUSTOM -> { val path = appSettingsManager.liveUpdateCustomTrackerPath.value - TrackerIconDecoder.decode(path)?.let { IconCompat.createWithBitmap(it) } - ?: IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) + if (path.isEmpty()) return IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) + val key = "custom|$path" + // 缓存命中:直接返回缓存的 Bitmap,避免主线程 IO + if (trackerIconCache.containsKey(key)) { + val bm = trackerIconCache[key] // Bitmap?, null = 曾解码失败 + if (bm != null) return IconCompat.createWithBitmap(bm) + return IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) + } + // 缓存未命中:先用 fallback,后台解码 + scheduleTrackerIconDecode(key, path) + IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) + } + } + + /** + * 在 Dispatchers.IO 解码自定义图标,解码成功后刷新当前活动通知。 + * 避免并发重复解码:同一 key 正在解码时跳过;解码结果过期(key 已变)时丢弃。 + */ + private fun scheduleTrackerIconDecode(key: String, path: String) { + if (trackerIconDecodeJob?.isActive == true) return + trackerIconDecodeJob = serviceScope.launch(Dispatchers.IO) { + val decoded = TrackerIconDecoder.decode(path) + // 校验解码期间配置未变化(用户切换图标类型/路径),丢弃过期结果 + val currentKey = "custom|${appSettingsManager.liveUpdateCustomTrackerPath.value}" + if (currentKey != key) { + decoded?.recycle() + return@launch + } + trackerIconCache[key] = decoded + // 解码成功且服务仍处于活跃状态时刷新当前通知 + if (decoded != null) { + withContext(Dispatchers.Main) { + val state = compositionService.state.value + if (state == MaaExecutionState.RUNNING || + state == MaaExecutionState.STARTING || + state == MaaExecutionState.STOPPING + ) { + updateNotification(currentSnapshot()) + } + } + } } } diff --git a/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt b/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt index 52032c81a..561605da6 100644 --- a/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt +++ b/app/src/main/java/com/aliothmoon/maameow/notification/TrackerIconDecoder.kt @@ -23,13 +23,15 @@ object TrackerIconDecoder { // 矢量格式:AndroidSVG 解析 SVG/XML 内容 return runCatching { - val svg = SVG.getFromInputStream(FileInputStream(path)) - val bitmap = Bitmap.createBitmap(targetSize, targetSize, Bitmap.Config.ARGB_8888) - svg.renderToCanvas( - Canvas(bitmap), - android.graphics.RectF(0f, 0f, targetSize.toFloat(), targetSize.toFloat()) - ) - bitmap + FileInputStream(path).use { stream -> + val svg = SVG.getFromInputStream(stream) + val bitmap = Bitmap.createBitmap(targetSize, targetSize, Bitmap.Config.ARGB_8888) + svg.renderToCanvas( + Canvas(bitmap), + android.graphics.RectF(0f, 0f, targetSize.toFloat(), targetSize.toFloat()) + ) + bitmap + } }.getOrNull() } From a1c1093797b6c50326815f85ca80ccb3f649ff95 Mon Sep 17 00:00:00 2001 From: WhiteMoon319 <3287047638@qq.com> Date: Fri, 21 Aug 2026 15:30:27 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(notification):=20=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=BA=BF=E7=A8=8B=E5=AE=89=E5=85=A8=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=85=A5=E5=9B=9E=E9=80=80=EF=BC=8C?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E5=BC=82=E6=AD=A5=E8=A7=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trackerIconCache 改用 ConcurrentHashMap,避免主线程读/IO 写数据竞争 - ConfigBackupManager 导入时 CUSTOM 图标无条件回退 DEFAULT(路径已清空) - 设置页自定义图标预览改 produceState + Dispatchers.IO 异步解码,避免阻塞主线程 --- .../data/preferences/ConfigBackupManager.kt | 4 ++-- .../domain/service/TaskExecutionService.kt | 9 +++++--- .../view/settings/LiveUpdateSettingsView.kt | 21 +++++++++++++------ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt b/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt index 06ba782bb..f92d186c6 100644 --- a/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt +++ b/app/src/main/java/com/aliothmoon/maameow/data/preferences/ConfigBackupManager.kt @@ -106,8 +106,8 @@ class ConfigBackupManager( private fun AppSettings.normalizedForImport() = copy( shizukuLaunchPackage = shizukuLaunchPackage.ifBlank { OFFICIAL_SHIZUKU_PACKAGE }, liveUpdateCustomTrackerPath = "", - liveUpdateTrackerIcon = if (this.liveUpdateTrackerIcon == "custom" && this.liveUpdateCustomTrackerPath.isBlank()) - "default" else this.liveUpdateTrackerIcon, + // 路径已无条件清空,CUSTOM 必定无文件,直接回退内置方案 + liveUpdateTrackerIcon = if (this.liveUpdateTrackerIcon == "custom") "default" else this.liveUpdateTrackerIcon, ) /** diff --git a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt index 590c89e51..1a8e2cb2b 100644 --- a/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt +++ b/app/src/main/java/com/aliothmoon/maameow/domain/service/TaskExecutionService.kt @@ -24,6 +24,7 @@ import com.aliothmoon.maameow.maa.callback.TaskRunInfo import com.aliothmoon.maameow.maa.callback.TaskRunStatus import com.aliothmoon.maameow.data.preferences.AppSettingsManager import com.aliothmoon.maameow.notification.TrackerIconDecoder +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -91,7 +92,8 @@ class TaskExecutionService : Service() { private var progressJob: Job? = null // 自定义图标缓存:key = "custom|$path",value = 解码后的 Bitmap(null 表示解码失败/无效) - private val trackerIconCache = mutableMapOf() + // 主线程读 + IO 线程写,用 ConcurrentHashMap 保证线程安全 + private val trackerIconCache = ConcurrentHashMap() private var trackerIconDecodeJob: Job? = null override fun onBind(intent: Intent?): IBinder? = null @@ -317,9 +319,10 @@ class TaskExecutionService : Service() { if (path.isEmpty()) return IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) val key = "custom|$path" // 缓存命中:直接返回缓存的 Bitmap,避免主线程 IO + val cached = trackerIconCache[key] + if (cached != null) return IconCompat.createWithBitmap(cached) + // null 可能是 key 不存在(未缓存)或解码失败,用 containsKey 区分 if (trackerIconCache.containsKey(key)) { - val bm = trackerIconCache[key] // Bitmap?, null = 曾解码失败 - if (bm != null) return IconCompat.createWithBitmap(bm) return IconCompat.createWithResource(this, R.drawable.ic_progress_tracker) } // 缓存未命中:先用 fallback,后台解码 diff --git a/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt index ce2f94b2b..bed67ee89 100644 --- a/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt +++ b/app/src/main/java/com/aliothmoon/maameow/presentation/view/settings/LiveUpdateSettingsView.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -47,6 +48,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController +import android.graphics.Bitmap import com.aliothmoon.maameow.R import com.aliothmoon.maameow.data.preferences.AppSettingsManager import com.aliothmoon.maameow.notification.TrackerIconDecoder @@ -57,7 +59,9 @@ import com.aliothmoon.maameow.presentation.components.SettingsGroupCard import com.aliothmoon.maameow.presentation.components.TopAppBar import com.aliothmoon.maameow.theme.MaaDesignTokens import java.io.File +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.compose.koinInject import timber.log.Timber @@ -456,14 +460,19 @@ fun LiveUpdateSettingsView(navController: NavController) { bottom = MaaDesignTokens.Spacing.md, ) ) { - val iconBitmap = remember(customTrackerPath) { - if (customTrackerPath.isNotEmpty()) - TrackerIconDecoder.decode(customTrackerPath, targetSize = 72) - else null + val iconBitmap by produceState(initialValue = null, key1 = customTrackerPath) { + value = if (customTrackerPath.isNotEmpty()) { + withContext(Dispatchers.IO) { + TrackerIconDecoder.decode(customTrackerPath, targetSize = 72) + } + } else { + null + } } - if (iconBitmap != null) { + val bitmap = iconBitmap + if (bitmap != null) { Image( - bitmap = iconBitmap.asImageBitmap(), + bitmap = bitmap.asImageBitmap(), contentDescription = null, modifier = Modifier .size(36.dp)