diff --git a/PROTOCOL.md b/PROTOCOL.md index 400b90d..bcff5eb 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -98,7 +98,7 @@ SkyLink 是一个多平台实时视频传输系统的通信协议,设计用于 ↓ 3. 检查魔数是否为 0xAA55 ✗ 否 → 丢弃该包 - ✓ 是 → 继续 + 是 → 继续 ↓ 4. 根据 frame_id 找到对应的帧缓冲区 如果不存在 → 创建新的缓冲区 diff --git a/skylink_android_app/app/src/main/java/com/skylink/app/network/PacketDef.kt b/skylink_android_app/app/src/main/java/com/skylink/app/network/PacketDef.kt index 34a032c..85f0f10 100644 --- a/skylink_android_app/app/src/main/java/com/skylink/app/network/PacketDef.kt +++ b/skylink_android_app/app/src/main/java/com/skylink/app/network/PacketDef.kt @@ -165,6 +165,7 @@ data class DecodedFrame( val lat: Double, val lon: Double, val alt: Double, + val photoNumber: Int = 0, // 照片编号(从reserve字段提取) val receivedTime: Long = System.currentTimeMillis() ) { override fun equals(other: Any?): Boolean { @@ -207,6 +208,7 @@ data class FrameBuffer( val lat: Double = 0.0, val lon: Double = 0.0, val alt: Double = 0.0, + val photoNumber: Int = 0, // 照片编号(从reserve字段提取) val receiveTimestamp: Long = System.currentTimeMillis() ) { /** diff --git a/skylink_android_app/app/src/main/java/com/skylink/app/network/UdpReceiver.kt b/skylink_android_app/app/src/main/java/com/skylink/app/network/UdpReceiver.kt index 2793215..c1e3b39 100644 --- a/skylink_android_app/app/src/main/java/com/skylink/app/network/UdpReceiver.kt +++ b/skylink_android_app/app/src/main/java/com/skylink/app/network/UdpReceiver.kt @@ -89,7 +89,7 @@ class UdpReceiver( Log.i(TAG, "UDP 接收缓冲区大小已设置为: ${socket!!.receiveBufferSize}") } - Log.i(TAG, "✓ UDP 接收器启动,监听端口 $listenPort") + Log.i(TAG, " UDP 接收器启动,监听端口 $listenPort") startReceiving() } catch (e: Exception) { @@ -185,7 +185,8 @@ class UdpReceiver( timestamp = header.timestamp, lat = header.lat, lon = header.lon, - alt = header.alt + alt = header.alt, + photoNumber = header.reserve // 从reserve字段提取照片编号 ) } @@ -228,7 +229,8 @@ class UdpReceiver( timestamp = frame.timestamp, lat = frame.lat, lon = frame.lon, - alt = frame.alt + alt = frame.alt, + photoNumber = frame.photoNumber // 传递照片编号 ) totalFrames++ diff --git a/skylink_android_app/app/src/main/java/com/skylink/app/ui/HomeFragment.kt b/skylink_android_app/app/src/main/java/com/skylink/app/ui/HomeFragment.kt index 5fccd17..f9d1c5e 100644 --- a/skylink_android_app/app/src/main/java/com/skylink/app/ui/HomeFragment.kt +++ b/skylink_android_app/app/src/main/java/com/skylink/app/ui/HomeFragment.kt @@ -11,6 +11,9 @@ package com.skylink.app.ui import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Color import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -19,6 +22,9 @@ import android.widget.Button import android.widget.ImageView import android.widget.TextView import android.widget.Toast +import android.widget.EditText +import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.skylink.app.R @@ -31,6 +37,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.util.* +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject class HomeFragment : Fragment(), UdpReceiverListener { @@ -48,6 +58,16 @@ class HomeFragment : Fragment(), UdpReceiverListener { private var statusTextView: TextView? = null private var saveButton: Button? = null private var recordButton: Button? = null + private var triggerCaptureButton: Button? = null + private var triggerCaptureOriginalButton: Button? = null + private var triggerCaptureThumbnailButton: Button? = null + + // HTTP客户端(用于调用ROS2服务) + private val httpClient = OkHttpClient() + + // ROS2节点配置 + private var ros2NodeIp = "192.168.1.100" // 默认IP,需要配置 + private var ros2HttpPort = 8080 // ======================================================================== // 业务逻辑 @@ -56,6 +76,7 @@ class HomeFragment : Fragment(), UdpReceiverListener { private var udpReceiver: UdpReceiver? = null private var currentFrame: DecodedFrame? = null private var isRecording = false + private var totalPhotosReceived = 0 // 接收到的总照片数 // ======================================================================== // 生命周期方法 @@ -92,6 +113,9 @@ class HomeFragment : Fragment(), UdpReceiverListener { statusTextView = view.findViewById(R.id.status_text) saveButton = view.findViewById(R.id.save_button) recordButton = view.findViewById(R.id.record_button) + triggerCaptureButton = view.findViewById(R.id.trigger_capture_button) + triggerCaptureOriginalButton = view.findViewById(R.id.trigger_capture_original_button) + triggerCaptureThumbnailButton = view.findViewById(R.id.trigger_capture_thumbnail_button) // 设置按钮点击监听 saveButton?.setOnClickListener { @@ -101,6 +125,18 @@ class HomeFragment : Fragment(), UdpReceiverListener { recordButton?.setOnClickListener { onRecordToggled() } + + triggerCaptureButton?.setOnClickListener { + showTriggerCaptureDialog() + } + + triggerCaptureOriginalButton?.setOnClickListener { + triggerCapture(0) // 原图模式 + } + + triggerCaptureThumbnailButton?.setOnClickListener { + triggerCapture(1) // 缩略图模式 + } } // ======================================================================== @@ -126,6 +162,11 @@ class HomeFragment : Fragment(), UdpReceiverListener { override fun onFrameDecoded(frame: DecodedFrame) { currentFrame = frame + + // 更新照片计数 + if (frame.photoNumber > 0) { + totalPhotosReceived++ + } // 移到后台线程执行解码,避免阻塞 UI 线程,进而影响 UDP 接收协程(如果是单核或高负载) lifecycleScope.launch(Dispatchers.Default) { @@ -133,7 +174,13 @@ class HomeFragment : Fragment(), UdpReceiverListener { if (bitmap != null) { withContext(Dispatchers.Main) { view?.let { - videoImageView?.setImageBitmap(bitmap) + // 在图像上叠加显示照片编号 + val bitmapWithText = if (frame.photoNumber > 0) { + addPhotoNumberToBitmap(bitmap, frame.photoNumber) + } else { + bitmap + } + videoImageView?.setImageBitmap(bitmapWithText) updateGpsInfo(frame) } } @@ -142,10 +189,12 @@ class HomeFragment : Fragment(), UdpReceiverListener { } override fun onStatsUpdated(fps: Int, kbps: Int, packetLoss: Double) { - statsTextView?.text = String.format( - "FPS: %d\nSpeed: %d KB/s\nPacket Loss: %.1f%%", - fps, kbps, packetLoss - ) + val statsText = StringBuilder() + statsText.append(String.format("FPS: %d\nSpeed: %d KB/s\nPacket Loss: %.1f%%", fps, kbps, packetLoss)) + if (totalPhotosReceived > 0) { + statsText.append(String.format("\nTotal Photos: %d", totalPhotosReceived)) + } + statsTextView?.text = statsText.toString() } override fun onError(message: String, exception: Exception?) { @@ -201,4 +250,217 @@ class HomeFragment : Fragment(), UdpReceiverListener { val formatter = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) return formatter.format(Date(millis)) } + + /** + * 在图像上叠加显示照片编号 + */ + private fun addPhotoNumberToBitmap(bitmap: Bitmap, photoNumber: Int): Bitmap { + val mutableBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(mutableBitmap) + + val text = "Photo #$photoNumber" + val paint = Paint().apply { + color = Color.WHITE + textSize = 48f + isAntiAlias = true + style = Paint.Style.FILL + } + + val textPaint = Paint(paint) + val textBounds = android.graphics.Rect() + textPaint.getTextBounds(text, 0, text.length, textBounds) + + // 绘制半透明背景 + val bgPaint = Paint().apply { + color = Color.argb(180, 0, 0, 0) + style = Paint.Style.FILL + } + val padding = 10 + canvas.drawRect( + 10f, 10f, + (textBounds.width() + padding * 2).toFloat(), + (textBounds.height() + padding * 2).toFloat(), + bgPaint + ) + + // 绘制文字 + canvas.drawText(text, (10 + padding).toFloat(), (10 + padding + textBounds.height()).toFloat(), textPaint) + + return mutableBitmap + } + + /** + * 显示触发拍照参数输入对话框 + */ + private fun showTriggerCaptureDialog() { + val view = layoutInflater.inflate(R.layout.dialog_trigger_capture, null) + val durationEdit = view.findViewById(R.id.duration_edit) + val countEdit = view.findViewById(R.id.count_edit) + val intervalEdit = view.findViewById(R.id.interval_edit) + val modeSpinner = view.findViewById(R.id.mode_spinner) + + // 设置Spinner适配器 + val modes = arrayOf("原图", "缩略图") + val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, modes) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + modeSpinner.adapter = adapter + + // 设置默认值 + durationEdit.setText("5.0") + countEdit.setText("10") + intervalEdit.setText("0.5") + + AlertDialog.Builder(requireContext()) + .setTitle("触发拍照") + .setView(view) + .setPositiveButton("确定") { _, _ -> + val duration = durationEdit.text.toString().toDoubleOrNull() ?: 5.0 + val count = countEdit.text.toString().toIntOrNull() ?: 10 + val interval = intervalEdit.text.toString().toDoubleOrNull() ?: 0.5 + val mode = modeSpinner.selectedItemPosition // 0=原图, 1=缩略图 + + triggerCaptureWithParams(duration, count, interval, mode) + } + .setNegativeButton("取消", null) + .setNeutralButton("设置") { _, _ -> + // 显示IP和端口配置对话框 + showConfigDialog() + } + .show() + } + + /** + * 显示配置对话框(IP和端口) + */ + private fun showConfigDialog() { + val view = layoutInflater.inflate(android.R.layout.simple_list_item_2, null) + val ipEdit = EditText(requireContext()) + ipEdit.hint = "ROS2节点IP地址" + ipEdit.setText(ros2NodeIp) + + val portEdit = EditText(requireContext()) + portEdit.hint = "HTTP端口" + portEdit.inputType = android.text.InputType.TYPE_CLASS_NUMBER + portEdit.setText(ros2HttpPort.toString()) + + val layout = android.widget.LinearLayout(requireContext()).apply { + orientation = android.widget.LinearLayout.VERTICAL + setPadding(50, 40, 50, 10) + addView(ipEdit) + addView(portEdit) + } + + AlertDialog.Builder(requireContext()) + .setTitle("ROS2节点配置") + .setView(layout) + .setPositiveButton("确定") { _, _ -> + ros2NodeIp = ipEdit.text.toString().ifEmpty { "192.168.1.100" } + ros2HttpPort = portEdit.text.toString().toIntOrNull() ?: 8080 + Toast.makeText(context, "配置已保存: $ros2NodeIp:$ros2HttpPort", Toast.LENGTH_SHORT).show() + } + .setNegativeButton("取消", null) + .show() + } + + /** + * 快速触发拍照(使用默认参数) + */ + private fun triggerCapture(mode: Int) { + triggerCaptureWithParams(5.0, 10, 0.5, mode) + } + + /** + * 使用参数触发拍照 + */ + private fun triggerCaptureWithParams(duration: Double, count: Int, interval: Double, mode: Int) { + val modeStr = if (mode == 0) "原图" else "缩略图" + + lifecycleScope.launch(Dispatchers.IO) { + try { + // 构建JSON请求 + val json = JSONObject().apply { + put("duration", duration) + put("count", count) + put("interval", interval) + put("transmission_mode", mode) + } + + val requestBody = json.toString().toRequestBody("application/json".toMediaType()) + val url = "http://$ros2NodeIp:$ros2HttpPort/api/v1/trigger_capture" + + val request = Request.Builder() + .url(url) + .post(requestBody) + .addHeader("User-Agent", "SkyLink-Android/1.0") + .build() + + val response = httpClient.newCall(request).execute() + + withContext(Dispatchers.Main) { + if (response.isSuccessful) { + val responseBody = response.body?.string() + if (responseBody != null) { + try { + val responseJson = JSONObject(responseBody) + val success = responseJson.optBoolean("success", false) + val message = responseJson.optString("message", "") + val totalPhotos = responseJson.optInt("total_photos", 0) + + if (success) { + Toast.makeText(context, + "拍照任务已启动!\n$message\n总照片数: $totalPhotos", + Toast.LENGTH_LONG).show() + updateStatus("拍照任务已启动 ($modeStr)") + } else { + Toast.makeText(context, + "请求失败: $message", + Toast.LENGTH_LONG).show() + updateStatus("请求失败: $message") + } + } catch (e: Exception) { + Toast.makeText(context, + "响应解析失败: ${e.message}", + Toast.LENGTH_SHORT).show() + updateStatus("响应解析失败") + } + } else { + Toast.makeText(context, "服务器返回空响应", Toast.LENGTH_SHORT).show() + updateStatus("服务器返回空响应") + } + } else { + val errorMsg = when (response.code) { + 400 -> "请求参数错误" + 404 -> "API端点不存在" + 500 -> "服务器内部错误" + else -> "HTTP错误: ${response.code}" + } + Toast.makeText(context, errorMsg, Toast.LENGTH_LONG).show() + updateStatus(errorMsg) + } + } + + } catch (e: java.net.SocketTimeoutException) { + withContext(Dispatchers.Main) { + Toast.makeText(context, + "请求超时,请检查网络连接和ROS2节点IP地址 ($ros2NodeIp:$ros2HttpPort)", + Toast.LENGTH_LONG).show() + updateStatus("请求超时") + } + } catch (e: java.net.ConnectException) { + withContext(Dispatchers.Main) { + Toast.makeText(context, + "无法连接到ROS2节点 ($ros2NodeIp:$ros2HttpPort)\n请确认HTTP服务器正在运行", + Toast.LENGTH_LONG).show() + updateStatus("连接失败") + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, + "调用失败: ${e.message}", + Toast.LENGTH_LONG).show() + updateStatus("调用失败: ${e.message}") + } + } + } + } } diff --git a/skylink_android_app/app/src/main/java/com/skylink/app/utils/BitmapUtils.kt b/skylink_android_app/app/src/main/java/com/skylink/app/utils/BitmapUtils.kt index 9afda34..3ca96c5 100644 --- a/skylink_android_app/app/src/main/java/com/skylink/app/utils/BitmapUtils.kt +++ b/skylink_android_app/app/src/main/java/com/skylink/app/utils/BitmapUtils.kt @@ -44,7 +44,7 @@ object BitmapUtils { // 实际解码 val bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size, options) - // Log.i(TAG, "✓ Bitmap 解码成功: ${bitmap?.width}x${bitmap?.height}") // 移除高频日志 + // Log.i(TAG, " Bitmap 解码成功: ${bitmap?.width}x${bitmap?.height}") // 移除高频日志 bitmap } catch (e: Exception) { Log.e(TAG, "Bitmap 解码失败", e) diff --git a/skylink_android_app/app/src/main/res/layout/dialog_trigger_capture.xml b/skylink_android_app/app/src/main/res/layout/dialog_trigger_capture.xml new file mode 100644 index 0000000..ea72cc8 --- /dev/null +++ b/skylink_android_app/app/src/main/res/layout/dialog_trigger_capture.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/skylink_android_app/app/src/main/res/layout/fragment_home.xml b/skylink_android_app/app/src/main/res/layout/fragment_home.xml index 284b3c5..dfae238 100644 --- a/skylink_android_app/app/src/main/res/layout/fragment_home.xml +++ b/skylink_android_app/app/src/main/res/layout/fragment_home.xml @@ -68,8 +68,44 @@ android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginStart="4dp" + android:layout_marginEnd="4dp" android:text="⏺ 开始录制" /> +