diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 9b97839..25eda37 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -11,7 +11,14 @@
-keepattributes Signature
-keepattributes InnerClasses
-keepattributes EnclosingMethod
+-keepattributes RuntimeVisibleAnnotations
+-keepattributes RuntimeVisibleParameterAnnotations
+-keepattributes AnnotationDefault
+-keepattributes KotlinMetadata
-dontwarn kotlin.**
+-keep class kotlin.Metadata { *; }
+-keep class kotlin.coroutines.Continuation
+-keep class kotlinx.coroutines.** { *; }
# --- Retrofit ---
-keep class retrofit2.** { *; }
@@ -63,6 +70,28 @@
# --- Keep PreferencesDataStore (Koin injects) ---
-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 ---
-keep class androidx.lifecycle.** { *; }
-dontwarn androidx.lifecycle.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1d7dd45..0f70773 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
@@ -42,6 +43,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
+ android:windowSoftInputMode="adjustNothing"
android:theme="@style/Theme.Cyrene">
@@ -83,6 +85,12 @@
android:resource="@xml/accessibility_config" />
+
+
+
(extraBufferCapacity = 4)
override val messageClearEvents: Flow = _messageClearEvents
+ private val _messageRemovals = MutableSharedFlow(extraBufferCapacity = 16)
+ override val messageRemovals: Flow = _messageRemovals
+
private val _isAssistantStreaming = MutableStateFlow(false)
override val isAssistantStreaming: StateFlow = _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")
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
index 02b7aef..591e3eb 100644
--- a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
+++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
@@ -63,7 +63,7 @@ val appModule = module {
// Repositories
single { AuthRepositoryImpl(get(), get(), get()) }
- single { ChatRepositoryImpl(get(), get(), get(), get(), get()) }
+ single { ChatRepositoryImpl(androidContext() as android.app.Application, get(), get(), get(), get(), get()) }
single { IoTRepositoryImpl(get(), get()) }
// UseCases
diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
index 87db464..bd46354 100644
--- a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
+++ b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
@@ -11,6 +11,7 @@ interface ChatRepository {
val connectionError: StateFlow
val isAssistantStreaming: StateFlow
val messageClearEvents: Flow
+ val messageRemovals: Flow
var currentSessionId: String?
fun getConversations(): Flow>
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
index e33f26b..6a77d29 100644
--- a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
@@ -48,7 +48,7 @@ class CyreneVoiceInteractionSession(context: Context) :
private fun resolveViewModel(): OverlayViewModel? {
return try {
GlobalContext.get().get()
- } 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}")
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt
new file mode 100644
index 0000000..cebb0da
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketKeepAliveService.kt
@@ -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)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
index ada21e4..3d54162 100644
--- a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
@@ -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) { }
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
index b9052a1..9f3d9cf 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
@@ -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 = {
diff --git a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
index 48cf520..443c961 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
@@ -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
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
index aec3714..9f3ac46 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
@@ -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 = "发送")
+ }
}
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/KeepAlivePage.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/KeepAlivePage.kt
new file mode 100644
index 0000000..23fd3ce
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/KeepAlivePage.kt
@@ -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))
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
index a9436ac..faf4096 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
@@ -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))
diff --git a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
index 59682c9..7f28780 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/theme/Theme.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/util/KeepAliveManager.kt b/app/src/main/java/top/yeij/cyrene/util/KeepAliveManager.kt
new file mode 100644
index 0000000..43c9f81
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/util/KeepAliveManager.kt
@@ -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) }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
index ac49f0d..18e0dc1 100644
--- a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
@@ -20,17 +20,8 @@ import top.yeij.cyrene.util.VoiceRecorder
private fun List.deduplicate(): List {
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()
+ return filter { seen.add(it.id) }
}
private fun List.removeWrappingDuplicates(): List {
@@ -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
}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt
index 67a143c..c17abfd 100644
--- a/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/OverlayViewModel.kt
@@ -19,17 +19,8 @@ import top.yeij.cyrene.voice.tts.TextToSpeechEngine
private fun List.deduplicate(): List {
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()
+ return filter { seen.add(it.id) }
}
private fun List.removeWrappingDuplicates(): List {
@@ -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) {