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