
package com.example.brodcast.compents.iptalk // // 导入所有需要的工具无需你去记直接复制即可 // import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import java.io.DataInputStream import java.io.DataOutputStream import java.io.File import java.io.FileOutputStream import java.net.Inet4Address import java.net.NetworkInterface import java.net.ServerSocket import java.net.Socket import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.UUID // // 1. 数据模型层定义 App 里的各种数据结构 // // 消息类型文本、图片、普通文件 enum class MessageType { TEXT, IMAGE, FILE } // 设备信息记录局域网里的一台设备 data class Device( val ip: String, // 设备的IP地址用于网络连接 val name: String, // 设备名字 var lastSeen: Long System.currentTimeMillis() // 最后一次心跳时间用来判断是否离线 ) // 聊天消息每一条消息的结构 data class ChatMessage( val id: String UUID.randomUUID().toString(), val senderIp: String, // 发送者的IP val senderName: String, // 发送者名字 val content: String, // 文本内容 或者 文件的本地路径 val type: MessageType, // 消息类型 val timestamp: Long System.currentTimeMillis(), // 时间戳 val fileName: String? null, // 文件名只有发文件时才需要 val isMe: Boolean false // 是不是自己发的用来决定UI靠左还是靠右 ) // 界面状态App 运行时的所有数据状态 data class LanUiState( val devices: ListDevice emptyList(), // 在线设备列表 val messages: ListChatMessage emptyList(), // 聊天记录 val myIp: String 0.0.0.0, // 本机IP val myName: String 设备_${(1000..9999).random()} // 本机名字 ) // // 2. 网络传输层负责底层的 TCP 收发相当于快递小哥 // class TcpManager( private val context: Context, // 当收到消息时回调给 ViewModel 去更新界面 private val onMessageReceived: (ChatMessage) - Unit ) { private val scope CoroutineScope(Dispatchers.IO SupervisorJob()) private var serverSocket: ServerSocket? null private val PORT 9999 // 统一的通信端口 // 开启 TCP 服务器等待别人连接 fun startServer() { scope.launch { try { serverSocket ServerSocket(PORT) while (true) { val client serverSocket?.accept() ?: continue // 阻塞等待直到有人连过来 handleClient(client) // 有人连过来交给它处理 } } catch (e: Exception) { e.printStackTrace() } } } // 处理客户端连接读取对方发来的数据 private fun handleClient(client: Socket) { scope.launch { try { val input DataInputStream(client.getInputStream()) // 读取协议头消息类型 val typeOrdinal input.readInt() val type MessageType.values().getOrNull(typeOrdinal) ?: returnlaunch val senderIp client.inetAddress.hostAddress ?: Unknown if (type MessageType.TEXT) { // 如果是文本读取长度然后读取内容 val length input.readInt() val bytes ByteArray(length) input.readFully(bytes) val text String(bytes, Charsets.UTF_8) onMessageReceived(ChatMessage(senderIpsenderIp, senderName 对方, content text,typeMessageType.TEXT)) } else if (type MessageType.IMAGE || type MessageType.FILE) { // 如果是文件先读取文件名 val nameLength input.readInt() val nameBytes ByteArray(nameLength) input.readFully(nameBytes) val fileName String(nameBytes, Charsets.UTF_8) // 读取文件大小 val fileLength input.readLong() // 准备保存文件的文件夹 val dir File(context.getExternalFilesDir(null), received) if (!dir.exists()) dir.mkdirs() val file File(dir, fileName) // ⭐ 关键采用 8KB 缓冲区流式读写防止大文件占用内存导致闪退 FileOutputStream(file).use { fos - val buffer ByteArray(8192) // 8KB var remaining fileLength while (remaining 0) { val read input.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt()) if (read -1) break fos.write(buffer, 0, read) remaining - read } } // 判断是图片还是普通文件回调给界面 val msgType if (fileName.endsWith(.jpg) || fileName.endsWith(.png)) MessageType.IMAGE else MessageType.FILE onMessageReceived(ChatMessage(senderIp, 对方, file.absolutePath, typemsgType, content fileName)) } client.close() // 处理完毕关闭连接 } catch (e: Exception) { e.printStackTrace() } } } // 发送文本消息 fun sendText(targetIp: String, text: String) { scope.launch { try { val socket Socket(targetIp, PORT) // 连到对方 val output DataOutputStream(socket.getOutputStream()) output.writeInt(MessageType.TEXT.ordinal) // 写入类型 val bytes text.toByteArray(Charsets.UTF_8) output.writeInt(bytes.size) // 写入长度 output.write(bytes) // 写入内容 output.flush() socket.close() } catch (e: Exception) { e.printStackTrace() } } } // 发送文件 fun sendFile(targetIp: String, fileUri: Uri, onSent: (String) - Unit) { scope.launch { try { // 1. 从文件选择器里拿到文件名字 var fileName file_${System.currentTimeMillis()} context.contentResolver.query(fileUri, null, null, null, null)?.use { cursor - val nameIndex cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) if (cursor.moveToFirst()) fileName cursor.getString(nameIndex) } // 2. 建立 TCP 连接 val socket Socket(targetIp, PORT) val output DataOutputStream(socket.getOutputStream()) // 3. 发送协议头类型 文件名长度 文件名 val type if (fileName.endsWith(.jpg) || fileName.endsWith(.png)) MessageType.IMAGE else MessageType.FILE output.writeInt(type.ordinal) val nameBytes fileName.toByteArray(Charsets.UTF_8) output.writeInt(nameBytes.size) output.write(nameBytes) // 4. 获取文件总大小写给对方 val inputStream context.contentResolver.openInputStream(fileUri) val fileLength inputStream?.available()?.toLong() ?: 0L output.writeLong(fileLength) // 5. ⭐ 流式发送文件内容每次发 8KB inputStream?.use { ins - val buffer ByteArray(8192) var read: Int while (ins.read(buffer).also { read it } ! -1) { output.write(buffer, 0, read) } } output.flush() socket.close() // 6. 通知 ViewModel 文件发送完成 onSent(fileName) } catch (e: Exception) { e.printStackTrace() } } } fun stop() { serverSocket?.close() scope.cancel() } } // // 3. 逻辑层 ViewModel数据管家管理状态调度网络请求 // class LanViewModel(private val context: Context) : ViewModel() { // 界面状态流UI 会监听这个数据并自动刷新 private val _uiState MutableStateFlow(LanUiState()) val uiState: StateFlowLanUiState _uiState // 初始化 TCP 管理器传入回调 private val tcpManager TcpManager(context) { msg - // 收到别人发来的消息时如果发送者IP不是我自己就加到聊天列表 val myIp _uiState.value.myIp if (msg.senderIp ! myIp) { _uiState.value _uiState.value.copy(messages _uiState.value.messages msg) } } init { // 1. 获取本机 IP val myIp getLocalIpAddress() ?: 0.0.0.0 _uiState.value _uiState.value.copy(myIp myIp) // 2. 开启 TCP 服务器 tcpManager.startServer() // 3. 这里原本有 UDP 广播发现逻辑为了代码简单你可以手动输入对方 IP 来代替 } // 获取本机局域网 IP (优先 IPv4) private fun getLocalIpAddress(): String? { try { val interfaces NetworkInterface.getNetworkInterfaces() while (interfaces.hasMoreElements()) { val networkInterface interfaces.nextElement() val addresses networkInterface.inetAddresses while (addresses.hasMoreElements()) { val address addresses.nextElement() if (!address.isLoopbackAddress address is Inet4Address) { return address.hostAddress } } } } catch (e: Exception) { e.printStackTrace() } return null } // 发送文本 // 发送文本 fun sendText(targetIp: String, text: String) { tcpManager.sendText(targetIp, text) // 本地立刻显示“我”发的消息 _uiState.value _uiState.value.copy( messages _uiState.value.messages ChatMessage( id java.util.UUID.randomUUID().toString(), senderIp targetIp, senderName 我, content text, // ⭐ 第一个 String 类型的内容 type MessageType.TEXT, // ⭐ 第二个 MessageType 类型 isMe true ) ) } // 发送文件 fun sendFile(targetIp: String, uri: Uri) { tcpManager.sendFile(targetIp, uri) { fileName - viewModelScope.launch { // 判断文件是不是图片 val msgType if (fileName.endsWith(.jpg) || fileName.endsWith(.png)) MessageType.IMAGE else MessageType.FILE _uiState.value _uiState.value.copy( messages _uiState.value.messages ChatMessage( id java.util.UUID.randomUUID().toString(), senderIp targetIp, senderName 我, content uri.toString(), // ⭐ 这里传字符串路径 type msgType, // ⭐ 这里传 MessageType fileName fileName, isMe true ) ) } } } override fun onCleared() { tcpManager.stop() } } // // 4. UI 层界面展示仿微信风格的气泡 // Composable fun LanChatScreen(onBackClick: () - Unit) { val context LocalContext.current // 创建 ViewModel val viewModel: LanViewModel viewModel(factory object : ViewModelProvider.Factory { override fun T : ViewModel create(modelClass: ClassT): T { return LanViewModel(context.applicationContext) as T } }) val state by viewModel.uiState.collectAsState() var inputText by remember { mutableStateOf() } // 当前选中的目标设备如果不选就不知道发给谁 var targetDevice by remember { mutableStateOfDevice?(null) } // 手动输入对方IP的输入框因为广播在部分模拟器/路由器下无法穿透所以加个手动输入功能 var manualIp by remember { mutableStateOf() } // 文件选择器用于发文件 val fileLauncher rememberLauncherForActivityResult( contract ActivityResultContracts.GetContent() ) { uri: Uri? - uri?.let { targetDevice?.let { device - viewModel.sendFile(device.ip, it) } ?: run { Toast.makeText(context, 请先点击上方设备选中目标, Toast.LENGTH_SHORT).show() } } } Column(modifier Modifier.fillMaxSize().background(Color(0xFFEDEDED))) { // ---------- 顶部状态栏 ---------- Column(modifier Modifier.fillMaxWidth().background(Color.White).padding(12.dp)) { // 显示自己的 IP 和名字 Row(verticalAlignment Alignment.CenterVertically) { Text(我的IP: ${state.myIp}, fontSize 14.sp, fontWeight FontWeight.Bold, color Color(0xFF6200EE)) Spacer(modifier Modifier.weight(1f)) Text(我的名字: ${state.myName}, fontSize 12.sp, color Color.Gray) } Spacer(modifier Modifier.height(8.dp)) // 手动输入目标 IP方便模拟器或跨设备测试 Row(verticalAlignment Alignment.CenterVertically) { OutlinedTextField( value manualIp, onValueChange { manualIp it }, placeholder { Text(手动输入对方IP (如 192.168.1.5), fontSize 12.sp) }, modifier Modifier.weight(1f).height(50.dp), textStyle TextStyle(fontSize 12.sp) ) Spacer(modifier Modifier.width(8.dp)) Button(onClick { if (manualIp.isNotBlank()) { targetDevice Device(manualIp, 手动设备) } }) { Text(连接) } } Spacer(modifier Modifier.height(8.dp)) // 在线设备列表横向排列 Text(在线设备点击选中:, fontSize 12.sp, color Color.Gray) LazyRow(horizontalArrangement Arrangement.spacedBy(8.dp), modifier Modifier.padding(top 4.dp)) { items(state.devices) { device - val isSelected targetDevice?.ip device.ip Card( colors CardDefaults.cardColors( containerColor if (isSelected) Color(0xFF6200EE) else Color(0xFFF0F0F0) ), modifier Modifier.clickable { targetDevice device }, shape RoundedCornerShape(16.dp) ) { Column(modifier Modifier.padding(horizontal 12.dp, vertical 6.dp), horizontalAlignment Alignment.CenterHorizontally) { Text(device.name, fontSize 12.sp, color if (isSelected) Color.White else Color.Black) Text(device.ip, fontSize 10.sp, color if (isSelected) Color.LightGray else Color.Gray) } } } } } // ---------- 聊天记录区 ---------- LazyColumn( modifier Modifier.weight(1f).padding(horizontal 12.dp, vertical 8.dp), verticalArrangement Arrangement.spacedBy(8.dp) ) { items(state.messages) { msg - ChatBubble(msg) // 使用气泡组件渲染 } } // ---------- 底部输入区 ---------- Row( modifier Modifier.fillMaxWidth().background(Color.White).padding(8.dp), verticalAlignment Alignment.CenterVertically ) { // 发文件按钮 IconButton(onClick { fileLauncher.launch(*/*) }) { Text(, fontSize 24.sp) } OutlinedTextField( value inputText, onValueChange { inputText it }, modifier Modifier.weight(1f), placeholder { Text(输入消息...) }, shape RoundedCornerShape(24.dp), maxLines 3 ) Spacer(modifier Modifier.width(8.dp)) Button( onClick { targetDevice?.let { device - if (inputText.isNotBlank()) { viewModel.sendText(device.ip, inputText) inputText } } ?: run { Toast.makeText(context, 请先在上方选中一个设备, Toast.LENGTH_SHORT).show() } }, shape RoundedCornerShape(24.dp), enabled inputText.isNotBlank() targetDevice ! null ) { Text(发送) } } } } // // 5. 聊天气泡组件复用的UI组件 // Composable fun ChatBubble(msg: ChatMessage) { // 格式化时间只显示 时:分 val timeStr SimpleDateFormat(HH:mm, Locale.getDefault()).format(Date(msg.timestamp)) // Row 决定气泡是靠左还是靠右 Row( modifier Modifier.fillMaxWidth(), horizontalArrangement if (msg.isMe) Arrangement.End else Arrangement.Start ) { Column( horizontalAlignment if (msg.isMe) Alignment.End else Alignment.Start, modifier Modifier.widthIn(max 280.dp) // 限制最大宽度 ) { // 发送者名字和 IP满足你“显示IP”的要求 Text(${msg.senderName} (${msg.senderIp}) $timeStr, fontSize 10.sp, color Color.Gray) Spacer(modifier Modifier.height(4.dp)) // 消息气泡卡片 Card( colors CardDefaults.cardColors( containerColor if (msg.isMe) Color(0xFF95EC69) else Color.White // 微信经典的绿色自己和白色对方 ), shape RoundedCornerShape( topStart 12.dp, topEnd 12.dp, bottomStart if (msg.isMe) 12.dp else 2.dp, bottomEnd if (msg.isMe) 2.dp else 12.dp ), elevation CardDefaults.cardElevation(defaultElevation 1.dp) ) { Box(modifier Modifier.padding(12.dp)) { when (msg.type) { MessageType.TEXT - { // 文本消息 Text(msg.content, fontSize 16.sp, color Color.Black) } MessageType.IMAGE - { // 图片消息使用 Coil 加载本地文件路径 AsyncImage( model File(msg.content), contentDescription null, modifier Modifier.size(150.dp).background(Color.LightGray) ) } MessageType.FILE - { // 文件消息显示一个文件图标和文件名 Row(verticalAlignment Alignment.CenterVertically) { Text(, fontSize 28.sp) Spacer(modifier Modifier.width(8.dp)) Text(msg.fileName ?: 未知文件, fontSize 14.sp, color Color(0xFF1565C0)) } } } } } } } }