diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..1d5e923
--- /dev/null
+++ b/TODO.md
@@ -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
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 63a67e1..1e52234 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,6 +2,12 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("androidx.navigation.safeargs.kotlin")
+ id("kotlin-kapt")
+ id("com.google.dagger.hilt.android")
+}
+
+kapt {
+ correctErrorTypes = true
}
android {
@@ -11,6 +17,12 @@ android {
lint {
abortOnError = false
}
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
defaultConfig {
applicationId = "com.mattintech.lchat"
@@ -50,6 +62,7 @@ android {
}
buildFeatures {
viewBinding = true
+ buildConfig = true
}
}
@@ -66,6 +79,16 @@ dependencies {
implementation("androidx.navigation:navigation-fragment-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")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index e64ca74..fe470fa 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -11,4 +11,17 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-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 * { *; }
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b8a5d57..f580b27 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
>
+
+ @Query("SELECT * FROM messages WHERE roomName = :roomName ORDER BY timestamp ASC")
+ suspend fun getMessagesForRoomOnce(roomName: String): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertMessage(message: MessageEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertMessages(messages: List)
+
+ @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
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/data/db/entities/MessageEntity.kt b/app/src/main/java/com/mattintech/lchat/data/db/entities/MessageEntity.kt
new file mode 100644
index 0000000..92ab367
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/data/db/entities/MessageEntity.kt
@@ -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
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/data/db/mappers/MessageMapper.kt b/app/src/main/java/com/mattintech/lchat/data/db/mappers/MessageMapper.kt
new file mode 100644
index 0000000..6cf80b2
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/data/db/mappers/MessageMapper.kt
@@ -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
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/data/db/migrations/Migrations.kt b/app/src/main/java/com/mattintech/lchat/data/db/migrations/Migrations.kt
new file mode 100644
index 0000000..7340bcb
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/data/db/migrations/Migrations.kt
@@ -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
+ // }
+ // }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/di/AppModule.kt b/app/src/main/java/com/mattintech/lchat/di/AppModule.kt
new file mode 100644
index 0000000..17f7e79
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/di/AppModule.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt
index 9d544a4..448e44d 100644
--- a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt
+++ b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt
@@ -9,7 +9,13 @@ import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
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 kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
@RequiresApi(Build.VERSION_CODES.O)
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 SERVICE_NAME = "lchat"
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
@@ -26,31 +38,82 @@ class WifiAwareManager(private val context: Context) {
private var subscribeDiscoverySession: SubscribeDiscoverySession? = null
private val peerHandles = ConcurrentHashMap()
+ private val peerRoomMapping = ConcurrentHashMap() // PeerHandle.toString() -> roomName
+ private var currentRoom: String? = null
+
+ // Replace callbacks with Flows
+ private val _messageFlow = MutableSharedFlow>()
+ val messageFlow: SharedFlow> = _messageFlow.asSharedFlow()
+
+ private val _connectionFlow = MutableSharedFlow>()
+ val connectionFlow: SharedFlow> = _connectionFlow.asSharedFlow()
+
+ // Keep legacy callbacks for backward compatibility
private var messageCallback: ((String, String, String) -> Unit)? = null
private var connectionCallback: ((String, Boolean) -> Unit)? = null
- private val attachCallback = object : AttachCallback() {
- override fun onAttached(session: WifiAwareSession) {
- Log.d(TAG, "Wi-Fi Aware attached")
- wifiAwareSession = session
- }
-
- override fun onAttachFailed() {
- Log.e(TAG, "Wi-Fi Aware attach failed")
+ // Exception handler for coroutine errors
+ private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
+ Log.e(TAG, "Coroutine exception: ", throwable)
+ }
+
+ private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
+
+ // Keep-alive tracking
+ private val lastPeerActivity = ConcurrentHashMap()
+ 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 = withContext(Dispatchers.IO) {
wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager
- if (wifiAwareManager?.isAvailable == true) {
- wifiAwareManager?.attach(attachCallback, null)
- } else {
+ // Always check if Wi-Fi Aware is available
+ if (wifiAwareManager?.isAvailable != true) {
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 = 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) {
+ currentRoom = roomName
val config = PublishConfig.Builder()
.setServiceName(SERVICE_NAME)
.setServiceSpecificInfo(roomName.toByteArray())
@@ -66,17 +129,56 @@ class WifiAwareManager(private val context: Context) {
val messageStr = String(message)
Log.d(TAG, "Host: Received message: $messageStr")
- if (messageStr == "CONNECT_REQUEST") {
- Log.d(TAG, "Host: Received connection request")
- acceptConnection(peerHandle)
- } else {
- handleIncomingMessage(peerHandle, message)
+ when (messageStr) {
+ "CONNECT_REQUEST" -> {
+ Log.d(TAG, "Host: Received connection request from peer")
+ coroutineScope.launch {
+ 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)
}
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()
.setServiceName(SERVICE_NAME)
.build()
@@ -97,24 +199,66 @@ class WifiAwareManager(private val context: Context) {
// Store peer handle for this room
peerHandles[roomName] = peerHandle
+ currentRoom = roomName
// Send connection request to host
Log.d(TAG, "Sending connection request to room: $roomName")
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
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
- connectToPeer(peerHandle, roomName)
- }, 500)
+ coroutineScope.launch {
+ delay(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) {
- 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)
}
private fun connectToPeer(peerHandle: PeerHandle, roomName: String) {
+ coroutineScope.launch {
+ connectToPeerAsync(peerHandle, roomName)
+ }
+ }
+
+ private suspend fun connectToPeerAsync(peerHandle: PeerHandle, roomName: String): Result = withContext(Dispatchers.IO) {
+ suspendCancellableCoroutine { continuation ->
Log.d(TAG, "connectToPeer: Starting connection to room: $roomName")
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle)
.setPskPassphrase("lchat-secure-key")
@@ -129,69 +273,155 @@ class WifiAwareManager(private val context: Context) {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- try {
- connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
- override fun onAvailable(network: android.net.Network) {
- Log.d(TAG, "onAvailable: Network connected for room: $roomName")
- connectionCallback?.invoke(roomName, true)
+ try {
+ var isResumed = false
+ val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: android.net.Network) {
+ 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) {
- Log.d(TAG, "onLost: Network lost for room: $roomName")
- connectionCallback?.invoke(roomName, false)
- }
+ connectivityManager.requestNetwork(networkRequest, callback, android.os.Handler(android.os.Looper.getMainLooper()))
- override fun onUnavailable() {
- Log.e(TAG, "onUnavailable: Network request failed for room: $roomName")
- connectionCallback?.invoke(roomName, false)
- }
+ Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName")
- override fun onCapabilitiesChanged(network: android.net.Network, networkCapabilities: NetworkCapabilities) {
- Log.d(TAG, "onCapabilitiesChanged: Capabilities changed for room: $roomName")
+ continuation.invokeOnCancellation {
+ connectivityManager.unregisterNetworkCallback(callback)
}
-
- override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) {
- Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName")
+ } catch (e: Exception) {
+ Log.e(TAG, "connectToPeer: Failed to request network", e)
+ continuation.resume(Result.failure(e))
+ coroutineScope.launch {
+ _connectionFlow.emit(Pair(roomName, false))
}
- }, android.os.Handler(android.os.Looper.getMainLooper()), 30000) // 30 second timeout
-
- 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)
+ 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()
+
+ private suspend fun acceptConnectionAsync(peerHandle: PeerHandle): Result = withContext(Dispatchers.IO) {
+ suspendCancellableCoroutine { continuation ->
+ Log.d(TAG, "acceptConnection: Accepting connection from client")
- val networkRequest = NetworkRequest.Builder()
- .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
- .setNetworkSpecifier(networkSpecifier)
- .build()
-
- val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
- override fun onAvailable(network: android.net.Network) {
- Log.d(TAG, "Client connected")
- peerHandles[peerHandle.toString()] = peerHandle
+ try {
+ val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle)
+ .setPskPassphrase("lchat-secure-key")
+ .setPort(PORT)
+ .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) {
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 parts = messageStr.split("|", limit = 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])
}
} 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()
+ var messagesSent = 0
- if (publishDiscoverySession != null) {
+ if (publishDiscoverySession != null && peerHandles.isNotEmpty()) {
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 ->
- 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) {
@@ -221,10 +468,116 @@ class WifiAwareManager(private val context: Context) {
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()
+
+ 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() {
+ Log.d(TAG, "Stopping WifiAwareManager")
+ stopKeepAlive()
publishDiscoverySession?.close()
+ publishDiscoverySession = null
subscribeDiscoverySession?.close()
+ subscribeDiscoverySession = null
+ // Close and clear the wifiAwareSession to force re-attachment
wifiAwareSession?.close()
+ wifiAwareSession = null
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")
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt
deleted file mode 100644
index 7cb4b38..0000000
--- a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt
+++ /dev/null
@@ -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
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt
new file mode 100644
index 0000000..ae04350
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt
@@ -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>(emptyList())
+ val messages: StateFlow> = _messages.asStateFlow()
+
+ // Flow that combines in-memory and database messages
+ fun getMessagesFlow(roomName: String): Flow> {
+ return messageDao.getMessagesForRoom(roomName)
+ .map { entities -> entities.map { it.toMessage() } }
+ .onStart { loadMessagesFromDatabase(roomName) }
+ }
+
+ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
+ val connectionState: StateFlow = _connectionState.asStateFlow()
+
+ private val _connectedUsers = MutableStateFlow>(emptyList())
+ val connectedUsers: StateFlow> = _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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt
index 6f5e186..b4f0f09 100644
--- a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt
+++ b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt
@@ -6,16 +6,21 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.navArgs
+import androidx.core.content.ContextCompat
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.network.WifiAwareManager
-import com.mattintech.lchat.network.WifiAwareManagerSingleton
+import com.mattintech.lchat.repository.ChatRepository
import com.mattintech.lchat.ui.adapters.MessageAdapter
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() {
companion object {
@@ -26,10 +31,8 @@ class ChatFragment : Fragment() {
private val binding get() = _binding!!
private val args: ChatFragmentArgs by navArgs()
- private lateinit var wifiAwareManager: WifiAwareManager
+ private val viewModel: ChatViewModel by viewModels()
private lateinit var messageAdapter: MessageAdapter
- private val messages = mutableListOf()
- private val userId = UUID.randomUUID().toString()
override fun onCreateView(
inflater: LayoutInflater,
@@ -45,8 +48,11 @@ class ChatFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
Log.d(TAG, "onViewCreated - room: ${args.roomName}, user: ${args.userName}, isHost: ${args.isHost}")
+ viewModel.initialize(args.roomName, args.userName, args.isHost)
+
setupUI()
- setupWifiAware()
+ observeViewModel()
+ updateRoomInfo()
}
private fun setupUI() {
@@ -68,58 +74,72 @@ class ChatFragment : Fragment() {
}
}
- private fun setupWifiAware() {
- wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext())
-
- wifiAwareManager.setMessageCallback { senderId, senderName, content ->
- Log.d(TAG, "Message received - from: $senderName, content: $content")
- val message = Message(
- 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)
+ private fun observeViewModel() {
+ lifecycleScope.launch {
+ viewModel.messages.collect { messages ->
+ messageAdapter.submitList(messages)
+ if (messages.isNotEmpty()) {
+ binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1)
+ }
}
}
- // No need to start host mode here - already started in LobbyFragment
- Log.d(TAG, "Chat setup complete - isHost: ${args.isHost}, room: ${args.roomName}")
+ lifecycleScope.launch {
+ viewModel.connectionState.collect { state ->
+ Log.d(TAG, "Connection state: $state")
+ updateConnectionStatus(state)
+ }
+ }
}
private fun sendMessage() {
val content = binding.messageInput.text?.toString()?.trim()
if (content.isNullOrEmpty()) return
- val message = Message(
- 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)
-
+ viewModel.sendMessage(content)
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() {
super.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
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt
index e72e620..464ff9d 100644
--- a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt
+++ b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt
@@ -1,20 +1,29 @@
package com.mattintech.lchat.ui
import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
-import androidx.lifecycle.ViewModelProvider
+import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.mattintech.lchat.R
import com.mattintech.lchat.databinding.FragmentLobbyBinding
-import com.mattintech.lchat.network.WifiAwareManager
-import com.mattintech.lchat.network.WifiAwareManagerSingleton
+import com.mattintech.lchat.viewmodel.LobbyEvent
+import com.mattintech.lchat.viewmodel.LobbyState
+import com.mattintech.lchat.viewmodel.LobbyViewModel
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() {
companion object {
@@ -24,7 +33,7 @@ class LobbyFragment : Fragment() {
private var _binding: FragmentLobbyBinding? = null
private val binding get() = _binding!!
- private lateinit var wifiAwareManager: WifiAwareManager
+ private val viewModel: LobbyViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
@@ -40,13 +49,23 @@ class LobbyFragment : Fragment() {
super.onViewCreated(view, savedInstanceState)
Log.d(TAG, "onViewCreated")
- Log.d(TAG, "Getting WifiAwareManager singleton")
- wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext())
-
setupUI()
+ observeViewModel()
}
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 ->
when (checkedId) {
R.id.hostRadio -> {
@@ -64,53 +83,77 @@ class LobbyFragment : Fragment() {
}
binding.actionButton.setOnClickListener {
- val userName = binding.nameInput.text?.toString()?.trim()
-
- if (userName.isNullOrEmpty()) {
- Toast.makeText(context, "Please enter your name", Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
+ val userName = binding.nameInput.text?.toString()?.trim() ?: ""
when (binding.modeRadioGroup.checkedRadioButtonId) {
R.id.hostRadio -> {
- val roomName = binding.roomInput.text?.toString()?.trim()
- if (roomName.isNullOrEmpty()) {
- Toast.makeText(context, "Please enter a room name", Toast.LENGTH_SHORT).show()
- return@setOnClickListener
- }
- startHostMode(roomName, userName)
+ val roomName = binding.roomInput.text?.toString()?.trim() ?: ""
+ viewModel.startHostMode(roomName, userName)
}
R.id.clientRadio -> {
- startClientMode(userName)
+ viewModel.startClientMode(userName)
+ }
+ }
+ }
+ }
+
+ private fun observeViewModel() {
+ // Collect saved user name
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.savedUserName.collect { savedName ->
+ if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) {
+ binding.nameInput.setText(savedName)
+ }
}
}
}
- 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()
+ // Collect state
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.state.collect { state ->
+ 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 startHostMode(roomName: String, userName: String) {
- Log.d(TAG, "Starting host mode - room: $roomName, user: $userName")
- wifiAwareManager.startHostMode(roomName)
- navigateToChat(roomName, userName, true)
- }
-
- private fun startClientMode(userName: String) {
- Log.d(TAG, "Starting client mode - user: $userName")
- binding.noRoomsText.visibility = View.VISIBLE
- binding.noRoomsText.text = getString(R.string.connecting)
- wifiAwareManager.startClientMode()
}
private fun navigateToChat(roomName: String, userName: String, isHost: Boolean) {
diff --git a/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt b/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt
index 66b4d4a..cd89835 100644
--- a/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt
+++ b/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt
@@ -35,12 +35,12 @@ class MessageAdapter : ListAdapter(Me
) : RecyclerView.ViewHolder(binding.root) {
fun bind(message: Message) {
- binding.senderName.text = message.senderName
+ binding.senderName.text = message.userName
binding.messageContent.text = message.content
binding.timestamp.text = timeFormat.format(Date(message.timestamp))
val layoutParams = binding.messageCard.layoutParams as ConstraintLayout.LayoutParams
- if (message.isLocal) {
+ if (message.isOwnMessage) {
layoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET
layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
binding.messageCard.setCardBackgroundColor(
diff --git a/app/src/main/java/com/mattintech/lchat/utils/PreferencesManager.kt b/app/src/main/java/com/mattintech/lchat/utils/PreferencesManager.kt
new file mode 100644
index 0000000..0b8ca75
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/utils/PreferencesManager.kt
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt
new file mode 100644
index 0000000..798a416
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt
@@ -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.Connected)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _messagesFlow = MutableStateFlow>>(flowOf(emptyList()))
+
+ val messages: StateFlow> = _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 {
+ return Triple(currentRoomName, currentUserName, isHost)
+ }
+
+ fun disconnect() {
+ chatRepository.stop()
+ _state.value = ChatState.Disconnected
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ disconnect()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt
new file mode 100644
index 0000000..befc1ea
--- /dev/null
+++ b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt
@@ -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.Idle)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _events = MutableSharedFlow()
+ val events: SharedFlow = _events.asSharedFlow()
+
+ private val _savedUserName = MutableStateFlow(null)
+ val savedUserName: StateFlow = _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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_circle.xml b/app/src/main/res/drawable/ic_circle.xml
new file mode 100644
index 0000000..a6f3dfa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_circle.xml
@@ -0,0 +1,5 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml
index 78ce89b..f9dd93a 100644
--- a/app/src/main/res/layout/fragment_chat.xml
+++ b/app/src/main/res/layout/fragment_chat.xml
@@ -4,13 +4,60 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..c9147a8 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,4 +7,10 @@
#FF018786
#FF000000
#FFFFFFFF
+
+
+ #4CAF50
+ #FFC107
+ #F44336
+ #2196F3
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index a6f97bd..fe0a227 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -11,5 +11,6 @@ buildscript {
plugins {
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
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index a6dfb2e..06a65ae 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,6 +1,12 @@
# 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.nonTransitiveRClass=true
-android.defaults.buildfeatures.buildconfig=true
-android.nonFinalResIds=false
\ No newline at end of file
+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
\ No newline at end of file