fix: IME layout, message dedup, animation order, and overlay input positioning

- ChatScreen: restructure to Box overlay layout so only input area
  rises with IME, messages stay fixed. Add opaque background to input
  area. Use reverseLayout with newest-first animation order.
- OverlayContent: remove all manual IME detection — system forces
  adjust=pan on VoiceInteractionSession windows, so manual padding
  caused double offset. Let system handle IME naturally.
- ChatRepositoryImpl: add messageRemovals flow to clean up wrapping
  stream_end/response when review/multi_message items arrive later.
  Track lastResponseId in both stream_end and response handlers.
- ChatViewModel/OverlayViewModel: fix dedup to check by message ID
  only. Sort descending (newest first). Observe messageRemovals.
- NavGraph: keep all tabs composed with graphicsLayer alpha toggle —
  prevents ChatScreen destruction and re-render on tab switch.
- CyreneVoiceInteractionSession: defer configureWindow via post()
  to override system softInputMode flags.
- AndroidManifest: set windowSoftInputMode=adjustNothing on main
  activity.
- Add WebSocketKeepAliveService for background connection persistence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 21:41:46 +08:00
parent eb94142404
commit 014437760d
18 changed files with 1063 additions and 311 deletions
+29
View File
@@ -11,7 +11,14 @@
-keepattributes Signature -keepattributes Signature
-keepattributes InnerClasses -keepattributes InnerClasses
-keepattributes EnclosingMethod -keepattributes EnclosingMethod
-keepattributes RuntimeVisibleAnnotations
-keepattributes RuntimeVisibleParameterAnnotations
-keepattributes AnnotationDefault
-keepattributes KotlinMetadata
-dontwarn kotlin.** -dontwarn kotlin.**
-keep class kotlin.Metadata { *; }
-keep class kotlin.coroutines.Continuation
-keep class kotlinx.coroutines.** { *; }
# --- Retrofit --- # --- Retrofit ---
-keep class retrofit2.** { *; } -keep class retrofit2.** { *; }
@@ -63,6 +70,28 @@
# --- Keep PreferencesDataStore (Koin injects) --- # --- Keep PreferencesDataStore (Koin injects) ---
-keep class top.yeij.cyrene.data.local.PreferencesDataStore { *; } -keep class top.yeij.cyrene.data.local.PreferencesDataStore { *; }
# --- Keep utility classes (VoiceRecorder, RuntimeLog, etc. injected by Koin) ---
-keep class top.yeij.cyrene.util.** { *; }
-keepclassmembers class top.yeij.cyrene.util.** { *; }
# --- Keep voice/TTS/STT classes (injected by Koin into OverlayViewModel) ---
-keep class top.yeij.cyrene.voice.** { *; }
-keepclassmembers class top.yeij.cyrene.voice.** { *; }
# --- Keep domain use cases (injected by Koin into ViewModels) ---
-keep class top.yeij.cyrene.domain.usecase.** { *; }
-keepclassmembers class top.yeij.cyrene.domain.usecase.** { *; }
# --- Keep network interceptors and ApiService (Koin singletons) ---
-keep class top.yeij.cyrene.data.remote.RetrofitClient { *; }
-keep class top.yeij.cyrene.data.remote.ApiService { *; }
-keep class top.yeij.cyrene.data.remote.AuthInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.DynamicUrlInterceptor { *; }
-keep class top.yeij.cyrene.data.remote.TokenAuthenticator { *; }
# --- Keep WebSocketService (injected into ChatRepositoryImpl) ---
-keep class top.yeij.cyrene.service.WebSocketService { *; }
# --- General AndroidX --- # --- General AndroidX ---
-keep class androidx.lifecycle.** { *; } -keep class androidx.lifecycle.** { *; }
-dontwarn androidx.lifecycle.** -dontwarn androidx.lifecycle.**
+8
View File
@@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- 推送 --> <!-- 推送 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -42,6 +43,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Cyrene"> android:theme="@style/Theme.Cyrene">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -83,6 +85,12 @@
android:resource="@xml/accessibility_config" /> android:resource="@xml/accessibility_config" />
</service> </service>
<!-- WebSocket 后台保活 -->
<service
android:name=".service.WebSocketKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- FileProvider:日志分享 --> <!-- FileProvider:日志分享 -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -89,7 +89,7 @@ class CyreneApplication : Application() {
private fun getRepo(): ChatRepositoryImpl? { private fun getRepo(): ChatRepositoryImpl? {
return try { return try {
GlobalContext.get().get() GlobalContext.get().get()
} catch (_: Exception) { } catch (_: Throwable) {
null null
} }
} }
@@ -1,5 +1,6 @@
package top.yeij.cyrene.data.repository package top.yeij.cyrene.data.repository
import android.app.Application
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -26,11 +27,13 @@ import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.domain.model.Conversation import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.service.WebSocketService import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.RuntimeLog import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID import java.util.UUID
class ChatRepositoryImpl( class ChatRepositoryImpl(
private val app: Application,
private val conversationDao: ConversationDao, private val conversationDao: ConversationDao,
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val webSocketService: WebSocketService, private val webSocketService: WebSocketService,
@@ -54,6 +57,9 @@ class ChatRepositoryImpl(
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4) private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
override val messageClearEvents: Flow<Unit> = _messageClearEvents override val messageClearEvents: Flow<Unit> = _messageClearEvents
private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
override val messageRemovals: Flow<String> = _messageRemovals
private val _isAssistantStreaming = MutableStateFlow(false) private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow() override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
@@ -81,19 +87,22 @@ class ChatRepositoryImpl(
override fun onAppForeground() { override fun onAppForeground() {
isAppInForeground = true isAppInForeground = true
notifiedMessageIds.clear() notifiedMessageIds.clear()
if (!_connectionState.value) { WebSocketKeepAliveService.stop(app)
webSocketService.forceReconnect() // Always reconnect and sync history when returning to foreground
} webSocketService.forceReconnect()
// Always request history on foreground to catch cross-device messages
scope.launch { scope.launch {
val sid = currentSessionId ?: return@launch val sid = currentSessionId ?: return@launch
RuntimeLog.general("app", "Foreground — requesting history for session=$sid") RuntimeLog.general("app", "Foreground — reconnecting and requesting history for session=$sid")
requestHistoryViaWs(sid) requestHistoryViaWs(sid)
} }
} }
override fun onAppBackground() { override fun onAppBackground() {
isAppInForeground = false isAppInForeground = false
if (_connectionState.value) {
WebSocketKeepAliveService.start(app)
RuntimeLog.general("app", "Started keep-alive service for background")
}
} }
init { init {
@@ -148,17 +157,6 @@ class ChatRepositoryImpl(
messageDao.deleteAll() messageDao.deleteAll()
preferencesDataStore.saveLastClearedTimestamp(now) preferencesDataStore.saveLastClearedTimestamp(now)
// Also clear server-side messages for all known sessions
try {
val sessions = conversationDao.getAllSnapshot()
sessions.forEach { session ->
try {
apiService.clearSessionMessages(session.id)
RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
} catch (_: Exception) { }
}
} catch (_: Exception) { }
_messageClearEvents.tryEmit(Unit) _messageClearEvents.tryEmit(Unit)
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now") RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
@@ -378,6 +376,7 @@ class ChatRepositoryImpl(
streamingContent = "" streamingContent = ""
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}" streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
_isAssistantStreaming.value = true _isAssistantStreaming.value = true
recentParsedContents.clear()
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId") RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
} }
@@ -405,6 +404,19 @@ class ChatRepositoryImpl(
} }
val ts = wsMsg.timestamp ?: System.currentTimeMillis() val ts = wsMsg.timestamp ?: System.currentTimeMillis()
// Dedup: suppress if streaming content wraps already-shown multi_message/review items
val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
val allContained = recentParsedContents.all { content.contains(it) }
if (allContained) {
RuntimeLog.chat("dedup", "Suppressed stream_end wrapping, ${recentParsedContents.size} items already shown")
recentParsedContents.clear()
_isAssistantStreaming.value = false
return
}
}
recentParsedContents.clear()
if (content.isNotBlank()) { if (content.isNotBlank()) {
ensureConversation(sid, content) ensureConversation(sid, content)
messageDao.upsert( messageDao.upsert(
@@ -419,6 +431,10 @@ class ChatRepositoryImpl(
) )
} }
lastResponseId = msgId
lastResponseContent = content
lastResponseTime = System.currentTimeMillis()
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true) emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
_isAssistantStreaming.value = false _isAssistantStreaming.value = false
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}") RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
@@ -616,7 +632,8 @@ class ChatRepositoryImpl(
val allContained = recentParsedContents.all { respContent.contains(it) } val allContained = recentParsedContents.all { respContent.contains(it) }
if (allContained) { if (allContained) {
messageDao.deleteById(respId) messageDao.deleteById(respId)
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId") _messageRemovals.tryEmit(respId)
RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB and live state id=$respId")
} }
} }
@@ -63,7 +63,7 @@ val appModule = module {
// Repositories // Repositories
single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) } single<AuthRepository> { AuthRepositoryImpl(get(), get(), get()) }
single<ChatRepository> { ChatRepositoryImpl(get(), get(), get(), get(), get()) } single<ChatRepository> { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get()) }
single<IoTRepository> { IoTRepositoryImpl(get(), get()) } single<IoTRepository> { IoTRepositoryImpl(get(), get()) }
// UseCases // UseCases
@@ -11,6 +11,7 @@ interface ChatRepository {
val connectionError: StateFlow<String?> val connectionError: StateFlow<String?>
val isAssistantStreaming: StateFlow<Boolean> val isAssistantStreaming: StateFlow<Boolean>
val messageClearEvents: Flow<Unit> val messageClearEvents: Flow<Unit>
val messageRemovals: Flow<String>
var currentSessionId: String? var currentSessionId: String?
fun getConversations(): Flow<List<Conversation>> fun getConversations(): Flow<List<Conversation>>
@@ -48,7 +48,7 @@ class CyreneVoiceInteractionSession(context: Context) :
private fun resolveViewModel(): OverlayViewModel? { private fun resolveViewModel(): OverlayViewModel? {
return try { return try {
GlobalContext.get().get<OverlayViewModel>() GlobalContext.get().get<OverlayViewModel>()
} catch (e: Exception) { } catch (e: Throwable) {
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e) Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
null null
} }
@@ -65,9 +65,17 @@ class CyreneVoiceInteractionSession(context: Context) :
lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleRegistry.currentState = Lifecycle.State.CREATED
val vm = overlayViewModel val vm = overlayViewModel
val session = this@CyreneVoiceInteractionSession
return ComposeView(context).apply { return ComposeView(context).apply {
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession) // Configure window as soon as view is attached — before system overrides flags
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession) addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
session.configureWindow()
}
override fun onViewDetachedFromWindow(v: View) {}
})
setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session)
setContent { setContent {
CyreneTheme { CyreneTheme {
if (vm != null) { if (vm != null) {
@@ -92,14 +100,22 @@ class CyreneVoiceInteractionSession(context: Context) :
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}") RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
lifecycleRegistry.currentState = Lifecycle.State.STARTED lifecycleRegistry.currentState = Lifecycle.State.STARTED
// Configure window: extend behind status bar, don't resize for IME // Defer window config — system may override softInputMode after onShow returns
configureWindow() try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true
val w = method.invoke(this) as? android.view.Window
w?.decorView?.post { configureWindow() }
} catch (e: Throwable) {
// Fallback: configure immediately
configureWindow()
}
// Only read screen content if user enabled it in settings (default off) // Only read screen content if user enabled it in settings (default off)
val autoScreenContext = try { val autoScreenContext = try {
val prefs: PreferencesDataStore = GlobalContext.get().get() val prefs: PreferencesDataStore = GlobalContext.get().get()
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
} catch (_: Exception) { } catch (_: Throwable) {
false false
} }
if (autoScreenContext) { if (autoScreenContext) {
@@ -111,16 +127,18 @@ class CyreneVoiceInteractionSession(context: Context) :
} }
} }
private fun configureWindow() { fun configureWindow() {
try { try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow") val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true method.isAccessible = true
val w = method.invoke(this) as? android.view.Window ?: return val w = method.invoke(this) as? android.view.Window ?: return
// Transparent window so the underlying screen is visible through the overlay
w.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.TRANSPARENT))
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) w.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME") Log.d(TAG, "Window configured: transparent bg, translucent status/nav")
} catch (e: Exception) { } catch (e: Throwable) {
Log.w(TAG, "Failed to configure window: ${e.message}") Log.w(TAG, "Failed to configure window: ${e.message}")
} }
} }
@@ -0,0 +1,84 @@
package top.yeij.cyrene.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
class WebSocketKeepAliveService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
isRunning = true
createChannel()
}
override fun onDestroy() {
isRunning = false
super.onDestroy()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("昔涟")
.setContentText("已连接,可在后台接收消息")
.setOngoing(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
startForeground(NOTIFICATION_ID, notification)
return START_STICKY
}
private fun createChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"连接状态",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "后台连接保活"
setShowBadge(false)
}
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
companion object {
private const val CHANNEL_ID = "cyrene_keepalive"
private const val NOTIFICATION_ID = 1
@Volatile
var isRunning: Boolean = false
private set
fun start(context: Context) {
if (isRunning) return
context.startForegroundService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
fun stop(context: Context) {
context.stopService(
Intent(context, WebSocketKeepAliveService::class.java)
)
}
}
}
@@ -244,11 +244,14 @@ class WebSocketService(
reconnectJob?.cancel() reconnectJob?.cancel()
reconnectJob = null reconnectJob = null
scope.launch { scope.launch {
if (!_isConnected.value) { try {
try { // Close existing socket directly without resetting shouldReconnect
connect(currentSessionId) cancelHeartbeat()
} catch (_: Exception) { } webSocket?.close(1000, "Reconnecting")
} webSocket = null
_isConnected.value = false
connect(currentSessionId)
} catch (_: Exception) { }
} }
} }
@@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -29,6 +30,7 @@ import top.yeij.cyrene.ui.screens.iot.IoTScreen
import top.yeij.cyrene.ui.screens.login.LoginScreen import top.yeij.cyrene.ui.screens.login.LoginScreen
import top.yeij.cyrene.ui.screens.about.AboutScreen import top.yeij.cyrene.ui.screens.about.AboutScreen
import top.yeij.cyrene.ui.screens.profile.ProfileScreen import top.yeij.cyrene.ui.screens.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.KeepAlivePage
import top.yeij.cyrene.ui.screens.settings.SettingsScreen import top.yeij.cyrene.ui.screens.settings.SettingsScreen
object Routes { object Routes {
@@ -38,6 +40,7 @@ object Routes {
const val IOT = "iot" const val IOT = "iot"
const val SETTINGS = "settings" const val SETTINGS = "settings"
const val ABOUT = "about" const val ABOUT = "about"
const val KEEP_ALIVE = "keep_alive"
} }
@Composable @Composable
@@ -72,6 +75,13 @@ fun CyreneNavGraph(
composable(Routes.SETTINGS) { composable(Routes.SETTINGS) {
SettingsScreen( SettingsScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
)
}
composable(Routes.KEEP_ALIVE) {
KeepAlivePage(
onBack = { navController.popBackStack() },
) )
} }
@@ -137,10 +147,28 @@ fun MainScreen(
.fillMaxHeight() .fillMaxHeight()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) { ) {
when (selectedTab) { // Keep all tabs composed to avoid destroying ChatScreen on tab switch.
0 -> ChatScreen() // Hidden tabs use graphicsLayer { alpha = 0f } — invisible but alive.
1 -> IoTScreen() Box(
2 -> ProfileScreen( modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = if (selectedTab == 0) 1f else 0f },
) {
ChatScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = if (selectedTab == 1) 1f else 0f },
) {
IoTScreen()
}
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = if (selectedTab == 2) 1f else 0f },
) {
ProfileScreen(
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) }, onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
onNavigateToAbout = { navController.navigate(Routes.ABOUT) }, onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
onLogout = { onLogout = {
@@ -1,6 +1,7 @@
package top.yeij.cyrene.ui.overlay package top.yeij.cyrene.ui.overlay
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
@@ -17,7 +18,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -41,9 +41,11 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -54,6 +56,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -188,6 +191,7 @@ fun OverlayContent(
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
viewModel = viewModel, viewModel = viewModel,
navBarHeightPx = navBarHeight,
) )
} else { } else {
PortraitContent( PortraitContent(
@@ -204,6 +208,7 @@ fun OverlayContent(
onDismiss = onDismiss, onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain, onNavigateToMain = onNavigateToMain,
viewModel = viewModel, viewModel = viewModel,
navBarHeightPx = navBarHeight,
) )
} }
} }
@@ -226,6 +231,7 @@ private fun PortraitContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel, viewModel: OverlayViewModel,
navBarHeightPx: Int,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -261,15 +267,14 @@ private fun PortraitContent(
} }
} }
// Input area at bottom, imePadding pushes it above full-screen IME // Input area at bottom; system adjust=pan handles IME offset
InputArea( InputArea(
state = state, state = state,
inputText = inputText, inputText = inputText,
viewModel = viewModel, viewModel = viewModel,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth(),
.imePadding(),
recordSec = recordSec, recordSec = recordSec,
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
@@ -294,6 +299,7 @@ private fun LandscapeContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onNavigateToMain: () -> Unit, onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel, viewModel: OverlayViewModel,
navBarHeightPx: Int,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -346,8 +352,7 @@ private fun LandscapeContent(
inputText = inputText, inputText = inputText,
viewModel = viewModel, viewModel = viewModel,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.imePadding(),
recordSec = recordSec, recordSec = recordSec,
isRecording = isRecording, isRecording = isRecording,
isLocked = isLocked, isLocked = isLocked,
@@ -406,7 +411,7 @@ private fun InputArea(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
shadowElevation = 8.dp, shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -11,13 +11,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -32,16 +34,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -102,8 +105,19 @@ fun ChatScreen(
val recordState by viewModel.voiceRecordState.collectAsState() val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState() val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState() val animIndex by viewModel.messageAnimIndex.collectAsState()
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
val listState = rememberLazyListState() val listState = rememberLazyListState()
// Track whether user is near the latest messages (visual bottom = index 0)
val isNearBottom by remember {
derivedStateOf {
val info = listState.layoutInfo
if (info.totalItemsCount == 0) return@derivedStateOf true
(info.visibleItemsInfo.firstOrNull()?.index ?: 0) <= 2
}
}
// Gesture tracking state // Gesture tracking state
var isDragging by remember { mutableStateOf(false) } var isDragging by remember { mutableStateOf(false) }
var dragOffsetX by remember { mutableStateOf(0f) } var dragOffsetX by remember { mutableStateOf(0f) }
@@ -116,11 +130,14 @@ fun ChatScreen(
val inCancelZone = isDragging && dragOffsetY < -120f val inCancelZone = isDragging && dragOffsetY < -120f
val inLockZone = isDragging && dragOffsetX > 60f val inLockZone = isDragging && dragOffsetX > 60f
LaunchedEffect(messages.size, isStreaming) { // Stay at bottom for new messages unless user scrolled up
if (messages.isNotEmpty()) { LaunchedEffect(Unit) {
val targetIndex = if (isStreaming) messages.size else messages.size - 1 snapshotFlow { messages.size to isNearBottom }
listState.animateScrollToItem(targetIndex) .collect { (_, nearBottom) ->
} if (nearBottom && listState.firstVisibleItemIndex != 0) {
listState.animateScrollToItem(0)
}
}
} }
// Animated "昔涟正在输入..." dots // Animated "昔涟正在输入..." dots
@@ -145,8 +162,14 @@ fun ChatScreen(
else -> CyreneStatus.OFFLINE else -> CyreneStatus.OFFLINE
} }
Scaffold( // Input area overlaid at bottom, with IME padding so only input moves up
topBar = { Box(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding(),
) {
Column(modifier = Modifier.fillMaxSize()) {
// Top status bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -155,212 +178,211 @@ fun ChatScreen(
) { ) {
StatusIndicator(status = status) StatusIndicator(status = status)
} }
},
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
) {
// "昔涟正在输入..." indicator
if (isStreaming) {
Text(
text = "昔涟正在输入${typingDots.value}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 2.dp),
textAlign = TextAlign.Start,
)
}
Row( // Messages area (fills space above input area)
modifier = Modifier PullToRefreshBox(
.fillMaxWidth() isRefreshing = isRefreshing,
.padding(horizontal = 12.dp, vertical = 8.dp), onRefresh = { viewModel.refreshMessages() },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
) { .weight(1f)
if (isRecording && isDragging) { .padding(bottom = 96.dp), // Reserve space for floating input bar
// Recording state with drag — show recording indicator ) {
Box( if (messages.isEmpty() && !isStreaming) {
modifier = Modifier Box(
.weight(1f) modifier = Modifier.fillMaxSize(),
.clip(RoundedCornerShape(12.dp)) contentAlignment = Alignment.Center,
.background( ) {
if (inCancelZone) MaterialTheme.colorScheme.errorContainer Text(
else MaterialTheme.colorScheme.surfaceVariant text = "开始和昔涟对话吧",
) style = MaterialTheme.typography.bodyLarge,
.padding(horizontal = 16.dp, vertical = 14.dp), color = MaterialTheme.colorScheme.onSurfaceVariant,
contentAlignment = Alignment.Center,
) {
Text(
text = when {
inCancelZone -> "松手取消"
inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Record button (drag anchor)
Box(
modifier = Modifier
.padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
// Locked (hands-free) mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
// Normal input mode
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 4,
shape = MaterialTheme.shapes.medium,
) )
// Voice record button with long-press gesture }
Box( } else {
modifier = Modifier LazyColumn(
.padding(start = 4.dp) modifier = Modifier.fillMaxSize(),
.size(48.dp) state = listState,
.onGloballyPositioned { recordButtonY = it.positionInRoot().y } reverseLayout = true,
.pointerInput(Unit) { ) {
detectDragGesturesAfterLongPress( itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
onDragStart = { offset -> AnimatedChatBubble(
isDragging = true message = message,
dragOffsetX = 0f animIndex = index.coerceAtMost(20),
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardVoice,
contentDescription = "按住录音",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
// Send button (only when text present) if (isStreaming) {
if (inputText.isNotBlank()) { item(key = "typing_indicator") {
IconButton( TypingIndicator()
onClick = { viewModel.sendMessage() },
enabled = !isStreaming,
) {
if (isStreaming) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
}
} }
} }
} }
} }
} }
}, }
) { padding ->
PullToRefreshBox( // Input area at bottom, moved up by IME
isRefreshing = isRefreshing, Column(
onRefresh = { viewModel.refreshMessages() },
modifier = Modifier modifier = Modifier
.fillMaxSize() .align(Alignment.BottomCenter)
.padding(padding), .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding()
.imePadding(),
) { ) {
if (messages.isEmpty() && !isStreaming) { // "昔涟正在输入..." indicator
Box( if (isStreaming) {
modifier = Modifier.fillMaxSize(), Text(
contentAlignment = Alignment.Center, text = "昔涟正在输入${typingDots.value}",
) { style = MaterialTheme.typography.labelSmall,
Text( color = MaterialTheme.colorScheme.primary,
text = "开始和昔涟对话吧", modifier = Modifier
style = MaterialTheme.typography.bodyLarge, .fillMaxWidth()
color = MaterialTheme.colorScheme.onSurfaceVariant, .padding(horizontal = 16.dp, vertical = 2.dp),
) textAlign = TextAlign.Start,
} )
} else { }
LazyColumn(
modifier = Modifier.fillMaxSize(), Row(
state = listState, modifier = Modifier
) { .fillMaxWidth()
items(messages, key = { it.id }) { message -> .padding(horizontal = 12.dp, vertical = 8.dp),
AnimatedChatBubble( verticalAlignment = Alignment.CenterVertically,
message = message, ) {
animIndex = animIndex[message.id] ?: 0, if (isRecording && isDragging) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(
if (inCancelZone) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = when {
inCancelZone -> "松手取消"
inLockZone -> "松手录音"
else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
},
style = MaterialTheme.typography.bodyMedium,
color = if (inCancelZone) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (isStreaming) { Box(
item(key = "typing_indicator") { modifier = Modifier
TypingIndicator() .padding(start = 8.dp)
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Mic,
contentDescription = "录音中",
tint = MaterialTheme.colorScheme.onPrimary,
)
}
} else if (isLocked) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 16.dp, vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
IconButton(onClick = { viewModel.finishRecord() }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "发送",
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
OutlinedTextField(
value = inputText,
onValueChange = { viewModel.onInputChanged(it) },
placeholder = { Text("输入消息...") },
modifier = Modifier.weight(1f),
maxLines = 4,
shape = MaterialTheme.shapes.medium,
)
Box(
modifier = Modifier
.padding(start = 4.dp)
.size(48.dp)
.onGloballyPositioned { recordButtonY = it.positionInRoot().y }
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
isDragging = true
dragOffsetX = 0f
dragOffsetY = 0f
viewModel.startRecord()
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetX += dragAmount.x
dragOffsetY += dragAmount.y
},
onDragEnd = {
isDragging = false
when {
dragOffsetY < -120f -> viewModel.cancelRecord()
dragOffsetX > 60f -> viewModel.lockRecord()
else -> viewModel.finishRecord()
}
dragOffsetX = 0f
dragOffsetY = 0f
},
onDragCancel = {
isDragging = false
viewModel.cancelRecord()
dragOffsetX = 0f
dragOffsetY = 0f
},
)
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardVoice,
contentDescription = "按住录音",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (inputText.isNotBlank()) {
IconButton(
onClick = { viewModel.sendMessage() },
enabled = !isStreaming,
) {
if (isStreaming) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
}
} }
} }
} }
@@ -0,0 +1,377 @@
package top.yeij.cyrene.ui.screens.settings
import android.app.Activity
import android.content.Intent
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BatterySaver
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.util.KeepAliveManager
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeepAlivePage(
onBack: () -> Unit,
) {
val context = LocalContext.current
val keepAliveManager = KeepAliveManager(context)
val fgRunning = WebSocketKeepAliveService.isRunning
val batteryExempt = keepAliveManager.isBatteryOptimizationExempt()
val canOverlay = keepAliveManager.canDrawOverlays()
val manufacturerName = keepAliveManager.getManufacturerName()
val batteryLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) {
// Re-check battery optimization after returning from settings
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("后台保活") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
// Header explanation
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
Icon(
Icons.Filled.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Android 系统会在应用进入后台后限制网络连接或终止进程,导致无法接收服务端主动推送的消息。请按照以下方法加强后台保活能力。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "保活方式",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// 1. Foreground Service
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Notifications,
contentDescription = null,
tint = if (fgRunning) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "前台服务通知",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (fgRunning) "已开启,通知栏显示「昔涟 — 已连接」" else "切后台时显示持久通知保活"
)
}
Switch(
checked = fgRunning,
onCheckedChange = {
if (it) {
WebSocketKeepAliveService.start(context)
} else {
WebSocketKeepAliveService.stop(context)
}
},
)
}
}
// 2. Battery Optimization
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.CheckCircle,
contentDescription = null,
tint = if (batteryExempt) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "忽略电池优化",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = if (batteryExempt) "已免除,Doze 模式不会限制网络"
else "未免除,后台待久会被系统限制网络(Doze 休眠)"
)
}
if (!batteryExempt) {
TextButton(onClick = {
batteryLauncher.launch(
keepAliveManager.openBatteryOptimizationSettings()
)
}) {
Text("去设置")
}
}
}
}
// 3. Auto-start (OEM-specific)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "自启动管理",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = when (manufacturerName) {
"xiaomi" -> "小米手机请在「安全中心 → 自启动管理」中允许昔涟自启动"
"huawei" -> "华为手机请在「手机管家 → 自启动管理」中允许昔涟自启动"
"oppo" -> "OPPO 手机请在「设置 → 应用自启动」中允许昔涟自启动"
"vivo" -> "vivo 手机请在「i管家 → 自启动」中允许昔涟自启动"
"oneplus" -> "一加手机请在「设置 → 自启动」中允许昔涟自启动"
"samsung" -> "三星手机请在「设置 → 电池 → 不受限制的应用」中添加昔涟"
else -> "请在系统设置中为昔涟开启「自启动/后台运行」权限"
}
)
}
TextButton(onClick = {
val intent = keepAliveManager.getAutoStartIntent()
if (intent != null) {
try {
context.startActivity(intent)
} catch (_: Exception) {
// Fallback to app info
try {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
} catch (_: Exception) { }
}
} else {
Toast.makeText(context, "未找到对应设置页面,请手动前往系统设置", Toast.LENGTH_LONG).show()
}
}) {
Text("去设置")
}
}
}
// 4. Lock task (recent apps lock)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.Security,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "锁定后台任务",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "进入最近任务界面(多任务键),将昔涟卡片下拉锁定,防止系统清理后台时误杀"
)
}
}
}
// 5. Battery saver passthrough
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.BatterySaver,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "电池优化白名单",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
Text(
text = "手动确认系统电池优化白名单,确保昔涟不被限制"
)
}
TextButton(onClick = {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
try {
context.startActivity(intent)
} catch (_: Exception) { }
}) {
Text("查看")
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "补充提示",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
Text(
text = """
不同的手机厂商对待后台应用的方式各不相同:
• 谷歌 Pixel / 原生 Android:开启电池优化豁免即可
• 小米 MIUI / HyperOS:需同时开启自启动 + 电池无限制
• 华为 HarmonyOS:需开启自启动 + 关闭省电模式限制
• OPPO ColorOS / vivo OriginOS:需开启自启动 + 后台运行
• 三星 OneUI:需添加到「不受限制的应用」列表
实际效果因系统版本和厂商策略而异。建议至少开启「前台服务通知」+「忽略电池优化」两项。
""".trimIndent(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.SettingsBrightness import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -68,6 +69,7 @@ import top.yeij.cyrene.viewmodel.SettingsViewModel
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToKeepAlive: () -> Unit = {},
viewModel: SettingsViewModel = koinInject(), viewModel: SettingsViewModel = koinInject(),
) { ) {
val baseUrl by viewModel.baseUrl.collectAsState() val baseUrl by viewModel.baseUrl.collectAsState()
@@ -464,6 +466,24 @@ fun SettingsScreen(
) )
} }
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Keep-alive
Text(
text = "后台保活",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(16.dp),
)
ListItem(
headlineContent = { Text("保活设置") },
supportingContent = { Text("前台服务、电池优化、自启动等保活方式") },
leadingContent = { Icon(Icons.Filled.Security, contentDescription = null) },
modifier = Modifier.clickable { onNavigateToKeepAlive() },
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider() HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -81,13 +81,18 @@ fun CyreneTheme(
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as? Activity)?.window
window.statusBarColor = colorScheme.background.toArgb() if (window != null) {
window.navigationBarColor = colorScheme.background.toArgb() window.statusBarColor = colorScheme.background.toArgb()
window.decorView.setBackgroundColor(colorScheme.background.toArgb()) window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).apply { window.decorView.setBackgroundColor(colorScheme.background.toArgb())
isAppearanceLightStatusBars = !darkTheme WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightNavigationBars = !darkTheme isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
} else {
// Non-Activity context (e.g. VoiceInteractionSession overlay) — transparent
view.rootView?.setBackgroundColor(android.graphics.Color.TRANSPARENT)
} }
} }
} }
@@ -0,0 +1,149 @@
package top.yeij.cyrene.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import top.yeij.cyrene.service.WebSocketKeepAliveService
class KeepAliveManager(private val context: Context) {
// --- 前台服务 ---
val isForegroundServiceRunning: Boolean
get() = WebSocketKeepAliveService.isRunning
// --- 电池优化 ---
fun isBatteryOptimizationExempt(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return pm.isIgnoringBatteryOptimizations(context.packageName)
}
fun openBatteryOptimizationSettings(): Intent {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
}
}
}
// --- 自启动 / 后台管理 (OEM-specific) ---
fun getAutoStartIntent(): Intent? {
val packageName = context.packageName
val manufacturers = listOf(
// Xiaomi
AutoStartIntent("xiaomi", Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity"
)
}),
AutoStartIntent("xiaomi", Intent().apply {
component = ComponentName(
"com.miui.securitycenter",
"com.miui.appmanager.ApplicationsManagerActivity"
)
}),
// Huawei
AutoStartIntent("huawei", Intent().apply {
component = ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
)
}),
AutoStartIntent("huawei", Intent().apply {
component = ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.optimize.process.ProtectActivity"
)
}),
// Oppo
AutoStartIntent("oppo", Intent().apply {
component = ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.permission.startup.StartupAppListActivity"
)
}),
AutoStartIntent("oppo", Intent().apply {
component = ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.permission.startup.FakeActivity"
)
}),
// Vivo
AutoStartIntent("vivo", Intent().apply {
component = ComponentName(
"com.vivo.permissionmanager",
"com.vivo.permissionmanager.activity.BgStartUpManagerActivity"
)
}),
AutoStartIntent("vivo", Intent().apply {
component = ComponentName(
"com.iqoo.secure",
"com.iqoo.secure.ui.phoneoptimize.AddWhiteListActivity"
)
}),
// Samsung
AutoStartIntent("samsung", Intent().apply {
component = ComponentName(
"com.samsung.android.lool",
"com.samsung.android.sm.ui.battery.BatteryActivity"
)
}),
// OnePlus
AutoStartIntent("oneplus", Intent().apply {
component = ComponentName(
"com.oneplus.security",
"com.oneplus.security.chainlaunch.view.ChainLaunchAppListActivity"
)
}),
// Generic fallback: app info
AutoStartIntent("generic", Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName")
)),
)
for (entry in manufacturers) {
val intent = entry.intent
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (intent.resolveActivity(context.packageManager) != null) {
return intent
}
}
return null
}
fun getManufacturerName(): String {
return Build.MANUFACTURER.lowercase()
}
private data class AutoStartIntent(val manufacturer: String, val intent: Intent)
// --- 悬浮窗权限 (optional, for overlay mode) ---
fun canDrawOverlays(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Settings.canDrawOverlays(context)
} else {
true
}
}
fun openOverlaySettings(): Intent {
return Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
}
}
@@ -20,17 +20,8 @@ import top.yeij.cyrene.util.VoiceRecorder
private fun List<Message>.deduplicate(): List<Message> { private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this if (isEmpty()) return this
val result = mutableListOf(this[0]) val seen = mutableSetOf<String>()
for (i in 1 until size) { return filter { seen.add(it.id) }
val prev = result.last()
val curr = this[i]
val isDuplicate = curr.id == prev.id ||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
if (!isDuplicate) {
result.add(curr)
}
}
return result
} }
private fun List<Message>.removeWrappingDuplicates(): List<Message> { private fun List<Message>.removeWrappingDuplicates(): List<Message> {
@@ -93,29 +84,19 @@ class ChatViewModel(
private var dbObserverJob: Job? = null private var dbObserverJob: Job? = null
init { init {
// Phase 1: find/create main session, reconnect WS, load server history // Phase 1: find/create main session, reconnect WS, load server history into DB, then observe DB
viewModelScope.launch { viewModelScope.launch {
try { try {
val sessionId = chatRepository.initializeSession() val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId currentSessionId = sessionId
chatRepository.currentSessionId = sessionId chatRepository.currentSessionId = sessionId
chatRepository.ensureConnected() chatRepository.ensureConnected()
loadMessagesFromDb(sessionId) chatRepository.loadMessagesFromServer(sessionId)
val serverMessages = chatRepository.loadMessagesFromServer(sessionId)
if (serverMessages.isNotEmpty()) {
val serverIds = serverMessages.map { it.id }.toSet()
_currentMessages.update { current ->
val localOnly = current.filter { it.id !in serverIds }
(serverMessages + localOnly)
.sortedBy { it.timestamp }
.deduplicate()
.removeWrappingDuplicates()
}
}
} catch (_: Exception) { } } catch (_: Exception) { }
loadMessagesFromDb(currentSessionId ?: return@launch)
} }
// Observe incoming live messages with atomic dedup // Observe incoming live messages — insert at correct descending position
viewModelScope.launch { viewModelScope.launch {
chatRepository.observeMessages().collect { message -> chatRepository.observeMessages().collect { message ->
try { try {
@@ -125,15 +106,12 @@ class ChatViewModel(
if (existingIdx >= 0) { if (existingIdx >= 0) {
updated[existingIdx] = message updated[existingIdx] = message
} else { } else {
val isDup = updated.any { // Insert at correct position for descending timestamp (newest first)
it.role == message.role && it.content == message.content && it.msgType == message.msgType val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
} if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
if (!isDup) { val idx = _messageAnimIndex.value.toMutableMap()
updated.add(message) idx[message.id] = animCounter++
val idx = _messageAnimIndex.value.toMutableMap() _messageAnimIndex.value = idx
idx[message.id] = animCounter++
_messageAnimIndex.value = idx
}
} }
updated.deduplicate() updated.deduplicate()
} }
@@ -151,6 +129,15 @@ class ChatViewModel(
animCounter = 0 animCounter = 0
} }
} }
// Observe message removals (e.g. wrapping stream_end deduped by review items)
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_currentMessages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
// Reset user-side sending state when server starts responding // Reset user-side sending state when server starts responding
viewModelScope.launch { viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming -> chatRepository.isAssistantStreaming.collect { streaming ->
@@ -194,7 +181,7 @@ class ChatViewModel(
val live = current.associateBy { it.id } val live = current.associateBy { it.id }
val db = messages.associateBy { it.id } val db = messages.associateBy { it.id }
(db + live).values (db + live).values
.sortedBy { it.timestamp } .sortedByDescending { it.timestamp }
.deduplicate() .deduplicate()
.removeWrappingDuplicates() .removeWrappingDuplicates()
} }
@@ -236,8 +223,8 @@ class ChatViewModel(
viewModelScope.launch { viewModelScope.launch {
chatRepository.connectWebSocket(sessionId) chatRepository.connectWebSocket(sessionId)
chatRepository.loadMessagesFromServer(sessionId) chatRepository.loadMessagesFromServer(sessionId)
loadMessagesFromDb(sessionId)
} }
loadMessagesFromDb(sessionId)
} }
fun refreshMessages() { fun refreshMessages() {
@@ -248,17 +235,7 @@ class ChatViewModel(
if (!isConnected.value) { if (!isConnected.value) {
chatRepository.ensureConnected() chatRepository.ensureConnected()
} }
val serverMessages = chatRepository.loadMessagesFromServer(sid) chatRepository.loadMessagesFromServer(sid)
if (serverMessages.isNotEmpty()) {
val serverIds = serverMessages.map { it.id }.toSet()
_currentMessages.update { current ->
val localOnly = current.filter { it.id !in serverIds }
(serverMessages + localOnly)
.sortedBy { it.timestamp }
.deduplicate()
.removeWrappingDuplicates()
}
}
} catch (_: Exception) { } } catch (_: Exception) { }
_isRefreshing.value = false _isRefreshing.value = false
} }
@@ -19,17 +19,8 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
private fun List<Message>.deduplicate(): List<Message> { private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this if (isEmpty()) return this
val result = mutableListOf(this[0]) val seen = mutableSetOf<String>()
for (i in 1 until size) { return filter { seen.add(it.id) }
val prev = result.last()
val curr = this[i]
val isDuplicate = curr.id == prev.id ||
(curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
if (!isDuplicate) {
result.add(curr)
}
}
return result
} }
private fun List<Message>.removeWrappingDuplicates(): List<Message> { private fun List<Message>.removeWrappingDuplicates(): List<Message> {
@@ -114,6 +105,16 @@ class OverlayViewModel(
} }
} }
} }
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
if (!streaming && _state.value == OverlayState.PROCESSING) {
delay(500)
if (_state.value == OverlayState.PROCESSING) {
setWaiting()
}
}
}
}
viewModelScope.launch { viewModelScope.launch {
ttsEngine.onDone.collect { ttsEngine.onDone.collect {
if (_state.value == OverlayState.SPEAKING) { if (_state.value == OverlayState.SPEAKING) {
@@ -128,6 +129,14 @@ class OverlayViewModel(
animCounter = 0 animCounter = 0
} }
} }
viewModelScope.launch {
chatRepository.messageRemovals.collect { msgId ->
_messages.update { list -> list.filter { it.id != msgId } }
val idx = _messageAnimIndex.value.toMutableMap()
idx.remove(msgId)
_messageAnimIndex.value = idx
}
}
} }
fun onInputChanged(text: String) { fun onInputChanged(text: String) {