fix: popBackStack guard, refresh dedup, action tag splitting, notification debug logging

- Navigation: guard popBackStack with currentDestination check to prevent
  double-pop during exit animation causing white screen (Settings→Main overlap)
- Chat refresh: clear all messages before reload to avoid local-UUID / server-ID
  duplication; split inline <action> tags from bulk-loaded HTTP/WS history messages
- Chat animation: restore AnimatedVisibility (fadeIn+slideInVertically) in
  AnimatedChatBubble composable
- Notification debug: add NOTIFY log category with strategic log points across
  the entire background/notification pipeline — WS lifecycle, foreground/background
  transitions, emitMessage decision reasons, keep-alive service events
- Settings UI: switch log tabs from TabRow to ScrollableTabRow, add fixed-height
  card-styled log viewer with entry count header
- Clean up obsolete launcher drawable/mipmap resources

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 08:08:53 +08:00
parent 08d78c976a
commit bc7630c43a
23 changed files with 410 additions and 157 deletions
@@ -44,7 +44,7 @@ class CyreneApplication : Application() {
// Track foreground/background state
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) {
override fun onActivityResumed(activity: Activity) {
if (activityCount.incrementAndGet() == 1) {
RuntimeLog.general("app", "App in foreground")
getRepo()?.cancelNotifications()
@@ -52,7 +52,7 @@ class CyreneApplication : Application() {
}
}
override fun onActivityStopped(activity: Activity) {
override fun onActivityPaused(activity: Activity) {
if (activityCount.decrementAndGet() == 0) {
RuntimeLog.general("app", "App in background")
getRepo()?.onAppBackground()
@@ -60,8 +60,8 @@ class CyreneApplication : Application() {
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})
@@ -1,16 +1,21 @@
package top.yeij.cyrene
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import org.koin.compose.koinInject
import top.yeij.cyrene.data.local.PreferencesDataStore
@@ -24,10 +29,15 @@ class MainActivity : ComponentActivity() {
private val isDefaultAssistant = mutableStateOf(false)
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* granted or denied — either way we continue */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
isDefaultAssistant.value = checkIsDefaultAssistant()
setContent {
@@ -81,4 +91,14 @@ class MainActivity : ComponentActivity() {
private fun openAssistantSettings() {
startActivity(Intent(Settings.ACTION_VOICE_INPUT_SETTINGS))
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@@ -25,6 +25,9 @@ interface MessageDao {
@Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
suspend fun migrateConversationId(oldId: String, newId: String)
@Query("DELETE FROM messages WHERE conversationId = :conversationId AND role = 'user'")
suspend fun deleteUserMessagesByConversation(conversationId: String)
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
@@ -1,10 +1,13 @@
package top.yeij.cyrene.data.remote
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import top.yeij.cyrene.data.remote.dto.AuthRequest
@@ -12,6 +15,7 @@ import top.yeij.cyrene.data.remote.dto.AuthResponse
import top.yeij.cyrene.data.remote.dto.ProfileResponse
import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.DeviceDto
import top.yeij.cyrene.data.remote.dto.FileUploadResponse
import top.yeij.cyrene.data.remote.dto.IoTControlRequest
import top.yeij.cyrene.data.remote.dto.MessagesListResponse
import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
@@ -46,10 +50,17 @@ interface ApiService {
@GET("api/v1/sessions/{id}/messages")
suspend fun getSessionMessages(
@Path("id") sessionId: String,
@Query("limit") limit: Int = 50,
@Query("limit") limit: Int = 500,
@Query("offset") offset: Int = 0,
): Response<MessagesListResponse>
// Files
@Multipart
@POST("api/v1/files/upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part,
): Response<FileUploadResponse>
// IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
@GET("api/v1/iot/devices")
suspend fun getDevices(): Response<List<DeviceDto>>
@@ -36,3 +36,12 @@ data class SessionMessageDto(
@SerializedName("content") val content: String,
@SerializedName("created_at") val createdAt: Long,
)
// POST /api/v1/files/upload — response
data class FileUploadResponse(
@SerializedName("id") val id: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("mime_type") val mimeType: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("url") val url: String? = null,
)
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
@@ -116,34 +117,38 @@ class ChatRepositoryImpl(
}
override fun onAppForeground() {
RuntimeLog.notify("state", "onAppForeground: wasForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground")
isAppInForeground = true
hasEverBeenForeground = true
notifiedMessageIds.clear()
notificationHelper.cancelAll()
KeepAliveReceiver.cancel(app)
WebSocketKeepAliveService.stop(app)
// Always reconnect and sync history when returning to foreground
webSocketService.forceReconnect()
RuntimeLog.notify("state", "onAppForeground: notifications cleared, keep-alive stopped")
scope.launch {
val sid = currentSessionId ?: return@launch
RuntimeLog.general("app", "Foreground — reconnecting and requesting history for session=$sid")
RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
requestHistoryViaWs(sid)
}
}
override fun onAppBackground() {
isAppInForeground = false
// Always start keep-alive — connection may be silently dead and need recovery
WebSocketKeepAliveService.start(app)
KeepAliveReceiver.schedule(app)
// Force reconnect after foreground service is up so the socket
// is tied to the service's lifecycle, not the foreground activity's
scope.launch {
kotlinx.coroutines.delay(1500) // let the service start first
webSocketService.forceReconnect()
RuntimeLog.general("app", "Background reconnect after service start, connected=${_connectionState.value}")
val currentlyConnected = _connectionState.value
RuntimeLog.notify("state", "onAppBackground: connected=$currentlyConnected hasEverBeen=$hasEverBeenForeground keepAliveStarted=true")
// Only reconnect if the WS is already dead. Tearing down a healthy
// connection creates a message loss window with no benefit.
if (!currentlyConnected) {
scope.launch {
kotlinx.coroutines.delay(1500) // let the service start first
webSocketService.forceReconnect()
RuntimeLog.general("app", "Background reconnect done, connected=${_connectionState.value}")
}
} else {
RuntimeLog.general("app", "WS healthy — skipping background reconnect to avoid message loss")
}
RuntimeLog.general("app", "Started keep-alive service + periodic alarm for background (connected=${_connectionState.value})")
}
init {
@@ -229,7 +234,7 @@ class ChatRepositoryImpl(
webSocketService.forceReconnect()
}
override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?) {
override suspend fun sendMessage(content: String, sessionId: String?, attachments: List<WSAttachment>?, localImageUris: List<String>) {
val messageId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val sid = sessionId ?: currentSessionId ?: "default"
@@ -238,8 +243,7 @@ class ChatRepositoryImpl(
scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
}
val imageUris = attachments?.filter { it.type == "image" }?.mapNotNull { it.url } ?: emptyList()
val hasImages = imageUris.isNotEmpty()
val hasImages = localImageUris.isNotEmpty()
val displayContent = content.ifBlank { "" }
val lastMsg = when {
hasImages && content.isBlank() -> "[图片]"
@@ -279,7 +283,7 @@ class ChatRepositoryImpl(
msgType = "chat",
timestamp = now,
isStreaming = false,
imageDataUris = imageUris,
imageDataUris = localImageUris,
)
webSocketService.sendMessage(content, sid, attachments = attachments)
@@ -361,7 +365,7 @@ class ChatRepositoryImpl(
ensureConversation(sessionId)
val messages = filteredDtos.map { dto ->
Message(
id = "db_${dto.id}",
id = "${dto.id}",
conversationId = sessionId,
role = dto.role,
content = dto.content,
@@ -369,19 +373,18 @@ class ChatRepositoryImpl(
timestamp = dto.createdAt,
)
}
val deduped = messages.removeWrappingDuplicates()
deduped.forEach { msg ->
messageDao.upsert(
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
val deduped = messages.removeWrappingDuplicates().splitInlineActions()
messageDao.deleteUserMessagesByConversation(sessionId)
messageDao.upsertAll(deduped.map { msg ->
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
}
})
RuntimeLog.http("loadMessages", "HTTP loaded ${deduped.size} messages (${messages.size} before dedup) for session=$sessionId")
deduped
} else {
@@ -397,11 +400,16 @@ class ChatRepositoryImpl(
}
private suspend fun requestHistoryViaWs(sessionId: String) {
// Wait up to 5s for WS to connect
if (!webSocketService.isConnected.value) {
withTimeoutOrNull(5000) {
val connected = withTimeoutOrNull(5000) {
webSocketService.isConnected.first { it }
}
if (connected != true) {
// WS couldn't connect, fall back to REST API
RuntimeLog.chat("history", "WS not connected after 5s, falling back to REST")
loadMessagesFromServer(sessionId)
return
}
}
webSocketService.requestHistory(sessionId)
}
@@ -491,9 +499,9 @@ class ChatRepositoryImpl(
lastResponseContent = content
lastResponseTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "stream_end: id=$msgId isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${content.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = wsMsg.msgType ?: "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
_isAssistantStreaming.value = false
RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
}
"response" -> {
@@ -528,14 +536,15 @@ class ChatRepositoryImpl(
recentParsedContents.add(text)
lastParsedTime = System.currentTimeMillis()
RuntimeLog.notify("trigger", "reply: id=$msgId role=$role isForeground=$isAppInForeground hasEverBeen=$hasEverBeenForeground content='${text.take(50)}'")
emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false, shouldNotify = true)
RuntimeLog.chat("receive", "Response msgId=$msgId role=$role msgType=$replyMsgType content=${text.take(80)}")
}
"review" -> {
recentParsedContents.clear()
wsMsg.reviewMessages?.forEach { review ->
val rawText = review.content ?: review.text ?: return@forEach
wsMsg.reviewMessages?.forEachIndexed { index, review ->
if (index > 0) delay(1000L)
val rawText = review.content ?: review.text ?: return@forEachIndexed
val role = review.role ?: "assistant"
val rvMsgType = review.type ?: review.msgType ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
@@ -580,6 +589,17 @@ class ChatRepositoryImpl(
)
}
"queued" -> {
emitMessage(
id = "queued_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = "system",
content = "消息已加入处理队列",
msgType = "system_info",
isStreaming = false,
)
}
"error" -> {
cancelStreamTimeout()
streamingContent = ""
@@ -633,18 +653,18 @@ class ChatRepositoryImpl(
timestamp = hist.timestamp ?: System.currentTimeMillis(),
)
}
val deduped = messageList.removeWrappingDuplicates()
deduped.forEach { msg ->
messageDao.upsert(
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
val deduped = messageList.removeWrappingDuplicates().splitInlineActions()
messageDao.upsertAll(deduped.map { msg ->
MessageEntity(
id = msg.id,
conversationId = msg.conversationId,
role = msg.role,
content = msg.content,
msgType = msg.msgType,
timestamp = msg.timestamp,
)
})
deduped.forEach { msg ->
emitMessage(
id = msg.id,
sessionId = msg.conversationId,
@@ -662,7 +682,8 @@ class ChatRepositoryImpl(
"multi_message" -> {
recentParsedContents.clear()
var isFirst = true
wsMsg.multiMessages?.forEach { item ->
wsMsg.multiMessages?.forEachIndexed { index, item ->
if (index > 0) delay(1000L)
val content = item.content ?: ""
recentParsedContents.add(content)
emitMessage(
@@ -708,6 +729,42 @@ class ChatRepositoryImpl(
imageDataUris: List<String> = emptyList(),
) {
if (content.isBlank() && msgType == "chat" && imageDataUris.isEmpty()) return
// Fallback: detect inline <action> tags missed by server parsing
if (role == "assistant" && msgType == "chat") {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
val match = actionRegex.find(content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(content, "").trim()
RuntimeLog.chat("receive", "Split inline <action> from chat: action='${actionText.take(40)}' remaining='${remaining.take(40)}'")
if (actionText.isNotEmpty()) {
emitMessage(
id = "${id}_action",
sessionId = sessionId,
role = "assistant",
content = actionText,
msgType = "action",
timestamp = timestamp,
shouldNotify = false,
)
}
if (remaining.isNotEmpty()) {
emitMessage(
id = id,
sessionId = sessionId,
role = role,
content = remaining,
msgType = msgType,
timestamp = timestamp + 1,
isStreaming = isStreaming,
shouldNotify = shouldNotify,
)
}
return
}
}
val message = Message(
id = id,
conversationId = sessionId,
@@ -720,11 +777,21 @@ class ChatRepositoryImpl(
)
_incomingMessages.tryEmit(message)
if (shouldNotify && hasEverBeenForeground && !isAppInForeground && role == "assistant" && !isStreaming) {
if (notifiedMessageIds.add(id)) {
notificationHelper.showMessageNotification(message)
RuntimeLog.general("app", "Notification sent for msgId=$id")
if (shouldNotify && role == "assistant" && !isStreaming) {
if (!hasEverBeenForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app has never been foregrounded")
} else if (isAppInForeground) {
RuntimeLog.notify("skip", "Not showing notification for $id: app is in foreground")
} else {
if (notifiedMessageIds.add(id)) {
notificationHelper.showMessageNotification(message)
RuntimeLog.notify("show", "Notification sent: id=$id content='${content.take(40)}'")
} else {
RuntimeLog.notify("dup", "Notification already sent for $id, skipping")
}
}
} else if (shouldNotify && role == "assistant" && isStreaming) {
RuntimeLog.notify("skip", "Not showing notification for $id: still streaming")
}
}
@@ -760,6 +827,35 @@ class ChatRepositoryImpl(
createdAt = createdAt,
)
/**
* Split inline `<action>` tags from assistant chat messages into separate messages.
* Used for bulk-loaded messages (HTTP history, WS history_response) that bypass emitMessage.
*/
private fun List<Message>.splitInlineActions(): List<Message> {
val actionRegex = Regex("""<action>(.*?)</action>\s*""")
return flatMap { msg ->
if (msg.role == "assistant" && msg.msgType == "chat") {
val match = actionRegex.find(msg.content)
if (match != null) {
val actionText = match.groupValues[1].trim()
val remaining = actionRegex.replaceFirst(msg.content, "").trim()
val result = mutableListOf<Message>()
if (actionText.isNotEmpty()) {
result.add(msg.copy(id = "${msg.id}_action", content = actionText, msgType = "action"))
}
if (remaining.isNotEmpty()) {
result.add(msg.copy(content = remaining))
}
if (result.isEmpty()) listOf(msg) else result
} else {
listOf(msg)
}
} else {
listOf(msg)
}
}
}
private fun MessageEntity.toDomain() = Message(
id = id,
conversationId = conversationId,
@@ -76,7 +76,7 @@ val appModule = module {
factory { GetConversationsUseCase(get()) }
// ViewModels
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get()) }
viewModel { ChatViewModel(androidContext() as android.app.Application, get(), get(), get(), get()) }
viewModel { IoTViewModel(get()) }
viewModel { OverlayViewModel(get(), get(), get()) }
viewModel { ProfileViewModel(get(), get(), get()) }
@@ -22,7 +22,7 @@ interface ChatRepository {
suspend fun connectWebSocket(sessionId: String?)
suspend fun sendMessage(content: String, sessionId: String?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null)
suspend fun sendMessage(content: String, sessionId: String?, attachments: List<top.yeij.cyrene.data.remote.dto.WSAttachment>? = null, localImageUris: List<String> = emptyList())
fun observeMessages(): Flow<Message>
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import org.koin.core.context.GlobalContext
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.util.RuntimeLog
class KeepAliveReceiver : BroadcastReceiver() {
@@ -21,6 +22,7 @@ class KeepAliveReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Keep-alive alarm fired")
RuntimeLog.notify("keepalive", "Alarm fired in background")
scope.launch {
try {
@@ -28,27 +30,24 @@ class KeepAliveReceiver : BroadcastReceiver() {
val token = prefs.token.firstOrNull()
if (token.isNullOrBlank()) {
Log.d(TAG, "No auth token, skipping wake-up")
RuntimeLog.notify("keepalive", "Skipping: no auth token")
return@launch
}
// Always restart foreground service
if (!WebSocketKeepAliveService.isRunning) {
WebSocketKeepAliveService.start(context)
Log.i(TAG, "Keep-alive service restarted")
RuntimeLog.notify("keepalive", "Foreground service restarted")
}
// Always force reconnect — connectionState may be stuck at true on a dead socket
val repo: ChatRepositoryImpl = GlobalContext.get().get()
val wasConnected = repo.connectionState.value
repo.ensureConnected()
Log.i(TAG, "WebSocket reconnection triggered, connected=${repo.connectionState.value}")
RuntimeLog.notify("keepalive", "WS reconnect triggered: wasConnected=$wasConnected nowConnected=${repo.connectionState.value}")
// Schedule next wake-up
schedule(context)
Log.d(TAG, "Keep-alive check complete, connected=${repo.connectionState.value}")
} catch (e: Throwable) {
Log.e(TAG, "Keep-alive check failed: ${e.message}", e)
// Schedule next anyway
RuntimeLog.notify("keepalive", "Failed: ${e.message}")
schedule(context)
}
}
@@ -14,6 +14,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.util.RuntimeLog
class WebSocketKeepAliveService : Service() {
@@ -27,6 +28,7 @@ class WebSocketKeepAliveService : Service() {
createChannel()
acquireWakeLock()
Log.i(TAG, "Service created, wakeLock held")
RuntimeLog.notify("keepalive", "WS keep-alive service created, wakeLock acquired")
}
override fun onDestroy() {
@@ -34,6 +36,7 @@ class WebSocketKeepAliveService : Service() {
releaseWakeLock()
scheduleRestart()
Log.i(TAG, "Service destroyed, restart scheduled")
RuntimeLog.notify("keepalive", "WS keep-alive service destroyed, restart scheduled in ${RESTART_DELAY_MS}ms")
super.onDestroy()
}
@@ -26,6 +26,7 @@ import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.util.RuntimeLog
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@@ -136,6 +137,7 @@ class WebSocketService(
_isConnected.value = true
_connectionError.value = null
startHeartbeat()
RuntimeLog.ws("lifecycle", "WS connected #$connId")
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -143,6 +145,8 @@ class WebSocketService(
lastMessageReceived = System.currentTimeMillis()
try {
val msg = gson.fromJson(text, WSServerMessage::class.java)
val preview = text.take(100).replace("\n", "\\n")
RuntimeLog.ws("receive", "type=${msg.type} id=${msg.messageId ?: "-"} preview=$preview")
_incomingMessages.tryEmit(msg)
} catch (e: Exception) {
Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
@@ -154,6 +158,7 @@ class WebSocketService(
Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
_isConnected.value = false
cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS closing #$connId code=$code reason='$reason'")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
@@ -162,6 +167,7 @@ class WebSocketService(
_isConnected.value = false
cancelHeartbeat()
scheduleReconnect()
RuntimeLog.ws("lifecycle", "WS closed #$connId code=$code")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
@@ -170,6 +176,7 @@ class WebSocketService(
Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
_isConnected.value = false
cancelHeartbeat()
RuntimeLog.ws("lifecycle", "WS failure #$connId http=$httpCode error='${t.message}'")
val errorMsg = when (httpCode) {
403 -> {
@@ -248,6 +255,7 @@ class WebSocketService(
}
fun forceReconnect() {
RuntimeLog.ws("lifecycle", "forceReconnect called")
shouldReconnect = true
reconnectJob?.cancel()
reconnectJob = null
@@ -264,6 +272,7 @@ class WebSocketService(
}
fun disconnect() {
RuntimeLog.ws("lifecycle", "WS disconnect — user requested")
shouldReconnect = false
reconnectJob?.cancel()
reconnectJob = null
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -23,6 +24,7 @@ import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -525,6 +527,10 @@ private fun ImagePreviewDialog(
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(48.dp),
)
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageUri)
@@ -604,6 +610,7 @@ private fun ChatMessageBubble(
contentDescription = "图片",
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 240.dp)
.padding(top = 6.dp, start = 6.dp, end = 6.dp)
.clip(RoundedCornerShape(8.dp))
.clickable { previewImageUri = uri },
@@ -17,6 +17,7 @@ import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
@@ -34,6 +35,7 @@ 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
import top.yeij.cyrene.util.RuntimeLog
object Routes {
const val LOGIN = "login"
@@ -52,6 +54,19 @@ fun CyreneNavGraph(
isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit,
) {
// After process death, the NavController may restore a stale back stack
// (e.g. showing SETTINGS instead of MAIN). Reset to the intended start.
LaunchedEffect(Unit) {
val entries = navController.currentBackStack.value
val currentRoute = navController.currentDestination?.route
RuntimeLog.general("nav", "NavGraph start — currentRoute=$currentRoute backStackSize=${entries.size}")
if (entries.size > 1 && entries.first().destination.route != startDestination) {
RuntimeLog.general("nav", "Resetting stale back stack to $startDestination")
navController.popBackStack(startDestination, inclusive = true)
navController.navigate(startDestination)
}
}
NavHost(
navController = navController,
startDestination = startDestination,
@@ -76,20 +91,32 @@ fun CyreneNavGraph(
composable(Routes.SETTINGS) {
SettingsScreen(
onBack = { navController.popBackStack() },
onBack = {
if (navController.currentDestination?.route == Routes.SETTINGS) {
navController.popBackStack()
}
},
onNavigateToKeepAlive = { navController.navigate(Routes.KEEP_ALIVE) },
)
}
composable(Routes.KEEP_ALIVE) {
KeepAlivePage(
onBack = { navController.popBackStack() },
onBack = {
if (navController.currentDestination?.route == Routes.KEEP_ALIVE) {
navController.popBackStack()
}
},
)
}
composable(Routes.ABOUT) {
AboutScreen(
onBack = { navController.popBackStack() },
onBack = {
if (navController.currentDestination?.route == Routes.ABOUT) {
navController.popBackStack()
}
},
)
}
}
@@ -136,7 +163,10 @@ fun MainScreen(
items.forEachIndexed { index, item ->
NavigationRailItem(
selected = selectedTab == index,
onClick = { selectedTab = index },
onClick = {
selectedTab = index
RuntimeLog.general("nav", "Tab switched to ${item.label} (index=$index)")
},
icon = item.icon,
label = { Text(item.label) },
)
@@ -3,6 +3,10 @@ package top.yeij.cyrene.ui.screens.chat
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -76,8 +80,10 @@ import top.yeij.cyrene.ui.components.CyreneStatus
import top.yeij.cyrene.ui.components.StatusIndicator
import top.yeij.cyrene.ui.components.TypingIndicator
import top.yeij.cyrene.util.RecordState
import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
import kotlin.math.min
@Composable
@@ -85,13 +91,27 @@ private fun AnimatedChatBubble(
message: Message,
animIndex: Int,
) {
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
)
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(min(animIndex, 10) * 60L)
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 },
),
) {
ChatBubble(
content = message.content,
role = message.role,
msgType = message.msgType,
timestamp = message.timestamp,
imageDataUris = message.imageDataUris,
)
}
}
@Composable
@@ -99,6 +119,10 @@ fun ChatScreen(
viewModel: ChatViewModel = koinViewModel(),
settingsViewModel: SettingsViewModel = koinInject(),
) {
// Track composition to diagnose navigation-related issues
LaunchedEffect(Unit) {
RuntimeLog.general("chat", "ChatScreen composed, ChatViewModel instance resolved")
}
val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState()
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -52,9 +53,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -847,23 +848,20 @@ fun SettingsScreen(
}
}
TabRow(selectedTabIndex = selectedTab) {
ScrollableTabRow(
selectedTabIndex = selectedTab,
edgePadding = 16.dp,
divider = {},
) {
tabs.forEachIndexed { index, label ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(label, maxLines = 1) },
text = { Text(label, maxLines = 1, style = MaterialTheme.typography.labelMedium) },
)
}
}
val filteredLogs = if (selectedTab == 0) {
logEntries
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat } else emptyList()
}
val currentCategory = if (selectedTab == 0) null else allCategories.getOrNull(selectedTab - 1)
Row(
@@ -900,7 +898,14 @@ fun SettingsScreen(
}
}
if (filteredLogs.isEmpty()) {
val displayLogs = if (selectedTab == 0) {
logEntries.takeLast(500)
} else {
val cat = allCategories.getOrNull(selectedTab - 1)
if (cat != null) logEntries.filter { it.category == cat }.takeLast(500) else emptyList()
}
if (displayLogs.isEmpty()) {
Text(
text = "暂无${tabs[selectedTab]}日志",
style = MaterialTheme.typography.bodySmall,
@@ -908,16 +913,29 @@ fun SettingsScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} else {
Column(
// Log count header
Text(
text = "${displayLogs.size}${if (displayLogs.size >= 500) "+" else ""}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
// Fixed-height scrollable log area
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.weight(1f, fill = false),
.padding(horizontal = 12.dp)
.height(280.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
RoundedCornerShape(8.dp),
)
.padding(8.dp),
) {
Column(
modifier = Modifier.verticalScroll(scrollState),
) {
filteredLogs.takeLast(500).forEach { entry ->
displayLogs.forEach { entry ->
Text(
text = entry.formatted(),
style = MaterialTheme.typography.bodySmall,
@@ -8,6 +8,7 @@ import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.R
import top.yeij.cyrene.domain.model.Message
class NotificationHelper(private val context: Context) {
@@ -47,7 +48,7 @@ class NotificationHelper(private val context: Context) {
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("昔涟")
.setContentText(preview)
.setAutoCancel(true)
@@ -55,7 +56,9 @@ class NotificationHelper(private val context: Context) {
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
notificationManager.notify(message.id.hashCode(), notification)
val notifyId = message.id.hashCode()
notificationManager.notify(notifyId, notification)
RuntimeLog.notify("posted", "Notification posted: id=$notifyId preview='$preview'")
}
fun cancelAll() {
@@ -20,6 +20,7 @@ enum class LogCategory(val label: String) {
GENERAL("通用"),
HTTP("网络"),
VOICE("语音"),
NOTIFY("通知"),
}
data class LogEntry(
@@ -59,6 +60,7 @@ object RuntimeLog {
fun general(tag: String, message: String) = log(LogCategory.GENERAL, tag, message)
fun http(tag: String, message: String) = log(LogCategory.HTTP, tag, message)
fun voice(tag: String, message: String) = log(LogCategory.VOICE, tag, message)
fun notify(tag: String, message: String) = log(LogCategory.NOTIFY, tag, message)
@Synchronized
fun getByCategory(category: LogCategory): List<LogEntry> {
@@ -2,7 +2,6 @@ package top.yeij.cyrene.viewmodel
import android.app.Application
import android.net.Uri
import android.util.Base64
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -18,7 +17,11 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.dto.WSAttachment
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
@@ -38,8 +41,14 @@ class ChatViewModel(
private val chatRepository: ChatRepository,
private val voiceRecorder: VoiceRecorder,
private val preferencesDataStore: PreferencesDataStore,
private val apiService: ApiService,
) : AndroidViewModel(application) {
companion object {
private var instanceCounter = 0
}
private val instanceId = ++instanceCounter
val isConnected: StateFlow<Boolean> = chatRepository.connectionState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@@ -78,22 +87,29 @@ class ChatViewModel(
private var sendTimeoutJob: Job? = null
init {
// Phase 1: find/create main session, reconnect WS, load server history into DB, then observe DB
RuntimeLog.general("app", "ChatViewModel instance #$instanceId created")
viewModelScope.launch {
try {
RuntimeLog.general("app", "ChatViewModel #$instanceId — initializing session...")
val sessionId = chatRepository.initializeSession()
currentSessionId = sessionId
chatRepository.currentSessionId = sessionId
RuntimeLog.general("app", "Session initialized: $sessionId")
chatRepository.ensureConnected()
chatRepository.loadMessagesFromServer(sessionId)
} catch (_: Exception) { }
// Fall back to persisted sessionId if initializeSession failed
} catch (e: Exception) {
RuntimeLog.general("app", "initializeSession failed: ${e.message}")
}
// Always try to load from DB, even if initializeSession failed.
// After process death the persisted session ID gives us the history.
val sid = currentSessionId
?: preferencesDataStore.currentSessionId.firstOrNull()
if (sid != null) {
currentSessionId = sid
chatRepository.currentSessionId = sid
RuntimeLog.general("app", "Loading messages from DB for session=$sid")
loadMessagesFromDb(sid)
} else {
RuntimeLog.general("app", "No session ID available — cannot load messages")
}
}
@@ -116,6 +132,11 @@ class ChatViewModel(
}
updated.deduplicate()
}
// Any non-user response means the server acknowledged our message
if (_isSending.value && message.role != "user") {
_isSending.value = false
sendTimeoutJob?.cancel()
}
} catch (e: Exception) {
Log.e("ChatViewModel", "Error processing message: ${e.message}", e)
}
@@ -149,26 +170,6 @@ class ChatViewModel(
}
}
// Auto-refresh when connection transitions from offline to online
viewModelScope.launch {
var wasOffline = false
chatRepository.connectionState.collect { connected ->
if (connected && wasOffline && currentSessionId != null) {
try {
val lastCleared = preferencesDataStore.lastClearedTimestamp
.firstOrNull()?.toLongOrNull() ?: 0L
val latestMessages = chatRepository.loadMessagesFromServer(currentSessionId!!)
val latestServerTs = latestMessages.maxOfOrNull { it.timestamp } ?: 0L
if (latestServerTs > lastCleared) {
// Newer messages exist on server — DB upsert already
// triggered the Room Flow; force a scroll to newest
RuntimeLog.chat("auto-refresh", "Reconnected, loaded ${latestMessages.size} messages")
}
} catch (_: Exception) { }
}
wasOffline = !connected
}
}
}
// --- Voice recording (WeChat-style gesture) ---
@@ -216,24 +217,45 @@ class ChatViewModel(
_selectedImageUris.value = emptyList()
}
private suspend fun uriToAttachment(uri: Uri): WSAttachment? {
private data class UploadResult(
val attachment: WSAttachment,
val thumbnailUrl: String,
)
private suspend fun uploadAndBuildAttachment(uri: Uri): UploadResult? {
return withContext(Dispatchers.IO) {
try {
val cr = getApplication<Application>().contentResolver
val mimeType = cr.getType(uri) ?: "image/*"
val inputStream = cr.openInputStream(uri) ?: return@withContext null
val bytes = inputStream.use { it.readBytes() }
val mimeType = cr.getType(uri) ?: "image/jpeg"
val filename = uri.lastPathSegment ?: "image"
val bytes = cr.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
if (bytes.isEmpty()) return@withContext null
val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
val dataUri = "data:$mimeType;base64,$b64"
WSAttachment(
// Upload to server, get file_id
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
val response = apiService.uploadFile(part)
if (!response.isSuccessful) {
Log.e("ChatViewModel", "Upload failed: ${response.code()} ${response.message()}")
return@withContext null
}
val fileId = response.body()?.id ?: return@withContext null
// Construct thumbnail URL
val baseUrl = preferencesDataStore.baseUrl.firstOrNull()
?.trimEnd('/') ?: "http://10.0.2.2:8080"
val thumbnailUrl = "$baseUrl/api/v1/files/$fileId/thumbnail"
val attachment = WSAttachment(
type = "image",
url = dataUri,
filename = uri.lastPathSegment ?: "image",
fileId = fileId,
thumbnailUrl = thumbnailUrl,
filename = filename,
size = bytes.size.toLong(),
)
UploadResult(attachment, thumbnailUrl)
} catch (e: Exception) {
Log.e("ChatViewModel", "Failed to convert URI to attachment: ${e.message}")
Log.e("ChatViewModel", "Failed to upload image: ${e.message}", e)
null
}
}
@@ -244,10 +266,14 @@ class ChatViewModel(
val text = _inputText.value.trim()
val uris = _selectedImageUris.value
if (text.isEmpty() && uris.isEmpty()) return
val sid = currentSessionId
if (sid == null) {
RuntimeLog.chat("send", "Cannot send — no current session")
return
}
_inputText.value = ""
_isSending.value = true
val sid = currentSessionId
sendTimeoutJob?.cancel()
sendTimeoutJob = viewModelScope.launch {
@@ -258,11 +284,19 @@ class ChatViewModel(
}
}
val localUriStrings = uris.map { it.toString() }
viewModelScope.launch {
val attachments = uris.mapNotNull { uriToAttachment(it) }
val results = uris.mapNotNull { uploadAndBuildAttachment(it) }
clearImages()
val attachments = results.map { it.attachment }
val thumbnailUrls = results.map { it.thumbnailUrl }
try {
chatRepository.sendMessage(text, sid, attachments.ifEmpty { null })
chatRepository.sendMessage(
text, sid,
attachments = attachments.ifEmpty { null },
localImageUris = thumbnailUrls.ifEmpty { localUriStrings },
)
} catch (e: Exception) {
Log.e("ChatViewModel", "sendMessage failed: ${e.message}", e)
_isSending.value = false
@@ -291,6 +325,7 @@ class ChatViewModel(
}
} catch (e: Exception) {
Log.e("ChatViewModel", "Error loading messages: ${e.message}", e)
RuntimeLog.general("app", "loadMessagesFromDb failed: ${e.message}")
}
}
}
@@ -317,6 +352,10 @@ class ChatViewModel(
viewModelScope.launch {
_isRefreshing.value = true
try {
// Clear all messages and let the DB observer rebuild from scratch.
// This avoids duplicates that occur when local-UUID messages survive
// the merge alongside server-ID versions loaded from HTTP.
_currentMessages.value = emptyList()
if (!isConnected.value) {
chatRepository.ensureConnected()
}
@@ -95,6 +95,12 @@ class OverlayViewModel(
updated.deduplicate()
}
// Any non-user response means the server acknowledged our message
if (_state.value == OverlayState.PROCESSING && message.role != "user") {
cancelProcessingTimeout()
setWaiting()
}
if (message.role == "assistant" && !message.isStreaming && message.msgType == "chat") {
if (message.id != lastAssistantMessageId && message.content.isNotBlank()) {
lastAssistantMessageId = message.id
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#6D3BC0"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 昔涟首字母 C,圆形背景 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 C67.255,30 78,40.745 78,54 C78,67.255 67.255,78 54,78 C40.745,78 30,67.255 30,54 C30,40.745 40.745,30 54,30 Z M54,36 C44.059,36 36,44.059 36,54 C36,63.941 44.059,72 54,72 C58.935,72 63.437,70.1 66.878,66.878 C68.523,65.301 69.761,63.394 70.505,61.289 C70.963,59.947 71.213,58.524 71.233,57.067 C71.239,55.712 71.041,54.369 70.646,53.084 L66.757,58.243 L58.243,49.729 L53.084,53.619 L44.57,45.106 L45.398,44.278 C47.881,41.795 51.235,40.233 54,40.233 Z" />
</vector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB