diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 62d63f6..1d7dd45 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -64,6 +64,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt
index 423470f..d26e34a 100644
--- a/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt
+++ b/app/src/main/java/top/yeij/cyrene/CyreneApplication.kt
@@ -1,32 +1,74 @@
package top.yeij.cyrene
+import android.app.Activity
import android.app.Application
+import android.os.Bundle
+import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.GlobalContext
+import org.koin.core.context.startKoin
import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
+import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.di.appModule
-import org.koin.android.ext.koin.androidContext
-import org.koin.core.context.startKoin
+import top.yeij.cyrene.util.NotificationHelper
+import top.yeij.cyrene.util.RuntimeLog
+import java.util.concurrent.atomic.AtomicInteger
class CyreneApplication : Application() {
private val initScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val activityCount = AtomicInteger(0)
override fun onCreate() {
super.onCreate()
+ RuntimeLog.general("app", "Application onCreate")
startKoin {
androidContext(this@CyreneApplication)
modules(appModule)
}
+ // Track foreground/background state
+ registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
+ override fun onActivityStarted(activity: Activity) {
+ if (activityCount.incrementAndGet() == 1) {
+ RuntimeLog.general("app", "App in foreground")
+ getRepo()?.onAppForeground()
+ }
+ }
+
+ override fun onActivityStopped(activity: Activity) {
+ if (activityCount.decrementAndGet() == 0) {
+ RuntimeLog.general("app", "App in background")
+ getRepo()?.onAppBackground()
+ }
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
+ override fun onActivityResumed(activity: Activity) {}
+ override fun onActivityPaused(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+ override fun onActivityDestroyed(activity: Activity) {}
+ })
+
+ // Set up background notification callback once Koin is ready
initScope.launch {
- val koin = org.koin.core.context.GlobalContext.get()
+ val notificationHelper = NotificationHelper(this@CyreneApplication)
+ val repo = getRepo()
+ repo?.setNotificationCallback { message ->
+ notificationHelper.showMessageNotification(message)
+ }
+ }
+
+ initScope.launch {
+ val koin = GlobalContext.get()
val prefs: PreferencesDataStore = koin.get()
val urlInterceptor: DynamicUrlInterceptor = koin.get()
val authInterceptor: AuthInterceptor = koin.get()
@@ -39,4 +81,16 @@ class CyreneApplication : Application() {
}
}
}
+
+ private fun getRepo(): ChatRepositoryImpl? {
+ return try {
+ GlobalContext.get().get()
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ companion object {
+ private const val TAG = "CyreneApp"
+ }
}
diff --git a/app/src/main/java/top/yeij/cyrene/MainActivity.kt b/app/src/main/java/top/yeij/cyrene/MainActivity.kt
index 4be0779..e73feda 100644
--- a/app/src/main/java/top/yeij/cyrene/MainActivity.kt
+++ b/app/src/main/java/top/yeij/cyrene/MainActivity.kt
@@ -7,6 +7,7 @@ import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.runtime.mutableStateOf
import androidx.navigation.compose.rememberNavController
import top.yeij.cyrene.service.CyreneVoiceInteractionService
import top.yeij.cyrene.ui.navigation.CyreneNavGraph
@@ -16,11 +17,13 @@ import top.yeij.cyrene.util.Constants
class MainActivity : ComponentActivity() {
+ private val isDefaultAssistant = mutableStateOf(false)
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- val isDefaultAssistant = checkIsDefaultAssistant()
+ isDefaultAssistant.value = checkIsDefaultAssistant()
setContent {
CyreneTheme {
@@ -29,24 +32,31 @@ class MainActivity : ComponentActivity() {
CyreneNavGraph(
navController = navController,
startDestination = Routes.MAIN,
- isDefaultAssistant = isDefaultAssistant,
+ isDefaultAssistant = isDefaultAssistant.value,
onOpenAssistantSettings = { openAssistantSettings() },
)
}
}
}
+ override fun onResume() {
+ super.onResume()
+ isDefaultAssistant.value = checkIsDefaultAssistant()
+ }
+
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
private fun checkIsDefaultAssistant(): Boolean {
- val componentName = ComponentName(this, CyreneVoiceInteractionService::class.java)
- val intent = Intent("android.service.voice.VoiceInteractionService")
- val services = packageManager.queryIntentServices(intent, 0)
- return services.any { it.serviceInfo.packageName == packageName }
- && CyreneVoiceInteractionService.isActive
+ // Standard Android check
+ val flat = ComponentName(this, CyreneVoiceInteractionService::class.java).flattenToString()
+ val current = Settings.Secure.getString(contentResolver, "voice_interaction_service")
+ if (current == flat) return true
+ // Fallback for COS and other custom OS: check persisted flag from service
+ if (CyreneVoiceInteractionService.wasEverActive(this)) return true
+ return false
}
private fun openAssistantSettings() {
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
index 44fbe21..314955c 100644
--- a/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/local/PreferencesDataStore.kt
@@ -22,6 +22,15 @@ class PreferencesDataStore(private val context: Context) {
private val KEY_USERNAME = stringPreferencesKey("username")
private val KEY_CLIENT_ID = stringPreferencesKey("client_id")
private val KEY_DEVICE_NAME = stringPreferencesKey("device_name")
+ private val KEY_CURRENT_SESSION_ID = stringPreferencesKey("current_session_id")
+ private val KEY_DASHSCOPE_API_KEY = stringPreferencesKey("dashscope_api_key")
+ private val KEY_DASHSCOPE_ENDPOINT = stringPreferencesKey("dashscope_endpoint")
+ private val KEY_DASHSCOPE_MODEL = stringPreferencesKey("dashscope_model")
+ private val KEY_LAST_CLEARED_TIMESTAMP = stringPreferencesKey("last_cleared_timestamp")
+ private val KEY_PROFILE_USER_ID = stringPreferencesKey("profile_user_id")
+ private val KEY_PROFILE_NICKNAME = stringPreferencesKey("profile_nickname")
+ private val KEY_PROFILE_IS_ADMIN = stringPreferencesKey("profile_is_admin")
+ private val KEY_PROFILE_CREATED_AT = stringPreferencesKey("profile_created_at")
}
val token: Flow = context.dataStore.data.map { it[KEY_TOKEN] }
@@ -32,6 +41,10 @@ class PreferencesDataStore(private val context: Context) {
val username: Flow = context.dataStore.data.map { it[KEY_USERNAME] }
val clientId: Flow = context.dataStore.data.map { it[KEY_CLIENT_ID] }
val deviceName: Flow = context.dataStore.data.map { it[KEY_DEVICE_NAME] }
+ val currentSessionId: Flow = context.dataStore.data.map { it[KEY_CURRENT_SESSION_ID] }
+ val dashScopeApiKey: Flow = context.dataStore.data.map { it[KEY_DASHSCOPE_API_KEY] }
+ val dashScopeEndpoint: Flow = context.dataStore.data.map { it[KEY_DASHSCOPE_ENDPOINT] }
+ val dashScopeModel: Flow = context.dataStore.data.map { it[KEY_DASHSCOPE_MODEL] }
suspend fun saveToken(token: String) {
context.dataStore.edit { it[KEY_TOKEN] = token }
@@ -57,6 +70,18 @@ class PreferencesDataStore(private val context: Context) {
context.dataStore.edit { it[KEY_USERNAME] = username }
}
+ suspend fun saveDashScopeApiKey(key: String) {
+ context.dataStore.edit { it[KEY_DASHSCOPE_API_KEY] = key }
+ }
+
+ suspend fun saveDashScopeEndpoint(endpoint: String) {
+ context.dataStore.edit { it[KEY_DASHSCOPE_ENDPOINT] = endpoint }
+ }
+
+ suspend fun saveDashScopeModel(model: String) {
+ context.dataStore.edit { it[KEY_DASHSCOPE_MODEL] = model }
+ }
+
suspend fun saveClientId(id: String) {
context.dataStore.edit { it[KEY_CLIENT_ID] = id }
}
@@ -65,6 +90,40 @@ class PreferencesDataStore(private val context: Context) {
context.dataStore.edit { it[KEY_DEVICE_NAME] = name }
}
+ suspend fun saveCurrentSessionId(id: String) {
+ context.dataStore.edit { it[KEY_CURRENT_SESSION_ID] = id }
+ }
+
+ val lastClearedTimestamp: Flow = context.dataStore.data.map { it[KEY_LAST_CLEARED_TIMESTAMP] }
+
+ suspend fun saveLastClearedTimestamp(timestamp: Long) {
+ context.dataStore.edit { it[KEY_LAST_CLEARED_TIMESTAMP] = timestamp.toString() }
+ }
+
+ // Cached profile for offline / local-first rendering
+ val profileUserId: Flow = context.dataStore.data.map { it[KEY_PROFILE_USER_ID] }
+ val profileNickname: Flow = context.dataStore.data.map { it[KEY_PROFILE_NICKNAME] }
+ val profileIsAdmin: Flow = context.dataStore.data.map { it[KEY_PROFILE_IS_ADMIN] }
+ val profileCreatedAt: Flow = context.dataStore.data.map { it[KEY_PROFILE_CREATED_AT] }
+
+ suspend fun saveProfileCache(userId: String, nickname: String, isAdmin: Boolean, createdAt: String) {
+ context.dataStore.edit {
+ it[KEY_PROFILE_USER_ID] = userId
+ it[KEY_PROFILE_NICKNAME] = nickname
+ it[KEY_PROFILE_IS_ADMIN] = isAdmin.toString()
+ it[KEY_PROFILE_CREATED_AT] = createdAt
+ }
+ }
+
+ suspend fun clearProfileCache() {
+ context.dataStore.edit {
+ it.remove(KEY_PROFILE_USER_ID)
+ it.remove(KEY_PROFILE_NICKNAME)
+ it.remove(KEY_PROFILE_IS_ADMIN)
+ it.remove(KEY_PROFILE_CREATED_AT)
+ }
+ }
+
suspend fun clearAll() {
context.dataStore.edit { it.clear() }
}
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt
index 69ee23d..81156bc 100644
--- a/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/ConversationDao.kt
@@ -13,6 +13,9 @@ interface ConversationDao {
@Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
fun getAll(): Flow>
+ @Query("SELECT * FROM conversations ORDER BY updatedAt DESC")
+ suspend fun getAllSnapshot(): List
+
@Query("SELECT * FROM conversations WHERE id = :id")
suspend fun getById(id: String): ConversationEntity?
diff --git a/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt
index 02c4173..f07490d 100644
--- a/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/local/dao/MessageDao.kt
@@ -21,4 +21,13 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE conversationId = :conversationId")
suspend fun deleteByConversation(conversationId: String)
+
+ @Query("UPDATE messages SET conversationId = :newId WHERE conversationId = :oldId")
+ suspend fun migrateConversationId(oldId: String, newId: String)
+
+ @Query("DELETE FROM messages WHERE id = :id")
+ suspend fun deleteById(id: String)
+
+ @Query("DELETE FROM messages")
+ suspend fun deleteAll()
}
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt
index 6de0fcc..a0aeb35 100644
--- a/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/ApiService.kt
@@ -6,12 +6,17 @@ import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
+import retrofit2.http.Query
import top.yeij.cyrene.data.remote.dto.AuthRequest
import top.yeij.cyrene.data.remote.dto.AuthResponse
-import top.yeij.cyrene.data.remote.dto.ConversationDto
+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.IoTControlRequest
-import top.yeij.cyrene.data.remote.dto.ReminderDto
+import top.yeij.cyrene.data.remote.dto.MessagesListResponse
+import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
+import top.yeij.cyrene.data.remote.dto.SessionDto
+import top.yeij.cyrene.data.remote.dto.SessionsListResponse
interface ApiService {
@@ -20,16 +25,32 @@ interface ApiService {
suspend fun login(@Body request: AuthRequest): Response
@POST("api/v1/auth/refresh")
- suspend fun refreshToken(@Body refreshToken: String): Response
+ suspend fun refreshToken(@Body request: RefreshTokenRequest): Response
- // Conversations
- @GET("api/v1/conversations")
- suspend fun getConversations(): Response>
+ @GET("api/v1/profile")
+ suspend fun getProfile(): Response
- @DELETE("api/v1/conversations/{id}")
- suspend fun deleteConversation(@Path("id") id: String): Response
+ // Sessions
+ @GET("api/v1/sessions")
+ suspend fun getSessions(): Response
- // IoT
+ @POST("api/v1/sessions")
+ suspend fun createSession(@Body request: CreateSessionRequest): Response
+
+ @DELETE("api/v1/sessions/{id}")
+ suspend fun deleteSession(@Path("id") id: String): Response
+
+ @DELETE("api/v1/sessions/{id}/messages")
+ suspend fun clearSessionMessages(@Path("id") sessionId: String): Response
+
+ @GET("api/v1/sessions/{id}/messages")
+ suspend fun getSessionMessages(
+ @Path("id") sessionId: String,
+ @Query("limit") limit: Int = 50,
+ @Query("offset") offset: Int = 0,
+ ): Response
+
+ // IoT — 注意:网关 API 文档未列出 IoT 端点,需确认网关是否代理了 /api/v1/iot/*
@GET("api/v1/iot/devices")
suspend fun getDevices(): Response>
@@ -38,11 +59,4 @@ interface ApiService {
@Path("id") deviceId: String,
@Body request: IoTControlRequest,
): Response
-
- // Reminders
- @GET("api/v1/reminders")
- suspend fun getReminders(): Response>
-
- @DELETE("api/v1/reminders/{id}")
- suspend fun deleteReminder(@Path("id") id: String): Response
}
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt
index a68174a..8b327ca 100644
--- a/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/RetrofitClient.kt
@@ -11,6 +11,7 @@ object RetrofitClient {
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
dynamicUrlInterceptor: DynamicUrlInterceptor,
+ tokenAuthenticator: TokenAuthenticator,
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
@@ -19,6 +20,7 @@ object RetrofitClient {
return OkHttpClient.Builder()
.addInterceptor(dynamicUrlInterceptor)
.addInterceptor(authInterceptor)
+ .authenticator(tokenAuthenticator)
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/TokenAuthenticator.kt b/app/src/main/java/top/yeij/cyrene/data/remote/TokenAuthenticator.kt
new file mode 100644
index 0000000..9cac278
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/TokenAuthenticator.kt
@@ -0,0 +1,84 @@
+package top.yeij.cyrene.data.remote
+
+import com.google.gson.Gson
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.runBlocking
+import okhttp3.Authenticator
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Route
+import top.yeij.cyrene.data.local.PreferencesDataStore
+import top.yeij.cyrene.data.remote.dto.AuthResponse
+import top.yeij.cyrene.data.remote.dto.RefreshTokenRequest
+import android.util.Log
+
+class TokenAuthenticator(
+ private val authInterceptor: AuthInterceptor,
+ private val preferencesDataStore: PreferencesDataStore,
+ private val dynamicUrlInterceptor: DynamicUrlInterceptor,
+) : Authenticator {
+
+ private val gson = Gson()
+ private val client = OkHttpClient()
+
+ override fun authenticate(route: Route?, response: okhttp3.Response): Request? {
+ if (response.code != 401) return null
+
+ synchronized(this) {
+ // Double-check: if token changed while we were waiting for lock, use it
+ val currentToken = authInterceptor.token
+ val requestAuthHeader = response.request.header("Authorization")
+ if (requestAuthHeader != null && currentToken != null &&
+ requestAuthHeader != "Bearer $currentToken"
+ ) {
+ return response.request.newBuilder()
+ .header("Authorization", "Bearer $currentToken")
+ .build()
+ }
+
+ // Try refresh
+ return try {
+ val refreshToken = runBlocking { preferencesDataStore.refreshToken.firstOrNull() }
+ ?: return null
+
+ val baseUrl = dynamicUrlInterceptor.baseUrl.trimEnd('/')
+ val refreshUrl = "$baseUrl/api/v1/auth/refresh"
+
+ val jsonBody = gson.toJson(RefreshTokenRequest(refreshToken))
+ val body = jsonBody.toRequestBody("application/json".toMediaType())
+
+ val refreshRequest = Request.Builder()
+ .url(refreshUrl)
+ .post(body)
+ .build()
+
+ val refreshResponse = client.newCall(refreshRequest).execute()
+ if (refreshResponse.isSuccessful) {
+ val authResponse = gson.fromJson(
+ refreshResponse.body?.string(),
+ AuthResponse::class.java
+ )
+ authInterceptor.token = authResponse.token
+ runBlocking {
+ preferencesDataStore.saveToken(authResponse.token)
+ authResponse.refreshToken?.let {
+ preferencesDataStore.saveRefreshToken(it)
+ }
+ }
+ Log.i("TokenAuthenticator", "Token refreshed successfully")
+ response.request.newBuilder()
+ .header("Authorization", "Bearer ${authResponse.token}")
+ .build()
+ } else {
+ Log.w("TokenAuthenticator", "Token refresh failed: ${refreshResponse.code}")
+ null
+ }
+ } catch (e: Exception) {
+ Log.e("TokenAuthenticator", "Token refresh error: ${e.message}", e)
+ null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt
index 037f509..6828016 100644
--- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/AuthDtos.kt
@@ -11,6 +11,19 @@ data class AuthResponse(
@SerializedName("token") val token: String,
@SerializedName("refresh_token") val refreshToken: String?,
@SerializedName("username") val username: String?,
+ @SerializedName("nickname") val nickname: String?,
@SerializedName("user_id") val userId: String?,
@SerializedName("expires") val expires: Long? = null,
)
+
+data class RefreshTokenRequest(
+ @SerializedName("refresh_token") val refreshToken: String,
+)
+
+data class ProfileResponse(
+ @SerializedName("user_id") val userId: String,
+ @SerializedName("username") val username: String,
+ @SerializedName("nickname") val nickname: String?,
+ @SerializedName("is_admin") val isAdmin: Boolean? = false,
+ @SerializedName("created_at") val createdAt: String? = null,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt
deleted file mode 100644
index fffd7d5..0000000
--- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ConversationDto.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package top.yeij.cyrene.data.remote.dto
-
-import com.google.gson.annotations.SerializedName
-
-data class ConversationDto(
- @SerializedName("id") val id: String,
- @SerializedName("title") val title: String,
- @SerializedName("last_message") val lastMessage: String?,
- @SerializedName("last_message_type") val lastMessageType: String?,
- @SerializedName("updated_at") val updatedAt: String,
- @SerializedName("created_at") val createdAt: String,
-)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt
deleted file mode 100644
index 4e23e9b..0000000
--- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/ReminderDto.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package top.yeij.cyrene.data.remote.dto
-
-import com.google.gson.annotations.SerializedName
-
-data class ReminderDto(
- @SerializedName("id") val id: String,
- @SerializedName("content") val content: String,
- @SerializedName("trigger_at") val triggerAt: String,
- @SerializedName("completed") val completed: Boolean,
-)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt
new file mode 100644
index 0000000..14f0ae6
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/SessionDtos.kt
@@ -0,0 +1,38 @@
+package top.yeij.cyrene.data.remote.dto
+
+import com.google.gson.annotations.SerializedName
+
+// POST /api/v1/sessions — request body
+data class CreateSessionRequest(
+ @SerializedName("session_id") val sessionId: String? = null,
+ @SerializedName("title") val title: String = "新的对话",
+ @SerializedName("is_main") val isMain: Boolean = false,
+)
+
+// GET /api/v1/sessions — response wrapper
+data class SessionsListResponse(
+ @SerializedName("sessions") val sessions: List,
+)
+
+data class SessionDto(
+ @SerializedName("id") val id: String,
+ @SerializedName("user_id") val userId: String?,
+ @SerializedName("title") val title: String,
+ @SerializedName("is_main") val isMain: Boolean?,
+ @SerializedName("created_at") val createdAt: Long,
+ @SerializedName("updated_at") val updatedAt: Long,
+)
+
+// GET /api/v1/sessions/{id}/messages — response wrapper
+data class MessagesListResponse(
+ @SerializedName("messages") val messages: List,
+)
+
+data class SessionMessageDto(
+ @SerializedName("id") val id: String,
+ @SerializedName("session_id") val sessionId: String,
+ @SerializedName("role") val role: String,
+ @SerializedName("msg_type") val msgType: String?,
+ @SerializedName("content") val content: String,
+ @SerializedName("created_at") val createdAt: Long,
+)
diff --git a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt
index 67846a8..9dd58ba 100644
--- a/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/remote/dto/WSDtos.kt
@@ -9,12 +9,25 @@ data class WSClientMessage(
@SerializedName("session_id") val sessionId: String? = null,
@SerializedName("mode") val mode: String? = null,
@SerializedName("content") val content: String? = null,
+ @SerializedName("audio_data") val audioData: String? = null,
+ @SerializedName("attachments") val attachments: List? = null,
@SerializedName("timestamp") val timestamp: Long? = null,
@SerializedName("client_id") val clientId: String? = null,
@SerializedName("device_name") val deviceName: String? = null,
@SerializedName("user_agent") val userAgent: String? = null,
)
+data class WSAttachment(
+ @SerializedName("type") val type: String,
+ @SerializedName("url") val url: String? = null,
+ @SerializedName("thumbnail_url") val thumbnailUrl: String? = null,
+ @SerializedName("filename") val filename: String? = null,
+ @SerializedName("width") val width: Int? = null,
+ @SerializedName("height") val height: Int? = null,
+ @SerializedName("size") val size: Long? = null,
+ @SerializedName("description") val description: String? = null,
+)
+
// --- Server → Client ---
data class WSClientInfo(
diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt
index 744ea8a..75f8260 100644
--- a/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/repository/AuthRepositoryImpl.kt
@@ -22,12 +22,13 @@ class AuthRepositoryImpl(
authInterceptor.token = body.token
preferencesDataStore.saveToken(body.token)
body.refreshToken?.let { preferencesDataStore.saveRefreshToken(it) }
- preferencesDataStore.saveUsername(body.username ?: body.userId ?: "开拓者")
+ val displayName = body.nickname ?: body.username ?: body.userId ?: "开拓者"
+ preferencesDataStore.saveUsername(displayName)
Result.success(
AuthResult(
token = body.token,
refreshToken = body.refreshToken,
- username = body.username ?: body.userId ?: "开拓者",
+ username = displayName,
)
)
} else {
diff --git a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
index 9d57603..11d8b53 100644
--- a/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
+++ b/app/src/main/java/top/yeij/cyrene/data/repository/ChatRepositoryImpl.kt
@@ -12,16 +12,22 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.withTimeoutOrNull
+import top.yeij.cyrene.data.local.PreferencesDataStore
import top.yeij.cyrene.data.local.dao.ConversationDao
import top.yeij.cyrene.data.local.dao.MessageDao
import top.yeij.cyrene.data.local.entity.ConversationEntity
import top.yeij.cyrene.data.local.entity.MessageEntity
import top.yeij.cyrene.data.remote.ApiService
+import top.yeij.cyrene.data.remote.dto.CreateSessionRequest
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
import top.yeij.cyrene.service.WebSocketService
+import top.yeij.cyrene.util.RuntimeLog
import java.util.UUID
class ChatRepositoryImpl(
@@ -29,6 +35,7 @@ class ChatRepositoryImpl(
private val messageDao: MessageDao,
private val webSocketService: WebSocketService,
private val apiService: ApiService,
+ private val preferencesDataStore: PreferencesDataStore,
) : ChatRepository {
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
@@ -39,25 +46,79 @@ class ChatRepositoryImpl(
private val _connectionState = MutableStateFlow(false)
override val connectionState: StateFlow = _connectionState.asStateFlow()
+ override val connectionError: StateFlow = webSocketService.connectionError
+
private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64)
override fun observeMessages(): Flow = _incomingMessages
+ private val _messageClearEvents = MutableSharedFlow(extraBufferCapacity = 4)
+ override val messageClearEvents: Flow = _messageClearEvents
+
+ private val _isAssistantStreaming = MutableStateFlow(false)
+ override val isAssistantStreaming: StateFlow = _isAssistantStreaming.asStateFlow()
+
private var streamingContent = ""
private var streamingMessageId: String? = null
- private var currentSessionId: String? = null
+ override var currentSessionId: String? = null
+
+ private var isAppInForeground = false
+ private var onBackgroundNotification: ((Message) -> Unit)? = null
+ private var historyRequested = false
+
+ // Duplicate suppression: track items from review/multi_message to skip wrapping response
+ private val recentParsedContents = mutableListOf()
+ private var lastParsedTime = 0L
+ // Track last response to clean up if review/multi_message arrives after
+ private var lastResponseId: String? = null
+ private var lastResponseContent: String? = null
+ private var lastResponseTime = 0L
+
+ fun setNotificationCallback(callback: ((Message) -> Unit)?) {
+ onBackgroundNotification = callback
+ }
+
+ override fun onAppForeground() {
+ isAppInForeground = true
+ if (!_connectionState.value) {
+ webSocketService.forceReconnect()
+ }
+ // Always request history on foreground to catch cross-device messages
+ scope.launch {
+ val sid = currentSessionId ?: return@launch
+ RuntimeLog.general("app", "Foreground — requesting history for session=$sid")
+ requestHistoryViaWs(sid)
+ }
+ }
+
+ override fun onAppBackground() {
+ isAppInForeground = false
+ }
init {
+ // Restore persisted session ID, then connect and load history
+ scope.launch {
+ val persistedSid = preferencesDataStore.currentSessionId.firstOrNull()
+ if (!persistedSid.isNullOrBlank()) {
+ currentSessionId = persistedSid
+ }
+ RuntimeLog.ws("init", "Connecting WebSocket session=$currentSessionId")
+ webSocketService.connect(currentSessionId)
+ loadConversationsFromServer()
+ }
scope.launch {
webSocketService.isConnected.collect { connected ->
_connectionState.value = connected
+ RuntimeLog.ws("connection", "Connected=$connected")
}
}
scope.launch {
webSocketService.incomingMessages.collect { wsMsg ->
try {
+ RuntimeLog.ws("receive", "type=${wsMsg.type} msgId=${wsMsg.messageId ?: "-"}")
handleServerMessage(wsMsg)
} catch (e: Exception) {
Log.e("ChatRepository", "Error handling ${wsMsg.type}: ${e.message}", e)
+ RuntimeLog.ws("error", "Handle error type=${wsMsg.type}: ${e.message}")
}
}
}
@@ -77,7 +138,39 @@ class ChatRepositoryImpl(
override suspend fun deleteConversation(id: String) {
conversationDao.deleteById(id)
- try { apiService.deleteConversation(id) } catch (_: Exception) { }
+ try { apiService.deleteSession(id) } catch (_: Exception) { }
+ }
+
+ override suspend fun clearLocalMessages() {
+ val now = System.currentTimeMillis()
+ messageDao.deleteAll()
+ preferencesDataStore.saveLastClearedTimestamp(now)
+
+ // Also clear server-side messages for all known sessions
+ try {
+ val sessions = conversationDao.getAllSnapshot()
+ sessions.forEach { session ->
+ try {
+ apiService.clearSessionMessages(session.id)
+ RuntimeLog.chat("clear", "Server messages cleared for session=${session.id}")
+ } catch (_: Exception) { }
+ }
+ } catch (_: Exception) { }
+
+ _messageClearEvents.tryEmit(Unit)
+
+ RuntimeLog.chat("clear", "Local messages cleared, timestamp=$now")
+ Log.i("ChatRepository", "Local messages cleared, timestamp: $now")
+ }
+
+ private suspend fun changeSessionId(newId: String) {
+ val oldId = currentSessionId
+ if (oldId != null && oldId != newId) {
+ messageDao.migrateConversationId(oldId, newId)
+ conversationDao.deleteById(oldId)
+ }
+ currentSessionId = newId
+ preferencesDataStore.saveCurrentSessionId(newId)
}
override suspend fun connectWebSocket(sessionId: String?) {
@@ -85,15 +178,26 @@ class ChatRepositoryImpl(
webSocketService.connect(sessionId)
}
- override suspend fun disconnectWebSocket() {
+ override suspend fun reconnectWebSocket() {
webSocketService.disconnect()
+ webSocketService.connect(currentSessionId)
+ }
+
+ override suspend fun ensureConnected() {
+ if (_connectionState.value) return
+ webSocketService.forceReconnect()
}
override suspend fun sendMessage(content: String, sessionId: String?) {
val messageId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val sid = sessionId ?: currentSessionId ?: "default"
- currentSessionId = sid
+ if (currentSessionId == null) {
+ currentSessionId = sid
+ scope.launch { preferencesDataStore.saveCurrentSessionId(sid) }
+ }
+
+ RuntimeLog.chat("send", "session=$sid msgId=$messageId content=${content.take(80)}")
conversationDao.upsert(
ConversationEntity(
@@ -117,7 +221,6 @@ class ChatRepositoryImpl(
)
)
- // Emit user message to UI
emitMessage(
id = messageId,
sessionId = sid,
@@ -133,18 +236,17 @@ class ChatRepositoryImpl(
override suspend fun loadConversationsFromServer() {
try {
- val response = apiService.getConversations()
+ val response = apiService.getSessions()
if (response.isSuccessful) {
- response.body()?.forEach { dto ->
- val timestamp = try { dto.updatedAt.toLong() } catch (_: Exception) { System.currentTimeMillis() }
+ response.body()?.sessions?.forEach { dto ->
conversationDao.upsert(
ConversationEntity(
id = dto.id,
title = dto.title,
- lastMessage = dto.lastMessage,
- lastMessageType = dto.lastMessageType,
- updatedAt = timestamp,
- createdAt = timestamp,
+ lastMessage = "",
+ lastMessageType = "chat",
+ updatedAt = dto.updatedAt,
+ createdAt = dto.createdAt,
)
)
}
@@ -152,11 +254,103 @@ class ChatRepositoryImpl(
} catch (_: Exception) { }
}
+ override suspend fun sendScreenContext(content: String) {
+ webSocketService.sendScreenContext(content, currentSessionId)
+ }
+
+ override suspend fun sendVoiceInput(audioBase64: String, mode: String) {
+ webSocketService.sendVoiceInput(audioBase64, currentSessionId, mode)
+ }
+
+ override suspend fun initializeSession(): String {
+ // Try to find an existing main session on the server
+ try {
+ val response = apiService.getSessions()
+ if (response.isSuccessful) {
+ val sessions = response.body()?.sessions ?: emptyList()
+ val mainSession = sessions.find { it.isMain == true }
+ if (mainSession != null) {
+ currentSessionId = mainSession.id
+ preferencesDataStore.saveCurrentSessionId(mainSession.id)
+ Log.i("ChatRepository", "Found main session: ${mainSession.id}")
+ return mainSession.id
+ }
+ }
+ } catch (_: Exception) { }
+
+ // No main session found on server, create one with deterministic ID
+ val sessionId = "session_admin_main"
+ try {
+ apiService.createSession(
+ CreateSessionRequest(
+ sessionId = sessionId,
+ title = "主对话",
+ isMain = true,
+ )
+ )
+ Log.i("ChatRepository", "Created main session: $sessionId")
+ } catch (e: Exception) {
+ Log.w("ChatRepository", "Failed to create main session: ${e.message}")
+ }
+
+ currentSessionId = sessionId
+ preferencesDataStore.saveCurrentSessionId(sessionId)
+ return sessionId
+ }
+
override suspend fun loadMessagesFromServer(sessionId: String): List {
currentSessionId = sessionId
- // Send history request via WebSocket
+ return try {
+ val response = apiService.getSessionMessages(sessionId)
+ if (response.isSuccessful) {
+ val messageDtos = response.body()?.messages ?: emptyList()
+ val lastCleared = preferencesDataStore.lastClearedTimestamp.firstOrNull()
+ ?.toLongOrNull() ?: 0L
+ val filteredDtos = messageDtos.filter { it.createdAt > lastCleared }
+ ensureConversation(sessionId)
+ filteredDtos.forEach { dto ->
+ messageDao.upsert(
+ MessageEntity(
+ id = "db_${dto.id}",
+ conversationId = sessionId,
+ role = dto.role,
+ content = dto.content,
+ msgType = dto.msgType ?: "chat",
+ timestamp = dto.createdAt,
+ )
+ )
+ }
+ RuntimeLog.http("loadMessages", "HTTP loaded ${filteredDtos.size} messages for session=$sessionId")
+ filteredDtos.map { dto ->
+ Message(
+ id = "db_${dto.id}",
+ conversationId = sessionId,
+ role = dto.role,
+ content = dto.content,
+ msgType = dto.msgType ?: "chat",
+ timestamp = dto.createdAt,
+ )
+ }.removeWrappingDuplicates()
+ } else {
+ RuntimeLog.http("loadMessages", "HTTP failed: ${response.code()} ${response.message()}, trying WS")
+ requestHistoryViaWs(sessionId)
+ emptyList()
+ }
+ } catch (e: Exception) {
+ RuntimeLog.http("loadMessages", "HTTP error: ${e.message}, trying WS")
+ requestHistoryViaWs(sessionId)
+ emptyList()
+ }
+ }
+
+ private suspend fun requestHistoryViaWs(sessionId: String) {
+ // Wait up to 5s for WS to connect
+ if (!webSocketService.isConnected.value) {
+ withTimeoutOrNull(5000) {
+ webSocketService.isConnected.first { it }
+ }
+ }
webSocketService.requestHistory(sessionId)
- return emptyList()
}
private suspend fun ensureConversation(sessionId: String, lastMessage: String = "") {
@@ -181,6 +375,8 @@ class ChatRepositoryImpl(
"stream_start" -> {
streamingContent = ""
streamingMessageId = wsMsg.messageId ?: "stream_${System.currentTimeMillis()}"
+ _isAssistantStreaming.value = true
+ RuntimeLog.chat("stream", "Stream start msgId=$streamingMessageId")
}
"stream_chunk" -> {
@@ -202,22 +398,28 @@ class ChatRepositoryImpl(
streamingContent = ""
streamingMessageId = null
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
- currentSessionId = sid
+ if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
+ changeSessionId(sid)
+ }
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
- ensureConversation(sid, content)
- messageDao.upsert(
- MessageEntity(
- id = msgId,
- conversationId = sid,
- role = "assistant",
- content = content,
- msgType = "chat",
- timestamp = ts,
+ if (content.isNotBlank()) {
+ ensureConversation(sid, content)
+ messageDao.upsert(
+ MessageEntity(
+ id = msgId,
+ conversationId = sid,
+ role = "assistant",
+ content = content,
+ msgType = "chat",
+ timestamp = ts,
+ )
)
- )
+ }
- emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false)
+ emitMessage(id = msgId, sessionId = sid, role = "assistant", content = content, msgType = "chat", timestamp = ts, isStreaming = false, shouldNotify = true)
+ _isAssistantStreaming.value = false
+ RuntimeLog.chat("stream", "Stream end msgId=$msgId content=${content.take(80)}")
}
"response" -> {
@@ -226,7 +428,22 @@ class ChatRepositoryImpl(
val replyMsgType = wsMsg.msgType ?: "chat"
val msgId = wsMsg.messageId ?: "r_${System.currentTimeMillis()}"
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
- currentSessionId = sid
+
+ // Suppress response if it wraps recently emitted review/multi_message items
+ val timeSinceParsed = System.currentTimeMillis() - lastParsedTime
+ if (timeSinceParsed < 3000 && recentParsedContents.isNotEmpty()) {
+ val allContained = recentParsedContents.all { text.contains(it) }
+ if (allContained) {
+ RuntimeLog.chat("dedup", "Suppressed wrapping response, ${recentParsedContents.size} items already shown")
+ recentParsedContents.clear()
+ return
+ }
+ }
+ recentParsedContents.clear()
+
+ if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
+ changeSessionId(sid)
+ }
val ts = wsMsg.timestamp ?: System.currentTimeMillis()
ensureConversation(sid, text)
@@ -241,18 +458,27 @@ class ChatRepositoryImpl(
)
)
- emitMessage(id = msgId, sessionId = sid, role = role, content = text, msgType = replyMsgType, timestamp = ts, isStreaming = false)
+ lastResponseId = msgId
+ lastResponseContent = text
+ lastResponseTime = System.currentTimeMillis()
+
+ 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 text = review.content ?: review.text ?: return@forEach
val role = review.role ?: "action"
val rvMsgType = review.msgType ?: review.role ?: "action"
val msgId = "rv_${System.currentTimeMillis()}_${review.hashCode()}"
-
+ recentParsedContents.add(text)
emitMessage(id = msgId, sessionId = wsMsg.sessionId ?: currentSessionId ?: "default", role = role, content = text, msgType = rvMsgType, isStreaming = false)
}
+ if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
+ // Clean up wrapping response that arrived before this review
+ cleanupWrappingResponse()
}
"thinking" -> {
@@ -285,6 +511,7 @@ class ChatRepositoryImpl(
}
"error" -> {
+ RuntimeLog.chat("error", "Server error: ${wsMsg.error ?: "未知错误"}")
emitMessage(
id = "err_${System.currentTimeMillis()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
@@ -295,47 +522,102 @@ class ChatRepositoryImpl(
)
}
+ "voice_transcript" -> {
+ val text = wsMsg.text ?: wsMsg.content ?: return
+ val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
+ val ts = wsMsg.timestamp ?: System.currentTimeMillis()
+ val msgId = wsMsg.messageId ?: "vt_${System.currentTimeMillis()}"
+ ensureConversation(sid)
+ messageDao.upsert(
+ MessageEntity(
+ id = msgId,
+ conversationId = sid,
+ role = "user",
+ content = text,
+ msgType = "chat",
+ timestamp = ts,
+ )
+ )
+ emitMessage(id = msgId, sessionId = sid, role = "user", content = text, msgType = "chat", timestamp = ts, isStreaming = false)
+ }
+
"history_response" -> {
val sid = wsMsg.sessionId ?: currentSessionId ?: "default"
+ if (currentSessionId == null || (wsMsg.sessionId != null && wsMsg.sessionId != currentSessionId)) {
+ changeSessionId(sid)
+ }
ensureConversation(sid)
- wsMsg.messages?.forEach { hist ->
+ val messages = wsMsg.messages ?: return
+ val messageList = messages.map { hist ->
val msgId = hist.id ?: "hist_${System.currentTimeMillis()}_${hist.hashCode()}"
- val role = hist.role ?: "system"
- val content = hist.content ?: ""
- val msgType = hist.msgType ?: "chat"
- val ts = hist.timestamp ?: System.currentTimeMillis()
-
+ Message(
+ id = msgId,
+ conversationId = sid,
+ role = hist.role ?: "system",
+ content = hist.content ?: "",
+ msgType = hist.msgType ?: "chat",
+ timestamp = hist.timestamp ?: System.currentTimeMillis(),
+ )
+ }
+ val deduped = messageList.removeWrappingDuplicates()
+ deduped.forEach { msg ->
messageDao.upsert(
MessageEntity(
- id = msgId,
- conversationId = sid,
- role = role,
- content = content,
- msgType = msgType,
- timestamp = ts,
+ id = msg.id,
+ conversationId = msg.conversationId,
+ role = msg.role,
+ content = msg.content,
+ msgType = msg.msgType,
+ timestamp = msg.timestamp,
)
)
-
- emitMessage(id = msgId, sessionId = sid, role = role, content = content, msgType = msgType, timestamp = ts, isStreaming = false)
+ emitMessage(
+ id = msg.id,
+ sessionId = msg.conversationId,
+ role = msg.role,
+ content = msg.content,
+ msgType = msg.msgType,
+ timestamp = msg.timestamp,
+ isStreaming = false,
+ shouldNotify = false,
+ )
}
+ RuntimeLog.chat("history", "Loaded ${deduped.size} messages from server history")
}
"multi_message" -> {
+ recentParsedContents.clear()
wsMsg.multiMessages?.forEach { item ->
+ val content = item.content ?: ""
+ recentParsedContents.add(content)
emitMessage(
id = "mm_${System.currentTimeMillis()}_${item.hashCode()}",
sessionId = wsMsg.sessionId ?: currentSessionId ?: "default",
role = item.role ?: "assistant",
- content = item.content ?: "",
+ content = content,
msgType = item.msgType ?: "chat",
timestamp = wsMsg.timestamp ?: System.currentTimeMillis(),
isStreaming = false,
)
}
+ if (recentParsedContents.isNotEmpty()) lastParsedTime = System.currentTimeMillis()
+ cleanupWrappingResponse()
}
}
}
+ private suspend fun cleanupWrappingResponse() {
+ val respId = lastResponseId ?: return
+ val respContent = lastResponseContent ?: return
+ val timeSinceResponse = System.currentTimeMillis() - lastResponseTime
+ if (timeSinceResponse > 5000 || recentParsedContents.size < 2) return
+ val allContained = recentParsedContents.all { respContent.contains(it) }
+ if (allContained) {
+ messageDao.deleteById(respId)
+ RuntimeLog.chat("dedup", "Cleaned up wrapping response from DB id=$respId")
+ }
+ }
+
private fun emitMessage(
id: String,
sessionId: String,
@@ -344,8 +626,8 @@ class ChatRepositoryImpl(
msgType: String,
isStreaming: Boolean = false,
timestamp: Long = System.currentTimeMillis(),
+ shouldNotify: Boolean = false,
) {
- // Skip messages with empty content to prevent empty bubbles
if (content.isBlank() && msgType == "chat") return
val message = Message(
id = id,
@@ -357,6 +639,33 @@ class ChatRepositoryImpl(
isStreaming = isStreaming,
)
_incomingMessages.tryEmit(message)
+
+ if (shouldNotify && !isAppInForeground && role == "assistant" && !isStreaming) {
+ onBackgroundNotification?.invoke(message)
+ }
+ }
+
+ /**
+ * Remove wrapper messages whose content contains the content of 2+ other messages.
+ * This handles the case where the server sends both a combined "response" and
+ * parsed "review"/"multi_message" items for the same turn.
+ */
+ private fun List.removeWrappingDuplicates(): List {
+ if (size < 3) return this
+ val toRemove = mutableSetOf()
+ for (msg in this) {
+ val containedCount = count { other ->
+ other.id != msg.id &&
+ other.content.isNotBlank() &&
+ other.content.length < msg.content.length &&
+ msg.content.contains(other.content) &&
+ kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
+ }
+ if (containedCount >= 2) {
+ toRemove.add(msg.id)
+ }
+ }
+ return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
}
private fun ConversationEntity.toDomain() = Conversation(
diff --git a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
index e8b03ef..02b7aef 100644
--- a/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
+++ b/app/src/main/java/top/yeij/cyrene/di/AppModule.kt
@@ -9,6 +9,7 @@ import top.yeij.cyrene.data.remote.ApiService
import top.yeij.cyrene.data.remote.AuthInterceptor
import top.yeij.cyrene.data.remote.DynamicUrlInterceptor
import top.yeij.cyrene.data.remote.RetrofitClient
+import top.yeij.cyrene.data.remote.TokenAuthenticator
import top.yeij.cyrene.data.repository.AuthRepositoryImpl
import top.yeij.cyrene.data.repository.ChatRepositoryImpl
import top.yeij.cyrene.data.repository.IoTRepositoryImpl
@@ -22,8 +23,13 @@ import top.yeij.cyrene.service.WebSocketService
import top.yeij.cyrene.viewmodel.ChatViewModel
import top.yeij.cyrene.viewmodel.IoTViewModel
import top.yeij.cyrene.viewmodel.OverlayViewModel
+import top.yeij.cyrene.viewmodel.ProfileViewModel
import top.yeij.cyrene.viewmodel.SettingsViewModel
+import top.yeij.cyrene.util.VoiceRecorder
+import top.yeij.cyrene.voice.stt.BackendSttProvider
+import top.yeij.cyrene.voice.stt.DashScopeSttService
import top.yeij.cyrene.voice.stt.SpeechRecognizer
+import top.yeij.cyrene.voice.stt.SttManager
import top.yeij.cyrene.voice.tts.TextToSpeechEngine
val appModule = module {
@@ -36,10 +42,11 @@ val appModule = module {
single { get().conversationDao() }
single { get().messageDao() }
- // Network interceptors (no runBlocking — using @Volatile caches)
+ // Network interceptors
single { AuthInterceptor() }
single { DynamicUrlInterceptor() }
- single { RetrofitClient.provideOkHttpClient(get(), get()) }
+ single { TokenAuthenticator(get(), get(), get()) }
+ single { RetrofitClient.provideOkHttpClient(get(), get(), get()) }
single { RetrofitClient.provideRetrofit(get()) }
single { get().create(ApiService::class.java) }
@@ -47,12 +54,16 @@ val appModule = module {
single { WebSocketService(get()) }
// Voice
- single { SpeechRecognizer() }
+ single { VoiceRecorder(androidContext()) }
+ single { SpeechRecognizer(androidContext()) }
single { TextToSpeechEngine(androidContext()) }
+ single { DashScopeSttService(androidContext()) }
+ single { BackendSttProvider(androidContext(), get()) }
+ single { SttManager(get(), get(), get()) }
// Repositories
single { AuthRepositoryImpl(get(), get(), get()) }
- single { ChatRepositoryImpl(get(), get(), get(), get()) }
+ single { ChatRepositoryImpl(get(), get(), get(), get(), get()) }
single { IoTRepositoryImpl(get(), get()) }
// UseCases
@@ -61,8 +72,9 @@ val appModule = module {
factory { GetConversationsUseCase(get()) }
// ViewModels
- viewModel { ChatViewModel(get()) }
+ viewModel { ChatViewModel(get(), get()) }
viewModel { IoTViewModel(get()) }
- viewModel { OverlayViewModel(get()) }
- single { SettingsViewModel(get(), get(), get()) }
+ viewModel { OverlayViewModel(get(), get(), get()) }
+ viewModel { ProfileViewModel(get(), get(), get()) }
+ single { SettingsViewModel(get(), get(), get(), get(), get()) }
}
diff --git a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
index de77254..87db464 100644
--- a/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
+++ b/app/src/main/java/top/yeij/cyrene/domain/repository/ChatRepository.kt
@@ -8,6 +8,10 @@ import top.yeij.cyrene.domain.model.Message
interface ChatRepository {
val connectionState: StateFlow
+ val connectionError: StateFlow
+ val isAssistantStreaming: StateFlow
+ val messageClearEvents: Flow
+ var currentSessionId: String?
fun getConversations(): Flow>
@@ -17,8 +21,6 @@ interface ChatRepository {
suspend fun connectWebSocket(sessionId: String?)
- suspend fun disconnectWebSocket()
-
suspend fun sendMessage(content: String, sessionId: String?)
fun observeMessages(): Flow
@@ -26,4 +28,19 @@ interface ChatRepository {
suspend fun loadConversationsFromServer()
suspend fun loadMessagesFromServer(sessionId: String): List
+
+ suspend fun initializeSession(): String
+
+ suspend fun reconnectWebSocket()
+
+ suspend fun ensureConnected()
+
+ suspend fun sendScreenContext(content: String)
+
+ suspend fun sendVoiceInput(audioBase64: String, mode: String = "voice_msg")
+
+ suspend fun clearLocalMessages()
+
+ fun onAppForeground()
+ fun onAppBackground()
}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneAccessibilityService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneAccessibilityService.kt
new file mode 100644
index 0000000..c09803c
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneAccessibilityService.kt
@@ -0,0 +1,67 @@
+package top.yeij.cyrene.service
+
+import android.accessibilityservice.AccessibilityService
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+
+class CyreneAccessibilityService : AccessibilityService() {
+
+ companion object {
+ @Volatile
+ private var instance: CyreneAccessibilityService? = null
+
+ fun getScreenContent(): String {
+ return instance?.captureScreenContent() ?: ""
+ }
+
+ fun isRunning(): Boolean = instance != null
+ }
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+ instance = this
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) {}
+
+ override fun onInterrupt() {}
+
+ override fun onDestroy() {
+ instance = null
+ super.onDestroy()
+ }
+
+ private fun captureScreenContent(): String {
+ val root = rootInActiveWindow ?: return ""
+ return try {
+ val sb = StringBuilder()
+ collectNodeText(root, sb, depth = 0)
+ sb.toString().trim()
+ } finally {
+ root.recycle()
+ }
+ }
+
+ private fun collectNodeText(node: AccessibilityNodeInfo, sb: StringBuilder, depth: Int) {
+ // Skip the root decor view itself content, and limit depth
+ if (depth > 16) return
+
+ val text = node.text?.toString()?.trim()
+ val contentDesc = node.contentDescription?.toString()?.trim()
+
+ if (!text.isNullOrEmpty() && text != contentDesc) {
+ if (sb.isNotEmpty()) sb.append("\n")
+ sb.append(text)
+ } else if (!contentDesc.isNullOrEmpty() && depth > 0) {
+ // Only include content descriptions for non-root nodes
+ if (sb.isNotEmpty()) sb.append("\n")
+ sb.append(contentDesc)
+ }
+
+ for (i in 0 until node.childCount) {
+ val child = node.getChild(i) ?: continue
+ collectNodeText(child, sb, depth + 1)
+ child.recycle()
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneSessionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneSessionService.kt
new file mode 100644
index 0000000..d9a48dc
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneSessionService.kt
@@ -0,0 +1,27 @@
+package top.yeij.cyrene.service
+
+import android.content.Intent
+import android.os.Bundle
+import android.service.voice.VoiceInteractionSessionService
+import android.util.Log
+
+class CyreneSessionService : VoiceInteractionSessionService() {
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.i(TAG, "Session service created")
+ }
+
+ override fun onNewSession(args: Bundle?): CyreneVoiceInteractionSession {
+ return CyreneVoiceInteractionSession(this)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Log.i(TAG, "Session service destroyed")
+ }
+
+ companion object {
+ private const val TAG = "CyreneSession"
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt
index d783021..b9f629c 100644
--- a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionService.kt
@@ -3,27 +3,35 @@ package top.yeij.cyrene.service
import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionService
+import android.util.Log
import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.util.Constants
class CyreneVoiceInteractionService : VoiceInteractionService() {
+ override fun onCreate() {
+ super.onCreate()
+ Log.i(TAG, "Service created")
+ }
+
override fun onReady() {
super.onReady()
isActive = true
+ getSharedPreferences(PREF_NAME, MODE_PRIVATE)
+ .edit().putBoolean(KEY_WAS_ACTIVE, true).apply()
+ Log.i(TAG, "Service ready")
}
override fun onPrepareToShowSession(args: Bundle, showFlags: Int) {
- // Called before the session is shown — populate args for the session.
- // Starting from API 36, session creation is handled by the system
- // based on android:sessionService in voice_interaction_config.xml.
+ Log.i(TAG, "onPrepareToShowSession")
}
override fun onShowSessionFailed(args: Bundle) {
- // Session failed to show — could be due to permissions or system state.
+ Log.e(TAG, "onShowSessionFailed")
}
override fun onLaunchVoiceAssistFromKeyguard() {
+ Log.i(TAG, "onLaunchVoiceAssistFromKeyguard")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(Constants.EXTRA_VOICE_ASSIST, true)
@@ -34,10 +42,19 @@ class CyreneVoiceInteractionService : VoiceInteractionService() {
override fun onShutdown() {
isActive = false
super.onShutdown()
+ Log.i(TAG, "Service shutdown")
}
companion object {
var isActive: Boolean = false
private set
+ private const val TAG = "CyreneVIS"
+ private const val PREF_NAME = "cyrene_assistant"
+ private const val KEY_WAS_ACTIVE = "was_assistant_active"
+
+ fun wasEverActive(context: android.content.Context): Boolean {
+ return context.getSharedPreferences(PREF_NAME, android.content.Context.MODE_PRIVATE)
+ .getBoolean(KEY_WAS_ACTIVE, false)
+ }
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
index c174523..aec24ce 100644
--- a/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/CyreneVoiceInteractionSession.kt
@@ -1,34 +1,97 @@
package top.yeij.cyrene.service
import android.content.Context
+import android.content.Intent
import android.os.Bundle
import android.service.voice.VoiceInteractionSession
+import android.util.Log
import android.view.View
+import android.view.WindowManager
import androidx.compose.ui.platform.ComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import org.koin.core.context.GlobalContext
+import top.yeij.cyrene.MainActivity
import top.yeij.cyrene.ui.overlay.OverlayContent
import top.yeij.cyrene.ui.theme.CyreneTheme
import top.yeij.cyrene.util.Constants
-import top.yeij.cyrene.voice.stt.SpeechRecognizer
-import top.yeij.cyrene.voice.tts.TextToSpeechEngine
+import top.yeij.cyrene.util.RuntimeLog
+import top.yeij.cyrene.viewmodel.OverlayViewModel
class CyreneVoiceInteractionSession(context: Context) :
- VoiceInteractionSession(context) {
+ VoiceInteractionSession(context), LifecycleOwner, SavedStateRegistryOwner {
- private val speechRecognizer: SpeechRecognizer by lazy {
- GlobalContext.get().get()
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ override val lifecycle: Lifecycle get() = lifecycleRegistry
+
+ private val savedStateRegistryController = SavedStateRegistryController.create(this)
+ override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
+
+ // Resolved eagerly with fallback — lazy would silently crash composition on failure
+ private var overlayViewModel: OverlayViewModel? = null
+ private set
+
+ init {
+ savedStateRegistryController.performAttach()
+ savedStateRegistryController.performRestore(null)
}
- private val ttsEngine: TextToSpeechEngine by lazy {
- GlobalContext.get().get()
+
+ private fun resolveViewModel(): OverlayViewModel? {
+ return try {
+ GlobalContext.get().get()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to resolve OverlayViewModel from Koin", e)
+ null
+ }
}
override fun onCreateContentView(): View {
+ Log.d(TAG, "onCreateContentView called")
+ RuntimeLog.general("overlay", "onCreateContentView")
+ overlayViewModel = resolveViewModel()
+ if (overlayViewModel == null) {
+ Log.w(TAG, "ViewModel unavailable — overlay will be static")
+ RuntimeLog.general("overlay", "ViewModel unavailable, overlay static")
+ }
+
+ // Configure window: prevent IME resize, don't cover status bar
+ try {
+ val method = VoiceInteractionSession::class.java.getDeclaredMethod("getWindow")
+ method.isAccessible = true
+ val w = method.invoke(this) as? android.view.Window
+ w?.apply {
+ setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
+ clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
+ clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
+ addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
+ }
+ } catch (_: Exception) { }
+
+ lifecycleRegistry.currentState = Lifecycle.State.CREATED
+ val vm = overlayViewModel
return ComposeView(context).apply {
+ setViewTreeLifecycleOwner(this@CyreneVoiceInteractionSession)
+ setViewTreeSavedStateRegistryOwner(this@CyreneVoiceInteractionSession)
setContent {
CyreneTheme {
- OverlayContent(
- onDismiss = { finish() },
- )
+ if (vm != null) {
+ OverlayContent(
+ onDismiss = { finish() },
+ onNavigateToMain = {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ },
+ viewModel = vm,
+ )
+ }
}
}
}
@@ -36,15 +99,24 @@ class CyreneVoiceInteractionSession(context: Context) :
override fun onShow(args: Bundle?, showFlags: Int) {
super.onShow(args, showFlags)
- val startListening = args?.getBoolean(Constants.EXTRA_START_LISTENING, false) ?: false
- if (startListening) {
- speechRecognizer.startListening()
+ RuntimeLog.general("overlay", "onShow, vm=${overlayViewModel != null}")
+ lifecycleRegistry.currentState = Lifecycle.State.STARTED
+
+ val screenContent = CyreneAccessibilityService.getScreenContent()
+ if (screenContent.isNotBlank()) {
+ overlayViewModel?.sendScreenContext(screenContent)
+ RuntimeLog.general("overlay", "Screen context sent, len=${screenContent.length}")
}
}
override fun onHide() {
+ RuntimeLog.general("overlay", "onHide")
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
super.onHide()
- speechRecognizer.stopListening()
- ttsEngine.stop()
+ overlayViewModel?.finish()
+ }
+
+ companion object {
+ private const val TAG = "CyreneVIS-Session"
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
index b32dee4..ada21e4 100644
--- a/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
+++ b/app/src/main/java/top/yeij/cyrene/service/WebSocketService.kt
@@ -26,6 +26,7 @@ import top.yeij.cyrene.data.remote.dto.WSClientMessage
import top.yeij.cyrene.data.remote.dto.WSServerMessage
import java.net.URLEncoder
import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
class WebSocketService(
private val preferencesDataStore: PreferencesDataStore,
@@ -42,9 +43,10 @@ class WebSocketService(
private var webSocket: WebSocket? = null
private var heartbeatJob: Job? = null
- private var reconnecting = false
+ private var reconnectJob: Job? = null
private var shouldReconnect = true
private var currentSessionId: String? = null
+ private val connectionId = AtomicInteger(0)
private var clientId: String = ""
private var deviceName: String = ""
@@ -52,6 +54,9 @@ class WebSocketService(
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow = _isConnected.asStateFlow()
+ private val _connectionError = MutableStateFlow(null)
+ val connectionError: StateFlow = _connectionError.asStateFlow()
+
private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64)
val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow()
@@ -75,7 +80,6 @@ class WebSocketService(
suspend fun connect(sessionId: String? = null) {
currentSessionId = sessionId
shouldReconnect = true
- reconnecting = false
initClientIdentity()
@@ -106,50 +110,74 @@ class WebSocketService(
}
val url = urlBuilder.toString()
- Log.i(TAG, "Connecting to $url")
+ val connId = connectionId.incrementAndGet()
+ Log.i(TAG, "[#$connId] Connecting to $url")
val request = Request.Builder()
.url(url)
.header("User-Agent", "Cyrene-Android/${Build.MODEL ?: "Device"}")
.build()
+ // Close previous socket silently
+ try { webSocket?.close(1000, "Reconnecting") } catch (_: Exception) { }
cancelHeartbeat()
- webSocket?.close(1000, "Reconnecting")
+
webSocket = httpClient.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
- Log.i(TAG, "Connected")
- reconnecting = false
+ if (connectionId.get() != connId) {
+ Log.d(TAG, "[#$connId] onOpen ignored (stale)")
+ return
+ }
+ Log.i(TAG, "[#$connId] Connected")
_isConnected.value = true
+ _connectionError.value = null
startHeartbeat()
}
override fun onMessage(webSocket: WebSocket, text: String) {
+ if (connectionId.get() != connId) return
try {
val msg = gson.fromJson(text, WSServerMessage::class.java)
_incomingMessages.tryEmit(msg)
} catch (e: Exception) {
- Log.w(TAG, "Failed to parse message: ${e.message}")
+ Log.w(TAG, "[#$connId] Failed to parse message: ${e.message}")
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
- Log.i(TAG, "Server closing: code=$code reason=$reason")
+ if (connectionId.get() != connId) return
+ Log.i(TAG, "[#$connId] Server closing: code=$code reason=$reason")
_isConnected.value = false
cancelHeartbeat()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
- Log.i(TAG, "Closed: code=$code reason=$reason")
+ if (connectionId.get() != connId) return
+ Log.i(TAG, "[#$connId] Closed: code=$code reason=$reason")
_isConnected.value = false
cancelHeartbeat()
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
- Log.e(TAG, "Connection failure: ${t.message} (response=${response?.code})", t)
+ if (connectionId.get() != connId) return
+ val httpCode = response?.code
+ Log.e(TAG, "[#$connId] Failure: ${t.message} (http=$httpCode)", t)
_isConnected.value = false
cancelHeartbeat()
- scheduleReconnect()
+
+ val errorMsg = when (httpCode) {
+ 403 -> {
+ Log.e(TAG, "[#$connId] WebSocket 403: 仅管理员用户可连接。请使用管理员账户登录。")
+ "仅管理员用户可连接"
+ }
+ 401 -> "认证失败,请重新登录"
+ else -> null
+ }
+ if (errorMsg != null) {
+ _connectionError.value = errorMsg
+ }
+ // onClosed will always follow, which triggers scheduleReconnect
}
})
}
@@ -179,7 +207,12 @@ class WebSocketService(
fun requestHistory(sessionId: String?) {
val msg = buildMessage("history", sessionId)
- webSocket?.send(gson.toJson(msg))
+ if (webSocket != null) {
+ webSocket?.send(gson.toJson(msg))
+ Log.i(TAG, "History requested for session=$sessionId")
+ } else {
+ Log.w(TAG, "Cannot request history: WebSocket is null")
+ }
}
fun sendPing() {
@@ -187,11 +220,44 @@ class WebSocketService(
webSocket?.send(gson.toJson(msg))
}
+ fun sendScreenContext(content: String, sessionId: String? = null) {
+ val msg = buildMessage("message", sessionId, mode = "text", content = content)
+ webSocket?.send(gson.toJson(msg))
+ }
+
+ fun sendVoiceInput(audioBase64: String, sessionId: String? = null, mode: String = "voice_msg") {
+ val msg = WSClientMessage(
+ type = "voice_input",
+ sessionId = sessionId ?: currentSessionId,
+ mode = mode,
+ audioData = audioBase64,
+ timestamp = System.currentTimeMillis(),
+ clientId = clientId.ifBlank { null },
+ deviceName = deviceName.ifBlank { null },
+ userAgent = "Cyrene-Android/${Build.MODEL ?: "Device"}",
+ )
+ webSocket?.send(gson.toJson(msg))
+ }
+
+ fun forceReconnect() {
+ shouldReconnect = true
+ reconnectJob?.cancel()
+ reconnectJob = null
+ scope.launch {
+ if (!_isConnected.value) {
+ try {
+ connect(currentSessionId)
+ } catch (_: Exception) { }
+ }
+ }
+ }
+
fun disconnect() {
shouldReconnect = false
- reconnecting = false
+ reconnectJob?.cancel()
+ reconnectJob = null
cancelHeartbeat()
- webSocket?.close(1000, "User disconnected")
+ try { webSocket?.close(1000, "User disconnected") } catch (_: Exception) { }
webSocket = null
_isConnected.value = false
}
@@ -214,13 +280,15 @@ class WebSocketService(
}
private fun scheduleReconnect() {
- if (reconnecting || !shouldReconnect) return
- reconnecting = true
- scope.launch {
+ if (reconnectJob?.isActive == true || !shouldReconnect) return
+ reconnectJob = scope.launch {
var attempt = 0
- while (attempt < 5 && shouldReconnect && !_isConnected.value) {
- val delayMs = (Math.pow(2.0, attempt.toDouble()) * 1000).toLong()
- Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1}/5)")
+ while (shouldReconnect && !_isConnected.value) {
+ val delayMs = minOf(
+ (Math.pow(2.0, attempt.toDouble()) * 1000).toLong(),
+ 30_000L
+ )
+ Log.i(TAG, "Reconnecting in ${delayMs}ms (attempt ${attempt + 1})")
delay(delayMs)
attempt++
if (shouldReconnect && !_isConnected.value) {
@@ -231,7 +299,7 @@ class WebSocketService(
}
}
}
- reconnecting = false
+ Log.i(TAG, "Reconnect loop ended (connected=${_isConnected.value}, shouldReconnect=$shouldReconnect)")
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
index 522b362..2652180 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/components/ChatBubble.kt
@@ -64,6 +64,7 @@ private fun ChatMessageBubble(
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant,
+ shadowElevation = 2.dp,
modifier = Modifier.widthIn(max = 300.dp),
) {
Text(
@@ -91,7 +92,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
- horizontalArrangement = Arrangement.Center,
+ horizontalArrangement = Arrangement.Start,
) {
Text(
text = content,
@@ -99,7 +100,7 @@ private fun ActionMessage(content: String, modifier: Modifier = Modifier) {
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
- textAlign = TextAlign.Center,
+ textAlign = TextAlign.Start,
)
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/components/TypingIndicator.kt b/app/src/main/java/top/yeij/cyrene/ui/components/TypingIndicator.kt
new file mode 100644
index 0000000..b9a17ea
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/components/TypingIndicator.kt
@@ -0,0 +1,77 @@
+package top.yeij.cyrene.ui.components
+
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TypingIndicator(modifier: Modifier = Modifier) {
+ val infiniteTransition = rememberInfiniteTransition(label = "typing")
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.Start,
+ ) {
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shadowElevation = 1.dp,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = "昔涟正在输入",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ repeat(3) { index ->
+ val alpha by infiniteTransition.animateFloat(
+ initialValue = 0.2f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(
+ durationMillis = 400,
+ delayMillis = index * 200,
+ ),
+ repeatMode = RepeatMode.Reverse,
+ ),
+ label = "dot_$index",
+ )
+ Box(
+ modifier = Modifier
+ .size(5.dp)
+ .alpha(alpha)
+ .background(
+ MaterialTheme.colorScheme.onSurfaceVariant,
+ CircleShape,
+ ),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
index ea73a23..0f370fd 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/navigation/NavGraph.kt
@@ -1,15 +1,17 @@
package top.yeij.cyrene.ui.navigation
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.DevicesOther
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.Scaffold
+import androidx.compose.material3.NavigationRail
+import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -20,13 +22,12 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
-import org.koin.compose.koinInject
import top.yeij.cyrene.ui.screens.chat.ChatScreen
import top.yeij.cyrene.ui.screens.iot.IoTScreen
import top.yeij.cyrene.ui.screens.login.LoginScreen
+import top.yeij.cyrene.ui.screens.about.AboutScreen
import top.yeij.cyrene.ui.screens.profile.ProfileScreen
import top.yeij.cyrene.ui.screens.settings.SettingsScreen
-import top.yeij.cyrene.viewmodel.SettingsViewModel
object Routes {
const val LOGIN = "login"
@@ -34,6 +35,7 @@ object Routes {
const val CHAT = "chat"
const val IOT = "iot"
const val SETTINGS = "settings"
+ const val ABOUT = "about"
}
@Composable
@@ -70,6 +72,12 @@ fun CyreneNavGraph(
onBack = { navController.popBackStack() },
)
}
+
+ composable(Routes.ABOUT) {
+ AboutScreen(
+ onBack = { navController.popBackStack() },
+ )
+ }
}
}
@@ -85,8 +93,6 @@ fun MainScreen(
isDefaultAssistant: Boolean,
onOpenAssistantSettings: () -> Unit,
) {
- val settingsViewModel: SettingsViewModel = koinInject()
-
val items = listOf(
BottomNavItem(
label = "对话",
@@ -107,28 +113,30 @@ fun MainScreen(
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
- Scaffold(
- bottomBar = {
- NavigationBar {
- items.forEachIndexed { index, item ->
- NavigationBarItem(
- selected = selectedTab == index,
- onClick = { selectedTab = index },
- icon = item.icon,
- label = { Text(item.label) },
- )
- }
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding(),
+ ) {
+ NavigationRail {
+ items.forEachIndexed { index, item ->
+ NavigationRailItem(
+ selected = selectedTab == index,
+ onClick = { selectedTab = index },
+ icon = item.icon,
+ label = { Text(item.label) },
+ )
}
- },
- ) { padding ->
- Box(modifier = Modifier.padding(padding)) {
+ }
+
+ Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
when (selectedTab) {
0 -> ChatScreen()
1 -> IoTScreen()
2 -> ProfileScreen(
onNavigateToSettings = { navController.navigate(Routes.SETTINGS) },
+ onNavigateToAbout = { navController.navigate(Routes.ABOUT) },
onLogout = {
- settingsViewModel.logout()
navController.navigate(Routes.LOGIN) {
popUpTo(Routes.MAIN) { inclusive = true }
}
@@ -136,6 +144,8 @@ fun MainScreen(
onNavigateToLogin = {
navController.navigate(Routes.LOGIN)
},
+ isDefaultAssistant = isDefaultAssistant,
+ onOpenAssistantSettings = onOpenAssistantSettings,
)
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
index 33b570d..76da014 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/overlay/OverlayContent.kt
@@ -1,62 +1,135 @@
package top.yeij.cyrene.ui.overlay
+import android.content.res.Configuration
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.KeyboardVoice
+import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
import org.koin.compose.koinInject
+import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble
-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.viewmodel.OverlayState
import top.yeij.cyrene.viewmodel.OverlayViewModel
+import kotlin.math.min
+
+@Composable
+private fun AnimatedChatBubble(
+ message: Message,
+ animIndex: Int,
+) {
+ var visible by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ delay(min(animIndex, 10) * 60L)
+ visible = true
+ }
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
+ slideInVertically(
+ animationSpec = androidx.compose.animation.core.tween(300),
+ initialOffsetY = { it / 4 },
+ ),
+ ) {
+ ChatBubble(
+ content = message.content,
+ role = message.role,
+ msgType = message.msgType,
+ timestamp = message.timestamp,
+ )
+ }
+}
@Composable
fun OverlayContent(
onDismiss: () -> Unit,
+ onNavigateToMain: () -> Unit,
viewModel: OverlayViewModel = koinInject(),
) {
val state by viewModel.state.collectAsState()
val messages by viewModel.messages.collectAsState()
- val recognizedText by viewModel.recognizedText.collectAsState()
+ val inputText by viewModel.inputText.collectAsState()
+ val recordState by viewModel.voiceRecordState.collectAsState()
+ val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
+ val animIndex by viewModel.messageAnimIndex.collectAsState()
val listState = rememberLazyListState()
+ val isProcessing = state == OverlayState.PROCESSING
+ val recordSec = recordDurationMs / 1000f
+ val isRecording = recordState == RecordState.RECORDING
+ val isLocked = recordState == RecordState.LOCKED
+
+ // Animated "昔涟正在输入..." dots
+ val typingDots = remember { mutableStateOf("") }
+ LaunchedEffect(isProcessing) {
+ if (isProcessing) {
+ val dots = arrayOf("", ".", "..", "...")
+ var i = 0
+ while (true) {
+ typingDots.value = dots[i % 4]
+ i++
+ delay(400)
+ }
+ } else {
+ typingDots.value = ""
+ }
+ }
+
+ val configuration = LocalConfiguration.current
+ val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
@@ -79,124 +152,430 @@ fun OverlayContent(
Box(
modifier = Modifier
.fillMaxSize()
- .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f))
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() },
- ) { onDismiss() },
+ .statusBarsPadding()
+ .navigationBarsPadding(),
) {
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .align(Alignment.BottomCenter)
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() },
- ) { /* consume click */ },
- shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
- shadowElevation = 8.dp,
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- ) {
- // Header
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- StatusIndicator(
- status = when (state) {
- OverlayState.LISTENING -> CyreneStatus.ONLINE
- OverlayState.PROCESSING -> CyreneStatus.THINKING
- OverlayState.SPEAKING -> CyreneStatus.SPEAKING
- OverlayState.WAITING -> CyreneStatus.ONLINE
- OverlayState.IDLE -> CyreneStatus.ONLINE
- },
- )
- Spacer(modifier = Modifier.weight(1f))
- IconButton(onClick = { onDismiss() }) {
- Icon(Icons.Filled.Close, contentDescription = "关闭")
- }
- }
-
- // Messages
- LazyColumn(
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f, fill = false)
- .height(200.dp),
- state = listState,
- ) {
- items(messages, key = { it.id }) { message ->
- ChatBubble(
- content = message.content,
- role = message.role,
- msgType = message.msgType,
- timestamp = message.timestamp,
- )
- }
- }
-
- // Recognized text display
- if (recognizedText.isNotEmpty()) {
- Text(
- text = recognizedText,
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(vertical = 8.dp),
- )
- }
-
- // Action button
- Row(
- modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
- horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
- ) {
- Button(
- onClick = {
- when (state) {
- OverlayState.LISTENING -> {
- viewModel.onSpeechFinal(recognizedText)
- }
- OverlayState.WAITING -> {
- viewModel.startListening()
- }
- else -> { }
- }
- },
- shape = CircleShape,
- modifier = Modifier.size(64.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = when (state) {
- OverlayState.LISTENING -> MaterialTheme.colorScheme.error
- OverlayState.PROCESSING -> MaterialTheme.colorScheme.secondary
- else -> MaterialTheme.colorScheme.primary
- },
- ),
- ) {
- Icon(
- Icons.Filled.Mic,
- contentDescription = "语音",
- modifier = Modifier.size(32.dp),
- )
- }
- }
-
- Text(
- text = when (state) {
- OverlayState.IDLE -> ""
- OverlayState.LISTENING -> "我在听…"
- OverlayState.PROCESSING -> "思考中…"
- OverlayState.SPEAKING -> "正在说话…"
- OverlayState.WAITING -> "点击继续说话"
- },
- style = MaterialTheme.typography.labelMedium,
- modifier = Modifier.fillMaxWidth(),
- textAlign = androidx.compose.ui.text.style.TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
+ if (isLandscape) {
+ LandscapeContent(
+ state = state,
+ messages = messages,
+ inputText = inputText,
+ isProcessing = isProcessing,
+ listState = listState,
+ recordSec = recordSec,
+ isRecording = isRecording,
+ isLocked = isLocked,
+ typingDots = typingDots.value,
+ animIndex = animIndex,
+ onDismiss = onDismiss,
+ onNavigateToMain = onNavigateToMain,
+ viewModel = viewModel,
+ )
+ } else {
+ PortraitContent(
+ state = state,
+ messages = messages,
+ inputText = inputText,
+ isProcessing = isProcessing,
+ listState = listState,
+ recordSec = recordSec,
+ isRecording = isRecording,
+ isLocked = isLocked,
+ typingDots = typingDots.value,
+ animIndex = animIndex,
+ onDismiss = onDismiss,
+ onNavigateToMain = onNavigateToMain,
+ viewModel = viewModel,
+ )
+ }
+ }
+ }
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun PortraitContent(
+ state: OverlayState,
+ messages: List,
+ inputText: String,
+ isProcessing: Boolean,
+ listState: androidx.compose.foundation.lazy.LazyListState,
+ recordSec: Float,
+ isRecording: Boolean,
+ isLocked: Boolean,
+ typingDots: String,
+ animIndex: Map,
+ onDismiss: () -> Unit,
+ onNavigateToMain: () -> Unit,
+ viewModel: OverlayViewModel,
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) { /* consume clicks */ },
+ ) {
+ // Messages + top bar stay fixed at top
+ Column(modifier = Modifier.fillMaxSize()) {
+ MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ state = listState,
+ ) {
+ if (messages.isNotEmpty()) {
+ items(messages, key = { it.id }) { message ->
+ AnimatedChatBubble(
+ message = message,
+ animIndex = animIndex[message.id] ?: 0,
+ )
+ }
+ }
+ if (isProcessing) {
+ item(key = "typing_indicator") {
+ TypingIndicator()
+ }
+ }
+ }
+ }
+
+ // Input area at bottom, imePadding pushes it above full-screen IME
+ InputArea(
+ state = state,
+ inputText = inputText,
+ viewModel = viewModel,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .imePadding(),
+ recordSec = recordSec,
+ isRecording = isRecording,
+ isLocked = isLocked,
+ typingDots = typingDots,
+ )
+ }
+}
+
+@Suppress("LongParameterList")
+@Composable
+private fun LandscapeContent(
+ state: OverlayState,
+ messages: List,
+ inputText: String,
+ isProcessing: Boolean,
+ listState: androidx.compose.foundation.lazy.LazyListState,
+ recordSec: Float,
+ isRecording: Boolean,
+ isLocked: Boolean,
+ typingDots: String,
+ animIndex: Map,
+ onDismiss: () -> Unit,
+ onNavigateToMain: () -> Unit,
+ viewModel: OverlayViewModel,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ) { /* consume clicks */ },
+ ) {
+ // Messages + top bar on the left, stay fixed
+ Column(modifier = Modifier.weight(1f)) {
+ MessageTopBar(onDismiss = onDismiss, onNavigateToMain = onNavigateToMain)
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ state = listState,
+ ) {
+ if (messages.isNotEmpty()) {
+ items(messages, key = { it.id }) { message ->
+ AnimatedChatBubble(
+ message = message,
+ animIndex = animIndex[message.id] ?: 0,
+ )
+ }
+ }
+ if (isProcessing) {
+ item(key = "typing_indicator") {
+ TypingIndicator()
+ }
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .width(1.dp)
+ .fillMaxHeight()
+ .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f))
+ )
+
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ InputArea(
+ state = state,
+ inputText = inputText,
+ viewModel = viewModel,
+ modifier = Modifier
+ .fillMaxWidth()
+ .imePadding(),
+ recordSec = recordSec,
+ isRecording = isRecording,
+ isLocked = isLocked,
+ typingDots = typingDots,
+ )
+ }
+ }
+}
+
+@Composable
+private fun MessageTopBar(
+ onDismiss: () -> Unit,
+ onNavigateToMain: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Filled.Close, contentDescription = "关闭")
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ IconButton(onClick = onNavigateToMain) {
+ Icon(
+ Icons.AutoMirrored.Filled.OpenInNew,
+ contentDescription = "进入主界面",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+}
+
+@Composable
+private fun InputArea(
+ state: OverlayState,
+ inputText: String,
+ viewModel: OverlayViewModel,
+ modifier: Modifier = Modifier,
+ recordSec: Float = 0f,
+ isRecording: Boolean = false,
+ isLocked: Boolean = false,
+ typingDots: String = "",
+) {
+ // Gesture tracking state — local to InputArea
+ var isDragging by remember { mutableStateOf(false) }
+ var dragOffsetX by remember { mutableStateOf(0f) }
+ var dragOffsetY by remember { mutableStateOf(0f) }
+ val inCancelZone = isDragging && dragOffsetY < -120f
+ val inLockZone = isDragging && dragOffsetX > 60f
+ val isProcessing = state == OverlayState.PROCESSING
+
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
+ shadowElevation = 8.dp,
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ ) {
+ // "昔涟正在输入..." indicator
+ if (isProcessing && typingDots.isNotEmpty()) {
+ Text(
+ text = "昔涟正在输入$typingDots",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp),
+ textAlign = TextAlign.Start,
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (isRecording && isDragging) {
+ // Recording with drag — show recording indicator
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(12.dp))
+ .background(
+ if (inCancelZone) MaterialTheme.colorScheme.errorContainer
+ else MaterialTheme.colorScheme.surfaceVariant
+ )
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = when {
+ inCancelZone -> "松手取消"
+ inLockZone -> "松手录音"
+ else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (inCancelZone) MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ 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 = 3,
+ shape = MaterialTheme.shapes.medium,
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ if (inputText.isNotBlank()) {
+ IconButton(
+ onClick = { viewModel.sendText() },
+ enabled = !isProcessing,
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.Send,
+ contentDescription = "发送",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ } else {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .pointerInput(Unit) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = { _ ->
+ 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,
+ )
+ }
+ }
+ }
+ }
+
+ // Status hint
+ val hint = when {
+ isLocked -> ""
+ isRecording && isDragging -> ""
+ else -> when (state) {
+ OverlayState.LISTENING -> "我在听..."
+ OverlayState.PROCESSING -> "思考中..."
+ OverlayState.SPEAKING -> "正在说话..."
+ OverlayState.WAITING -> "长按麦克风开始说话"
+ OverlayState.IDLE -> ""
+ }
+ }
+ if (hint.isNotEmpty()) {
+ Text(
+ text = hint,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/about/AboutScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/about/AboutScreen.kt
new file mode 100644
index 0000000..563ea53
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/about/AboutScreen.kt
@@ -0,0 +1,191 @@
+package top.yeij.cyrene.ui.screens.about
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+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.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.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.Code
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Person
+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.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AboutScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+
+ Scaffold(
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text("关于") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ Text(
+ text = "Cyrene",
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontWeight = FontWeight.Bold,
+ fontSize = 28.sp,
+ ),
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 24.dp),
+ )
+ Text(
+ text = "昔涟",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ text = "v0.1.0",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 4.dp),
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ HorizontalDivider()
+ 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),
+ )
+
+ ListItem(
+ headlineContent = { Text("简介") },
+ leadingContent = {
+ Icon(Icons.Filled.Info, contentDescription = null)
+ },
+ supportingContent = {
+ Text(
+ text = "Cyrene 是一款基于 Android 的智能语音助手,支持实时对话、IoT 设备管理和语音交互。",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ },
+ )
+
+ ListItem(
+ headlineContent = { Text("开发者") },
+ leadingContent = {
+ Icon(Icons.Filled.Person, contentDescription = null)
+ },
+ supportingContent = { Text("AskaEth") },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+
+ ListItem(
+ headlineContent = { Text("源代码") },
+ leadingContent = {
+ Icon(Icons.Filled.Code, contentDescription = null)
+ },
+ supportingContent = { Text("git.yeij.top/AskaEth/Cyrene-For-Android") },
+ trailingContent = {
+ Icon(
+ Icons.AutoMirrored.Filled.OpenInNew,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse("https://git.yeij.top/AskaEth/Cyrene-For-Android")
+ }
+ context.startActivity(intent)
+ },
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ HorizontalDivider()
+ 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),
+ )
+
+ val techStack = listOf(
+ "Kotlin" to "主语言",
+ "Jetpack Compose" to "UI 框架",
+ "Material Design 3" to "设计系统",
+ "Room" to "本地数据库",
+ "OkHttp + Retrofit" to "网络请求",
+ "WebSocket" to "实时通信",
+ "Koin" to "依赖注入",
+ )
+ techStack.forEach { (name, desc) ->
+ ListItem(
+ headlineContent = { Text(name) },
+ supportingContent = { Text(desc) },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "© 2026 AskaEth. All rights reserved.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
index 869eb94..aec3714 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/chat/ChatScreen.kt
@@ -1,56 +1,121 @@
package top.yeij.cyrene.ui.screens.chat
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Box
+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.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.KeyboardVoice
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
-import org.koin.compose.koinInject
+import kotlinx.coroutines.delay
+import org.koin.androidx.compose.koinViewModel
+import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.ui.components.ChatBubble
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.viewmodel.ChatViewModel
+import kotlin.math.min
+@Composable
+private fun AnimatedChatBubble(
+ message: Message,
+ animIndex: Int,
+) {
+ var visible by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ delay(min(animIndex, 10) * 60L)
+ visible = true
+ }
+ AnimatedVisibility(
+ visible = visible,
+ enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(300)) +
+ slideInVertically(
+ animationSpec = androidx.compose.animation.core.tween(300),
+ initialOffsetY = { it / 4 },
+ ),
+ ) {
+ ChatBubble(
+ content = message.content,
+ role = message.role,
+ msgType = message.msgType,
+ timestamp = message.timestamp,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(
- viewModel: ChatViewModel = koinInject(),
+ viewModel: ChatViewModel = koinViewModel(),
) {
val messages by viewModel.currentMessages.collectAsState()
val inputText by viewModel.inputText.collectAsState()
val isStreaming by viewModel.isStreaming.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
+ val isRefreshing by viewModel.isRefreshing.collectAsState()
+ val recordState by viewModel.voiceRecordState.collectAsState()
+ val recordDurationMs by viewModel.voiceRecordDurationMs.collectAsState()
+ val animIndex by viewModel.messageAnimIndex.collectAsState()
val listState = rememberLazyListState()
+ // Gesture tracking state
+ var isDragging by remember { mutableStateOf(false) }
+ var dragOffsetX by remember { mutableStateOf(0f) }
+ var dragOffsetY by remember { mutableStateOf(0f) }
+ var recordButtonY by remember { mutableStateOf(0f) }
+
+ val recordSec = recordDurationMs / 1000f
+ val isRecording = recordState == RecordState.RECORDING
+ val isLocked = recordState == RecordState.LOCKED
+ val inCancelZone = isDragging && dragOffsetY < -120f
+ val inLockZone = isDragging && dragOffsetX > 60f
+
LaunchedEffect(messages.size, isStreaming) {
if (messages.isNotEmpty()) {
val targetIndex = if (isStreaming) messages.size else messages.size - 1
@@ -58,6 +123,22 @@ fun ChatScreen(
}
}
+ // Animated "昔涟正在输入..." dots
+ val typingDots = remember { mutableStateOf("") }
+ LaunchedEffect(isStreaming) {
+ if (isStreaming) {
+ val dots = arrayOf("", ".", "..", "...")
+ var i = 0
+ while (true) {
+ typingDots.value = dots[i % 4]
+ i++
+ delay(400)
+ }
+ } else {
+ typingDots.value = ""
+ }
+ }
+
val status = when {
isStreaming -> CyreneStatus.THINKING
isConnected -> CyreneStatus.ONLINE
@@ -76,122 +157,214 @@ fun ChatScreen(
}
},
bottomBar = {
- Row(
+ Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
+ .navigationBarsPadding(),
) {
- OutlinedTextField(
- value = inputText,
- onValueChange = { viewModel.onInputChanged(it) },
- placeholder = { Text("输入消息...") },
- modifier = Modifier.weight(1f),
- maxLines = 4,
- shape = MaterialTheme.shapes.medium,
- )
- IconButton(
- onClick = { viewModel.sendMessage() },
- enabled = inputText.isNotBlank() && !isStreaming,
+ // "昔涟正在输入..." indicator
+ if (isStreaming) {
+ Text(
+ text = "昔涟正在输入${typingDots.value}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 2.dp),
+ textAlign = TextAlign.Start,
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
) {
- if (isStreaming) {
- CircularProgressIndicator(
- modifier = Modifier.padding(4.dp),
- strokeWidth = 2.dp,
- )
+ if (isRecording && isDragging) {
+ // Recording state with drag — show recording indicator
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(12.dp))
+ .background(
+ if (inCancelZone) MaterialTheme.colorScheme.errorContainer
+ else MaterialTheme.colorScheme.surfaceVariant
+ )
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = when {
+ inCancelZone -> "松手取消"
+ inLockZone -> "松手录音"
+ else -> "%.1f\" 上滑取消 右滑松手".format(recordSec)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (inCancelZone) MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ // Record button (drag anchor)
+ Box(
+ modifier = Modifier
+ .padding(start = 8.dp)
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary)
+ .offset { IntOffset(dragOffsetX.toInt(), dragOffsetY.toInt()) },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ Icons.Filled.Mic,
+ contentDescription = "录音中",
+ tint = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+ } else if (isLocked) {
+ // Locked (hands-free) mode
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Filled.Lock,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "%.1f\" 松手录音中 — 点击结束".format(recordSec),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ }
+ IconButton(onClick = { viewModel.finishRecord() }) {
+ Icon(
+ Icons.AutoMirrored.Filled.Send,
+ contentDescription = "发送",
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
} else {
- Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
+ // 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(
+ 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,
+ )
+ }
+ // Send button (only when text present)
+ if (inputText.isNotBlank()) {
+ IconButton(
+ onClick = { viewModel.sendMessage() },
+ enabled = !isStreaming,
+ ) {
+ if (isStreaming) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ )
+ } else {
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
+ }
+ }
+ }
}
}
}
},
) { padding ->
- if (messages.isEmpty() && !isStreaming) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = "开始和昔涟对话吧",
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- } else {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding),
- state = listState,
- ) {
- items(messages, key = { it.id }) { message ->
- ChatBubble(
- content = message.content,
- role = message.role,
- msgType = message.msgType,
- timestamp = message.timestamp,
+ PullToRefreshBox(
+ isRefreshing = isRefreshing,
+ onRefresh = { viewModel.refreshMessages() },
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ if (messages.isEmpty() && !isStreaming) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = "开始和昔涟对话吧",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
- if (isStreaming) {
- item(key = "typing_indicator") {
- TypingIndicator()
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = listState,
+ ) {
+ items(messages, key = { it.id }) { message ->
+ AnimatedChatBubble(
+ message = message,
+ animIndex = animIndex[message.id] ?: 0,
+ )
+ }
+ if (isStreaming) {
+ item(key = "typing_indicator") {
+ TypingIndicator()
+ }
}
}
}
}
}
}
-
-@Composable
-private fun TypingIndicator(modifier: Modifier = Modifier) {
- val infiniteTransition = rememberInfiniteTransition(label = "typing")
-
- Row(
- modifier = modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.Start,
- ) {
- Surface(
- shape = MaterialTheme.shapes.large,
- color = MaterialTheme.colorScheme.surfaceVariant,
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- text = "昔涟正在输入",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- repeat(3) { index ->
- val alpha by infiniteTransition.animateFloat(
- initialValue = 0.2f,
- targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(
- durationMillis = 400,
- delayMillis = index * 200,
- ),
- repeatMode = RepeatMode.Reverse,
- ),
- label = "dot_$index",
- )
- Box(
- modifier = Modifier
- .size(5.dp)
- .alpha(alpha)
- .background(
- MaterialTheme.colorScheme.onSurfaceVariant,
- CircleShape,
- ),
- )
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt
index 5ca770a..653bf36 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/iot/IoTScreen.kt
@@ -16,14 +16,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
-import org.koin.compose.koinInject
+import org.koin.androidx.compose.koinViewModel
import top.yeij.cyrene.ui.components.DeviceCard
import top.yeij.cyrene.viewmodel.IoTViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IoTScreen(
- viewModel: IoTViewModel = koinInject(),
+ viewModel: IoTViewModel = koinViewModel(),
) {
val devices by viewModel.devices.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt
index 8549079..27a8253 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/profile/ProfileScreen.kt
@@ -1,133 +1,389 @@
package top.yeij.cyrene.ui.screens.profile
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
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.lazy.LazyColumn
+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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.automirrored.filled.Help
+import androidx.compose.material.icons.filled.AdminPanelSettings
+import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
+import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.koin.compose.koinInject
-import top.yeij.cyrene.viewmodel.SettingsViewModel
+import top.yeij.cyrene.viewmodel.ProfileViewModel
@Composable
fun ProfileScreen(
onNavigateToSettings: () -> Unit,
onLogout: () -> Unit,
onNavigateToLogin: () -> Unit,
- settingsViewModel: SettingsViewModel = koinInject(),
+ onNavigateToAbout: () -> Unit = {},
+ isDefaultAssistant: Boolean = false,
+ onOpenAssistantSettings: () -> Unit = {},
+ profileViewModel: ProfileViewModel = koinInject(),
) {
- val username by settingsViewModel.username.collectAsState()
- val isLoggedIn by settingsViewModel.isLoggedIn.collectAsState()
+ val profile by profileViewModel.profile.collectAsState()
+ var showLogoutDialog by remember { mutableStateOf(false) }
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
+ LaunchedEffect(Unit) {
+ profileViewModel.fetchFreshProfile()
+ }
+
+ if (showLogoutDialog) {
+ AlertDialog(
+ onDismissRequest = { showLogoutDialog = false },
+ title = { Text("退出登录") },
+ text = { Text("确定要退出登录吗?") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showLogoutDialog = false
+ profileViewModel.logout()
+ onLogout()
+ },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.error,
+ ),
+ ) {
+ Text("退出")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showLogoutDialog = false }) {
+ Text("取消")
+ }
+ },
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState()),
) {
// Profile header
- item {
- Column(
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ // Avatar
+ val initials = if (profile.nickname.isNotBlank()) {
+ profile.nickname.take(1)
+ } else if (profile.username.isNotBlank()) {
+ profile.username.take(1)
+ } else {
+ "?"
+ }
+ Box(
modifier = Modifier
- .fillMaxWidth()
- .padding(24.dp),
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
) {
Text(
- text = if (isLoggedIn) username.ifEmpty { "开拓者" } else "未登录",
+ text = initials,
+ style = MaterialTheme.typography.headlineLarge,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ if (profile.isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ } else if (!profile.isLoggedIn) {
+ Text(
+ text = "未登录",
style = MaterialTheme.typography.headlineMedium,
- color = if (!isLoggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
- modifier = if (!isLoggedIn) {
- Modifier.clickable { onNavigateToLogin() }
- } else {
- Modifier
- },
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.clickable { onNavigateToLogin() },
)
Text(
- text = "与昔涟同行",
+ text = "点击登录以查看个人信息",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ } else {
+ // Nickname
+ Text(
+ text = profile.nickname.ifEmpty { profile.username },
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ )
+ // Username
+ if (profile.nickname.isNotBlank() && profile.nickname != profile.username) {
+ Text(
+ text = "@${profile.username}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ // Admin badge
+ if (profile.isAdmin) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Surface(
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Filled.AdminPanelSettings,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "管理员",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ }
+ }
}
}
- item { HorizontalDivider() }
- item { Spacer(modifier = Modifier.height(8.dp)) }
+ HorizontalDivider()
- // Settings
- item {
- ListItem(
- headlineContent = { Text("设置") },
- leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
- trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
- modifier = Modifier.clickable { onNavigateToSettings() },
+ if (profile.isLoggedIn) {
+ // User info card
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "账号信息",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
+
+ ProfileInfoCard(
+ items = listOf(
+ ProfileInfoItem(Icons.Filled.Tag, "用户 ID", profile.userId),
+ ProfileInfoItem(Icons.Filled.CalendarMonth, "注册时间", profile.createdAt.ifEmpty { "未知" }),
+ ),
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ HorizontalDivider()
}
- // Reminders
- item {
- ListItem(
- headlineContent = { Text("提醒") },
- leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
- trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
- )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Assistant status
+ Text(
+ text = "助手",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ shape = RoundedCornerShape(16.dp),
+ color = if (isDefaultAssistant)
+ Color(0xFF4CAF50).copy(alpha = 0.1f)
+ else
+ MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ Icons.Filled.Circle,
+ contentDescription = null,
+ modifier = Modifier.size(10.dp),
+ tint = if (isDefaultAssistant) Color(0xFF4CAF50) else Color(0xFFFF5722),
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = if (isDefaultAssistant) "已设为默认助手" else "未设为默认助手",
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ if (!isDefaultAssistant) {
+ Text(
+ text = "设为默认助手后,长按电源键或Home键即可呼出昔涟",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (!isDefaultAssistant) {
+ TextButton(onClick = onOpenAssistantSettings) {
+ Text("去设置")
+ }
+ }
+ }
}
- item { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) }
+ Spacer(modifier = Modifier.height(16.dp))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(8.dp))
- // About
- item {
- ListItem(
- headlineContent = { Text("关于") },
- leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
- supportingContent = { Text("Cyrene v0.1.0") },
- )
- }
+ // Menu items
+ Text(
+ text = "其他",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
- // Help
- item {
- ListItem(
- headlineContent = { Text("使用帮助") },
- leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
- )
- }
+ ListItem(
+ headlineContent = { Text("设置") },
+ leadingContent = { Icon(Icons.Filled.Settings, contentDescription = null) },
+ trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
+ modifier = Modifier.clickable { onNavigateToSettings() },
+ )
- item { Spacer(modifier = Modifier.height(24.dp)) }
+ ListItem(
+ headlineContent = { Text("提醒") },
+ leadingContent = { Icon(Icons.Filled.Notifications, contentDescription = null) },
+ trailingContent = { Icon(Icons.Filled.ChevronRight, contentDescription = null) },
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+
+ ListItem(
+ headlineContent = { Text("关于") },
+ leadingContent = { Icon(Icons.Filled.Info, contentDescription = null) },
+ supportingContent = { Text("Cyrene v0.1.0") },
+ modifier = Modifier.clickable { onNavigateToAbout() },
+ )
+
+ ListItem(
+ headlineContent = { Text("使用帮助") },
+ leadingContent = { Icon(Icons.AutoMirrored.Filled.Help, contentDescription = null) },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
// Logout
- if (isLoggedIn) {
- item {
- ListItem(
- headlineContent = {
+ if (profile.isLoggedIn) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = "退出登录",
+ color = MaterialTheme.colorScheme.error,
+ )
+ },
+ leadingContent = {
+ Icon(
+ Icons.AutoMirrored.Filled.ExitToApp,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ )
+ },
+ modifier = Modifier.clickable { showLogoutDialog = true },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+}
+
+private data class ProfileInfoItem(
+ val icon: ImageVector,
+ val label: String,
+ val value: String,
+)
+
+@Composable
+private fun ProfileInfoCard(items: List) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ shape = RoundedCornerShape(16.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
+ ) {
+ Column {
+ items.forEachIndexed { index, item ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ item.icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
Text(
- text = "退出登录",
- color = MaterialTheme.colorScheme.error,
+ text = item.label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- },
- leadingContent = {
- Icon(
- Icons.AutoMirrored.Filled.ExitToApp,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.error,
+ Text(
+ text = item.value,
+ style = MaterialTheme.typography.bodyMedium,
)
- },
- modifier = Modifier.clickable { onLogout() },
- )
+ }
+ }
+ if (index < items.size - 1) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+ }
}
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
index efd7a50..aef4fce 100644
--- a/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
+++ b/app/src/main/java/top/yeij/cyrene/ui/screens/settings/SettingsScreen.kt
@@ -2,12 +2,16 @@ package top.yeij.cyrene.ui.screens.settings
import android.widget.Toast
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -16,9 +20,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.SettingsBrightness
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
@@ -27,13 +35,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+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
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
@@ -41,6 +56,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
+import top.yeij.cyrene.util.LogCategory
+import top.yeij.cyrene.util.RuntimeLog
import top.yeij.cyrene.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -52,6 +69,9 @@ fun SettingsScreen(
val baseUrl by viewModel.baseUrl.collectAsState()
val themeMode by viewModel.themeMode.collectAsState()
val wakeWord by viewModel.wakeWord.collectAsState()
+ val dashScopeApiKey by viewModel.dashScopeApiKey.collectAsState()
+ val dashScopeEndpoint by viewModel.dashScopeEndpoint.collectAsState()
+ val dashScopeModel by viewModel.dashScopeModel.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -101,7 +121,7 @@ fun SettingsScreen(
)
OutlinedTextField(
value = baseUrl,
- onValueChange = { viewModel.saveBaseUrl(it) },
+ onValueChange = { viewModel.updateBaseUrlInput(it) },
label = { Text("服务器地址") },
placeholder = { Text("http://192.168.1.x:8080") },
singleLine = true,
@@ -203,6 +223,287 @@ fun SettingsScreen(
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium,
)
+
+ Spacer(modifier = Modifier.height(16.dp))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // DashScope STT
+ Text(
+ text = "语音识别 (DashScope)",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(16.dp),
+ )
+
+ OutlinedTextField(
+ value = dashScopeApiKey,
+ onValueChange = { viewModel.updateDashScopeApiKeyInput(it) },
+ label = { Text("API Key") },
+ placeholder = { Text("sk-xxxxxxxxxxxxxxxx") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ scope.launch {
+ viewModel.saveDashScopeApiKey(dashScopeApiKey)
+ Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
+ }
+ },
+ ),
+ trailingIcon = {
+ FilledTonalIconButton(onClick = {
+ scope.launch {
+ viewModel.saveDashScopeApiKey(dashScopeApiKey)
+ Toast.makeText(context, "API Key 已保存", Toast.LENGTH_SHORT).show()
+ }
+ }) {
+ Icon(Icons.Filled.Check, contentDescription = "保存")
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = dashScopeEndpoint,
+ onValueChange = { viewModel.updateDashScopeEndpointInput(it) },
+ label = { Text("WebSocket 端点") },
+ placeholder = { Text("wss://dashscope.aliyuncs.com/api-ws/v1/inference") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ scope.launch {
+ viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
+ Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
+ }
+ },
+ ),
+ trailingIcon = {
+ FilledTonalIconButton(onClick = {
+ scope.launch {
+ viewModel.saveDashScopeEndpoint(dashScopeEndpoint)
+ Toast.makeText(context, "端点已保存", Toast.LENGTH_SHORT).show()
+ }
+ }) {
+ Icon(Icons.Filled.Check, contentDescription = "保存")
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = dashScopeModel,
+ onValueChange = { viewModel.updateDashScopeModelInput(it) },
+ label = { Text("模型") },
+ placeholder = { Text("fun-asr-realtime") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ scope.launch {
+ viewModel.saveDashScopeModel(dashScopeModel)
+ Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
+ }
+ },
+ ),
+ trailingIcon = {
+ FilledTonalIconButton(onClick = {
+ scope.launch {
+ viewModel.saveDashScopeModel(dashScopeModel)
+ Toast.makeText(context, "模型已保存", Toast.LENGTH_SHORT).show()
+ }
+ }) {
+ Icon(Icons.Filled.Check, contentDescription = "保存")
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = "未配置 API Key 时,语音输入将自动使用后端服务处理。",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Data management
+ Text(
+ text = "数据",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(16.dp),
+ )
+
+ var showClearDialog by remember { mutableStateOf(false) }
+ ListItem(
+ headlineContent = { Text("清空本地消息记录") },
+ supportingContent = { Text("仅清除本地数据库,服务器消息仍保留。下次加载将只获取清除时间之后的消息。") },
+ leadingContent = { Icon(Icons.Filled.DeleteForever, contentDescription = null, tint = MaterialTheme.colorScheme.error) },
+ modifier = Modifier.clickable { showClearDialog = true },
+ )
+
+ if (showClearDialog) {
+ AlertDialog(
+ onDismissRequest = { showClearDialog = false },
+ title = { Text("确认清空") },
+ text = { Text("将清空所有本地消息记录。服务器上的消息不会被删除,但下次加载历史时将只获取本次清除之后的消息。") },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showClearDialog = false
+ viewModel.clearLocalMessages()
+ Toast.makeText(context, "本地消息已清空", Toast.LENGTH_SHORT).show()
+ },
+ colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Text("清空")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showClearDialog = false }) {
+ Text("取消")
+ }
+ },
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ HorizontalDivider()
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Runtime logs
+ Text(
+ text = "运行日志",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(16.dp),
+ )
+
+ val logEntries by RuntimeLog.entries.collectAsState()
+ var selectedTab by remember { mutableStateOf(0) }
+ val tabs = listOf("全部") + LogCategory.entries.map { it.label }
+ val allCategories = LogCategory.entries.toList()
+
+ // Scroll to bottom
+ val scrollState = rememberScrollState()
+ androidx.compose.runtime.LaunchedEffect(logEntries.size, selectedTab) {
+ if (logEntries.isNotEmpty()) {
+ scrollState.animateScrollTo(scrollState.maxValue)
+ }
+ }
+
+ TabRow(selectedTabIndex = selectedTab) {
+ tabs.forEachIndexed { index, label ->
+ Tab(
+ selected = selectedTab == index,
+ onClick = { selectedTab = index },
+ text = { Text(label, maxLines = 1) },
+ )
+ }
+ }
+
+ 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(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedButton(
+ onClick = {
+ val file = RuntimeLog.exportToFile(context, currentCategory)
+ RuntimeLog.shareFile(context, file)
+ },
+ ) {
+ Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(if (currentCategory != null) "导出${currentCategory.label}" else "导出全部")
+ }
+ OutlinedButton(
+ onClick = {
+ val file = RuntimeLog.exportAllAsZip(context)
+ RuntimeLog.shareFile(context, file)
+ },
+ ) {
+ Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("打包全部")
+ }
+ OutlinedButton(
+ onClick = { RuntimeLog.clear() },
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Text("清空")
+ }
+ }
+
+ if (filteredLogs.isEmpty()) {
+ Text(
+ text = "暂无${tabs[selectedTab]}日志",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .weight(1f, fill = false),
+ ) {
+ Column(
+ modifier = Modifier.verticalScroll(scrollState),
+ ) {
+ filteredLogs.takeLast(500).forEach { entry ->
+ Text(
+ text = entry.formatted(),
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 1.dp),
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(80.dp))
}
}
}
diff --git a/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt
new file mode 100644
index 0000000..aa7fda1
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/util/NotificationHelper.kt
@@ -0,0 +1,64 @@
+package top.yeij.cyrene.util
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import top.yeij.cyrene.MainActivity
+import top.yeij.cyrene.domain.model.Message
+
+class NotificationHelper(private val context: Context) {
+
+ private val notificationManager = context.getSystemService(NotificationManager::class.java)
+
+ init {
+ createChannel()
+ }
+
+ private fun createChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "消息通知",
+ NotificationManager.IMPORTANCE_HIGH,
+ ).apply {
+ description = "昔涟的新消息通知"
+ enableVibration(true)
+ setShowBadge(true)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ fun showMessageNotification(message: Message) {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ context, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ val preview = if (message.content.length > 50) {
+ message.content.take(50) + "..."
+ } else {
+ message.content
+ }
+
+ val notification = NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("昔涟")
+ .setContentText(preview)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .build()
+
+ notificationManager.notify(message.id.hashCode(), notification)
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "cyrene_messages"
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt b/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt
new file mode 100644
index 0000000..9d5cde1
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/util/RuntimeLog.kt
@@ -0,0 +1,126 @@
+package top.yeij.cyrene.util
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+enum class LogCategory(val label: String) {
+ CHAT("聊天"),
+ WS("WebSocket"),
+ STT("语音识别"),
+ GENERAL("通用"),
+ HTTP("网络"),
+ VOICE("语音"),
+}
+
+data class LogEntry(
+ val timestamp: Long,
+ val category: LogCategory,
+ val tag: String,
+ val message: String,
+) {
+ fun formatted(): String {
+ val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
+ return "[${sdf.format(Date(timestamp))}] [${category.name}] [$tag] $message"
+ }
+}
+
+object RuntimeLog {
+
+ private const val MAX_ENTRIES = 2000
+
+ private val _entries = MutableStateFlow>(emptyList())
+ val entries: StateFlow> = _entries.asStateFlow()
+
+ private val buffer = ArrayDeque(MAX_ENTRIES)
+
+ @Synchronized
+ fun log(category: LogCategory, tag: String, message: String) {
+ val entry = LogEntry(System.currentTimeMillis(), category, tag, message)
+ if (buffer.size >= MAX_ENTRIES) {
+ buffer.removeFirst()
+ }
+ buffer.addLast(entry)
+ _entries.value = buffer.toList()
+ }
+
+ fun chat(tag: String, message: String) = log(LogCategory.CHAT, tag, message)
+ fun ws(tag: String, message: String) = log(LogCategory.WS, tag, message)
+ fun stt(tag: String, message: String) = log(LogCategory.STT, tag, message)
+ 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)
+
+ @Synchronized
+ fun getByCategory(category: LogCategory): List {
+ return buffer.filter { it.category == category }
+ }
+
+ @Synchronized
+ fun exportAsText(category: LogCategory): String {
+ val entries = buffer.filter { it.category == category }
+ val header = "=== Cyrene ${category.label}日志 导出时间: ${
+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
+ } ===\n\n"
+ return header + entries.joinToString("\n") { it.formatted() }
+ }
+
+ @Synchronized
+ fun exportAllAsText(): String {
+ val header = "=== Cyrene 全部日志 导出时间: ${
+ SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
+ } ===\n\n"
+ val byCategory = buffer.groupBy { it.category }
+ return header + byCategory.entries.joinToString("\n\n") { (cat, entries) ->
+ "--- ${cat.label} (${cat.name}) ---\n" + entries.joinToString("\n") { it.formatted() }
+ }
+ }
+
+ fun exportToFile(context: Context, category: LogCategory? = null): File {
+ val text = if (category != null) exportAsText(category) else exportAllAsText()
+ val label = category?.name?.lowercase() ?: "all"
+ val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+ val file = File(context.cacheDir, "cyrene_log_${label}_$dateStr.txt")
+ file.writeText(text)
+ return file
+ }
+
+ fun exportAllAsZip(context: Context): File {
+ val dateStr = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
+ val zipFile = File(context.cacheDir, "cyrene_logs_$dateStr.zip")
+ ZipOutputStream(zipFile.outputStream().buffered()).use { zos ->
+ LogCategory.entries.forEach { cat ->
+ val text = exportAsText(cat)
+ zos.putNextEntry(ZipEntry("${cat.name.lowercase()}.txt"))
+ zos.write(text.toByteArray(Charsets.UTF_8))
+ zos.closeEntry()
+ }
+ }
+ return zipFile
+ }
+
+ fun shareFile(context: Context, file: File) {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = if (file.extension == "zip") "application/zip" else "text/plain"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "导出日志"))
+ }
+
+ @Synchronized
+ fun clear() {
+ buffer.clear()
+ _entries.value = emptyList()
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/util/VoiceRecorder.kt b/app/src/main/java/top/yeij/cyrene/util/VoiceRecorder.kt
new file mode 100644
index 0000000..826f0af
--- /dev/null
+++ b/app/src/main/java/top/yeij/cyrene/util/VoiceRecorder.kt
@@ -0,0 +1,158 @@
+package top.yeij.cyrene.util
+
+import android.content.Context
+import android.media.MediaRecorder
+import android.os.Build
+import android.util.Base64
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.io.File
+
+enum class RecordState {
+ IDLE,
+ RECORDING,
+ LOCKED, // hands-free mode
+ CANCELLING,
+}
+
+class VoiceRecorder(private val context: Context) {
+
+ private var recorder: MediaRecorder? = null
+ private var outputFile: File? = null
+ private var timerJob: Job? = null
+
+ private val _state = MutableStateFlow(RecordState.IDLE)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _durationMs = MutableStateFlow(0L)
+ val durationMs: StateFlow = _durationMs.asStateFlow()
+
+ fun start() {
+ if (_state.value != RecordState.IDLE) return
+ try {
+ outputFile = File(context.cacheDir, "voice_${System.currentTimeMillis()}.aac")
+ outputFile?.delete()
+
+ recorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ MediaRecorder(context)
+ } else {
+ @Suppress("DEPRECATION")
+ MediaRecorder()
+ }
+
+ recorder?.apply {
+ setAudioSource(MediaRecorder.AudioSource.MIC)
+ setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
+ setAudioSamplingRate(16000)
+ setAudioEncodingBitRate(32000)
+ setAudioChannels(1)
+ setOutputFile(outputFile?.absolutePath)
+ prepare()
+ start()
+ }
+
+ _state.value = RecordState.RECORDING
+ startTimer()
+ Log.i(TAG, "Recording started: ${outputFile?.absolutePath}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start recording: ${e.message}", e)
+ cleanup()
+ }
+ }
+
+ fun lock() {
+ if (_state.value == RecordState.RECORDING) {
+ _state.value = RecordState.LOCKED
+ }
+ }
+
+ fun stop(): File? {
+ if (_state.value == RecordState.IDLE) return null
+ val file = outputFile
+ try {
+ recorder?.apply {
+ try { stop() } catch (_: Exception) { }
+ try { release() } catch (_: Exception) { }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping recorder: ${e.message}", e)
+ }
+ recorder = null
+ cancelTimer()
+ _state.value = RecordState.IDLE
+ _durationMs.value = 0L
+ Log.i(TAG, "Recording stopped: ${file?.absolutePath}, size=${file?.length()}")
+ return if (file != null && file.exists() && file.length() > 0) file else null
+ }
+
+ fun cancel() {
+ val file = outputFile
+ try {
+ recorder?.apply {
+ try { stop() } catch (_: Exception) { }
+ try { release() } catch (_: Exception) { }
+ }
+ } catch (e: Exception) { }
+ recorder = null
+ file?.delete()
+ cancelTimer()
+ _state.value = RecordState.IDLE
+ _durationMs.value = 0L
+ Log.i(TAG, "Recording cancelled")
+ }
+
+ fun cleanup() {
+ try { recorder?.release() } catch (_: Exception) { }
+ recorder = null
+ outputFile?.delete()
+ outputFile = null
+ cancelTimer()
+ _state.value = RecordState.IDLE
+ _durationMs.value = 0L
+ }
+
+ fun getBase64(): String? {
+ val file = outputFile ?: return null
+ if (!file.exists()) return null
+ return try {
+ val bytes = file.readBytes()
+ Base64.encodeToString(bytes, Base64.NO_WRAP)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to encode audio: ${e.message}", e)
+ null
+ }
+ }
+
+ fun deleteFile() {
+ outputFile?.delete()
+ outputFile = null
+ }
+
+ private fun startTimer() {
+ cancelTimer()
+ val startTime = System.currentTimeMillis()
+ timerJob = CoroutineScope(Dispatchers.Main).launch {
+ while (true) {
+ delay(100)
+ _durationMs.value = System.currentTimeMillis() - startTime
+ }
+ }
+ }
+
+ private fun cancelTimer() {
+ timerJob?.cancel()
+ timerJob = null
+ }
+
+ companion object {
+ private const val TAG = "VoiceRecorder"
+ }
+}
diff --git a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
index c79571f..ac49f0d 100644
--- a/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
+++ b/app/src/main/java/top/yeij/cyrene/viewmodel/ChatViewModel.kt
@@ -3,23 +3,64 @@ package top.yeij.cyrene.viewmodel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import top.yeij.cyrene.domain.model.Conversation
import top.yeij.cyrene.domain.model.Message
import top.yeij.cyrene.domain.repository.ChatRepository
+import top.yeij.cyrene.util.RecordState
+import top.yeij.cyrene.util.RuntimeLog
+import top.yeij.cyrene.util.VoiceRecorder
+
+private fun List.deduplicate(): List {
+ if (isEmpty()) return this
+ val result = mutableListOf(this[0])
+ for (i in 1 until size) {
+ val prev = result.last()
+ val curr = this[i]
+ val isDuplicate = curr.id == prev.id ||
+ (curr.role == prev.role && curr.content == prev.content && curr.msgType == prev.msgType)
+ if (!isDuplicate) {
+ result.add(curr)
+ }
+ }
+ return result
+}
+
+private fun List.removeWrappingDuplicates(): List {
+ if (size < 3) return this
+ val toRemove = mutableSetOf()
+ for (msg in this) {
+ val containedCount = count { other ->
+ other.id != msg.id &&
+ other.content.isNotBlank() &&
+ other.content.length < msg.content.length &&
+ msg.content.contains(other.content) &&
+ kotlin.math.abs(other.timestamp - msg.timestamp) < 2000
+ }
+ if (containedCount >= 2) {
+ toRemove.add(msg.id)
+ }
+ }
+ return if (toRemove.isEmpty()) this else filter { it.id !in toRemove }
+}
class ChatViewModel(
private val chatRepository: ChatRepository,
+ private val voiceRecorder: VoiceRecorder,
) : ViewModel() {
val isConnected: StateFlow = chatRepository.connectionState
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+ val connectionError: StateFlow = chatRepository.connectionError
+
val conversations: StateFlow> = chatRepository.getConversations()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@@ -29,37 +70,144 @@ class ChatViewModel(
private val _inputText = MutableStateFlow("")
val inputText: StateFlow = _inputText.asStateFlow()
- private val _isStreaming = MutableStateFlow(false)
- val isStreaming: StateFlow = _isStreaming.asStateFlow()
+ private val _isSending = MutableStateFlow(false)
+ val isStreaming: StateFlow = kotlinx.coroutines.flow.combine(
+ _isSending,
+ chatRepository.isAssistantStreaming,
+ ) { sending, assistant -> sending || assistant }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
+
+ private val _isRefreshing = MutableStateFlow(false)
+ val isRefreshing: StateFlow = _isRefreshing.asStateFlow()
+
+ // Voice recording state
+ val voiceRecordState: StateFlow = voiceRecorder.state
+ val voiceRecordDurationMs: StateFlow = voiceRecorder.durationMs
+
+ // Animation ordering for message bubbles
+ private var animCounter = 0
+ private val _messageAnimIndex = MutableStateFlow