commit ed5ee568486b286153cf6cff22683d8d3b5aca63 Author: Matt Hills Date: Thu Jul 3 17:52:05 2025 -0400 initial commit - chats working diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36c3114 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/jarRepositories.xml +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Android Profiling +*.hprof + +# Cordova plugins for Cordova-based hybrid apps +# Uncomment the following line if you're using Cordova +# plugins/ +# platforms/ + +# macOS +.DS_Store + +# Windows +Thumbs.db + +# Gradle Wrapper +!gradle/wrapper/gradle-wrapper.jar + +# IDE-specific files +.idea/ +*.iws +*.ipr + +# VS Code +.vscode/ + +# Eclipse +.classpath +.project +.settings/ + +# NDK +obj/ + +# Backup files +*.bak +*~ +*.swp +*.swo + +# Temporary files +*.tmp +*.temp + +# Private/sensitive files +secrets.properties +apikeys.properties + +# Kotlin +.kotlin/ + +# Build reports +build/reports/ + +# Claude Code +.claude/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4523f33 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# LocalChat (lchat) + +A peer-to-peer chat application for Android using Wi-Fi Aware (NAN - Neighbor Awareness Networking) technology. Chat with nearby users without internet or traditional Wi-Fi access points. + +## Overview + +**Package Name:** `com.mattintech.lchat` +**Min SDK:** API 26 (Android 8.0) +**Technology:** Wi-Fi Aware (NAN) + +## Features + +### Core Functionality +- **Dual Mode Operation:** Single APK that can function as both host and client +- **Direct P2P Communication:** No internet or router required +- **Real-time Messaging:** Instant message delivery to nearby devices +- **Auto-discovery:** Automatically find and connect to nearby chat rooms +- **Session Management:** Maintain chat sessions while devices are in range + +### User Features +- Create or join chat rooms +- Set custom nicknames +- View active users in range +- Message history (session-based) +- Material Design 3 interface +- Background service for persistent connections + +## Technical Requirements + +### Android Permissions +```xml + + + + +``` + +### Architecture + +#### Single APK Design +The app operates in two modes within a single application: +1. **Host Mode** - Publishes a chat service for others to discover +2. **Client Mode** - Subscribes to discover available chat services + +#### Key Components +- `WifiAwareManager` - Core Wi-Fi Aware functionality +- `PublishConfig` - Configuration for hosting chat rooms +- `SubscribeConfig` - Configuration for discovering chat rooms +- `WifiAwareSession` - Manages aware connections +- `NetworkSpecifier` - Establishes data paths between devices + +### Data Flow +1. **Discovery Phase** + - Host publishes service with room name + - Clients subscribe to discover services + - Service discovery triggers connection UI + +2. **Connection Phase** + - Client requests connection to host + - Host accepts/manages connections + - Bidirectional data path established + +3. **Communication Phase** + - Messages sent over established data path + - All connected clients receive messages + - Host manages client list and broadcasting + +## Project Structure +``` +lchat/ +├── app/ +│ ├── src/main/java/com/mattintech/lchat/ +│ │ ├── MainActivity.kt +│ │ ├── network/ +│ │ │ ├── WifiAwareManager.kt +│ │ │ ├── ChatService.kt +│ │ │ └── MessageHandler.kt +│ │ ├── ui/ +│ │ │ ├── ChatFragment.kt +│ │ │ ├── LobbyFragment.kt +│ │ │ └── adapters/ +│ │ ├── data/ +│ │ │ ├── Message.kt +│ │ │ ├── User.kt +│ │ │ └── ChatRepository.kt +│ │ └── utils/ +│ └── src/main/res/ +├── gradle/ +└── build.gradle.kts +``` + +## Development Roadmap + +### Phase 1: Foundation +- [ ] Basic Android project setup +- [ ] Wi-Fi Aware permission handling +- [ ] Simple host/client mode switching + +### Phase 2: Core Messaging +- [ ] Message sending/receiving +- [ ] User management +- [ ] Basic UI implementation + +### Phase 3: Enhanced Features +- [ ] Message persistence +- [ ] Reconnection handling +- [ ] Advanced UI features + +### Phase 4: Polish +- [ ] Error handling +- [ ] Performance optimization +- [ ] UI/UX refinements + +## Building + +```bash +./gradlew assembleDebug +``` + +## Testing + +Wi-Fi Aware requires physical devices for testing (API 26+). Emulator support is limited. + +## License + +[To be determined] \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e0380b5 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("androidx.navigation.safeargs.kotlin") +} + +android { + namespace = "com.mattintech.lchat" + compileSdk = 34 + + lint { + abortOnError = false + } + + defaultConfig { + applicationId = "com.mattintech.lchat" + minSdk = 29 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + viewBinding = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") + implementation("androidx.fragment:fragment-ktx:1.6.2") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.6") + implementation("androidx.navigation:navigation-ui-ktx:2.7.6") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e64ca74 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b8a5d57 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/MainActivity.kt b/app/src/main/java/com/mattintech/lchat/MainActivity.kt new file mode 100644 index 0000000..f1a6701 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/MainActivity.kt @@ -0,0 +1,78 @@ +package com.mattintech.lchat + +import android.Manifest +import android.util.Log +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +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 + +class MainActivity : AppCompatActivity() { + + companion object { + private const val TAG = LOG_PREFIX + "MainActivity:" + } + + private lateinit var binding: ActivityMainBinding + + private val locationPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + when { + permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> { + Log.d(TAG, "Location permission granted") + } + else -> { + Snackbar.make( + binding.root, + getString(R.string.permission_required), + Snackbar.LENGTH_LONG + ).show() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d(TAG, "onCreate") + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + checkPermissions() + } + + private fun checkPermissions() { + val permissionsToRequest = mutableListOf() + + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) != PackageManager.PERMISSION_GRANTED + ) { + permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, + Manifest.permission.NEARBY_WIFI_DEVICES + ) != PackageManager.PERMISSION_GRANTED + ) { + permissionsToRequest.add(Manifest.permission.NEARBY_WIFI_DEVICES) + } + } + + if (permissionsToRequest.isNotEmpty()) { + Log.d(TAG, "Requesting permissions: $permissionsToRequest") + locationPermissionRequest.launch(permissionsToRequest.toTypedArray()) + } else { + Log.d(TAG, "All permissions already granted") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/data/Message.kt b/app/src/main/java/com/mattintech/lchat/data/Message.kt new file mode 100644 index 0000000..e429fa5 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/data/Message.kt @@ -0,0 +1,10 @@ +package com.mattintech.lchat.data + +data class Message( + val id: String, + val senderId: String, + val senderName: String, + val content: String, + val timestamp: Long, + val isLocal: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/data/User.kt b/app/src/main/java/com/mattintech/lchat/data/User.kt new file mode 100644 index 0000000..5081646 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/data/User.kt @@ -0,0 +1,8 @@ +package com.mattintech.lchat.data + +data class User( + val id: String, + val name: String, + val isHost: Boolean = false, + val lastSeen: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/network/ChatService.kt b/app/src/main/java/com/mattintech/lchat/network/ChatService.kt new file mode 100644 index 0000000..43897c7 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/network/ChatService.kt @@ -0,0 +1,20 @@ +package com.mattintech.lchat.network + +import android.app.Service +import android.content.Intent +import android.os.IBinder + +class ChatService : Service() { + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onCreate() { + super.onCreate() + } + + override fun onDestroy() { + super.onDestroy() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt new file mode 100644 index 0000000..9d544a4 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt @@ -0,0 +1,230 @@ +package com.mattintech.lchat.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.aware.* +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.mattintech.lchat.utils.LOG_PREFIX +import java.util.concurrent.ConcurrentHashMap + +@RequiresApi(Build.VERSION_CODES.O) +class WifiAwareManager(private val context: Context) { + + companion object { + private const val TAG = LOG_PREFIX + "WifiAwareManager:" + private const val SERVICE_NAME = "lchat" + private const val PORT = 8888 + } + + private var wifiAwareManager: android.net.wifi.aware.WifiAwareManager? = null + private var wifiAwareSession: WifiAwareSession? = null + private var publishDiscoverySession: PublishDiscoverySession? = null + private var subscribeDiscoverySession: SubscribeDiscoverySession? = null + + private val peerHandles = ConcurrentHashMap() + private var messageCallback: ((String, String, String) -> Unit)? = null + private var connectionCallback: ((String, Boolean) -> Unit)? = null + + private val attachCallback = object : AttachCallback() { + override fun onAttached(session: WifiAwareSession) { + Log.d(TAG, "Wi-Fi Aware attached") + wifiAwareSession = session + } + + override fun onAttachFailed() { + Log.e(TAG, "Wi-Fi Aware attach failed") + } + } + + 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") + } + } + + fun startHostMode(roomName: String) { + val config = PublishConfig.Builder() + .setServiceName(SERVICE_NAME) + .setServiceSpecificInfo(roomName.toByteArray()) + .build() + + wifiAwareSession?.publish(config, object : DiscoverySessionCallback() { + override fun onPublishStarted(session: PublishDiscoverySession) { + Log.d(TAG, "Publish started for room: $roomName") + publishDiscoverySession = session + } + + override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) { + 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) + } + } + }, null) + } + + fun startClientMode() { + val config = SubscribeConfig.Builder() + .setServiceName(SERVICE_NAME) + .build() + + wifiAwareSession?.subscribe(config, object : DiscoverySessionCallback() { + override fun onSubscribeStarted(session: SubscribeDiscoverySession) { + Log.d(TAG, "Subscribe started") + subscribeDiscoverySession = session + } + + override fun onServiceDiscovered( + peerHandle: PeerHandle, + serviceSpecificInfo: ByteArray?, + matchFilter: List? + ) { + val roomName = serviceSpecificInfo?.let { String(it) } ?: "Unknown" + Log.d(TAG, "Discovered room: $roomName") + + // Store peer handle for this room + peerHandles[roomName] = peerHandle + + // Send connection request to host + Log.d(TAG, "Sending connection request to room: $roomName") + subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray()) + + // Wait a bit for host to prepare, then connect + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + connectToPeer(peerHandle, roomName) + }, 500) + } + + override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) { + handleIncomingMessage(peerHandle, message) + } + }, null) + } + + private fun connectToPeer(peerHandle: PeerHandle, roomName: String) { + Log.d(TAG, "connectToPeer: Starting connection to room: $roomName") + val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle) + .setPskPassphrase("lchat-secure-key") + .build() + + val networkRequest = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(networkSpecifier) + .build() + + Log.d(TAG, "connectToPeer: Network request created for room: $roomName") + + 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) + } + + override fun onLost(network: android.net.Network) { + Log.d(TAG, "onLost: Network lost for room: $roomName") + connectionCallback?.invoke(roomName, false) + } + + override fun onUnavailable() { + Log.e(TAG, "onUnavailable: Network request failed for room: $roomName") + 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") + } + }, 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) + } + } + + 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() + + 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 + } + + 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 { + val messageStr = String(message) + val parts = messageStr.split("|", limit = 3) + if (parts.size == 3) { + messageCallback?.invoke(parts[0], parts[1], parts[2]) + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing message", e) + } + } + + fun sendMessage(userId: String, userName: String, content: String) { + val message = "$userId|$userName|$content".toByteArray() + + if (publishDiscoverySession != null) { + peerHandles.values.forEach { peerHandle -> + publishDiscoverySession?.sendMessage(peerHandle, 0, message) + } + } else if (subscribeDiscoverySession != null) { + peerHandles.values.forEach { peerHandle -> + subscribeDiscoverySession?.sendMessage(peerHandle, 0, message) + } + } + } + + fun setMessageCallback(callback: (String, String, String) -> Unit) { + messageCallback = callback + } + + fun setConnectionCallback(callback: (String, Boolean) -> Unit) { + connectionCallback = callback + } + + fun stop() { + publishDiscoverySession?.close() + subscribeDiscoverySession?.close() + wifiAwareSession?.close() + peerHandles.clear() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt new file mode 100644 index 0000000..7cb4b38 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt @@ -0,0 +1,20 @@ +package com.mattintech.lchat.network + +import android.content.Context + +object WifiAwareManagerSingleton { + private var instance: WifiAwareManager? = null + + fun getInstance(context: Context): WifiAwareManager { + if (instance == null) { + instance = WifiAwareManager(context.applicationContext) + instance!!.initialize() + } + return instance!! + } + + fun reset() { + instance?.stop() + instance = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt new file mode 100644 index 0000000..6f5e186 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt @@ -0,0 +1,125 @@ +package com.mattintech.lchat.ui + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.mattintech.lchat.data.Message +import com.mattintech.lchat.databinding.FragmentChatBinding +import com.mattintech.lchat.network.WifiAwareManager +import com.mattintech.lchat.network.WifiAwareManagerSingleton +import com.mattintech.lchat.ui.adapters.MessageAdapter +import com.mattintech.lchat.utils.LOG_PREFIX +import java.util.UUID + +class ChatFragment : Fragment() { + + companion object { + private const val TAG = LOG_PREFIX + "ChatFragment:" + } + + private var _binding: FragmentChatBinding? = null + private val binding get() = _binding!! + + private val args: ChatFragmentArgs by navArgs() + private lateinit var wifiAwareManager: WifiAwareManager + private lateinit var messageAdapter: MessageAdapter + private val messages = mutableListOf() + private val userId = UUID.randomUUID().toString() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d(TAG, "onCreateView") + _binding = FragmentChatBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Log.d(TAG, "onViewCreated - room: ${args.roomName}, user: ${args.userName}, isHost: ${args.isHost}") + + setupUI() + setupWifiAware() + } + + private fun setupUI() { + messageAdapter = MessageAdapter() + binding.messagesRecyclerView.apply { + adapter = messageAdapter + layoutManager = LinearLayoutManager(context).apply { + stackFromEnd = true + } + } + + binding.sendButton.setOnClickListener { + sendMessage() + } + + binding.messageInput.setOnEditorActionListener { _, _, _ -> + sendMessage() + true + } + } + + 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) + } + } + + // No need to start host mode here - already started in LobbyFragment + Log.d(TAG, "Chat setup complete - isHost: ${args.isHost}, room: ${args.roomName}") + } + + 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) + + binding.messageInput.text?.clear() + } + + override fun onDestroyView() { + super.onDestroyView() + Log.d(TAG, "onDestroyView") + // Don't stop WifiAwareManager here - it's shared across fragments + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt new file mode 100644 index 0000000..e72e620 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt @@ -0,0 +1,130 @@ +package com.mattintech.lchat.ui + +import android.os.Bundle +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.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.utils.LOG_PREFIX + +class LobbyFragment : Fragment() { + + companion object { + private const val TAG = LOG_PREFIX + "LobbyFragment:" + } + + private var _binding: FragmentLobbyBinding? = null + private val binding get() = _binding!! + + private lateinit var wifiAwareManager: WifiAwareManager + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Log.d(TAG, "onCreateView") + _binding = FragmentLobbyBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Log.d(TAG, "onViewCreated") + + Log.d(TAG, "Getting WifiAwareManager singleton") + wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext()) + + setupUI() + } + + private fun setupUI() { + binding.modeRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.hostRadio -> { + binding.roomLayout.visibility = View.VISIBLE + binding.actionButton.text = getString(R.string.start_hosting) + binding.roomsRecyclerView.visibility = View.GONE + binding.noRoomsText.visibility = View.GONE + } + R.id.clientRadio -> { + binding.roomLayout.visibility = View.GONE + binding.actionButton.text = getString(R.string.search_rooms) + binding.roomsRecyclerView.visibility = View.VISIBLE + } + } + } + + 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 + } + + 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) + } + 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() + } + } + } + } + + private fun startHostMode(roomName: String, userName: String) { + Log.d(TAG, "Starting host mode - room: $roomName, user: $userName") + wifiAwareManager.startHostMode(roomName) + navigateToChat(roomName, userName, true) + } + + private fun startClientMode(userName: String) { + Log.d(TAG, "Starting client mode - user: $userName") + binding.noRoomsText.visibility = View.VISIBLE + binding.noRoomsText.text = getString(R.string.connecting) + wifiAwareManager.startClientMode() + } + + private fun navigateToChat(roomName: String, userName: String, isHost: Boolean) { + Log.d(TAG, "Navigating to chat - room: $roomName, user: $userName, isHost: $isHost") + val action = LobbyFragmentDirections.actionLobbyToChat( + roomName = roomName, + userName = userName, + isHost = isHost + ) + findNavController().navigate(action) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt b/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt new file mode 100644 index 0000000..66b4d4a --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt @@ -0,0 +1,69 @@ +package com.mattintech.lchat.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.mattintech.lchat.R +import com.mattintech.lchat.data.Message +import com.mattintech.lchat.databinding.ItemMessageBinding +import java.text.SimpleDateFormat +import java.util.* + +class MessageAdapter : ListAdapter(MessageDiffCallback()) { + + private val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault()) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder { + val binding = ItemMessageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return MessageViewHolder(binding) + } + + override fun onBindViewHolder(holder: MessageViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + inner class MessageViewHolder( + private val binding: ItemMessageBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(message: Message) { + binding.senderName.text = message.senderName + binding.messageContent.text = message.content + binding.timestamp.text = timeFormat.format(Date(message.timestamp)) + + val layoutParams = binding.messageCard.layoutParams as ConstraintLayout.LayoutParams + if (message.isLocal) { + layoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET + layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + binding.messageCard.setCardBackgroundColor( + ContextCompat.getColor(binding.root.context, R.color.purple_200) + ) + } else { + layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID + layoutParams.endToEnd = ConstraintLayout.LayoutParams.UNSET + binding.messageCard.setCardBackgroundColor( + ContextCompat.getColor(binding.root.context, R.color.teal_200) + ) + } + binding.messageCard.layoutParams = layoutParams + } + } + + class MessageDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: Message, newItem: Message): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/utils/Constants.kt b/app/src/main/java/com/mattintech/lchat/utils/Constants.kt new file mode 100644 index 0000000..09199fd --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/utils/Constants.kt @@ -0,0 +1,3 @@ +package com.mattintech.lchat.utils + +const val LOG_PREFIX = "LChat::" \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..1af7a2d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..012c92f --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml new file mode 100644 index 0000000..78ce89b --- /dev/null +++ b/app/src/main/res/layout/fragment_chat.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_lobby.xml b/app/src/main/res/layout/fragment_lobby.xml new file mode 100644 index 0000000..ceb854f --- /dev/null +++ b/app/src/main/res/layout/fragment_lobby.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +