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) {