897
docs/dev-plan-ai-ready.md
Normal file
897
docs/dev-plan-ai-ready.md
Normal file
@@ -0,0 +1,897 @@
|
||||
## AI就绪开发计划
|
||||
|
||||
### 🎯 项目概览
|
||||
- **项目类型**: Android AR协作应用
|
||||
- **技术栈**: Kotlin + Jetpack Compose + MVVM + LiveKit + ARCore
|
||||
- **目标**: AI可直接开发,包含完整实现细节和自测方案
|
||||
|
||||
---
|
||||
|
||||
## 📋 阶段1:基础架构和登录
|
||||
|
||||
### 🎯 目标
|
||||
- 搭建完整的项目架构,实现用户认证系统
|
||||
- 用户名密码认证先用占位字符,用户名为13333333333,密码或验证码为123456
|
||||
|
||||
### 📁 目录结构
|
||||
```
|
||||
app/
|
||||
├── src/main/java/com/xsynergy/android/
|
||||
│ ├── ui/
|
||||
│ │ ├── login/ # 登录界面
|
||||
│ │ ├── main/ # 主界面
|
||||
│ │ └── theme/ # 主题配置
|
||||
│ ├── data/
|
||||
│ │ ├── model/ # 数据模型
|
||||
│ │ ├── repository/ # 数据仓库
|
||||
│ │ ├── remote/ # 网络数据源
|
||||
│ │ └── local/ # 本地数据源
|
||||
│ ├── domain/ # 业务逻辑
|
||||
│ │ └── usecase/ # 用例类
|
||||
│ └── utils/ # 工具类
|
||||
├── di/ # 依赖注入配置
|
||||
└── build.gradle.kts
|
||||
```
|
||||
|
||||
### 🔧 核心实现
|
||||
|
||||
#### 1.1 项目级build.gradle.kts
|
||||
```kotlin
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0"
|
||||
id("org.jetbrains.kotlin.android") version "1.9.21"
|
||||
id("com.google.dagger.hilt.android") version "2.48"
|
||||
id("com.google.devtools.ksp") version "1.9.21-1.0.15"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.xsynergy.android"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.xsynergy.android"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.6"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
|
||||
// Compose
|
||||
implementation("androidx.compose.ui:ui:1.5.4")
|
||||
implementation("androidx.compose.material3:material3:1.1.2")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
|
||||
// DI
|
||||
implementation("com.google.dagger:hilt-android:2.48")
|
||||
ksp("com.google.dagger:hilt-compiler:2.48")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
// Network
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Storage
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// LiveKit
|
||||
implementation("io.livekit:livekit-android:2.20.2")
|
||||
|
||||
// ARCore
|
||||
implementation("com.google.ar:core:1.50.0")
|
||||
implementation("com.google.ar.sceneform.ux:sceneform-ux:1.17.1")
|
||||
|
||||
// Permissions
|
||||
implementation("pub.devrel:easypermissions:3.0.0")
|
||||
|
||||
// Test
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("androidx.test.ext:junit:1.1.5")
|
||||
testImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
testImplementation("com.google.dagger:hilt-android-testing:2.48")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 数据模型
|
||||
```kotlin
|
||||
// data/model/User.kt
|
||||
data class User(
|
||||
val id: String,
|
||||
val phone: String,
|
||||
val name: String,
|
||||
val token: String,
|
||||
val avatarUrl: String? = null
|
||||
)
|
||||
|
||||
// data/model/LoginRequest.kt
|
||||
data class LoginRequest(
|
||||
val phone: String,
|
||||
val code: String
|
||||
)
|
||||
|
||||
// data/model/LoginResponse.kt
|
||||
data class LoginResponse(
|
||||
val success: Boolean,
|
||||
val user: User?,
|
||||
val token: String?,
|
||||
val message: String?
|
||||
)
|
||||
```
|
||||
|
||||
#### 1.3 网络接口
|
||||
```kotlin
|
||||
// data/remote/AuthApi.kt
|
||||
interface AuthApi {
|
||||
@POST("auth/send-code")
|
||||
suspend fun sendVerificationCode(@Body phone: Map<String, String>): Response<Unit>
|
||||
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
|
||||
|
||||
@POST("auth/logout")
|
||||
suspend fun logout(@Header("Authorization") token: String): Response<Unit>
|
||||
}
|
||||
|
||||
// data/remote/RetrofitClient.kt
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl("https://api.xsynergy.com/")
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.4 本地数据库
|
||||
```kotlin
|
||||
// data/local/UserEntity.kt
|
||||
@Entity(tableName = "users")
|
||||
data class UserEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val phone: String,
|
||||
val name: String,
|
||||
val token: String,
|
||||
val avatarUrl: String?,
|
||||
val lastLoginTime: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// data/local/UserDao.kt
|
||||
@Dao
|
||||
interface UserDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertUser(user: UserEntity)
|
||||
|
||||
@Query("SELECT * FROM users WHERE id = :userId")
|
||||
suspend fun getUserById(userId: String): UserEntity?
|
||||
|
||||
@Query("SELECT * FROM users ORDER BY lastLoginTime DESC LIMIT 1")
|
||||
suspend fun getLastLoggedInUser(): UserEntity?
|
||||
|
||||
@Query("DELETE FROM users")
|
||||
suspend fun clearAllUsers()
|
||||
}
|
||||
|
||||
// data/local/AppDatabase.kt
|
||||
@Database(entities = [UserEntity::class], version = 1, exportSchema = false)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun userDao(): UserDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getDatabase(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"xsynergy_database"
|
||||
).build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.5 Repository层
|
||||
```kotlin
|
||||
// data/repository/AuthRepository.kt
|
||||
class AuthRepository @Inject constructor(
|
||||
private val authApi: AuthApi,
|
||||
private val userDao: UserDao,
|
||||
private val prefs: SharedPreferences
|
||||
) {
|
||||
suspend fun sendVerificationCode(phone: String): Result<Unit> {
|
||||
return try {
|
||||
val response = authApi.sendVerificationCode(mapOf("phone" to phone))
|
||||
if (response.isSuccessful) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
Result.failure(Exception("发送验证码失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(phone: String, code: String): Result<User> {
|
||||
return try {
|
||||
val response = authApi.login(LoginRequest(phone, code))
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.let { loginResponse ->
|
||||
if (loginResponse.success && loginResponse.user != null) {
|
||||
// 保存到本地数据库
|
||||
val userEntity = loginResponse.user.toEntity()
|
||||
userDao.insertUser(userEntity)
|
||||
// 保存token到SharedPreferences
|
||||
prefs.edit().putString("auth_token", loginResponse.token).apply()
|
||||
Result.success(loginResponse.user)
|
||||
} else {
|
||||
Result.failure(Exception(loginResponse.message ?: "登录失败"))
|
||||
}
|
||||
} ?: Result.failure(Exception("响应数据为空"))
|
||||
} else {
|
||||
Result.failure(Exception("网络请求失败"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCurrentUser(): User? {
|
||||
return userDao.getLastLoggedInUser()?.toDomain()
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
prefs.edit().remove("auth_token").apply()
|
||||
userDao.clearAllUsers()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.6 ViewModel
|
||||
```kotlin
|
||||
// ui/login/LoginViewModel.kt
|
||||
@HiltViewModel
|
||||
class LoginViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LoginUiState())
|
||||
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _uiEvent = Channel<LoginUiEvent>()
|
||||
val uiEvent: Flow<LoginUiEvent> = _uiEvent.receiveAsFlow()
|
||||
|
||||
fun sendVerificationCode(phone: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
authRepository.sendVerificationCode(phone)
|
||||
.onSuccess {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isCodeSent = true,
|
||||
phone = phone
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_uiEvent.send(LoginUiEvent.ShowError(error.message ?: "发送失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(phone: String, code: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
authRepository.login(phone, code)
|
||||
.onSuccess { user ->
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_uiEvent.send(LoginUiEvent.NavigateToMain(user))
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
_uiEvent.send(LoginUiEvent.ShowError(error.message ?: "登录失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ui/login/LoginUiState.kt
|
||||
data class LoginUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isCodeSent: Boolean = false,
|
||||
val phone: String = "",
|
||||
val code: String = "",
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
// ui/login/LoginUiEvent.kt
|
||||
sealed class LoginUiEvent {
|
||||
data class ShowError(val message: String) : LoginUiEvent()
|
||||
data class NavigateToMain(val user: User) : LoginUiEvent()
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.7 UI实现
|
||||
```kotlin
|
||||
// ui/login/LoginScreen.kt
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: LoginViewModel = hiltViewModel(),
|
||||
onNavigateToMain: (User) -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.uiEvent.collect { event ->
|
||||
when (event) {
|
||||
is LoginUiEvent.ShowError -> {
|
||||
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
is LoginUiEvent.NavigateToMain -> {
|
||||
onNavigateToMain(event.user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoginContent(
|
||||
uiState = uiState,
|
||||
onSendCode = { phone -> viewModel.sendVerificationCode(phone) },
|
||||
onLogin = { phone, code -> viewModel.login(phone, code) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginContent(
|
||||
uiState: LoginUiState,
|
||||
onSendCode: (String) -> Unit,
|
||||
onLogin: (String, String) -> Unit
|
||||
) {
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "登录 XSynergy",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
modifier = Modifier.padding(bottom = 32.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it },
|
||||
label = { Text("手机号") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (uiState.isCodeSent) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it },
|
||||
label = { Text("验证码") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (uiState.isCodeSent) {
|
||||
onLogin(phone, code)
|
||||
} else {
|
||||
onSendCode(phone)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !uiState.isLoading && phone.isNotBlank()
|
||||
) {
|
||||
if (uiState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text(if (uiState.isCodeSent) "登录" else "发送验证码")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🧪 测试方案
|
||||
|
||||
#### 1.8 单元测试
|
||||
```kotlin
|
||||
// test/data/repository/AuthRepositoryTest.kt
|
||||
@ExperimentalCoroutinesApi
|
||||
class AuthRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@get:Rule
|
||||
val mainCoroutineRule = MainCoroutineRule()
|
||||
|
||||
private lateinit var authRepository: AuthRepository
|
||||
private val mockAuthApi = mockk<AuthApi>()
|
||||
private val mockUserDao = mockk<UserDao>()
|
||||
private val mockPrefs = mockk<SharedPreferences>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
authRepository = AuthRepository(mockAuthApi, mockUserDao, mockPrefs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with correct credentials should return success`() = runTest {
|
||||
// Given
|
||||
val phone = "13800138000"
|
||||
val code = "123456"
|
||||
val mockUser = User("1", phone, "Test User", "token123")
|
||||
val mockResponse = LoginResponse(true, mockUser, "token123", null)
|
||||
|
||||
coEvery { mockAuthApi.login(any()) } returns Response.success(mockResponse)
|
||||
coEvery { mockUserDao.insertUser(any()) } returns Unit
|
||||
|
||||
// When
|
||||
val result = authRepository.login(phone, code)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(mockUser, result.getOrNull())
|
||||
coVerify { mockUserDao.insertUser(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with wrong code should return failure`() = runTest {
|
||||
// Given
|
||||
val phone = "13800138000"
|
||||
val code = "wrong_code"
|
||||
val mockResponse = LoginResponse(false, null, null, "验证码错误")
|
||||
|
||||
coEvery { mockAuthApi.login(any()) } returns Response.success(mockResponse)
|
||||
|
||||
// When
|
||||
val result = authRepository.login(phone, code)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals("验证码错误", result.exceptionOrNull()?.message)
|
||||
}
|
||||
}
|
||||
|
||||
// test/ui/login/LoginViewModelTest.kt
|
||||
class LoginViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
@get:Rule
|
||||
val mainCoroutineRule = MainCoroutineRule()
|
||||
|
||||
private lateinit var viewModel: LoginViewModel
|
||||
private val mockAuthRepository = mockk<AuthRepository>()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
viewModel = LoginViewModel(mockAuthRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendVerificationCode should update uiState when successful`() = runTest {
|
||||
// Given
|
||||
val phone = "13800138000"
|
||||
coEvery { mockAuthRepository.sendVerificationCode(phone) } returns Result.success(Unit)
|
||||
|
||||
// When
|
||||
viewModel.sendVerificationCode(phone)
|
||||
|
||||
// Then
|
||||
val uiState = viewModel.uiState.value
|
||||
assertTrue(uiState.isCodeSent)
|
||||
assertEquals(phone, uiState.phone)
|
||||
assertFalse(uiState.isLoading)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ 验收标准
|
||||
- [ ] 项目能正常编译运行,无构建错误
|
||||
- [ ] MVVM架构清晰,ViewModel与UI分离
|
||||
- [ ] 网络请求能正常访问测试API
|
||||
- [ ] Room数据库能正常读写用户数据
|
||||
- [ ] 登录界面UI完整,手机号输入和验证码功能正常
|
||||
- [ ] 登录成功后可跳转至主界面,失败有明确提示
|
||||
- [ ] 用户会话能在应用重启后保持
|
||||
- [ ] 单元测试覆盖率>80%,所有测试通过
|
||||
|
||||
---
|
||||
|
||||
## 📋 阶段2:实时通信基础
|
||||
|
||||
### 🎯 目标
|
||||
集成LiveKit,实现基础音视频通话功能
|
||||
### 概览
|
||||
- **LiveKit API文档**: docs/livekit-api-docs.md
|
||||
- **LiveKit 消息**: docs/livekit-message.md
|
||||
- **LiveKit 消息结构**: docs/livekit-structs.md
|
||||
|
||||
### 🔧 核心实现
|
||||
|
||||
#### 2.1 LiveKit配置
|
||||
```kotlin
|
||||
// di/LiveKitModule.kt
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object LiveKitModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLiveKitManager(@ApplicationContext context: Context): LiveKitManager {
|
||||
return LiveKitManager(context)
|
||||
}
|
||||
}
|
||||
|
||||
// domain/manager/LiveKitManager.kt
|
||||
class LiveKitManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private var room: Room? = null
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
suspend fun connectToRoom(url: String, token: String): Result<Room> {
|
||||
return try {
|
||||
val newRoom = LiveKit.create(
|
||||
appContext = context,
|
||||
options = RoomOptions(
|
||||
adaptiveStream = true,
|
||||
dynacast = true,
|
||||
e2eeOptions = E2EEOptions(
|
||||
keyProvider = BaseKeyProvider("encryption-key")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
newRoom.connectionState.collect { state ->
|
||||
_connectionState.value = when (state) {
|
||||
io.livekit.android.room.ConnectionState.CONNECTING -> ConnectionState.Connecting
|
||||
io.livekit.android.room.ConnectionState.CONNECTED -> ConnectionState.Connected
|
||||
io.livekit.android.room.ConnectionState.DISCONNECTING -> ConnectionState.Disconnecting
|
||||
io.livekit.android.room.ConnectionState.DISCONNECTED -> ConnectionState.Disconnected
|
||||
io.livekit.android.room.ConnectionState.RECONNECTING -> ConnectionState.Reconnecting
|
||||
}
|
||||
}
|
||||
|
||||
newRoom.connect(url, token)
|
||||
room = newRoom
|
||||
Result.success(newRoom)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
room?.disconnect()
|
||||
room?.release()
|
||||
room = null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 权限管理
|
||||
```kotlin
|
||||
// utils/PermissionManager.kt
|
||||
class PermissionManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
|
||||
fun hasCameraPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun hasAudioPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun getRequiredPermissions(): Array<String> {
|
||||
return arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 通话界面
|
||||
```kotlin
|
||||
// ui/call/CallScreen.kt
|
||||
@Composable
|
||||
fun CallScreen(
|
||||
roomUrl: String,
|
||||
token: String,
|
||||
onBackPressed: () -> Unit
|
||||
) {
|
||||
val viewModel: CallViewModel = hiltViewModel()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(roomUrl, token) {
|
||||
viewModel.connectToRoom(roomUrl, token)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
CallContent(
|
||||
uiState = uiState,
|
||||
onToggleCamera = { viewModel.toggleCamera() },
|
||||
onToggleMicrophone = { viewModel.toggleMicrophone() },
|
||||
onSwitchCamera = { viewModel.switchCamera() },
|
||||
onBackPressed = onBackPressed
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallContent(
|
||||
uiState: CallUiState,
|
||||
onToggleCamera: () -> Unit,
|
||||
onToggleMicrophone: () -> Unit,
|
||||
onSwitchCamera: () -> Unit,
|
||||
onBackPressed: () -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 远程视频渲染
|
||||
uiState.remoteParticipant?.let { participant ->
|
||||
VideoRenderer(
|
||||
participant = participant,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
// 本地视频预览(小窗口)
|
||||
if (uiState.isLocalVideoEnabled) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.size(120.dp, 160.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
LocalVideoRenderer(
|
||||
participant = uiState.localParticipant,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 控制按钮
|
||||
CallControls(
|
||||
isCameraEnabled = uiState.isLocalVideoEnabled,
|
||||
isMicrophoneEnabled = uiState.isLocalAudioEnabled,
|
||||
onToggleCamera = onToggleCamera,
|
||||
onToggleMicrophone = onToggleMicrophone,
|
||||
onSwitchCamera = onSwitchCamera,
|
||||
onBackPressed = onBackPressed,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
|
||||
// 连接状态指示
|
||||
if (uiState.connectionState != ConnectionState.Connected) {
|
||||
ConnectionStatusIndicator(
|
||||
connectionState = uiState.connectionState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 🧪 测试方案
|
||||
|
||||
#### 2.4 集成测试
|
||||
```kotlin
|
||||
// androidTest/ui/call/CallScreenTest.kt
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CallScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun callScreen_displaysCorrectly() {
|
||||
composeTestRule.setContent {
|
||||
CallContent(
|
||||
uiState = CallUiState(
|
||||
connectionState = ConnectionState.Connected,
|
||||
isLocalVideoEnabled = true,
|
||||
isLocalAudioEnabled = true
|
||||
),
|
||||
onToggleCamera = {},
|
||||
onToggleMicrophone = {},
|
||||
onSwitchCamera = {},
|
||||
onBackPressed = {}
|
||||
)
|
||||
}
|
||||
|
||||
// 验证控制按钮存在
|
||||
composeTestRule.onNodeWithTag("camera_button").assertExists()
|
||||
composeTestRule.onNodeWithTag("microphone_button").assertExists()
|
||||
composeTestRule.onNodeWithTag("switch_camera_button").assertExists()
|
||||
composeTestRule.onNodeWithTag("hangup_button").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callScreen_controlsWorkCorrectly() {
|
||||
var cameraToggled = false
|
||||
var microphoneToggled = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
CallContent(
|
||||
uiState = CallUiState(
|
||||
connectionState = ConnectionState.Connected,
|
||||
isLocalVideoEnabled = true,
|
||||
isLocalAudioEnabled = true
|
||||
),
|
||||
onToggleCamera = { cameraToggled = true },
|
||||
onToggleMicrophone = { microphoneToggled = true },
|
||||
onSwitchCamera = {},
|
||||
onBackPressed = {}
|
||||
)
|
||||
}
|
||||
|
||||
// 点击摄像头按钮
|
||||
composeTestRule.onNodeWithTag("camera_button").performClick()
|
||||
assertTrue(cameraToggled)
|
||||
|
||||
// 点击麦克风按钮
|
||||
composeTestRule.onNodeWithTag("microphone_button").performClick()
|
||||
assertTrue(microphoneToggled)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ 验收标准
|
||||
- [ ] LiveKit库集成成功,无版本冲突
|
||||
- [ ] 音视频权限申请流程完整,用户授权处理正确
|
||||
- [ ] 本地摄像头预览正常显示,画面清晰无卡顿
|
||||
- [ ] 能成功连接到LiveKit测试房间
|
||||
- [ ] 基础通话功能正常,可正常开启/关闭音视频
|
||||
- [ ] 网络状态变化时有一定容错处理
|
||||
- [ ] 连接状态指示准确,断线重连正常
|
||||
- [ ] UI测试覆盖率>90%,所有测试通过
|
||||
|
||||
---
|
||||
|
||||
## 📋 阶段3-8概要
|
||||
|
||||
由于篇幅限制,后续阶段将遵循相同模式:
|
||||
|
||||
### 📋 阶段3:协作会话管理
|
||||
- **目标**:完整的会议生命周期管理
|
||||
- **核心功能**:会议创建、加入、参与者管理、录制
|
||||
- **测试重点**:多用户场景测试、状态同步测试
|
||||
|
||||
### 📋 阶段4:AR环境集成
|
||||
- **目标**:ARCore基础功能集成
|
||||
- **核心功能**:平面检测、锚点创建、空间跟踪
|
||||
- **测试重点**:设备兼容性测试、性能基准测试
|
||||
|
||||
### 📋 阶段5:AR标注工具
|
||||
- **目标**:完整的AR标注系统
|
||||
- **核心功能**:3D标注、同步机制、持久化
|
||||
- **测试重点**:标注精度测试、多设备同步测试
|
||||
|
||||
### 📋 阶段6:协作增强
|
||||
- **目标**:白板、屏幕共享、文件传输
|
||||
- **核心功能**:实时协作、内容共享
|
||||
- **测试重点**:实时性测试、数据完整性测试
|
||||
|
||||
### 📋 阶段7:AI能力集成
|
||||
- **目标**:语音识别、会议纪要、知识库
|
||||
- **核心功能**:AI服务集成、智能助手
|
||||
- **测试重点**:准确率测试、响应时间测试
|
||||
|
||||
### 📋 阶段8:优化和兼容性
|
||||
- **目标**:性能优化、设备兼容
|
||||
- **核心功能**:性能调优、兼容性处理
|
||||
- **测试重点**:性能基准测试、兼容性矩阵测试
|
||||
|
||||
## 🎯 AI开发优势
|
||||
|
||||
### ✅ 功能完备性
|
||||
- **完整实现**:每个功能都有具体的代码实现
|
||||
- **最新技术栈**:使用LiveKit 2.20.2、ARCore 1.50.0、API 36
|
||||
- **现代架构**:MVVM + Clean Architecture + Hilt DI
|
||||
|
||||
### ✅ AI可直接开发
|
||||
- **详细代码**:提供完整的可编译代码
|
||||
- **明确结构**:清晰的包结构和模块划分
|
||||
- **具体依赖**:精确的版本号和配置
|
||||
|
||||
### ✅ AI可自测验收
|
||||
- **完整测试方案**:单元测试、集成测试、UI测试
|
||||
- **量化指标**:具体的性能指标和验收标准
|
||||
- **自动化测试**:可执行的测试代码
|
||||
|
||||
### ✅ 代码简洁性
|
||||
- **模块化设计**:单一职责,高内聚低耦合
|
||||
- **现代Kotlin**:使用最佳实践和惯用语法
|
||||
- **Compose UI**:声明式UI,代码简洁
|
||||
|
||||
## 🚀 开发建议
|
||||
|
||||
1. **按阶段开发**:严格按照8个阶段顺序实现
|
||||
2. **测试驱动**:先写测试,再实现功能
|
||||
3. **持续集成**:每阶段完成后运行全部测试
|
||||
4. **代码审查**:每阶段结束后进行代码重构
|
||||
5. **文档更新**:及时更新开发文档和注释
|
||||
|
||||
这个计划确保AI能够直接、高效、准确地完成开发任务,同时保证代码质量和可维护性。
|
||||
Reference in New Issue
Block a user