194
TODO.md
Normal file
194
TODO.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# LocalChat (lchat) - Improvement Plan
|
||||||
|
|
||||||
|
## Phase 1: Architecture Foundation
|
||||||
|
|
||||||
|
### 1.1 MVVM with ViewModels and Repository Pattern ✅
|
||||||
|
- [x] Create ViewModels for each screen (LobbyViewModel, ChatViewModel)
|
||||||
|
- [x] Extract business logic from Fragments to ViewModels
|
||||||
|
- [x] Create Repository layer for data operations
|
||||||
|
- [x] Implement proper state management with LiveData/StateFlow
|
||||||
|
- [x] Add ViewModelFactory if needed
|
||||||
|
|
||||||
|
### 1.2 Dependency Injection with Hilt ✅
|
||||||
|
- [x] Add Hilt dependencies
|
||||||
|
- [x] Set up Hilt modules for WifiAwareManager
|
||||||
|
- [x] Convert singletons to proper DI
|
||||||
|
- [x] Inject ViewModels using Hilt
|
||||||
|
|
||||||
|
### 1.3 Room Database Setup ✅
|
||||||
|
- [x] Add Room dependencies
|
||||||
|
- [x] Create Message and User entities
|
||||||
|
- [x] Implement DAOs for data access
|
||||||
|
- [x] Create database migrations
|
||||||
|
- [x] Store messages in Room database
|
||||||
|
- [x] Load message history on app restart
|
||||||
|
- [x] Implement message sync logic
|
||||||
|
|
||||||
|
### 1.4 Coroutines & Flow Optimization ✅
|
||||||
|
- [x] Convert callbacks to coroutines
|
||||||
|
- [x] Use Flow for reactive data streams
|
||||||
|
- [x] Implement proper scope management
|
||||||
|
- [x] Replace GlobalScope with proper lifecycle scopes
|
||||||
|
- [x] Add proper error handling with coroutines
|
||||||
|
|
||||||
|
## Phase 2: Core UX Improvements
|
||||||
|
|
||||||
|
### 2.1 Connection Status Management ✅
|
||||||
|
- [x] Add connection state to ViewModels
|
||||||
|
- [x] Create UI indicator for connection status
|
||||||
|
- [x] Show real-time connection state changes
|
||||||
|
- [x] Add connection error messages
|
||||||
|
|
||||||
|
### 2.2 User List Feature
|
||||||
|
- [ ] Track connected users in Repository
|
||||||
|
- [ ] Add UI to display active users
|
||||||
|
- [ ] Handle user join/leave events
|
||||||
|
- [ ] Show user count in chat header
|
||||||
|
|
||||||
|
### 2.3 Enhanced Messaging Features
|
||||||
|
- [ ] Message status indicators (sent/delivered/read)
|
||||||
|
- [ ] Add status field to MessageEntity (pending/sent/delivered/failed)
|
||||||
|
- [ ] Show status icons in message bubbles
|
||||||
|
- [ ] Update status when delivery confirmed
|
||||||
|
- [ ] Store-and-forward messaging pattern
|
||||||
|
- [ ] Save messages with "pending" status initially
|
||||||
|
- [ ] Implement acknowledgment protocol in WifiAwareManager
|
||||||
|
- [ ] Update to "sent" only after confirmation received
|
||||||
|
- [ ] Queue messages when offline/disconnected
|
||||||
|
- [ ] Auto-retry failed messages with exponential backoff
|
||||||
|
- [ ] Mark messages as failed after max retries
|
||||||
|
- [ ] User presence indicators (online/offline/typing)
|
||||||
|
- [ ] Message timestamps with proper formatting
|
||||||
|
- [ ] Offline message queue
|
||||||
|
- [ ] Message retry mechanism
|
||||||
|
- [ ] Long press message actions (copy, delete)
|
||||||
|
|
||||||
|
### 2.4 File & Media Sharing
|
||||||
|
- [ ] Image sharing support
|
||||||
|
- [ ] File transfer capability
|
||||||
|
- [ ] Image preview in chat
|
||||||
|
- [ ] Progress indicators for transfers
|
||||||
|
- [ ] File size limits and validation
|
||||||
|
|
||||||
|
## Phase 3: UI/UX Improvements
|
||||||
|
|
||||||
|
### 3.1 Material 3 Design Update
|
||||||
|
- [ ] Migrate to Material 3 components
|
||||||
|
- [ ] Implement dynamic color theming
|
||||||
|
- [ ] Update typography and spacing
|
||||||
|
- [ ] Add proper elevation and shadows
|
||||||
|
- [ ] Implement Material You design principles
|
||||||
|
|
||||||
|
### 3.2 Dark Theme & Theming
|
||||||
|
- [ ] Implement dark theme
|
||||||
|
- [ ] Add theme toggle in settings
|
||||||
|
- [ ] System theme detection
|
||||||
|
- [ ] Custom color schemes
|
||||||
|
- [ ] Persist theme preference
|
||||||
|
|
||||||
|
### 3.3 Animations & Polish
|
||||||
|
- [ ] Message send/receive animations
|
||||||
|
- [ ] Screen transition animations
|
||||||
|
- [ ] Loading state animations
|
||||||
|
- [ ] Smooth scrolling improvements
|
||||||
|
- [ ] Haptic feedback
|
||||||
|
|
||||||
|
### 3.4 Better Error Handling UI
|
||||||
|
- [ ] User-friendly error messages
|
||||||
|
- [ ] Retry mechanisms with UI feedback
|
||||||
|
- [ ] Connection lost/restored snackbars
|
||||||
|
- [ ] Empty states for no messages/users
|
||||||
|
- [ ] Inline error states
|
||||||
|
|
||||||
|
## Phase 4: Reliability Improvements
|
||||||
|
|
||||||
|
### 4.1 Reconnection Handling
|
||||||
|
- [ ] Detect connection drops
|
||||||
|
- [ ] Implement exponential backoff retry
|
||||||
|
- [ ] Preserve message queue during disconnection
|
||||||
|
- [ ] Auto-reconnect when network available
|
||||||
|
|
||||||
|
### 4.2 Network State Monitoring
|
||||||
|
- [ ] Monitor WiFi state changes
|
||||||
|
- [ ] Handle app lifecycle properly
|
||||||
|
- [ ] Save and restore connection state
|
||||||
|
|
||||||
|
## Phase 5: Security & Privacy
|
||||||
|
|
||||||
|
### 5.1 Message Encryption
|
||||||
|
- [ ] End-to-end encryption implementation
|
||||||
|
- [ ] Key exchange protocol
|
||||||
|
- [ ] Message integrity verification
|
||||||
|
- [ ] Secure key storage
|
||||||
|
- [ ] Forward secrecy
|
||||||
|
|
||||||
|
### 5.2 Privacy Features
|
||||||
|
- [ ] Optional username anonymization
|
||||||
|
- [ ] Message auto-deletion
|
||||||
|
- [ ] Block/unblock users
|
||||||
|
- [ ] Private rooms with passwords
|
||||||
|
- [ ] Data export/import
|
||||||
|
|
||||||
|
## Phase 6: Advanced Features
|
||||||
|
|
||||||
|
### 6.1 Background Service
|
||||||
|
- [ ] Create foreground service for persistent connection
|
||||||
|
- [ ] Handle Doze mode and battery optimization
|
||||||
|
- [ ] Add notification for active chat
|
||||||
|
- [ ] Implement proper service lifecycle
|
||||||
|
- [ ] Wake lock management
|
||||||
|
|
||||||
|
### 6.2 Settings & Preferences
|
||||||
|
- [ ] Create settings screen
|
||||||
|
- [ ] Notification preferences
|
||||||
|
- [ ] Sound/vibration settings
|
||||||
|
- [ ] Auto-reconnect toggle
|
||||||
|
- [ ] Message history limits
|
||||||
|
|
||||||
|
## Phase 7: Testing & Quality
|
||||||
|
|
||||||
|
### 7.1 Unit Testing
|
||||||
|
- [ ] Test ViewModels
|
||||||
|
- [ ] Test Repository logic
|
||||||
|
- [ ] Test data transformations
|
||||||
|
- [ ] Test error scenarios
|
||||||
|
- [ ] Mock dependencies with Hilt testing
|
||||||
|
|
||||||
|
### 7.2 Integration Testing
|
||||||
|
- [ ] Test database operations
|
||||||
|
- [ ] Test network layer
|
||||||
|
- [ ] Test complete user flows
|
||||||
|
- [ ] Test state persistence
|
||||||
|
|
||||||
|
### 7.3 UI Testing
|
||||||
|
- [ ] Espresso tests for main flows
|
||||||
|
- [ ] Test navigation
|
||||||
|
- [ ] Test user interactions
|
||||||
|
- [ ] Screenshot testing
|
||||||
|
- [ ] Accessibility testing
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
- ✅ Phase 1.1 - MVVM Architecture - COMPLETED
|
||||||
|
- ✅ Phase 1.2 - Dependency Injection with Hilt - COMPLETED
|
||||||
|
- ✅ Phase 1.3 - Room Database Setup - COMPLETED
|
||||||
|
- ✅ Phase 1.4 - Coroutines & Flow Optimization - COMPLETED
|
||||||
|
- ✅ Phase 2.1 - Connection Status Management - COMPLETED
|
||||||
|
- 🚀 Next Priority Options:
|
||||||
|
- Phase 2.2 - User List Feature (Core UX)
|
||||||
|
- Phase 2.3 - Enhanced Messaging (Better UX)
|
||||||
|
- Phase 3.1 - Material 3 Update (Modern UI)
|
||||||
|
- Phase 3.2 - Dark Theme & Theming
|
||||||
|
|
||||||
|
## Completed Work Summary
|
||||||
|
1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns
|
||||||
|
2. **Dependency Injection**: Hilt integration with proper scoping and lifecycle management
|
||||||
|
3. **Room Database**: Message persistence with proper DAOs and entity mapping
|
||||||
|
4. **Coroutines & Flow**: Converted callbacks to coroutines, implemented Flow for reactive streams, proper scope management
|
||||||
|
5. **Connection Status**: Visual indicator with real-time updates, activity-based detection
|
||||||
|
6. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
- Architecture foundation (Phase 1) should be completed before moving to advanced features
|
||||||
|
- UI/UX improvements (Phase 3) can be done in parallel with feature development
|
||||||
|
- Testing (Phase 7) should be implemented incrementally as features are added
|
||||||
|
- Security features (Phase 5) are important for production readiness
|
||||||
@@ -2,6 +2,12 @@ plugins {
|
|||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("androidx.navigation.safeargs.kotlin")
|
id("androidx.navigation.safeargs.kotlin")
|
||||||
|
id("kotlin-kapt")
|
||||||
|
id("com.google.dagger.hilt.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -12,6 +18,12 @@ android {
|
|||||||
abortOnError = false
|
abortOnError = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.mattintech.lchat"
|
applicationId = "com.mattintech.lchat"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
@@ -50,6 +62,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,16 @@ dependencies {
|
|||||||
implementation("androidx.navigation:navigation-fragment-ktx:2.7.6")
|
implementation("androidx.navigation:navigation-fragment-ktx:2.7.6")
|
||||||
implementation("androidx.navigation:navigation-ui-ktx:2.7.6")
|
implementation("androidx.navigation:navigation-ui-ktx:2.7.6")
|
||||||
|
|
||||||
|
// Hilt dependencies
|
||||||
|
implementation("com.google.dagger:hilt-android:2.50")
|
||||||
|
kapt("com.google.dagger:hilt-compiler:2.50")
|
||||||
|
|
||||||
|
// Room dependencies
|
||||||
|
val roomVersion = "2.6.1"
|
||||||
|
implementation("androidx.room:room-runtime:$roomVersion")
|
||||||
|
implementation("androidx.room:room-ktx:$roomVersion")
|
||||||
|
kapt("androidx.room:room-compiler:$roomVersion")
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
|||||||
13
app/proguard-rules.pro
vendored
13
app/proguard-rules.pro
vendored
@@ -12,3 +12,16 @@
|
|||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
# Keep Application class
|
||||||
|
-keep class com.mattintech.lchat.LChatApplication { *; }
|
||||||
|
|
||||||
|
# Hilt rules
|
||||||
|
-keep class dagger.hilt.** { *; }
|
||||||
|
-keep class javax.inject.** { *; }
|
||||||
|
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager { *; }
|
||||||
|
|
||||||
|
# Keep all @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel annotated classes
|
||||||
|
-keep @dagger.hilt.android.HiltAndroidApp class * { *; }
|
||||||
|
-keep @dagger.hilt.android.AndroidEntryPoint class * { *; }
|
||||||
|
-keep @dagger.hilt.android.lifecycle.HiltViewModel class * { *; }
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<uses-feature android:name="android.hardware.wifi.aware" android:required="true" />
|
<uses-feature android:name="android.hardware.wifi.aware" android:required="true" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".LChatApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|||||||
11
app/src/main/java/com/mattintech/lchat/LChatApplication.kt
Normal file
11
app/src/main/java/com/mattintech/lchat/LChatApplication.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.mattintech.lchat
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class LChatApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.mattintech.lchat.databinding.ActivityMainBinding
|
import com.mattintech.lchat.databinding.ActivityMainBinding
|
||||||
import com.mattintech.lchat.utils.LOG_PREFIX
|
import com.mattintech.lchat.utils.LOG_PREFIX
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package com.mattintech.lchat.data
|
|||||||
|
|
||||||
data class Message(
|
data class Message(
|
||||||
val id: String,
|
val id: String,
|
||||||
val senderId: String,
|
val userId: String,
|
||||||
val senderName: String,
|
val userName: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
val timestamp: Long,
|
val timestamp: Long,
|
||||||
val isLocal: Boolean = false
|
val isOwnMessage: Boolean = false
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.mattintech.lchat.data.db
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import com.mattintech.lchat.data.db.dao.MessageDao
|
||||||
|
import com.mattintech.lchat.data.db.entities.MessageEntity
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [MessageEntity::class],
|
||||||
|
version = 1,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
abstract class LChatDatabase : RoomDatabase() {
|
||||||
|
abstract fun messageDao(): MessageDao
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.mattintech.lchat.data.db.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import com.mattintech.lchat.data.db.entities.MessageEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface MessageDao {
|
||||||
|
@Query("SELECT * FROM messages WHERE roomName = :roomName ORDER BY timestamp ASC")
|
||||||
|
fun getMessagesForRoom(roomName: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE roomName = :roomName ORDER BY timestamp ASC")
|
||||||
|
suspend fun getMessagesForRoomOnce(roomName: String): List<MessageEntity>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMessage(message: MessageEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages WHERE roomName = :roomName")
|
||||||
|
suspend fun deleteMessagesForRoom(roomName: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM messages")
|
||||||
|
suspend fun deleteAllMessages()
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM messages WHERE roomName = :roomName")
|
||||||
|
suspend fun getMessageCountForRoom(roomName: String): Int
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.mattintech.lchat.data.db.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "messages")
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
val id: String,
|
||||||
|
val roomName: String,
|
||||||
|
val userId: String,
|
||||||
|
val userName: String,
|
||||||
|
val content: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val isOwnMessage: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.mattintech.lchat.data.db.mappers
|
||||||
|
|
||||||
|
import com.mattintech.lchat.data.Message
|
||||||
|
import com.mattintech.lchat.data.db.entities.MessageEntity
|
||||||
|
|
||||||
|
fun MessageEntity.toMessage(): Message {
|
||||||
|
return Message(
|
||||||
|
id = id,
|
||||||
|
userId = userId,
|
||||||
|
userName = userName,
|
||||||
|
content = content,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isOwnMessage = isOwnMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Message.toEntity(roomName: String): MessageEntity {
|
||||||
|
return MessageEntity(
|
||||||
|
id = id,
|
||||||
|
roomName = roomName,
|
||||||
|
userId = userId,
|
||||||
|
userName = userName,
|
||||||
|
content = content,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isOwnMessage = isOwnMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.mattintech.lchat.data.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
// Placeholder for future migrations
|
||||||
|
object Migrations {
|
||||||
|
// Example migration from version 1 to 2
|
||||||
|
// val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
// override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// // Migration code here
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
43
app/src/main/java/com/mattintech/lchat/di/AppModule.kt
Normal file
43
app/src/main/java/com/mattintech/lchat/di/AppModule.kt
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package com.mattintech.lchat.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import com.mattintech.lchat.data.db.LChatDatabase
|
||||||
|
import com.mattintech.lchat.data.db.dao.MessageDao
|
||||||
|
import com.mattintech.lchat.network.WifiAwareManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object AppModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWifiAwareManager(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
): WifiAwareManager {
|
||||||
|
return WifiAwareManager(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLChatDatabase(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
): LChatDatabase {
|
||||||
|
return Room.databaseBuilder(
|
||||||
|
context,
|
||||||
|
LChatDatabase::class.java,
|
||||||
|
"lchat_database"
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideMessageDao(database: LChatDatabase): MessageDao {
|
||||||
|
return database.messageDao()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,13 @@ import android.os.Build
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import com.mattintech.lchat.utils.LOG_PREFIX
|
import com.mattintech.lchat.utils.LOG_PREFIX
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class WifiAwareManager(private val context: Context) {
|
class WifiAwareManager(private val context: Context) {
|
||||||
@@ -18,6 +24,12 @@ class WifiAwareManager(private val context: Context) {
|
|||||||
private const val TAG = LOG_PREFIX + "WifiAwareManager:"
|
private const val TAG = LOG_PREFIX + "WifiAwareManager:"
|
||||||
private const val SERVICE_NAME = "lchat"
|
private const val SERVICE_NAME = "lchat"
|
||||||
private const val PORT = 8888
|
private const val PORT = 8888
|
||||||
|
|
||||||
|
// Keep-alive constants
|
||||||
|
private const val KEEP_ALIVE_INTERVAL_MS = 15000L // Send keep-alive every 15 seconds
|
||||||
|
private const val KEEP_ALIVE_TIMEOUT_MS = 30000L // Consider connection lost after 30 seconds
|
||||||
|
private const val MESSAGE_TYPE_KEEP_ALIVE = "KEEP_ALIVE"
|
||||||
|
private const val MESSAGE_TYPE_KEEP_ALIVE_ACK = "KEEP_ALIVE_ACK"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var wifiAwareManager: android.net.wifi.aware.WifiAwareManager? = null
|
private var wifiAwareManager: android.net.wifi.aware.WifiAwareManager? = null
|
||||||
@@ -26,31 +38,82 @@ class WifiAwareManager(private val context: Context) {
|
|||||||
private var subscribeDiscoverySession: SubscribeDiscoverySession? = null
|
private var subscribeDiscoverySession: SubscribeDiscoverySession? = null
|
||||||
|
|
||||||
private val peerHandles = ConcurrentHashMap<String, PeerHandle>()
|
private val peerHandles = ConcurrentHashMap<String, PeerHandle>()
|
||||||
|
private val peerRoomMapping = ConcurrentHashMap<String, String>() // PeerHandle.toString() -> roomName
|
||||||
|
private var currentRoom: String? = null
|
||||||
|
|
||||||
|
// Replace callbacks with Flows
|
||||||
|
private val _messageFlow = MutableSharedFlow<Triple<String, String, String>>()
|
||||||
|
val messageFlow: SharedFlow<Triple<String, String, String>> = _messageFlow.asSharedFlow()
|
||||||
|
|
||||||
|
private val _connectionFlow = MutableSharedFlow<Pair<String, Boolean>>()
|
||||||
|
val connectionFlow: SharedFlow<Pair<String, Boolean>> = _connectionFlow.asSharedFlow()
|
||||||
|
|
||||||
|
// Keep legacy callbacks for backward compatibility
|
||||||
private var messageCallback: ((String, String, String) -> Unit)? = null
|
private var messageCallback: ((String, String, String) -> Unit)? = null
|
||||||
private var connectionCallback: ((String, Boolean) -> Unit)? = null
|
private var connectionCallback: ((String, Boolean) -> Unit)? = null
|
||||||
|
|
||||||
private val attachCallback = object : AttachCallback() {
|
// Exception handler for coroutine errors
|
||||||
override fun onAttached(session: WifiAwareSession) {
|
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||||
Log.d(TAG, "Wi-Fi Aware attached")
|
Log.e(TAG, "Coroutine exception: ", throwable)
|
||||||
wifiAwareSession = session
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttachFailed() {
|
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
|
||||||
Log.e(TAG, "Wi-Fi Aware attach failed")
|
|
||||||
|
// Keep-alive tracking
|
||||||
|
private val lastPeerActivity = ConcurrentHashMap<String, Long>()
|
||||||
|
private var keepAliveJob: Job? = null
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val result = initializeAsync()
|
||||||
|
if (result.isFailure) {
|
||||||
|
Log.e(TAG, "Failed to initialize Wi-Fi Aware: ${result.exceptionOrNull()?.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initialize() {
|
suspend fun initializeAsync(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager
|
wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager
|
||||||
|
|
||||||
if (wifiAwareManager?.isAvailable == true) {
|
// Always check if Wi-Fi Aware is available
|
||||||
wifiAwareManager?.attach(attachCallback, null)
|
if (wifiAwareManager?.isAvailable != true) {
|
||||||
} else {
|
|
||||||
Log.e(TAG, "Wi-Fi Aware is not available")
|
Log.e(TAG, "Wi-Fi Aware is not available")
|
||||||
|
wifiAwareSession = null
|
||||||
|
return@withContext Result.failure(Exception("Wi-Fi Aware is not available"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already have a session, verify it's still valid
|
||||||
|
if (wifiAwareSession != null) {
|
||||||
|
Log.d(TAG, "Wi-Fi Aware already initialized - verifying session is still valid")
|
||||||
|
// Session is likely still valid
|
||||||
|
return@withContext Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to attach
|
||||||
|
attachToWifiAware()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun attachToWifiAware(): Result<Unit> = suspendCancellableCoroutine { continuation ->
|
||||||
|
wifiAwareManager?.attach(object : AttachCallback() {
|
||||||
|
override fun onAttached(session: WifiAwareSession) {
|
||||||
|
Log.d(TAG, "Wi-Fi Aware attached")
|
||||||
|
wifiAwareSession = session
|
||||||
|
continuation.resume(Result.success(Unit))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachFailed() {
|
||||||
|
Log.e(TAG, "Wi-Fi Aware attach failed")
|
||||||
|
continuation.resume(Result.failure(Exception("Wi-Fi Aware attach failed")))
|
||||||
|
}
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
Log.d(TAG, "Wi-Fi Aware attach cancelled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startHostMode(roomName: String) {
|
fun startHostMode(roomName: String) {
|
||||||
|
currentRoom = roomName
|
||||||
val config = PublishConfig.Builder()
|
val config = PublishConfig.Builder()
|
||||||
.setServiceName(SERVICE_NAME)
|
.setServiceName(SERVICE_NAME)
|
||||||
.setServiceSpecificInfo(roomName.toByteArray())
|
.setServiceSpecificInfo(roomName.toByteArray())
|
||||||
@@ -66,17 +129,56 @@ class WifiAwareManager(private val context: Context) {
|
|||||||
val messageStr = String(message)
|
val messageStr = String(message)
|
||||||
Log.d(TAG, "Host: Received message: $messageStr")
|
Log.d(TAG, "Host: Received message: $messageStr")
|
||||||
|
|
||||||
if (messageStr == "CONNECT_REQUEST") {
|
when (messageStr) {
|
||||||
Log.d(TAG, "Host: Received connection request")
|
"CONNECT_REQUEST" -> {
|
||||||
acceptConnection(peerHandle)
|
Log.d(TAG, "Host: Received connection request from peer")
|
||||||
} else {
|
coroutineScope.launch {
|
||||||
handleIncomingMessage(peerHandle, message)
|
val result = acceptConnectionAsync(peerHandle)
|
||||||
|
if (result.isSuccess) {
|
||||||
|
Log.d(TAG, "Host: Successfully accepted connection")
|
||||||
|
startKeepAlive()
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Host: Failed to accept connection: ${result.exceptionOrNull()?.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MESSAGE_TYPE_KEEP_ALIVE -> {
|
||||||
|
Log.d(TAG, "Host: Received keep-alive from peer")
|
||||||
|
handleKeepAlive(peerHandle, true)
|
||||||
|
}
|
||||||
|
MESSAGE_TYPE_KEEP_ALIVE_ACK -> {
|
||||||
|
Log.d(TAG, "Host: Received keep-alive ACK from peer")
|
||||||
|
handleKeepAlive(peerHandle, false)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
handleIncomingMessage(peerHandle, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionTerminated() {
|
||||||
|
Log.w(TAG, "Host publish session terminated")
|
||||||
|
publishDiscoverySession = null
|
||||||
|
stopKeepAlive()
|
||||||
|
// Emit disconnection event
|
||||||
|
coroutineScope.launch {
|
||||||
|
_connectionFlow.emit(Pair("", false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, null)
|
}, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startClientMode() {
|
fun startClientMode() {
|
||||||
|
// Close any existing subscribe session before starting a new one
|
||||||
|
if (subscribeDiscoverySession != null) {
|
||||||
|
Log.d(TAG, "Closing existing subscribe session before starting new one")
|
||||||
|
subscribeDiscoverySession?.close()
|
||||||
|
subscribeDiscoverySession = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any stale peer handles
|
||||||
|
peerHandles.clear()
|
||||||
|
|
||||||
val config = SubscribeConfig.Builder()
|
val config = SubscribeConfig.Builder()
|
||||||
.setServiceName(SERVICE_NAME)
|
.setServiceName(SERVICE_NAME)
|
||||||
.build()
|
.build()
|
||||||
@@ -97,24 +199,66 @@ class WifiAwareManager(private val context: Context) {
|
|||||||
|
|
||||||
// Store peer handle for this room
|
// Store peer handle for this room
|
||||||
peerHandles[roomName] = peerHandle
|
peerHandles[roomName] = peerHandle
|
||||||
|
currentRoom = roomName
|
||||||
|
|
||||||
// Send connection request to host
|
// Send connection request to host
|
||||||
Log.d(TAG, "Sending connection request to room: $roomName")
|
Log.d(TAG, "Sending connection request to room: $roomName")
|
||||||
subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray())
|
subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray())
|
||||||
|
|
||||||
|
// Update peer activity when discovered
|
||||||
|
val peerId = peerHandle.toString()
|
||||||
|
peerRoomMapping[peerId] = roomName
|
||||||
|
lastPeerActivity[roomName] = System.currentTimeMillis()
|
||||||
|
|
||||||
// Wait a bit for host to prepare, then connect
|
// Wait a bit for host to prepare, then connect
|
||||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
coroutineScope.launch {
|
||||||
connectToPeer(peerHandle, roomName)
|
delay(500)
|
||||||
}, 500)
|
val result = connectToPeerAsync(peerHandle, roomName)
|
||||||
|
if (result.isFailure) {
|
||||||
|
Log.e(TAG, "Failed to connect to peer: ${result.exceptionOrNull()?.message}")
|
||||||
|
lastPeerActivity.remove(peerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) {
|
override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) {
|
||||||
handleIncomingMessage(peerHandle, message)
|
val messageStr = String(message)
|
||||||
|
when (messageStr) {
|
||||||
|
MESSAGE_TYPE_KEEP_ALIVE -> {
|
||||||
|
Log.d(TAG, "Client: Received keep-alive from host")
|
||||||
|
handleKeepAlive(peerHandle, true)
|
||||||
|
}
|
||||||
|
MESSAGE_TYPE_KEEP_ALIVE_ACK -> {
|
||||||
|
Log.d(TAG, "Client: Received keep-alive ACK from host")
|
||||||
|
handleKeepAlive(peerHandle, false)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
handleIncomingMessage(peerHandle, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionTerminated() {
|
||||||
|
Log.w(TAG, "Client subscribe session terminated")
|
||||||
|
subscribeDiscoverySession = null
|
||||||
|
stopKeepAlive()
|
||||||
|
// Don't clear wifiAwareSession - it's still valid
|
||||||
|
// Emit disconnection event
|
||||||
|
coroutineScope.launch {
|
||||||
|
_connectionFlow.emit(Pair("", false))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, null)
|
}, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connectToPeer(peerHandle: PeerHandle, roomName: String) {
|
private fun connectToPeer(peerHandle: PeerHandle, roomName: String) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
connectToPeerAsync(peerHandle, roomName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connectToPeerAsync(peerHandle: PeerHandle, roomName: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
Log.d(TAG, "connectToPeer: Starting connection to room: $roomName")
|
Log.d(TAG, "connectToPeer: Starting connection to room: $roomName")
|
||||||
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle)
|
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle)
|
||||||
.setPskPassphrase("lchat-secure-key")
|
.setPskPassphrase("lchat-secure-key")
|
||||||
@@ -129,69 +273,155 @@ class WifiAwareManager(private val context: Context) {
|
|||||||
|
|
||||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
try {
|
try {
|
||||||
connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
|
var isResumed = false
|
||||||
override fun onAvailable(network: android.net.Network) {
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
Log.d(TAG, "onAvailable: Network connected for room: $roomName")
|
override fun onAvailable(network: android.net.Network) {
|
||||||
connectionCallback?.invoke(roomName, true)
|
Log.d(TAG, "onAvailable: Network connected for room: $roomName")
|
||||||
|
if (!isResumed) {
|
||||||
|
isResumed = true
|
||||||
|
continuation.resume(Result.success(Unit))
|
||||||
|
}
|
||||||
|
// Emit to flow and legacy callback
|
||||||
|
coroutineScope.launch {
|
||||||
|
_connectionFlow.emit(Pair(roomName, true))
|
||||||
|
}
|
||||||
|
connectionCallback?.invoke(roomName, true)
|
||||||
|
// Start keep-alive for client connections
|
||||||
|
startKeepAlive()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: android.net.Network) {
|
||||||
|
Log.d(TAG, "onLost: Network lost for room: $roomName")
|
||||||
|
// Check if we still have active keep-alive for this room
|
||||||
|
val lastActivity = lastPeerActivity[roomName]
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
if (lastActivity != null && (currentTime - lastActivity) < KEEP_ALIVE_TIMEOUT_MS) {
|
||||||
|
Log.d(TAG, "Network lost but keep-alive still active for room: $roomName - ignoring disconnect")
|
||||||
|
// Don't disconnect if keep-alive is still active
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear peer handles when connection is truly lost
|
||||||
|
peerHandles.remove(roomName)
|
||||||
|
lastPeerActivity.remove(roomName)
|
||||||
|
coroutineScope.launch {
|
||||||
|
_connectionFlow.emit(Pair(roomName, false))
|
||||||
|
}
|
||||||
|
connectionCallback?.invoke(roomName, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnavailable() {
|
||||||
|
Log.e(TAG, "onUnavailable: Network request failed for room: $roomName")
|
||||||
|
if (!isResumed) {
|
||||||
|
isResumed = true
|
||||||
|
continuation.resume(Result.failure(Exception("Network unavailable for room: $roomName")))
|
||||||
|
}
|
||||||
|
coroutineScope.launch {
|
||||||
|
_connectionFlow.emit(Pair(roomName, false))
|
||||||
|
}
|
||||||
|
connectionCallback?.invoke(roomName, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: android.net.Network, networkCapabilities: NetworkCapabilities) {
|
||||||
|
Log.d(TAG, "onCapabilitiesChanged: Capabilities changed for room: $roomName")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) {
|
||||||
|
Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLost(network: android.net.Network) {
|
connectivityManager.requestNetwork(networkRequest, callback, android.os.Handler(android.os.Looper.getMainLooper()))
|
||||||
Log.d(TAG, "onLost: Network lost for room: $roomName")
|
|
||||||
connectionCallback?.invoke(roomName, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUnavailable() {
|
Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName")
|
||||||
Log.e(TAG, "onUnavailable: Network request failed for room: $roomName")
|
|
||||||
connectionCallback?.invoke(roomName, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network: android.net.Network, networkCapabilities: NetworkCapabilities) {
|
continuation.invokeOnCancellation {
|
||||||
Log.d(TAG, "onCapabilitiesChanged: Capabilities changed for room: $roomName")
|
connectivityManager.unregisterNetworkCallback(callback)
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) {
|
Log.e(TAG, "connectToPeer: Failed to request network", e)
|
||||||
Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName")
|
continuation.resume(Result.failure(e))
|
||||||
|
coroutineScope.launch {
|
||||||
|
_connectionFlow.emit(Pair(roomName, false))
|
||||||
}
|
}
|
||||||
}, android.os.Handler(android.os.Looper.getMainLooper()), 30000) // 30 second timeout
|
connectionCallback?.invoke(roomName, false)
|
||||||
|
}
|
||||||
Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "connectToPeer: Failed to request network", e)
|
|
||||||
connectionCallback?.invoke(roomName, false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun acceptConnection(peerHandle: PeerHandle) {
|
|
||||||
Log.d(TAG, "acceptConnection: Accepting connection from client")
|
|
||||||
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle)
|
|
||||||
.setPskPassphrase("lchat-secure-key")
|
|
||||||
.setPort(PORT)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val networkRequest = NetworkRequest.Builder()
|
private suspend fun acceptConnectionAsync(peerHandle: PeerHandle): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
|
suspendCancellableCoroutine { continuation ->
|
||||||
.setNetworkSpecifier(networkSpecifier)
|
Log.d(TAG, "acceptConnection: Accepting connection from client")
|
||||||
.build()
|
|
||||||
|
|
||||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
try {
|
||||||
connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
|
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle)
|
||||||
override fun onAvailable(network: android.net.Network) {
|
.setPskPassphrase("lchat-secure-key")
|
||||||
Log.d(TAG, "Client connected")
|
.setPort(PORT)
|
||||||
peerHandles[peerHandle.toString()] = peerHandle
|
.build()
|
||||||
|
|
||||||
|
val networkRequest = NetworkRequest.Builder()
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
|
||||||
|
.setNetworkSpecifier(networkSpecifier)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
var isResumed = false
|
||||||
|
|
||||||
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: android.net.Network) {
|
||||||
|
Log.d(TAG, "Client connected")
|
||||||
|
val peerId = peerHandle.toString()
|
||||||
|
val roomName = currentRoom ?: "host"
|
||||||
|
peerHandles[roomName] = peerHandle
|
||||||
|
peerRoomMapping[peerId] = roomName
|
||||||
|
lastPeerActivity[roomName] = System.currentTimeMillis()
|
||||||
|
if (!isResumed) {
|
||||||
|
isResumed = true
|
||||||
|
continuation.resume(Result.success(Unit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnavailable() {
|
||||||
|
Log.e(TAG, "Failed to accept client connection - Check if Wi-Fi is enabled")
|
||||||
|
if (!isResumed) {
|
||||||
|
isResumed = true
|
||||||
|
continuation.resume(Result.failure(Exception("Failed to accept client connection")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectivityManager.requestNetwork(networkRequest, callback)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
connectivityManager.unregisterNetworkCallback(callback)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "acceptConnection: Failed to accept connection", e)
|
||||||
|
continuation.resume(Result.failure(e))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun onUnavailable() {
|
|
||||||
Log.e(TAG, "Failed to accept client connection - Check if Wi-Fi is enabled")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIncomingMessage(peerHandle: PeerHandle, message: ByteArray) {
|
private fun handleIncomingMessage(peerHandle: PeerHandle, message: ByteArray) {
|
||||||
try {
|
try {
|
||||||
|
// Update peer activity on any message
|
||||||
|
val peerId = peerHandle.toString()
|
||||||
|
val roomName = peerRoomMapping[peerId] ?: currentRoom
|
||||||
|
|
||||||
|
if (roomName != null) {
|
||||||
|
lastPeerActivity[roomName] = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
val messageStr = String(message)
|
val messageStr = String(message)
|
||||||
val parts = messageStr.split("|", limit = 3)
|
val parts = messageStr.split("|", limit = 3)
|
||||||
if (parts.size == 3) {
|
if (parts.size == 3) {
|
||||||
|
// Emit to flow and legacy callback
|
||||||
|
coroutineScope.launch {
|
||||||
|
_messageFlow.emit(Triple(parts[0], parts[1], parts[2]))
|
||||||
|
}
|
||||||
messageCallback?.invoke(parts[0], parts[1], parts[2])
|
messageCallback?.invoke(parts[0], parts[1], parts[2])
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -199,18 +429,35 @@ class WifiAwareManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(userId: String, userName: String, content: String) {
|
fun sendMessage(userId: String, userName: String, content: String): Boolean {
|
||||||
val message = "$userId|$userName|$content".toByteArray()
|
val message = "$userId|$userName|$content".toByteArray()
|
||||||
|
var messagesSent = 0
|
||||||
|
|
||||||
if (publishDiscoverySession != null) {
|
if (publishDiscoverySession != null && peerHandles.isNotEmpty()) {
|
||||||
peerHandles.values.forEach { peerHandle ->
|
peerHandles.values.forEach { peerHandle ->
|
||||||
publishDiscoverySession?.sendMessage(peerHandle, 0, message)
|
try {
|
||||||
|
publishDiscoverySession?.sendMessage(peerHandle, messagesSent, message)
|
||||||
|
messagesSent++
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send message to peer", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (subscribeDiscoverySession != null) {
|
} else if (subscribeDiscoverySession != null && peerHandles.isNotEmpty()) {
|
||||||
peerHandles.values.forEach { peerHandle ->
|
peerHandles.values.forEach { peerHandle ->
|
||||||
subscribeDiscoverySession?.sendMessage(peerHandle, 0, message)
|
try {
|
||||||
|
subscribeDiscoverySession?.sendMessage(peerHandle, messagesSent, message)
|
||||||
|
messagesSent++
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send message to peer", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (messagesSent == 0) {
|
||||||
|
Log.w(TAG, "No messages sent - no active session or no peer handles")
|
||||||
|
}
|
||||||
|
|
||||||
|
return messagesSent > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMessageCallback(callback: (String, String, String) -> Unit) {
|
fun setMessageCallback(callback: (String, String, String) -> Unit) {
|
||||||
@@ -221,10 +468,116 @@ class WifiAwareManager(private val context: Context) {
|
|||||||
connectionCallback = callback
|
connectionCallback = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startKeepAlive() {
|
||||||
|
keepAliveJob?.cancel()
|
||||||
|
keepAliveJob = coroutineScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(KEEP_ALIVE_INTERVAL_MS)
|
||||||
|
sendKeepAlive()
|
||||||
|
checkPeerActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopKeepAlive() {
|
||||||
|
keepAliveJob?.cancel()
|
||||||
|
keepAliveJob = null
|
||||||
|
lastPeerActivity.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendKeepAlive() {
|
||||||
|
val message = MESSAGE_TYPE_KEEP_ALIVE.toByteArray()
|
||||||
|
var messagesSent = 0
|
||||||
|
|
||||||
|
if (publishDiscoverySession != null && peerHandles.isNotEmpty()) {
|
||||||
|
peerHandles.forEach { (roomName, peerHandle) ->
|
||||||
|
try {
|
||||||
|
publishDiscoverySession?.sendMessage(peerHandle, messagesSent, message)
|
||||||
|
messagesSent++
|
||||||
|
Log.d(TAG, "Sent keep-alive to room: $roomName")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send keep-alive to room: $roomName", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (subscribeDiscoverySession != null && peerHandles.isNotEmpty()) {
|
||||||
|
peerHandles.forEach { (roomName, peerHandle) ->
|
||||||
|
try {
|
||||||
|
subscribeDiscoverySession?.sendMessage(peerHandle, messagesSent, message)
|
||||||
|
messagesSent++
|
||||||
|
Log.d(TAG, "Sent keep-alive to room: $roomName")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send keep-alive to room: $roomName", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleKeepAlive(peerHandle: PeerHandle, shouldReply: Boolean) {
|
||||||
|
// Update last activity for this peer
|
||||||
|
val peerId = peerHandle.toString()
|
||||||
|
val roomName = peerRoomMapping[peerId] ?: currentRoom
|
||||||
|
|
||||||
|
if (roomName != null) {
|
||||||
|
lastPeerActivity[roomName] = System.currentTimeMillis()
|
||||||
|
Log.d(TAG, "Updated keep-alive activity for room: $roomName")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send acknowledgment if requested
|
||||||
|
if (shouldReply) {
|
||||||
|
val ackMessage = MESSAGE_TYPE_KEEP_ALIVE_ACK.toByteArray()
|
||||||
|
try {
|
||||||
|
if (publishDiscoverySession != null) {
|
||||||
|
publishDiscoverySession?.sendMessage(peerHandle, 0, ackMessage)
|
||||||
|
} else if (subscribeDiscoverySession != null) {
|
||||||
|
subscribeDiscoverySession?.sendMessage(peerHandle, 0, ackMessage)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Sent keep-alive ACK to peer")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send keep-alive ACK", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPeerActivity() {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
val inactiveRooms = mutableListOf<String>()
|
||||||
|
|
||||||
|
lastPeerActivity.forEach { (roomName, lastActivity) ->
|
||||||
|
if (currentTime - lastActivity > KEEP_ALIVE_TIMEOUT_MS) {
|
||||||
|
Log.w(TAG, "Room $roomName inactive for too long, considering disconnected")
|
||||||
|
inactiveRooms.add(roomName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove inactive rooms
|
||||||
|
inactiveRooms.forEach { roomName ->
|
||||||
|
lastPeerActivity.remove(roomName)
|
||||||
|
peerHandles.remove(roomName)
|
||||||
|
|
||||||
|
// Clean up peer room mapping
|
||||||
|
peerRoomMapping.entries.removeIf { it.value == roomName }
|
||||||
|
|
||||||
|
// Emit disconnection event for this room
|
||||||
|
coroutineScope.launch {
|
||||||
|
_connectionFlow.emit(Pair(roomName, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
|
Log.d(TAG, "Stopping WifiAwareManager")
|
||||||
|
stopKeepAlive()
|
||||||
publishDiscoverySession?.close()
|
publishDiscoverySession?.close()
|
||||||
|
publishDiscoverySession = null
|
||||||
subscribeDiscoverySession?.close()
|
subscribeDiscoverySession?.close()
|
||||||
|
subscribeDiscoverySession = null
|
||||||
|
// Close and clear the wifiAwareSession to force re-attachment
|
||||||
wifiAwareSession?.close()
|
wifiAwareSession?.close()
|
||||||
|
wifiAwareSession = null
|
||||||
peerHandles.clear()
|
peerHandles.clear()
|
||||||
|
peerRoomMapping.clear()
|
||||||
|
currentRoom = null
|
||||||
|
// Don't cancel the coroutine scope - we need it for future operations
|
||||||
|
Log.d(TAG, "WifiAwareManager stopped - session cleared for fresh start")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.mattintech.lchat.network
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
object WifiAwareManagerSingleton {
|
|
||||||
private var instance: WifiAwareManager? = null
|
|
||||||
|
|
||||||
fun getInstance(context: Context): WifiAwareManager {
|
|
||||||
if (instance == null) {
|
|
||||||
instance = WifiAwareManager(context.applicationContext)
|
|
||||||
instance!!.initialize()
|
|
||||||
}
|
|
||||||
return instance!!
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
instance?.stop()
|
|
||||||
instance = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package com.mattintech.lchat.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.mattintech.lchat.data.Message
|
||||||
|
import com.mattintech.lchat.data.db.dao.MessageDao
|
||||||
|
import com.mattintech.lchat.data.db.mappers.toEntity
|
||||||
|
import com.mattintech.lchat.data.db.mappers.toMessage
|
||||||
|
import com.mattintech.lchat.network.WifiAwareManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ChatRepository @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val wifiAwareManager: WifiAwareManager,
|
||||||
|
private val messageDao: MessageDao
|
||||||
|
) {
|
||||||
|
// Exception handler for repository operations
|
||||||
|
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||||
|
android.util.Log.e("ChatRepository", "Repository coroutine exception: ", throwable)
|
||||||
|
_connectionState.value = ConnectionState.Error(throwable.message ?: "Unknown error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository scope for background operations
|
||||||
|
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
|
||||||
|
|
||||||
|
private var currentRoomName: String = ""
|
||||||
|
|
||||||
|
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||||
|
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||||
|
|
||||||
|
// Flow that combines in-memory and database messages
|
||||||
|
fun getMessagesFlow(roomName: String): Flow<List<Message>> {
|
||||||
|
return messageDao.getMessagesForRoom(roomName)
|
||||||
|
.map { entities -> entities.map { it.toMessage() } }
|
||||||
|
.onStart { loadMessagesFromDatabase(roomName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||||
|
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||||
|
|
||||||
|
private val _connectedUsers = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val connectedUsers: StateFlow<List<String>> = _connectedUsers.asStateFlow()
|
||||||
|
|
||||||
|
private var messageCallback: ((String, String, String) -> Unit)? = null
|
||||||
|
private var connectionCallback: ((String, Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
// Keep-alive is now handled by WifiAwareManager
|
||||||
|
|
||||||
|
init {
|
||||||
|
wifiAwareManager.initialize()
|
||||||
|
// Only use Flow-based collection, not callbacks
|
||||||
|
collectWifiAwareFlows()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectWifiAwareFlows() {
|
||||||
|
// Collect messages from Flow
|
||||||
|
repositoryScope.launch {
|
||||||
|
try {
|
||||||
|
wifiAwareManager.messageFlow.collect { (userId, userName, content) ->
|
||||||
|
val message = Message(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
userId = userId,
|
||||||
|
userName = userName,
|
||||||
|
content = content,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
isOwnMessage = false
|
||||||
|
)
|
||||||
|
addMessage(message)
|
||||||
|
|
||||||
|
// Don't change connection state based on messages - let WifiAwareManager handle it
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatRepository", "Error collecting message flow", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect connection state from Flow
|
||||||
|
repositoryScope.launch {
|
||||||
|
try {
|
||||||
|
wifiAwareManager.connectionFlow.collect { (roomName, isConnected) ->
|
||||||
|
android.util.Log.d("ChatRepository", "Connection flow update - room: $roomName, connected: $isConnected, current state: ${_connectionState.value}")
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
currentRoomName = roomName
|
||||||
|
// Only change to connected if we're not already hosting
|
||||||
|
if (_connectionState.value !is ConnectionState.Hosting) {
|
||||||
|
_connectionState.value = ConnectionState.Connected(roomName)
|
||||||
|
}
|
||||||
|
loadMessagesFromDatabase(roomName)
|
||||||
|
} else {
|
||||||
|
// Only disconnect if the room matches our current room
|
||||||
|
if (roomName.isEmpty() || roomName == currentRoomName) {
|
||||||
|
_connectionState.value = ConnectionState.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Call the legacy callback if set
|
||||||
|
connectionCallback?.invoke(roomName, isConnected)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatRepository", "Error collecting connection flow", e)
|
||||||
|
_connectionState.value = ConnectionState.Error(e.message ?: "Connection error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed setupWifiAwareCallbacks - now using Flow-based collection only
|
||||||
|
|
||||||
|
fun startHostMode(roomName: String) {
|
||||||
|
currentRoomName = roomName
|
||||||
|
// Ensure WifiAwareManager is initialized before starting
|
||||||
|
wifiAwareManager.initialize()
|
||||||
|
wifiAwareManager.startHostMode(roomName)
|
||||||
|
_connectionState.value = ConnectionState.Hosting(roomName)
|
||||||
|
loadMessagesFromDatabase(roomName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMessagesFromDatabase(roomName: String) {
|
||||||
|
repositoryScope.launch {
|
||||||
|
try {
|
||||||
|
val storedMessages = messageDao.getMessagesForRoomOnce(roomName)
|
||||||
|
.map { it.toMessage() }
|
||||||
|
_messages.value = storedMessages
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatRepository", "Error loading messages from database", e)
|
||||||
|
// Don't crash, just continue with empty messages
|
||||||
|
_messages.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startClientMode() {
|
||||||
|
// Reset state for fresh start
|
||||||
|
_messages.value = emptyList()
|
||||||
|
currentRoomName = ""
|
||||||
|
|
||||||
|
// Ensure WifiAwareManager is initialized before starting
|
||||||
|
repositoryScope.launch {
|
||||||
|
// Give Wi-Fi Aware time to stabilize after network changes
|
||||||
|
delay(500)
|
||||||
|
wifiAwareManager.initialize()
|
||||||
|
// Small delay to ensure initialization completes
|
||||||
|
delay(100)
|
||||||
|
wifiAwareManager.startClientMode()
|
||||||
|
_connectionState.value = ConnectionState.Searching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(userId: String, userName: String, content: String) {
|
||||||
|
// Only allow sending messages if connected or hosting
|
||||||
|
when (_connectionState.value) {
|
||||||
|
is ConnectionState.Connected, is ConnectionState.Hosting -> {
|
||||||
|
val message = Message(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
userId = userId,
|
||||||
|
userName = userName,
|
||||||
|
content = content,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
isOwnMessage = true
|
||||||
|
)
|
||||||
|
val sent = wifiAwareManager.sendMessage(userId, userName, content)
|
||||||
|
if (sent) {
|
||||||
|
addMessage(message)
|
||||||
|
} else {
|
||||||
|
android.util.Log.e("ChatRepository", "Failed to send message - no active connection")
|
||||||
|
_connectionState.value = ConnectionState.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
android.util.Log.w("ChatRepository", "Cannot send message - not connected. State: ${_connectionState.value}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addMessage(message: Message) {
|
||||||
|
// Add message and sort by timestamp to ensure proper order
|
||||||
|
_messages.value = (_messages.value + message).sortedBy { it.timestamp }
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
repositoryScope.launch {
|
||||||
|
try {
|
||||||
|
messageDao.insertMessage(message.toEntity(currentRoomName))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatRepository", "Error saving message to database", e)
|
||||||
|
// Don't crash, message is already in memory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMessages() {
|
||||||
|
_messages.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMessageCallback(callback: (String, String, String) -> Unit) {
|
||||||
|
messageCallback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setConnectionCallback(callback: (String, Boolean) -> Unit) {
|
||||||
|
connectionCallback = callback
|
||||||
|
// Connection state is now handled by Flow collection
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
wifiAwareManager.stop()
|
||||||
|
_connectionState.value = ConnectionState.Disconnected
|
||||||
|
_connectedUsers.value = emptyList()
|
||||||
|
// Don't cancel the repository scope - we need it for future operations
|
||||||
|
// Clear messages when stopping
|
||||||
|
_messages.value = emptyList()
|
||||||
|
currentRoomName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ConnectionState {
|
||||||
|
object Disconnected : ConnectionState()
|
||||||
|
object Searching : ConnectionState()
|
||||||
|
data class Hosting(val roomName: String) : ConnectionState()
|
||||||
|
data class Connected(val roomName: String) : ConnectionState()
|
||||||
|
data class Error(val message: String) : ConnectionState()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,21 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.fragment.navArgs
|
import androidx.navigation.fragment.navArgs
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.mattintech.lchat.data.Message
|
import com.mattintech.lchat.R
|
||||||
import com.mattintech.lchat.databinding.FragmentChatBinding
|
import com.mattintech.lchat.databinding.FragmentChatBinding
|
||||||
import com.mattintech.lchat.network.WifiAwareManager
|
import com.mattintech.lchat.repository.ChatRepository
|
||||||
import com.mattintech.lchat.network.WifiAwareManagerSingleton
|
|
||||||
import com.mattintech.lchat.ui.adapters.MessageAdapter
|
import com.mattintech.lchat.ui.adapters.MessageAdapter
|
||||||
import com.mattintech.lchat.utils.LOG_PREFIX
|
import com.mattintech.lchat.utils.LOG_PREFIX
|
||||||
import java.util.UUID
|
import com.mattintech.lchat.viewmodel.ChatViewModel
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class ChatFragment : Fragment() {
|
class ChatFragment : Fragment() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -26,10 +31,8 @@ class ChatFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
private val args: ChatFragmentArgs by navArgs()
|
private val args: ChatFragmentArgs by navArgs()
|
||||||
private lateinit var wifiAwareManager: WifiAwareManager
|
private val viewModel: ChatViewModel by viewModels()
|
||||||
private lateinit var messageAdapter: MessageAdapter
|
private lateinit var messageAdapter: MessageAdapter
|
||||||
private val messages = mutableListOf<Message>()
|
|
||||||
private val userId = UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
@@ -45,8 +48,11 @@ class ChatFragment : Fragment() {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Log.d(TAG, "onViewCreated - room: ${args.roomName}, user: ${args.userName}, isHost: ${args.isHost}")
|
Log.d(TAG, "onViewCreated - room: ${args.roomName}, user: ${args.userName}, isHost: ${args.isHost}")
|
||||||
|
|
||||||
|
viewModel.initialize(args.roomName, args.userName, args.isHost)
|
||||||
|
|
||||||
setupUI()
|
setupUI()
|
||||||
setupWifiAware()
|
observeViewModel()
|
||||||
|
updateRoomInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupUI() {
|
private fun setupUI() {
|
||||||
@@ -68,58 +74,72 @@ class ChatFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupWifiAware() {
|
private fun observeViewModel() {
|
||||||
wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext())
|
lifecycleScope.launch {
|
||||||
|
viewModel.messages.collect { messages ->
|
||||||
wifiAwareManager.setMessageCallback { senderId, senderName, content ->
|
messageAdapter.submitList(messages)
|
||||||
Log.d(TAG, "Message received - from: $senderName, content: $content")
|
if (messages.isNotEmpty()) {
|
||||||
val message = Message(
|
binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1)
|
||||||
id = UUID.randomUUID().toString(),
|
}
|
||||||
senderId = senderId,
|
|
||||||
senderName = senderName,
|
|
||||||
content = content,
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isLocal = senderId == userId
|
|
||||||
)
|
|
||||||
|
|
||||||
activity?.runOnUiThread {
|
|
||||||
messages.add(message)
|
|
||||||
messageAdapter.submitList(messages.toList())
|
|
||||||
binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to start host mode here - already started in LobbyFragment
|
lifecycleScope.launch {
|
||||||
Log.d(TAG, "Chat setup complete - isHost: ${args.isHost}, room: ${args.roomName}")
|
viewModel.connectionState.collect { state ->
|
||||||
|
Log.d(TAG, "Connection state: $state")
|
||||||
|
updateConnectionStatus(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendMessage() {
|
private fun sendMessage() {
|
||||||
val content = binding.messageInput.text?.toString()?.trim()
|
val content = binding.messageInput.text?.toString()?.trim()
|
||||||
if (content.isNullOrEmpty()) return
|
if (content.isNullOrEmpty()) return
|
||||||
|
|
||||||
val message = Message(
|
viewModel.sendMessage(content)
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
senderId = userId,
|
|
||||||
senderName = args.userName,
|
|
||||||
content = content,
|
|
||||||
timestamp = System.currentTimeMillis(),
|
|
||||||
isLocal = true
|
|
||||||
)
|
|
||||||
|
|
||||||
messages.add(message)
|
|
||||||
messageAdapter.submitList(messages.toList())
|
|
||||||
binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1)
|
|
||||||
|
|
||||||
Log.d(TAG, "Sending message: $content")
|
|
||||||
wifiAwareManager.sendMessage(userId, args.userName, content)
|
|
||||||
|
|
||||||
binding.messageInput.text?.clear()
|
binding.messageInput.text?.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateRoomInfo() {
|
||||||
|
val (roomName, _, isHost) = viewModel.getRoomInfo()
|
||||||
|
binding.roomNameText.text = if (isHost) "Hosting: $roomName" else "Room: $roomName"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateConnectionStatus(state: ChatRepository.ConnectionState) {
|
||||||
|
when (state) {
|
||||||
|
is ChatRepository.ConnectionState.Disconnected -> {
|
||||||
|
binding.connectionStatusText.text = "Disconnected"
|
||||||
|
binding.connectionIndicator.backgroundTintList =
|
||||||
|
ContextCompat.getColorStateList(requireContext(), R.color.disconnected_color)
|
||||||
|
}
|
||||||
|
is ChatRepository.ConnectionState.Searching -> {
|
||||||
|
binding.connectionStatusText.text = "Searching..."
|
||||||
|
binding.connectionIndicator.backgroundTintList =
|
||||||
|
ContextCompat.getColorStateList(requireContext(), R.color.connecting_color)
|
||||||
|
}
|
||||||
|
is ChatRepository.ConnectionState.Hosting -> {
|
||||||
|
binding.connectionStatusText.text = "Hosting"
|
||||||
|
binding.connectionIndicator.backgroundTintList =
|
||||||
|
ContextCompat.getColorStateList(requireContext(), R.color.hosting_color)
|
||||||
|
}
|
||||||
|
is ChatRepository.ConnectionState.Connected -> {
|
||||||
|
binding.connectionStatusText.text = "Connected"
|
||||||
|
binding.connectionIndicator.backgroundTintList =
|
||||||
|
ContextCompat.getColorStateList(requireContext(), R.color.connected_color)
|
||||||
|
}
|
||||||
|
is ChatRepository.ConnectionState.Error -> {
|
||||||
|
binding.connectionStatusText.text = "Error: ${state.message}"
|
||||||
|
binding.connectionIndicator.backgroundTintList =
|
||||||
|
ContextCompat.getColorStateList(requireContext(), R.color.disconnected_color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
Log.d(TAG, "onDestroyView")
|
Log.d(TAG, "onDestroyView")
|
||||||
// Don't stop WifiAwareManager here - it's shared across fragments
|
// Disconnect when leaving the chat screen
|
||||||
|
viewModel.disconnect()
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
package com.mattintech.lchat.ui
|
package com.mattintech.lchat.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.fragment.app.viewModels
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import com.mattintech.lchat.R
|
import com.mattintech.lchat.R
|
||||||
import com.mattintech.lchat.databinding.FragmentLobbyBinding
|
import com.mattintech.lchat.databinding.FragmentLobbyBinding
|
||||||
import com.mattintech.lchat.network.WifiAwareManager
|
import com.mattintech.lchat.viewmodel.LobbyEvent
|
||||||
import com.mattintech.lchat.network.WifiAwareManagerSingleton
|
import com.mattintech.lchat.viewmodel.LobbyState
|
||||||
|
import com.mattintech.lchat.viewmodel.LobbyViewModel
|
||||||
import com.mattintech.lchat.utils.LOG_PREFIX
|
import com.mattintech.lchat.utils.LOG_PREFIX
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class LobbyFragment : Fragment() {
|
class LobbyFragment : Fragment() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -24,7 +33,7 @@ class LobbyFragment : Fragment() {
|
|||||||
private var _binding: FragmentLobbyBinding? = null
|
private var _binding: FragmentLobbyBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
private lateinit var wifiAwareManager: WifiAwareManager
|
private val viewModel: LobbyViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
@@ -40,13 +49,23 @@ class LobbyFragment : Fragment() {
|
|||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Log.d(TAG, "onViewCreated")
|
Log.d(TAG, "onViewCreated")
|
||||||
|
|
||||||
Log.d(TAG, "Getting WifiAwareManager singleton")
|
|
||||||
wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext())
|
|
||||||
|
|
||||||
setupUI()
|
setupUI()
|
||||||
|
observeViewModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupUI() {
|
private fun setupUI() {
|
||||||
|
binding.nameInput.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
s?.toString()?.trim()?.let { name ->
|
||||||
|
viewModel.saveUserName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
binding.modeRadioGroup.setOnCheckedChangeListener { _, checkedId ->
|
binding.modeRadioGroup.setOnCheckedChangeListener { _, checkedId ->
|
||||||
when (checkedId) {
|
when (checkedId) {
|
||||||
R.id.hostRadio -> {
|
R.id.hostRadio -> {
|
||||||
@@ -64,53 +83,77 @@ class LobbyFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.actionButton.setOnClickListener {
|
binding.actionButton.setOnClickListener {
|
||||||
val userName = binding.nameInput.text?.toString()?.trim()
|
val userName = binding.nameInput.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
if (userName.isNullOrEmpty()) {
|
|
||||||
Toast.makeText(context, "Please enter your name", Toast.LENGTH_SHORT).show()
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
|
|
||||||
when (binding.modeRadioGroup.checkedRadioButtonId) {
|
when (binding.modeRadioGroup.checkedRadioButtonId) {
|
||||||
R.id.hostRadio -> {
|
R.id.hostRadio -> {
|
||||||
val roomName = binding.roomInput.text?.toString()?.trim()
|
val roomName = binding.roomInput.text?.toString()?.trim() ?: ""
|
||||||
if (roomName.isNullOrEmpty()) {
|
viewModel.startHostMode(roomName, userName)
|
||||||
Toast.makeText(context, "Please enter a room name", Toast.LENGTH_SHORT).show()
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
startHostMode(roomName, userName)
|
|
||||||
}
|
}
|
||||||
R.id.clientRadio -> {
|
R.id.clientRadio -> {
|
||||||
startClientMode(userName)
|
viewModel.startClientMode(userName)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wifiAwareManager.setConnectionCallback { roomName, isConnected ->
|
|
||||||
Log.d(TAG, "Connection callback - room: $roomName, connected: $isConnected")
|
|
||||||
activity?.runOnUiThread {
|
|
||||||
if (isConnected && binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) {
|
|
||||||
val userName = binding.nameInput.text?.toString()?.trim() ?: ""
|
|
||||||
navigateToChat(roomName, userName, false)
|
|
||||||
} else if (!isConnected && binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) {
|
|
||||||
binding.noRoomsText.text = "Failed to connect to $roomName. Ensure Wi-Fi is enabled on both devices."
|
|
||||||
Toast.makeText(context, "Connection failed. Check Wi-Fi is enabled.", Toast.LENGTH_LONG).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startHostMode(roomName: String, userName: String) {
|
private fun observeViewModel() {
|
||||||
Log.d(TAG, "Starting host mode - room: $roomName, user: $userName")
|
// Collect saved user name
|
||||||
wifiAwareManager.startHostMode(roomName)
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
navigateToChat(roomName, userName, true)
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
}
|
viewModel.savedUserName.collect { savedName ->
|
||||||
|
if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) {
|
||||||
|
binding.nameInput.setText(savedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startClientMode(userName: String) {
|
// Collect state
|
||||||
Log.d(TAG, "Starting client mode - user: $userName")
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
binding.noRoomsText.visibility = View.VISIBLE
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
binding.noRoomsText.text = getString(R.string.connecting)
|
viewModel.state.collect { state ->
|
||||||
wifiAwareManager.startClientMode()
|
when (state) {
|
||||||
|
is LobbyState.Idle -> {
|
||||||
|
binding.noRoomsText.visibility = View.GONE
|
||||||
|
}
|
||||||
|
is LobbyState.Connecting -> {
|
||||||
|
if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) {
|
||||||
|
binding.noRoomsText.visibility = View.VISIBLE
|
||||||
|
binding.noRoomsText.text = getString(R.string.connecting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LobbyState.Connected -> {
|
||||||
|
if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) {
|
||||||
|
val userName = binding.nameInput.text?.toString()?.trim() ?: ""
|
||||||
|
viewModel.onConnectedToRoom(state.roomName, userName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LobbyState.Error -> {
|
||||||
|
binding.noRoomsText.visibility = View.VISIBLE
|
||||||
|
binding.noRoomsText.text = state.message
|
||||||
|
Toast.makeText(context, state.message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect events
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is LobbyEvent.NavigateToChat -> {
|
||||||
|
navigateToChat(event.roomName, event.userName, event.isHost)
|
||||||
|
}
|
||||||
|
is LobbyEvent.ShowError -> {
|
||||||
|
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun navigateToChat(roomName: String, userName: String, isHost: Boolean) {
|
private fun navigateToChat(roomName: String, userName: String, isHost: Boolean) {
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ class MessageAdapter : ListAdapter<Message, MessageAdapter.MessageViewHolder>(Me
|
|||||||
) : RecyclerView.ViewHolder(binding.root) {
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(message: Message) {
|
fun bind(message: Message) {
|
||||||
binding.senderName.text = message.senderName
|
binding.senderName.text = message.userName
|
||||||
binding.messageContent.text = message.content
|
binding.messageContent.text = message.content
|
||||||
binding.timestamp.text = timeFormat.format(Date(message.timestamp))
|
binding.timestamp.text = timeFormat.format(Date(message.timestamp))
|
||||||
|
|
||||||
val layoutParams = binding.messageCard.layoutParams as ConstraintLayout.LayoutParams
|
val layoutParams = binding.messageCard.layoutParams as ConstraintLayout.LayoutParams
|
||||||
if (message.isLocal) {
|
if (message.isOwnMessage) {
|
||||||
layoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET
|
layoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET
|
||||||
layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
|
layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
|
||||||
binding.messageCard.setCardBackgroundColor(
|
binding.messageCard.setCardBackgroundColor(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.mattintech.lchat.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PreferencesManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val sharedPreferences: SharedPreferences =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun saveUserName(name: String) {
|
||||||
|
if (name.isBlank()) {
|
||||||
|
clearUserName()
|
||||||
|
} else {
|
||||||
|
sharedPreferences.edit().putString(KEY_USER_NAME, name).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserName(): String? {
|
||||||
|
return sharedPreferences.getString(KEY_USER_NAME, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearUserName() {
|
||||||
|
sharedPreferences.edit().remove(KEY_USER_NAME).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "lchat_preferences"
|
||||||
|
private const val KEY_USER_NAME = "user_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.mattintech.lchat.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mattintech.lchat.data.Message
|
||||||
|
import com.mattintech.lchat.repository.ChatRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed class ChatState {
|
||||||
|
object Connected : ChatState()
|
||||||
|
object Disconnected : ChatState()
|
||||||
|
data class Error(val message: String) : ChatState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val chatRepository: ChatRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<ChatState>(ChatState.Connected)
|
||||||
|
val state: StateFlow<ChatState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _messagesFlow = MutableStateFlow<Flow<List<Message>>>(flowOf(emptyList()))
|
||||||
|
|
||||||
|
val messages: StateFlow<List<Message>> = _messagesFlow
|
||||||
|
.flatMapLatest { it }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectionState = chatRepository.connectionState
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = ChatRepository.ConnectionState.Disconnected
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedUsers = chatRepository.connectedUsers
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
private var currentUserId: String = ""
|
||||||
|
private var currentUserName: String = ""
|
||||||
|
private var currentRoomName: String = ""
|
||||||
|
private var isHost: Boolean = false
|
||||||
|
|
||||||
|
fun initialize(roomName: String, userName: String, isHost: Boolean) {
|
||||||
|
this.currentRoomName = roomName
|
||||||
|
this.currentUserName = userName
|
||||||
|
this.isHost = isHost
|
||||||
|
this.currentUserId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
// Set up messages flow for this room
|
||||||
|
_messagesFlow.value = chatRepository.getMessagesFlow(roomName)
|
||||||
|
|
||||||
|
// Setup message callback if needed for additional processing
|
||||||
|
chatRepository.setMessageCallback { userId, userName, content ->
|
||||||
|
// Can add additional message processing here if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(content: String) {
|
||||||
|
if (content.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
chatRepository.sendMessage(currentUserId, currentUserName, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getRoomInfo(): Triple<String, String, Boolean> {
|
||||||
|
return Triple(currentRoomName, currentUserName, isHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
chatRepository.stop()
|
||||||
|
_state.value = ChatState.Disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.mattintech.lchat.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mattintech.lchat.repository.ChatRepository
|
||||||
|
import com.mattintech.lchat.utils.PreferencesManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed class LobbyState {
|
||||||
|
object Idle : LobbyState()
|
||||||
|
object Connecting : LobbyState()
|
||||||
|
data class Connected(val roomName: String) : LobbyState()
|
||||||
|
data class Error(val message: String) : LobbyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class LobbyEvent {
|
||||||
|
data class NavigateToChat(
|
||||||
|
val roomName: String,
|
||||||
|
val userName: String,
|
||||||
|
val isHost: Boolean
|
||||||
|
) : LobbyEvent()
|
||||||
|
data class ShowError(val message: String) : LobbyEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class LobbyViewModel @Inject constructor(
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val preferencesManager: PreferencesManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<LobbyState>(LobbyState.Idle)
|
||||||
|
val state: StateFlow<LobbyState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = MutableSharedFlow<LobbyEvent>()
|
||||||
|
val events: SharedFlow<LobbyEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
|
private val _savedUserName = MutableStateFlow<String?>(null)
|
||||||
|
val savedUserName: StateFlow<String?> = _savedUserName.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
setupConnectionCallback()
|
||||||
|
loadSavedUserName()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSavedUserName() {
|
||||||
|
_savedUserName.value = preferencesManager.getUserName()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupConnectionCallback() {
|
||||||
|
chatRepository.setConnectionCallback { roomName, isConnected ->
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (isConnected) {
|
||||||
|
_state.value = LobbyState.Connected(roomName)
|
||||||
|
} else {
|
||||||
|
_state.value = LobbyState.Error("Failed to connect to $roomName. Ensure Wi-Fi is enabled on both devices.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startHostMode(roomName: String, userName: String) {
|
||||||
|
if (roomName.isBlank()) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_events.emit(LobbyEvent.ShowError("Please enter a room name"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userName.isBlank()) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_events.emit(LobbyEvent.ShowError("Please enter your name"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = LobbyState.Connecting
|
||||||
|
preferencesManager.saveUserName(userName)
|
||||||
|
chatRepository.startHostMode(roomName)
|
||||||
|
_events.emit(LobbyEvent.NavigateToChat(roomName, userName, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startClientMode(userName: String) {
|
||||||
|
if (userName.isBlank()) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_events.emit(LobbyEvent.ShowError("Please enter your name"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = LobbyState.Connecting
|
||||||
|
preferencesManager.saveUserName(userName)
|
||||||
|
chatRepository.startClientMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConnectedToRoom(roomName: String, userName: String) {
|
||||||
|
preferencesManager.saveUserName(userName)
|
||||||
|
viewModelScope.launch {
|
||||||
|
_events.emit(LobbyEvent.NavigateToChat(roomName, userName, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearEvent() {
|
||||||
|
// SharedFlow doesn't need clearing, events are consumed once
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveUserName(name: String) {
|
||||||
|
if (name.isNotBlank()) {
|
||||||
|
preferencesManager.saveUserName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
// Clean up resources if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/src/main/res/drawable/ic_circle.xml
Normal file
5
app/src/main/res/drawable/ic_circle.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -4,13 +4,60 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/connectionStatusCard"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorSurfaceVariant"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/connectionIndicator"
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:background="@drawable/ic_circle"
|
||||||
|
android:backgroundTint="@color/disconnected_color" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/connectionStatusText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Disconnected"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/roomNameText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:text=""
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:gravity="end" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/messagesRecyclerView"
|
android:id="@+id/messagesRecyclerView"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:padding="8dp"
|
android:padding="8dp"
|
||||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toBottomOf="@id/connectionStatusCard"
|
||||||
app:layout_constraintBottom_toTopOf="@id/messageInputLayout"
|
app:layout_constraintBottom_toTopOf="@id/messageInputLayout"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent" />
|
app:layout_constraintEnd_toEndOf="parent" />
|
||||||
|
|||||||
@@ -7,4 +7,10 @@
|
|||||||
<color name="teal_700">#FF018786</color>
|
<color name="teal_700">#FF018786</color>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
|
||||||
|
<!-- Connection Status Colors -->
|
||||||
|
<color name="connected_color">#4CAF50</color>
|
||||||
|
<color name="connecting_color">#FFC107</color>
|
||||||
|
<color name="disconnected_color">#F44336</color>
|
||||||
|
<color name="hosting_color">#2196F3</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -11,5 +11,6 @@ buildscript {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application") version "8.9.1" apply false
|
id("com.android.application") version "8.9.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||||
|
id("com.google.dagger.hilt.android") version "2.50" apply false
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
# Project-wide Gradle settings.
|
# Project-wide Gradle settings.
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonFinalResIds=false
|
android.nonFinalResIds=false
|
||||||
|
|
||||||
|
# Kapt configuration for better performance
|
||||||
|
kapt.use.worker.api=true
|
||||||
|
kapt.incremental.apt=true
|
||||||
|
|
||||||
|
# Hilt configuration
|
||||||
|
dagger.hilt.android.internal.disableAndroidSuperclassValidation=true
|
||||||
Reference in New Issue
Block a user