Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f81ff3596 | |||
| 3cd82301a8 | |||
| c6e43a9f96 | |||
| 613686989e | |||
| 8085c55c24 | |||
| 62f6128a2e | |||
| 50d1aae039 | |||
| e22cc7cf5c | |||
| b1f1aa0cad | |||
| 961cc0728f | |||
| e44ce67873 | |||
| 996de88bfd | |||
| ab9069814e | |||
| ccf26b80e8 | |||
| 4719344ae8 | |||
| d564cec7cf |
194
TODO.md
Normal file
194
TODO.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# LocalChat (lchat) - Improvement Plan
|
||||
|
||||
## Phase 1: Architecture Foundation
|
||||
|
||||
### 1.1 MVVM with ViewModels and Repository Pattern ✅
|
||||
- [x] Create ViewModels for each screen (LobbyViewModel, ChatViewModel)
|
||||
- [x] Extract business logic from Fragments to ViewModels
|
||||
- [x] Create Repository layer for data operations
|
||||
- [x] Implement proper state management with LiveData/StateFlow
|
||||
- [x] Add ViewModelFactory if needed
|
||||
|
||||
### 1.2 Dependency Injection with Hilt ✅
|
||||
- [x] Add Hilt dependencies
|
||||
- [x] Set up Hilt modules for WifiAwareManager
|
||||
- [x] Convert singletons to proper DI
|
||||
- [x] Inject ViewModels using Hilt
|
||||
|
||||
### 1.3 Room Database Setup ✅
|
||||
- [x] Add Room dependencies
|
||||
- [x] Create Message and User entities
|
||||
- [x] Implement DAOs for data access
|
||||
- [x] Create database migrations
|
||||
- [x] Store messages in Room database
|
||||
- [x] Load message history on app restart
|
||||
- [x] Implement message sync logic
|
||||
|
||||
### 1.4 Coroutines & Flow Optimization ✅
|
||||
- [x] Convert callbacks to coroutines
|
||||
- [x] Use Flow for reactive data streams
|
||||
- [x] Implement proper scope management
|
||||
- [x] Replace GlobalScope with proper lifecycle scopes
|
||||
- [x] Add proper error handling with coroutines
|
||||
|
||||
## Phase 2: Core UX Improvements
|
||||
|
||||
### 2.1 Connection Status Management ✅
|
||||
- [x] Add connection state to ViewModels
|
||||
- [x] Create UI indicator for connection status
|
||||
- [x] Show real-time connection state changes
|
||||
- [x] Add connection error messages
|
||||
|
||||
### 2.2 User List Feature
|
||||
- [ ] Track connected users in Repository
|
||||
- [ ] Add UI to display active users
|
||||
- [ ] Handle user join/leave events
|
||||
- [ ] Show user count in chat header
|
||||
|
||||
### 2.3 Enhanced Messaging Features
|
||||
- [ ] Message status indicators (sent/delivered/read)
|
||||
- [ ] Add status field to MessageEntity (pending/sent/delivered/failed)
|
||||
- [ ] Show status icons in message bubbles
|
||||
- [ ] Update status when delivery confirmed
|
||||
- [ ] Store-and-forward messaging pattern
|
||||
- [ ] Save messages with "pending" status initially
|
||||
- [ ] Implement acknowledgment protocol in WifiAwareManager
|
||||
- [ ] Update to "sent" only after confirmation received
|
||||
- [ ] Queue messages when offline/disconnected
|
||||
- [ ] Auto-retry failed messages with exponential backoff
|
||||
- [ ] Mark messages as failed after max retries
|
||||
- [ ] User presence indicators (online/offline/typing)
|
||||
- [ ] Message timestamps with proper formatting
|
||||
- [ ] Offline message queue
|
||||
- [ ] Message retry mechanism
|
||||
- [ ] Long press message actions (copy, delete)
|
||||
|
||||
### 2.4 File & Media Sharing
|
||||
- [ ] Image sharing support
|
||||
- [ ] File transfer capability
|
||||
- [ ] Image preview in chat
|
||||
- [ ] Progress indicators for transfers
|
||||
- [ ] File size limits and validation
|
||||
|
||||
## Phase 3: UI/UX Improvements
|
||||
|
||||
### 3.1 Material 3 Design Update
|
||||
- [ ] Migrate to Material 3 components
|
||||
- [ ] Implement dynamic color theming
|
||||
- [ ] Update typography and spacing
|
||||
- [ ] Add proper elevation and shadows
|
||||
- [ ] Implement Material You design principles
|
||||
|
||||
### 3.2 Dark Theme & Theming
|
||||
- [ ] Implement dark theme
|
||||
- [ ] Add theme toggle in settings
|
||||
- [ ] System theme detection
|
||||
- [ ] Custom color schemes
|
||||
- [ ] Persist theme preference
|
||||
|
||||
### 3.3 Animations & Polish
|
||||
- [ ] Message send/receive animations
|
||||
- [ ] Screen transition animations
|
||||
- [ ] Loading state animations
|
||||
- [ ] Smooth scrolling improvements
|
||||
- [ ] Haptic feedback
|
||||
|
||||
### 3.4 Better Error Handling UI
|
||||
- [ ] User-friendly error messages
|
||||
- [ ] Retry mechanisms with UI feedback
|
||||
- [ ] Connection lost/restored snackbars
|
||||
- [ ] Empty states for no messages/users
|
||||
- [ ] Inline error states
|
||||
|
||||
## Phase 4: Reliability Improvements
|
||||
|
||||
### 4.1 Reconnection Handling
|
||||
- [ ] Detect connection drops
|
||||
- [ ] Implement exponential backoff retry
|
||||
- [ ] Preserve message queue during disconnection
|
||||
- [ ] Auto-reconnect when network available
|
||||
|
||||
### 4.2 Network State Monitoring
|
||||
- [ ] Monitor WiFi state changes
|
||||
- [ ] Handle app lifecycle properly
|
||||
- [ ] Save and restore connection state
|
||||
|
||||
## Phase 5: Security & Privacy
|
||||
|
||||
### 5.1 Message Encryption
|
||||
- [ ] End-to-end encryption implementation
|
||||
- [ ] Key exchange protocol
|
||||
- [ ] Message integrity verification
|
||||
- [ ] Secure key storage
|
||||
- [ ] Forward secrecy
|
||||
|
||||
### 5.2 Privacy Features
|
||||
- [ ] Optional username anonymization
|
||||
- [ ] Message auto-deletion
|
||||
- [ ] Block/unblock users
|
||||
- [ ] Private rooms with passwords
|
||||
- [ ] Data export/import
|
||||
|
||||
## Phase 6: Advanced Features
|
||||
|
||||
### 6.1 Background Service
|
||||
- [ ] Create foreground service for persistent connection
|
||||
- [ ] Handle Doze mode and battery optimization
|
||||
- [ ] Add notification for active chat
|
||||
- [ ] Implement proper service lifecycle
|
||||
- [ ] Wake lock management
|
||||
|
||||
### 6.2 Settings & Preferences
|
||||
- [ ] Create settings screen
|
||||
- [ ] Notification preferences
|
||||
- [ ] Sound/vibration settings
|
||||
- [ ] Auto-reconnect toggle
|
||||
- [ ] Message history limits
|
||||
|
||||
## Phase 7: Testing & Quality
|
||||
|
||||
### 7.1 Unit Testing
|
||||
- [ ] Test ViewModels
|
||||
- [ ] Test Repository logic
|
||||
- [ ] Test data transformations
|
||||
- [ ] Test error scenarios
|
||||
- [ ] Mock dependencies with Hilt testing
|
||||
|
||||
### 7.2 Integration Testing
|
||||
- [ ] Test database operations
|
||||
- [ ] Test network layer
|
||||
- [ ] Test complete user flows
|
||||
- [ ] Test state persistence
|
||||
|
||||
### 7.3 UI Testing
|
||||
- [ ] Espresso tests for main flows
|
||||
- [ ] Test navigation
|
||||
- [ ] Test user interactions
|
||||
- [ ] Screenshot testing
|
||||
- [ ] Accessibility testing
|
||||
|
||||
## Current Status
|
||||
- ✅ Phase 1.1 - MVVM Architecture - COMPLETED
|
||||
- ✅ Phase 1.2 - Dependency Injection with Hilt - COMPLETED
|
||||
- ✅ Phase 1.3 - Room Database Setup - COMPLETED
|
||||
- ✅ Phase 1.4 - Coroutines & Flow Optimization - COMPLETED
|
||||
- ✅ Phase 2.1 - Connection Status Management - COMPLETED
|
||||
- 🚀 Next Priority Options:
|
||||
- Phase 2.2 - User List Feature (Core UX)
|
||||
- Phase 2.3 - Enhanced Messaging (Better UX)
|
||||
- Phase 3.1 - Material 3 Update (Modern UI)
|
||||
- Phase 3.2 - Dark Theme & Theming
|
||||
|
||||
## Completed Work Summary
|
||||
1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns
|
||||
2. **Dependency Injection**: Hilt integration with proper scoping and lifecycle management
|
||||
3. **Room Database**: Message persistence with proper DAOs and entity mapping
|
||||
4. **Coroutines & Flow**: Converted callbacks to coroutines, implemented Flow for reactive streams, proper scope management
|
||||
5. **Connection Status**: Visual indicator with real-time updates, activity-based detection
|
||||
6. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep
|
||||
|
||||
## Development Notes
|
||||
- Architecture foundation (Phase 1) should be completed before moving to advanced features
|
||||
- UI/UX improvements (Phase 3) can be done in parallel with feature development
|
||||
- Testing (Phase 7) should be implemented incrementally as features are added
|
||||
- Security features (Phase 5) are important for production readiness
|
||||
@@ -2,6 +2,12 @@ plugins {
|
||||
id("com.android.application")
|
||||
id("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")
|
||||
|
||||
13
app/proguard-rules.pro
vendored
13
app/proguard-rules.pro
vendored
@@ -12,3 +12,16 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# 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 * { *; }
|
||||
@@ -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"
|
||||
|
||||
11
app/src/main/java/com/mattintech/lchat/LChatApplication.kt
Normal file
11
app/src/main/java/com/mattintech/lchat/LChatApplication.kt
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.mattintech.lchat
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class LChatApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.mattintech.lchat.databinding.ActivityMainBinding
|
||||
import com.mattintech.lchat.utils.LOG_PREFIX
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.mattintech.lchat.data.db
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.mattintech.lchat.data.db.dao.MessageDao
|
||||
import com.mattintech.lchat.data.db.entities.MessageEntity
|
||||
|
||||
@Database(
|
||||
entities = [MessageEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class LChatDatabase : RoomDatabase() {
|
||||
abstract fun messageDao(): MessageDao
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.mattintech.lchat.data.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import com.mattintech.lchat.data.db.entities.MessageEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@Query("SELECT * FROM messages WHERE roomName = :roomName ORDER BY timestamp ASC")
|
||||
fun getMessagesForRoom(roomName: String): Flow<List<MessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM messages WHERE roomName = :roomName ORDER BY timestamp ASC")
|
||||
suspend fun getMessagesForRoomOnce(roomName: String): List<MessageEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessage(message: MessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||
|
||||
@Query("DELETE FROM messages WHERE roomName = :roomName")
|
||||
suspend fun deleteMessagesForRoom(roomName: String)
|
||||
|
||||
@Query("DELETE FROM messages")
|
||||
suspend fun deleteAllMessages()
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE roomName = :roomName")
|
||||
suspend fun getMessageCountForRoom(roomName: String): Int
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.mattintech.lchat.data.db.entities
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "messages")
|
||||
data class MessageEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val roomName: String,
|
||||
val userId: String,
|
||||
val userName: String,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
val isOwnMessage: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.mattintech.lchat.data.db.mappers
|
||||
|
||||
import com.mattintech.lchat.data.Message
|
||||
import com.mattintech.lchat.data.db.entities.MessageEntity
|
||||
|
||||
fun MessageEntity.toMessage(): Message {
|
||||
return Message(
|
||||
id = id,
|
||||
userId = userId,
|
||||
userName = userName,
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
isOwnMessage = isOwnMessage
|
||||
)
|
||||
}
|
||||
|
||||
fun Message.toEntity(roomName: String): MessageEntity {
|
||||
return MessageEntity(
|
||||
id = id,
|
||||
roomName = roomName,
|
||||
userId = userId,
|
||||
userName = userName,
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
isOwnMessage = isOwnMessage
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.mattintech.lchat.data.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
// Placeholder for future migrations
|
||||
object Migrations {
|
||||
// Example migration from version 1 to 2
|
||||
// val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// // Migration code here
|
||||
// }
|
||||
// }
|
||||
}
|
||||
43
app/src/main/java/com/mattintech/lchat/di/AppModule.kt
Normal file
43
app/src/main/java/com/mattintech/lchat/di/AppModule.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package com.mattintech.lchat.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.mattintech.lchat.data.db.LChatDatabase
|
||||
import com.mattintech.lchat.data.db.dao.MessageDao
|
||||
import com.mattintech.lchat.network.WifiAwareManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWifiAwareManager(
|
||||
@ApplicationContext context: Context
|
||||
): WifiAwareManager {
|
||||
return WifiAwareManager(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLChatDatabase(
|
||||
@ApplicationContext context: Context
|
||||
): LChatDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
LChatDatabase::class.java,
|
||||
"lchat_database"
|
||||
).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideMessageDao(database: LChatDatabase): MessageDao {
|
||||
return database.messageDao()
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import 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() {
|
||||
// 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<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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initializeAsync(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager
|
||||
|
||||
// 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)
|
||||
|
||||
fun initialize() {
|
||||
wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager
|
||||
|
||||
if (wifiAwareManager?.isAvailable == true) {
|
||||
wifiAwareManager?.attach(attachCallback, null)
|
||||
} else {
|
||||
Log.e(TAG, "Wi-Fi Aware is not available")
|
||||
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)
|
||||
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) {
|
||||
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")
|
||||
@@ -130,19 +274,53 @@ class WifiAwareManager(private val context: Context) {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
try {
|
||||
connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -153,17 +331,32 @@ class WifiAwareManager(private val context: Context) {
|
||||
override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) {
|
||||
Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName")
|
||||
}
|
||||
}, android.os.Handler(android.os.Looper.getMainLooper()), 30000) // 30 second timeout
|
||||
}
|
||||
|
||||
connectivityManager.requestNetwork(networkRequest, callback, android.os.Handler(android.os.Looper.getMainLooper()))
|
||||
|
||||
Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName")
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "connectToPeer: Failed to request network", e)
|
||||
continuation.resume(Result.failure(e))
|
||||
coroutineScope.launch {
|
||||
_connectionFlow.emit(Pair(roomName, false))
|
||||
}
|
||||
connectionCallback?.invoke(roomName, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptConnection(peerHandle: PeerHandle) {
|
||||
|
||||
private suspend fun acceptConnectionAsync(peerHandle: PeerHandle): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
Log.d(TAG, "acceptConnection: Accepting connection from client")
|
||||
|
||||
try {
|
||||
val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle)
|
||||
.setPskPassphrase("lchat-secure-key")
|
||||
.setPort(PORT)
|
||||
@@ -175,23 +368,60 @@ class WifiAwareManager(private val context: Context) {
|
||||
.build()
|
||||
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() {
|
||||
var isResumed = false
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: android.net.Network) {
|
||||
Log.d(TAG, "Client connected")
|
||||
peerHandles[peerHandle.toString()] = peerHandle
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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,20 +429,37 @@ 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) {
|
||||
messageCallback = callback
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.mattintech.lchat.network
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object WifiAwareManagerSingleton {
|
||||
private var instance: WifiAwareManager? = null
|
||||
|
||||
fun getInstance(context: Context): WifiAwareManager {
|
||||
if (instance == null) {
|
||||
instance = WifiAwareManager(context.applicationContext)
|
||||
instance!!.initialize()
|
||||
}
|
||||
return instance!!
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
instance?.stop()
|
||||
instance = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.mattintech.lchat.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.mattintech.lchat.data.Message
|
||||
import com.mattintech.lchat.data.db.dao.MessageDao
|
||||
import com.mattintech.lchat.data.db.mappers.toEntity
|
||||
import com.mattintech.lchat.data.db.mappers.toMessage
|
||||
import com.mattintech.lchat.network.WifiAwareManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ChatRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val wifiAwareManager: WifiAwareManager,
|
||||
private val messageDao: MessageDao
|
||||
) {
|
||||
// Exception handler for repository operations
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
android.util.Log.e("ChatRepository", "Repository coroutine exception: ", throwable)
|
||||
_connectionState.value = ConnectionState.Error(throwable.message ?: "Unknown error")
|
||||
}
|
||||
|
||||
// Repository scope for background operations
|
||||
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler)
|
||||
|
||||
private var currentRoomName: String = ""
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages.asStateFlow()
|
||||
|
||||
// Flow that combines in-memory and database messages
|
||||
fun getMessagesFlow(roomName: String): Flow<List<Message>> {
|
||||
return messageDao.getMessagesForRoom(roomName)
|
||||
.map { entities -> entities.map { it.toMessage() } }
|
||||
.onStart { loadMessagesFromDatabase(roomName) }
|
||||
}
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _connectedUsers = MutableStateFlow<List<String>>(emptyList())
|
||||
val connectedUsers: StateFlow<List<String>> = _connectedUsers.asStateFlow()
|
||||
|
||||
private var messageCallback: ((String, String, String) -> Unit)? = null
|
||||
private var connectionCallback: ((String, Boolean) -> Unit)? = null
|
||||
|
||||
// Keep-alive is now handled by WifiAwareManager
|
||||
|
||||
init {
|
||||
wifiAwareManager.initialize()
|
||||
// Only use Flow-based collection, not callbacks
|
||||
collectWifiAwareFlows()
|
||||
}
|
||||
|
||||
private fun collectWifiAwareFlows() {
|
||||
// Collect messages from Flow
|
||||
repositoryScope.launch {
|
||||
try {
|
||||
wifiAwareManager.messageFlow.collect { (userId, userName, content) ->
|
||||
val message = Message(
|
||||
id = UUID.randomUUID().toString(),
|
||||
userId = userId,
|
||||
userName = userName,
|
||||
content = content,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isOwnMessage = false
|
||||
)
|
||||
addMessage(message)
|
||||
|
||||
// Don't change connection state based on messages - let WifiAwareManager handle it
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepository", "Error collecting message flow", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect connection state from Flow
|
||||
repositoryScope.launch {
|
||||
try {
|
||||
wifiAwareManager.connectionFlow.collect { (roomName, isConnected) ->
|
||||
android.util.Log.d("ChatRepository", "Connection flow update - room: $roomName, connected: $isConnected, current state: ${_connectionState.value}")
|
||||
|
||||
if (isConnected) {
|
||||
currentRoomName = roomName
|
||||
// Only change to connected if we're not already hosting
|
||||
if (_connectionState.value !is ConnectionState.Hosting) {
|
||||
_connectionState.value = ConnectionState.Connected(roomName)
|
||||
}
|
||||
loadMessagesFromDatabase(roomName)
|
||||
} else {
|
||||
// Only disconnect if the room matches our current room
|
||||
if (roomName.isEmpty() || roomName == currentRoomName) {
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
// Call the legacy callback if set
|
||||
connectionCallback?.invoke(roomName, isConnected)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepository", "Error collecting connection flow", e)
|
||||
_connectionState.value = ConnectionState.Error(e.message ?: "Connection error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removed setupWifiAwareCallbacks - now using Flow-based collection only
|
||||
|
||||
fun startHostMode(roomName: String) {
|
||||
currentRoomName = roomName
|
||||
// Ensure WifiAwareManager is initialized before starting
|
||||
wifiAwareManager.initialize()
|
||||
wifiAwareManager.startHostMode(roomName)
|
||||
_connectionState.value = ConnectionState.Hosting(roomName)
|
||||
loadMessagesFromDatabase(roomName)
|
||||
}
|
||||
|
||||
private fun loadMessagesFromDatabase(roomName: String) {
|
||||
repositoryScope.launch {
|
||||
try {
|
||||
val storedMessages = messageDao.getMessagesForRoomOnce(roomName)
|
||||
.map { it.toMessage() }
|
||||
_messages.value = storedMessages
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepository", "Error loading messages from database", e)
|
||||
// Don't crash, just continue with empty messages
|
||||
_messages.value = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startClientMode() {
|
||||
// Reset state for fresh start
|
||||
_messages.value = emptyList()
|
||||
currentRoomName = ""
|
||||
|
||||
// Ensure WifiAwareManager is initialized before starting
|
||||
repositoryScope.launch {
|
||||
// Give Wi-Fi Aware time to stabilize after network changes
|
||||
delay(500)
|
||||
wifiAwareManager.initialize()
|
||||
// Small delay to ensure initialization completes
|
||||
delay(100)
|
||||
wifiAwareManager.startClientMode()
|
||||
_connectionState.value = ConnectionState.Searching
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(userId: String, userName: String, content: String) {
|
||||
// Only allow sending messages if connected or hosting
|
||||
when (_connectionState.value) {
|
||||
is ConnectionState.Connected, is ConnectionState.Hosting -> {
|
||||
val message = Message(
|
||||
id = UUID.randomUUID().toString(),
|
||||
userId = userId,
|
||||
userName = userName,
|
||||
content = content,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isOwnMessage = true
|
||||
)
|
||||
val sent = wifiAwareManager.sendMessage(userId, userName, content)
|
||||
if (sent) {
|
||||
addMessage(message)
|
||||
} else {
|
||||
android.util.Log.e("ChatRepository", "Failed to send message - no active connection")
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
android.util.Log.w("ChatRepository", "Cannot send message - not connected. State: ${_connectionState.value}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMessage(message: Message) {
|
||||
// Add message and sort by timestamp to ensure proper order
|
||||
_messages.value = (_messages.value + message).sortedBy { it.timestamp }
|
||||
|
||||
// Save to database
|
||||
repositoryScope.launch {
|
||||
try {
|
||||
messageDao.insertMessage(message.toEntity(currentRoomName))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepository", "Error saving message to database", e)
|
||||
// Don't crash, message is already in memory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessages() {
|
||||
_messages.value = emptyList()
|
||||
}
|
||||
|
||||
fun setMessageCallback(callback: (String, String, String) -> Unit) {
|
||||
messageCallback = callback
|
||||
}
|
||||
|
||||
fun setConnectionCallback(callback: (String, Boolean) -> Unit) {
|
||||
connectionCallback = callback
|
||||
// Connection state is now handled by Flow collection
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
wifiAwareManager.stop()
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
_connectedUsers.value = emptyList()
|
||||
// Don't cancel the repository scope - we need it for future operations
|
||||
// Clear messages when stopping
|
||||
_messages.value = emptyList()
|
||||
currentRoomName = ""
|
||||
}
|
||||
|
||||
sealed class ConnectionState {
|
||||
object Disconnected : ConnectionState()
|
||||
object Searching : ConnectionState()
|
||||
data class Hosting(val roomName: String) : ConnectionState()
|
||||
data class Connected(val roomName: String) : ConnectionState()
|
||||
data class Error(val message: String) : ConnectionState()
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,21 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.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())
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
// 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)
|
||||
wifiAwareManager.startClientMode()
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mattintech.lchat.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PreferencesManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val sharedPreferences: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun saveUserName(name: String) {
|
||||
if (name.isBlank()) {
|
||||
clearUserName()
|
||||
} else {
|
||||
sharedPreferences.edit().putString(KEY_USER_NAME, name).apply()
|
||||
}
|
||||
}
|
||||
|
||||
fun getUserName(): String? {
|
||||
return sharedPreferences.getString(KEY_USER_NAME, null)
|
||||
}
|
||||
|
||||
fun clearUserName() {
|
||||
sharedPreferences.edit().remove(KEY_USER_NAME).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "lchat_preferences"
|
||||
private const val KEY_USER_NAME = "user_name"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.mattintech.lchat.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mattintech.lchat.data.Message
|
||||
import com.mattintech.lchat.repository.ChatRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class ChatState {
|
||||
object Connected : ChatState()
|
||||
object Disconnected : ChatState()
|
||||
data class Error(val message: String) : ChatState()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class ChatViewModel @Inject constructor(
|
||||
private val chatRepository: ChatRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<ChatState>(ChatState.Connected)
|
||||
val state: StateFlow<ChatState> = _state.asStateFlow()
|
||||
|
||||
private val _messagesFlow = MutableStateFlow<Flow<List<Message>>>(flowOf(emptyList()))
|
||||
|
||||
val messages: StateFlow<List<Message>> = _messagesFlow
|
||||
.flatMapLatest { it }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
val connectionState = chatRepository.connectionState
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = ChatRepository.ConnectionState.Disconnected
|
||||
)
|
||||
|
||||
val connectedUsers = chatRepository.connectedUsers
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5000),
|
||||
initialValue = emptyList()
|
||||
)
|
||||
|
||||
private var currentUserId: String = ""
|
||||
private var currentUserName: String = ""
|
||||
private var currentRoomName: String = ""
|
||||
private var isHost: Boolean = false
|
||||
|
||||
fun initialize(roomName: String, userName: String, isHost: Boolean) {
|
||||
this.currentRoomName = roomName
|
||||
this.currentUserName = userName
|
||||
this.isHost = isHost
|
||||
this.currentUserId = UUID.randomUUID().toString()
|
||||
|
||||
// Set up messages flow for this room
|
||||
_messagesFlow.value = chatRepository.getMessagesFlow(roomName)
|
||||
|
||||
// Setup message callback if needed for additional processing
|
||||
chatRepository.setMessageCallback { userId, userName, content ->
|
||||
// Can add additional message processing here if needed
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(content: String) {
|
||||
if (content.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
chatRepository.sendMessage(currentUserId, currentUserName, content)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRoomInfo(): Triple<String, String, Boolean> {
|
||||
return Triple(currentRoomName, currentUserName, isHost)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
chatRepository.stop()
|
||||
_state.value = ChatState.Disconnected
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.mattintech.lchat.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.mattintech.lchat.repository.ChatRepository
|
||||
import com.mattintech.lchat.utils.PreferencesManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed class LobbyState {
|
||||
object Idle : LobbyState()
|
||||
object Connecting : LobbyState()
|
||||
data class Connected(val roomName: String) : LobbyState()
|
||||
data class Error(val message: String) : LobbyState()
|
||||
}
|
||||
|
||||
sealed class LobbyEvent {
|
||||
data class NavigateToChat(
|
||||
val roomName: String,
|
||||
val userName: String,
|
||||
val isHost: Boolean
|
||||
) : LobbyEvent()
|
||||
data class ShowError(val message: String) : LobbyEvent()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class LobbyViewModel @Inject constructor(
|
||||
private val chatRepository: ChatRepository,
|
||||
private val preferencesManager: PreferencesManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<LobbyState>(LobbyState.Idle)
|
||||
val state: StateFlow<LobbyState> = _state.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<LobbyEvent>()
|
||||
val events: SharedFlow<LobbyEvent> = _events.asSharedFlow()
|
||||
|
||||
private val _savedUserName = MutableStateFlow<String?>(null)
|
||||
val savedUserName: StateFlow<String?> = _savedUserName.asStateFlow()
|
||||
|
||||
init {
|
||||
setupConnectionCallback()
|
||||
loadSavedUserName()
|
||||
}
|
||||
|
||||
private fun loadSavedUserName() {
|
||||
_savedUserName.value = preferencesManager.getUserName()
|
||||
}
|
||||
|
||||
private fun setupConnectionCallback() {
|
||||
chatRepository.setConnectionCallback { roomName, isConnected ->
|
||||
viewModelScope.launch {
|
||||
if (isConnected) {
|
||||
_state.value = LobbyState.Connected(roomName)
|
||||
} else {
|
||||
_state.value = LobbyState.Error("Failed to connect to $roomName. Ensure Wi-Fi is enabled on both devices.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startHostMode(roomName: String, userName: String) {
|
||||
if (roomName.isBlank()) {
|
||||
viewModelScope.launch {
|
||||
_events.emit(LobbyEvent.ShowError("Please enter a room name"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (userName.isBlank()) {
|
||||
viewModelScope.launch {
|
||||
_events.emit(LobbyEvent.ShowError("Please enter your name"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.value = LobbyState.Connecting
|
||||
preferencesManager.saveUserName(userName)
|
||||
chatRepository.startHostMode(roomName)
|
||||
_events.emit(LobbyEvent.NavigateToChat(roomName, userName, true))
|
||||
}
|
||||
}
|
||||
|
||||
fun startClientMode(userName: String) {
|
||||
if (userName.isBlank()) {
|
||||
viewModelScope.launch {
|
||||
_events.emit(LobbyEvent.ShowError("Please enter your name"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.value = LobbyState.Connecting
|
||||
preferencesManager.saveUserName(userName)
|
||||
chatRepository.startClientMode()
|
||||
}
|
||||
}
|
||||
|
||||
fun onConnectedToRoom(roomName: String, userName: String) {
|
||||
preferencesManager.saveUserName(userName)
|
||||
viewModelScope.launch {
|
||||
_events.emit(LobbyEvent.NavigateToChat(roomName, userName, false))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearEvent() {
|
||||
// SharedFlow doesn't need clearing, events are consumed once
|
||||
}
|
||||
|
||||
fun saveUserName(name: String) {
|
||||
if (name.isNotBlank()) {
|
||||
preferencesManager.saveUserName(name)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Clean up resources if needed
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/drawable/ic_circle.xml
Normal file
5
app/src/main/res/drawable/ic_circle.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -4,13 +4,60 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user