16 Commits
v0.1.0 ... main

Author SHA1 Message Date
6f81ff3596 Merge pull request #7 from mattintech/develop
Develop
2025-07-04 08:19:53 -04:00
3cd82301a8 Merge pull request #6 from mattintech/feature/optimization
Feature/optimization
2025-07-04 08:19:27 -04:00
c6e43a9f96 fixing conneciton state after a wifi disconnect 2025-07-04 07:59:11 -04:00
613686989e improving reconnection logic. added keep alive 2025-07-04 07:47:27 -04:00
8085c55c24 moving to coroutines 2025-07-03 22:14:29 -04:00
62f6128a2e Merge pull request #5 from mattintech/feature/persistance
storing chats
2025-07-03 21:45:14 -04:00
50d1aae039 storing chats 2025-07-03 21:43:49 -04:00
e22cc7cf5c Merge pull request #4 from mattintech/feature/depInj
adding shared prefs to save the users name
2025-07-03 21:32:03 -04:00
b1f1aa0cad adding shared prefs to save the users name 2025-07-03 21:31:35 -04:00
961cc0728f Merge pull request #3 from mattintech/feature/depInj
adding DepedencyInjnection with HILT
2025-07-03 21:14:56 -04:00
e44ce67873 adding DepedencyInjnection with HILT 2025-07-03 21:13:23 -04:00
996de88bfd Merge pull request #2 from mattintech/feature/connectionStatus
adding connection status
2025-07-03 20:42:57 -04:00
ab9069814e updating current status 2025-07-03 20:42:48 -04:00
ccf26b80e8 adding connection status 2025-07-03 20:38:49 -04:00
4719344ae8 refactoring to a proper MVVM 2025-07-03 20:21:11 -04:00
d564cec7cf Merge pull request #1 from mattintech/main
Updateing develop from CICD pipline changes.
2025-07-03 20:02:02 -04:00
27 changed files with 1520 additions and 189 deletions

194
TODO.md Normal file
View 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

View File

@@ -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 {
@@ -12,6 +18,12 @@ android {
abortOnError = false
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
defaultConfig {
applicationId = "com.mattintech.lchat"
minSdk = 29
@@ -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")

View File

@@ -12,3 +12,16 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-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 * { *; }

View File

@@ -15,6 +15,7 @@
<uses-feature android:name="android.hardware.wifi.aware" android:required="true" />
<application
android:name=".LChatApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View 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()
}
}

View File

@@ -11,7 +11,9 @@ import androidx.core.content.ContextCompat
import com.google.android.material.snackbar.Snackbar
import com.mattintech.lchat.databinding.ActivityMainBinding
import com.mattintech.lchat.utils.LOG_PREFIX
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
companion object {

View File

@@ -2,9 +2,9 @@ package com.mattintech.lchat.data
data class Message(
val id: String,
val senderId: String,
val senderName: String,
val userId: String,
val userName: String,
val content: String,
val timestamp: Long,
val isLocal: Boolean = false
val isOwnMessage: Boolean = false
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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
)
}

View File

@@ -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
// }
// }
}

View 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()
}
}

View File

@@ -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<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 connectionCallback: ((String, Boolean) -> Unit)? = null
private val attachCallback = object : AttachCallback() {
override fun onAttached(session: WifiAwareSession) {
Log.d(TAG, "Wi-Fi Aware attached")
wifiAwareSession = session
}
// Exception handler for coroutine errors
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e(TAG, "Coroutine exception: ", throwable)
}
override fun onAttachFailed() {
Log.e(TAG, "Wi-Fi Aware attach failed")
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
// 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
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<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) {
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<Unit> = 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()
val networkRequest = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE)
.setNetworkSpecifier(networkSpecifier)
.build()
private suspend fun acceptConnectionAsync(peerHandle: PeerHandle): Result<Unit> = withContext(Dispatchers.IO) {
suspendCancellableCoroutine { continuation ->
Log.d(TAG, "acceptConnection: Accepting connection from client")
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<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() {
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")
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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<Message>()
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
}
}

View File

@@ -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)
}
}
}
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()
viewModel.startClientMode(userName)
}
}
}
}
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 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)
}
}
}
}
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()
// 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 navigateToChat(roomName: String, userName: String, isHost: Boolean) {

View File

@@ -35,12 +35,12 @@ class MessageAdapter : ListAdapter<Message, MessageAdapter.MessageViewHolder>(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(

View File

@@ -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"
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View 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>

View File

@@ -4,13 +4,60 @@
android:layout_width="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
android:id="@+id/messagesRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="8dp"
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_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

View File

@@ -7,4 +7,10 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</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>

View File

@@ -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
}

View File

@@ -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
# Kapt configuration for better performance
kapt.use.worker.api=true
kapt.incremental.apt=true
# Hilt configuration
dagger.hilt.android.internal.disableAndroidSuperclassValidation=true