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