feat: 添加 USB 摄像头连接功能

- 新增 Android USB 摄像头 APP (MJPEG 服务器)
- 电脑端支持 ADB 端口转发连接
- 修复 .gitignore 忽略 Android 文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
let5sne.win10
2026-02-12 22:23:43 +08:00
parent 35d05d4701
commit 767271d499
652 changed files with 28034 additions and 22 deletions

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="USB摄像头"
android:theme="@style/Theme.AppCompat.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:keepScreenOn="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,159 @@
package com.usbwebcam
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageFormat
import android.graphics.Rect
import android.graphics.YuvImage
import android.hardware.camera2.*
import android.media.Image
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.util.Size
import android.view.Surface
import android.view.SurfaceHolder
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.*
class CameraHelper(
private val context: Context,
private val onFrame: (ByteArray, Int, Int) -> Unit
) {
private var cameraDevice: CameraDevice? = null
private var captureSession: CameraCaptureSession? = null
private var imageReader: ImageReader? = null
private var backgroundThread: HandlerThread? = null
private var backgroundHandler: Handler? = null
private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private val cameraId = cameraManager.cameraIdList.firstOrNull {
cameraManager.getCameraCharacteristics(it)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: cameraManager.cameraIdList.first()
fun start() {
startBackgroundThread()
openCamera()
}
fun stop() {
closeCamera()
stopBackgroundThread()
}
private fun startBackgroundThread() {
backgroundThread = HandlerThread("CameraBackground").apply {
start()
backgroundHandler = Handler(looper)
}
}
private fun stopBackgroundThread() {
backgroundThread?.quitSafely()
backgroundThread?.join()
backgroundThread = null
backgroundHandler = null
}
private fun openCamera() {
try {
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
override fun onOpened(camera: CameraDevice) {
cameraDevice = camera
createCameraPreviewSession()
}
override fun onDisconnected(camera: CameraDevice) {
camera.close()
cameraDevice = null
}
override fun onError(camera: CameraDevice, error: Int) {
camera.close()
cameraDevice = null
}
}, backgroundHandler!!)
} catch (e: SecurityException) {
e.printStackTrace()
}
}
private fun createCameraPreviewSession() {
try {
// 获取支持的尺寸
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val previewSize = map?.getOutputSizes(SurfaceHolder::class.java)?.firstOrNull {
it.width <= 1280 && it.height <= 720
} ?: Size(1280, 720)
imageReader = ImageReader.newInstance(previewSize.width, previewSize.height, ImageFormat.JPEG, 2)
imageReader?.setOnImageAvailableListener(imageAvailableListener, backgroundHandler)
val captureRequest = cameraDevice?.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)?.apply {
addTarget(imageReader!!.surface)
set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
}
cameraDevice?.createCaptureSession(
listOf(imageReader!!.surface),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(session: CameraCaptureSession) {
captureSession = session
captureRequest?.let {
session.setRepeatingRequest(it.build(), null, backgroundHandler)
}
}
override fun onConfigureFailed(session: CameraCaptureSession) {}
},
backgroundHandler
)
} catch (e: Exception) {
e.printStackTrace()
}
}
private val imageAvailableListener = ImageReader.OnImageAvailableListener { reader ->
val image = reader.acquireLatestImage() ?: return@OnImageAvailableListener
try {
val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
onFrame(bytes, image.width, image.height)
} finally {
image.close()
}
}
fun captureAndSave() {
// 保存最后一帧
imageReader?.acquireLatestImage()?.use { image ->
val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val file = File(
context.getExternalFilesDir(null),
"envelope_$timestamp.jpg"
)
FileOutputStream(file).use { it.write(bytes) }
}
}
private fun closeCamera() {
captureSession?.close()
captureSession = null
imageReader?.close()
imageReader = null
cameraDevice?.close()
cameraDevice = null
}
}

View File

@@ -0,0 +1,101 @@
package com.usbwebcam
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
class MainActivity : AppCompatActivity() {
private var mjpegServer: MjpegServer? = null
private var cameraHelper: CameraHelper? = null
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
startCamera()
} else {
Toast.makeText(this, "需要相机权限", Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val btnStart = findViewById<Button>(R.id.btn_start)
val btnStop = findViewById<Button>(R.id.btn_stop)
val btnCapture = findViewById<Button>(R.id.btn_capture)
val tvStatus = findViewById<TextView>(R.id.tv_status)
btnStart.setOnClickListener {
if (checkPermission()) {
startCamera()
}
}
btnStop.setOnClickListener {
stopCamera()
}
btnCapture.setOnClickListener {
cameraHelper?.captureAndSave()
Toast.makeText(this, "图片已保存到相册", Toast.LENGTH_SHORT).show()
}
// 自动启动
if (checkPermission()) {
startCamera()
}
}
private fun checkPermission(): Boolean {
return if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
true
} else {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
false
}
}
private fun startCamera() {
cameraHelper = CameraHelper(this) { frame, _, _ ->
mjpegServer?.updateFrame(frame)
}
mjpegServer = MjpegServer(8080)
mjpegServer?.start {
runOnUiThread {
findViewById<TextView>(R.id.tv_status).text =
"服务运行中端口: 8080IP: 无需IP (ADB模式)USB连接命令:adb forward tcp:8080 tcp:8080"
findViewById<Button>(R.id.btn_start).isEnabled = false
findViewById<Button>(R.id.btn_stop).isEnabled = true
}
}
cameraHelper?.start()
}
private fun stopCamera() {
cameraHelper?.stop()
mjpegServer?.stop()
findViewById<TextView>(R.id.tv_status).text = "服务已停止"
findViewById<Button>(R.id.btn_start).isEnabled = true
findViewById<Button>(R.id.btn_stop).isEnabled = false
}
override fun onDestroy() {
super.onDestroy()
stopCamera()
}
}

View File

@@ -0,0 +1,150 @@
package com.usbwebcam
import java.io.OutputStream
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
class MjpegServer(private val port: Int) {
private var serverSocket: ServerSocket? = null
private var serverThread: Thread? = null
private val clients = CopyOnWriteArrayList<ClientHandler>()
private val running = AtomicBoolean(false)
private var currentFrame: ByteArray? = null
private val frameLock = Any()
fun start(onStarted: () -> Unit) {
serverThread = Thread {
try {
serverSocket = ServerSocket(port)
running.set(true)
onStarted()
while (running.get()) {
val client = try {
serverSocket?.accept()
} catch (e: Exception) {
null
} ?: continue
val handler = ClientHandler(client)
clients.add(handler)
handler.start()
// 发送当前帧
val frameToSend = synchronized(frameLock) {
currentFrame
}
if (frameToSend != null) {
handler.sendFrame(frameToSend)
}
}
} catch (e: Exception) {
if (running.get()) {
e.printStackTrace()
}
}
}.apply {
name = "MjpegServerThread"
start()
}
}
fun updateFrame(frame: ByteArray) {
synchronized(frameLock) {
currentFrame = frame
}
// 广播给所有客户端
val iterator = clients.iterator()
while (iterator.hasNext()) {
val handler = iterator.next()
if (handler.isSocketActive()) {
handler.sendFrame(frame)
} else {
iterator.remove()
}
}
}
fun stop() {
if (!running.getAndSet(false)) return
try {
serverSocket?.close()
} catch (e: Exception) {
// ignore
}
clients.forEach { it.close() }
clients.clear()
serverSocket = null
}
private class ClientHandler(private val socket: Socket) : Thread() {
private var clientStream: OutputStream? = null
@Volatile
private var initialized = false
override fun run() {
try {
val os = socket.getOutputStream()
clientStream = os
socket.setSoTimeout(5000)
// 读取HTTP请求 (清空缓冲区)
val buffer = ByteArray(1024)
socket.getInputStream().read(buffer)
// 发送MJPEG流头 - 正确的HTTP格式
val header = byteArrayOf(
*("HTTP/1.1 200 OK\r\n").toByteArray(),
*("Content-Type: multipart/x-mixed-replace; boundary=boundary\r\n").toByteArray(),
*("Cache-Control: no-cache\r\n").toByteArray(),
*("Connection: close\r\n").toByteArray(),
*("\r\n").toByteArray() // 空行表示header结束
)
os.write(header)
os.flush()
initialized = true
} catch (e: Exception) {
close()
}
}
fun sendFrame(frame: ByteArray) {
if (!initialized || clientStream == null) return
try {
// MJPEG frame header with \r\n
val header = byteArrayOf(
*("--boundary\r\n").toByteArray(),
*("Content-Type: image/jpeg\r\n").toByteArray(),
*("Content-Length: ${frame.size}\r\n").toByteArray(),
*("\r\n").toByteArray()
)
clientStream?.write(header)
clientStream?.write(frame)
clientStream?.write(("\r\n").toByteArray())
clientStream?.flush()
} catch (e: Exception) {
close()
}
}
fun close() {
try {
socket.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun isSocketActive(): Boolean {
return !socket.isClosed && socket.isConnected
}
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center">
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="等待启动..."
android:textSize="16sp"
android:textColor="#333"
android:layout_marginBottom="32dp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<Button
android:id="@+id/btn_start"
android:layout_width="120dp"
android:layout_height="60dp"
android:text="启动"
android:textSize="18sp"
android:layout_marginEnd="16dp" />
<Button
android:id="@+id/btn_stop"
android:layout_width="120dp"
android:layout_height="60dp"
android:text="停止"
android:textSize="18sp"
android:enabled="false" />
</LinearLayout>
<Button
android:id="@+id/btn_capture"
android:layout_width="200dp"
android:layout_height="70dp"
android:text="拍照保存"
android:textSize="20sp"
android:layout_marginTop="24dp"
android:backgroundTint="#4CAF50" />
</LinearLayout>