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
+8
View File
@@ -14,6 +14,7 @@
<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_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- 推送 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -42,6 +43,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Cyrene">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -83,6 +85,12 @@
android:resource="@xml/accessibility_config" />
</service>
<!-- WebSocket 后台保活 -->
<service
android:name=".service.WebSocketKeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- FileProvider:日志分享 -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -89,7 +89,7 @@ class CyreneApplication : Application() {
private fun getRepo(): ChatRepositoryImpl? {
return try {
GlobalContext.get().get()
} catch (_: Exception) {
} catch (_: Throwable) {
null
}
}
@@ -1,5 +1,6 @@
package top.yeij.cyrene.data.repository
import android.app.Application
import android.util.Log
import kotlinx.coroutines.CoroutineExceptionHandler
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.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.WebSocketKeepAliveService
import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID
class ChatRepositoryImpl(
private val app: Application,
private val conversationDao: ConversationDao,
private val messageDao: MessageDao,
private val webSocketService: WebSocketService,
@@ -54,6 +57,9 @@ class ChatRepositoryImpl(
private val _messageClearEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
override val messageClearEvents: Flow<Unit> = _messageClearEvents
private val _messageRemovals = MutableSharedFlow<String>(extraBufferCapacity = 16)
override val messageRemovals: Flow<String> = _messageRemovals
private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow<Boolean> = _isAssistantStreaming.asStateFlow()
@@ -81,19 +87,22 @@ class ChatRepositoryImpl(
override fun onAppForeground() {
isAppInForeground = true
notifiedMessageIds.clear()
if (!_connectionState.value) {
webSocketService.forceReconnect()
}
// Always request history on foreground to catch cross-device messages
WebSocketKeepAliveService.stop(app)
// Always reconnect and sync history when returning to foreground
webSocketService.forceReconnect()
scope.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)
}
}
override fun onAppBackground() {
isAppInForeground = false
if (_connectionState.value) {
WebSocketKeepAliveService.start(app)
RuntimeLog.general("app", "Started keep-alive service for background")
}
}
init {
@@ -148,17 +157,6 @@ class ChatRepositoryImpl(
messageDao.deleteAll()
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)
RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
@@ -378,6 +376,7 @@ class ChatRepositoryImpl(
streamingContent = ""
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
_isAssistantStreaming.value = true
recentParsedContents.clear()
RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
}
@@ -405,6 +404,19 @@ class ChatRepositoryImpl(
}
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()) {
ensureConversation(sid, content)
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)
_isAssistantStreaming.value = false
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
@@ -616,7 +632,8 @@ class ChatRepositoryImpl(
val allContained = recentParsedContents.all { respContent.contains(it) }
if (allContained) {
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
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()) }
// UseCases
@@ -11,6 +11,7 @@ interface ChatRepository {
val connectionError: StateFlow<String?>
val isAssistantStreaming: StateFlow<Boolean>
val messageClearEvents: Flow<Unit>
val messageRemovals: Flow<String>
var currentSessionId: String?
fun getConversations(): Flow<List<Conversation>>
@@ -48,7 +48,7 @@ class CyreneVoiceInteractionSession(context: Context) :
private fun resolveViewModel(): OverlayViewModel? {
return try {
GlobalContext.get().get<OverlayViewModel>()
} catch (e: Exception) {
} catch (e: Throwable) {
Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
null
}
@@ -65,9 +65,17 @@ class CyreneVoiceInteractionSession(context: Context) :
lifecycleRegistry.currentState = Lifecycle.State.CREATED
val vm = overlayViewModel
val session = this@CyreneVoiceInteractionSession
return ComposeView(context).apply {
setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
// Configure window as soon as view is attached — before system overrides flags
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
session.configureWindow()
}
override fun onViewDetachedFromWindow(v: View) {}
})
setViewTreeLifecycleOwner(session)
setViewTreeSavedStateRegistryOwner(session)
setContent {
CyreneTheme {
if (vm != null) {
@@ -92,14 +100,22 @@ class CyreneVoiceInteractionSession(context: Context) :
RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
lifecycleRegistry.currentState = Lifecycle.State.STARTED
// Configure window: extend behind status bar, don't resize for IME
configureWindow()
// Defer window config — system may override softInputMode after onShow returns
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)
val autoScreenContext = try {
val prefs: PreferencesDataStore = GlobalContext.get().get()
runBlocking { prefs.autoScreenContext.firstOrNull() } ?: false
} catch (_: Exception) {
} catch (_: Throwable) {
false
}
if (autoScreenContext) {
@@ -111,16 +127,18 @@ class CyreneVoiceInteractionSession(context: Context) :
}
}
private fun configureWindow() {
fun configureWindow() {
try {
val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
method.isAccessible = true
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_NAVIGATION)
w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
Log.d(TAG, "Window configured: translucent status/nav, adjust nothing for IME")
} catch (e: Exception) {
Log.d(TAG, "Window configured: transparent bg, translucent status/nav")
} catch (e: Throwable) {
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 = null
scope.launch {
if (!_isConnected.value) {
try {
connect(currentSessionId)
} catch (_: Exception) { }
}
try {
// Close existing socket directly without resetting shouldReconnect
cancelHeartbeat()
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
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.about.AboutScreen
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.KeepAlivePage
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
object Routes {
@@ -38,6 +40,7 @@ object Routes {
const val IOT = "iot"
const val SETTINGS = "settings"
const val ABOUT = "about"
const val KEEP_ALIVE = "keep_alive"
}
@Composable
@@ -72,6 +75,13 @@ fun CyreneNavGraph(
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack() },
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
)
}
composable(Routes.KEEP_ALIVE) {
KeepAlivePage(
onBack = { navController.popBackStack() },
)
}
@@ -137,10 +147,28 @@ fun MainScreen(
.fillMaxHeight()
.background(MaterialTheme.colorScheme.background),
) {
when (selectedTab) {
0 -> ChatScreen()
1 -> IoTScreen()
2 -> ProfileScreen(
// Keep all tabs composed to avoid destroying ChatScreen on tab switch.
// Hidden tabs use graphicsLayer { alpha = 0f } — invisible but alive.
Box(
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) },
onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
onLogout = {
@@ -1,6 +1,7 @@
package top.yeij.cyrene.ui.overlay
import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -41,9 +41,11 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
@@ -188,6 +191,7 @@ fun OverlayContent(
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
navBarHeightPx = navBarHeight,
)
} else {
PortraitContent(
@@ -204,6 +208,7 @@ fun OverlayContent(
onDismiss = onDismiss,
onNavigateToMain = onNavigateToMain,
viewModel = viewModel,
navBarHeightPx = navBarHeight,
)
}
}
@@ -226,6 +231,7 @@ private fun PortraitContent(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
navBarHeightPx: Int,
) {
Box(
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(
state = state,
inputText = inputText,
viewModel = viewModel,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding(),
.fillMaxWidth(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
@@ -294,6 +299,7 @@ private fun LandscapeContent(
onDismiss: () -> Unit,
onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel,
navBarHeightPx: Int,
) {
Row(
modifier = Modifier
@@ -346,8 +352,7 @@ private fun LandscapeContent(
inputText = inputText,
viewModel = viewModel,
modifier = Modifier
.fillMaxWidth()
.imePadding(),
.fillMaxWidth(),
recordSec = recordSec,
isRecording = isRecording,
isLocked = isLocked,
@@ -406,7 +411,7 @@ private fun InputArea(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
shadowElevation = 8.dp,
color = MaterialTheme.colorScheme.surface,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
) {
Column(
modifier = Modifier
@@ -11,13 +11,15 @@ 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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -32,16 +34,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -102,8 +105,19 @@ fun ChatScreen(
val recordState by viewModel.voiceRecordState.collectAsState()
val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
val animIndex by viewModel.messageAnimIndex.collectAsState()
// reverseLayout: index 0 = newest (visual bottom), index N-1 = oldest (visual top)
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
var isDragging by remember { mutableStateOf(false) }
var dragOffsetX by remember { mutableStateOf(0f) }
@@ -116,11 +130,14 @@ fun ChatScreen(
val inCancelZone = isDragging && dragOffsetY < -120f
val inLockZone = isDragging && dragOffsetX > 60f
LaunchedEffect(messages.size, isStreaming) {
if (messages.isNotEmpty()) {
val targetIndex = if (isStreaming) messages.size else messages.size - 1
listState.animateScrollToItem(targetIndex)
}
// Stay at bottom for new messages unless user scrolled up
LaunchedEffect(Unit) {
snapshotFlow { messages.size to isNearBottom }
.collect { (_, nearBottom) ->
if (nearBottom && listState.firstVisibleItemIndex != 0) {
listState.animateScrollToItem(0)
}
}
}
// Animated "昔涟正在输入..." dots
@@ -145,8 +162,14 @@ fun ChatScreen(
else -> CyreneStatus.OFFLINE
}
Scaffold(
topBar = {
// Input area overlaid at bottom, with IME padding so only input moves up
Box(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding(),
) {
Column(modifier = Modifier.fillMaxSize()) {
// Top status bar
Row(
modifier = Modifier
.fillMaxWidth()
@@ -155,212 +178,211 @@ fun ChatScreen(
) {
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(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isRecording && isDragging) {
// Recording state with drag — show recording indicator
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,
)
}
// 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,
// Messages area (fills space above input area)
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { viewModel.refreshMessages() },
modifier = Modifier
.weight(1f)
.padding(bottom = 96.dp), // Reserve space for floating input bar
) {
if (messages.isEmpty() && !isStreaming) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "开始和昔涟对话吧",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Voice record button with long-press gesture
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,
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
) {
itemsIndexed(messages, key = { _, msg -> msg.id }) { index, message ->
AnimatedChatBubble(
message = message,
animIndex = index.coerceAtMost(20),
)
}
// Send button (only when text present)
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 = "发送")
}
if (isStreaming) {
item(key = "typing_indicator") {
TypingIndicator()
}
}
}
}
}
},
) { padding ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { viewModel.refreshMessages() },
}
// Input area at bottom, moved up by IME
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.navigationBarsPadding()
.imePadding(),
) {
if (messages.isEmpty() && !isStreaming) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "开始和昔涟对话吧",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
) {
items(messages, key = { it.id }) { message ->
AnimatedChatBubble(
message = message,
animIndex = animIndex[message.id] ?: 0,
// "昔涟正在输入..." 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(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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) {
item(key = "typing_indicator") {
TypingIndicator()
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) {
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.Palette
import androidx.compose.material.icons.filled.SettingsBrightness
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
@@ -68,6 +69,7 @@ import top.yeij.cyrene.viewmodel.SettingsViewModel
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onNavigateToKeepAlive: () -> Unit = {},
viewModel: SettingsViewModel = koinInject(),
) {
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))
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
@@ -81,13 +81,18 @@ fun CyreneTheme(
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
val window = (view.context as? Activity)?.window
if (window != null) {
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
window.decorView.setBackgroundColor(colorScheme.background.toArgb())
WindowCompat.getInsetsController(window, view).apply {
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> {
if (isEmpty()) return this
val result = mutableListOf(this[0])
for (i in 1 until size) {
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
val seen = mutableSetOf<String>()
return filter { seen.add(it.id) }
}
private fun List<Message>.removeWrappingDuplicates(): List<Message> {
@@ -93,29 +84,19 @@ class ChatViewModel(
private var dbObserverJob: Job? = null
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 {
try {
val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
chatRepository.ensureConnected()
loadMessagesFromDb(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()
}
}
chatRepository.loadMessagesFromServer(sessionId)
} catch (_: Exception) { }
loadMessagesFromDb(currentSessionId ?: return@launch)
}
// Observe incoming live messages with atomic dedup
// Observe incoming live messages — insert at correct descending position
viewModelScope.launch {
chatRepository.observeMessages().collect { message ->
try {
@@ -125,15 +106,12 @@ class ChatViewModel(
if (existingIdx >= 0) {
updated[existingIdx] = message
} else {
val isDup = updated.any {
it.role == message.role && it.content == message.content && it.msgType == message.msgType
}
if (!isDup) {
updated.add(message)
val idx = _messageAnimIndex.value.toMutableMap()
idx[message.id] = animCounter++
_messageAnimIndex.value = idx
}
// Insert at correct position for descending timestamp (newest first)
val insertAt = updated.indexOfFirst { it.timestamp <= message.timestamp }
if (insertAt >= 0) updated.add(insertAt, message) else updated.add(message)
val idx = _messageAnimIndex.value.toMutableMap()
idx[message.id] = animCounter++
_messageAnimIndex.value = idx
}
updated.deduplicate()
}
@@ -151,6 +129,15 @@ class ChatViewModel(
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
viewModelScope.launch {
chatRepository.isAssistantStreaming.collect { streaming ->
@@ -194,7 +181,7 @@ class ChatViewModel(
val live = current.associateBy { it.id }
val db = messages.associateBy { it.id }
(db + live).values
.sortedBy { it.timestamp }
.sortedByDescending { it.timestamp }
.deduplicate()
.removeWrappingDuplicates()
}
@@ -236,8 +223,8 @@ class ChatViewModel(
viewModelScope.launch {
chatRepository.connectWebSocket(sessionId)
chatRepository.loadMessagesFromServer(sessionId)
loadMessagesFromDb(sessionId)
}
loadMessagesFromDb(sessionId)
}
fun refreshMessages() {
@@ -248,17 +235,7 @@ class ChatViewModel(
if (!isConnected.value) {
chatRepository.ensureConnected()
}
val serverMessages = 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()
}
}
chatRepository.loadMessagesFromServer(sid)
} catch (_: Exception) { }
_isRefreshing.value = false
}
@@ -19,17 +19,8 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
private fun List<Message>.deduplicate(): List<Message> {
if (isEmpty()) return this
val result = mutableListOf(this[0])
for (i in 1 until size) {
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
val seen = mutableSetOf<String>()
return filter { seen.add(it.id) }
}
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 {
ttsEngine.onDone.collect {
if (_state.value == OverlayState.SPEAKING) {
@@ -128,6 +129,14 @@ class OverlayViewModel(
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) {