fix: 修复Android app UI不显示信息及拍照保存问题

- 切换到Light主题,解决深色模式下文字不可见
- 重新布局为横屏左右分栏,显示设备IP和连接方式
- 拍照改用MediaStore写入系统相册,修复原acquireLatestImage竞争导致的空帧
- 修正ImageFormat导入包路径
- 补充gradle-wrapper.jar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
let5sne.win10
2026-02-14 20:50:33 +08:00
parent 86cb704eae
commit 99b1849e7f
5 changed files with 146 additions and 90 deletions

View File

@@ -11,7 +11,7 @@
<application <application
android:allowBackup="true" android:allowBackup="true"
android:label="USB摄像头" android:label="USB摄像头"
android:theme="@style/Theme.AppCompat.NoActionBar"> android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@@ -1,21 +1,16 @@
package com.usbwebcam package com.usbwebcam
import android.content.ContentValues
import android.content.Context 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.hardware.camera2.*
import android.media.Image import android.graphics.ImageFormat
import android.media.ImageReader import android.media.ImageReader
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.HandlerThread import android.os.HandlerThread
import android.provider.MediaStore
import android.util.Size import android.util.Size
import android.view.Surface
import android.view.SurfaceHolder import android.view.SurfaceHolder
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -29,6 +24,9 @@ class CameraHelper(
private var backgroundThread: HandlerThread? = null private var backgroundThread: HandlerThread? = null
private var backgroundHandler: Handler? = null private var backgroundHandler: Handler? = null
@Volatile
private var latestJpeg: ByteArray? = null
private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
private val cameraId = cameraManager.cameraIdList.firstOrNull { private val cameraId = cameraManager.cameraIdList.firstOrNull {
cameraManager.getCameraCharacteristics(it) cameraManager.getCameraCharacteristics(it)
@@ -84,7 +82,6 @@ class CameraHelper(
private fun createCameraPreviewSession() { private fun createCameraPreviewSession() {
try { try {
// 获取支持的尺寸
val characteristics = cameraManager.getCameraCharacteristics(cameraId) val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
val previewSize = map?.getOutputSizes(SurfaceHolder::class.java)?.firstOrNull { val previewSize = map?.getOutputSizes(SurfaceHolder::class.java)?.firstOrNull {
@@ -125,26 +122,47 @@ class CameraHelper(
val buffer = image.planes[0].buffer val buffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining()) val bytes = ByteArray(buffer.remaining())
buffer.get(bytes) buffer.get(bytes)
latestJpeg = bytes
onFrame(bytes, image.width, image.height) onFrame(bytes, image.width, image.height)
} finally { } finally {
image.close() image.close()
} }
} }
fun captureAndSave() { /**
// 保存最一帧 * 保存最一帧到系统相册,返回是否成功
imageReader?.acquireLatestImage()?.use { image -> */
val buffer = image.planes[0].buffer fun captureAndSave(): Boolean {
val bytes = ByteArray(buffer.remaining()) val jpeg = latestJpeg ?: return false
buffer.get(bytes)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val file = File( val filename = "envelope_$timestamp.jpg"
context.getExternalFilesDir(null),
"envelope_$timestamp.jpg"
)
FileOutputStream(file).use { it.write(bytes) } val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/信封拍照")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
?: return false
return try {
resolver.openOutputStream(uri)?.use { it.write(jpeg) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, contentValues, null, null)
}
true
} catch (e: Exception) {
resolver.delete(uri, null, null)
e.printStackTrace()
false
} }
} }
@@ -156,25 +174,15 @@ class CameraHelper(
e.printStackTrace() e.printStackTrace()
} }
try { try { captureSession?.close() } catch (_: Exception) {}
captureSession?.close()
} catch (e: Exception) {
e.printStackTrace()
}
captureSession = null captureSession = null
try { try { imageReader?.close() } catch (_: Exception) {}
imageReader?.close()
} catch (e: Exception) {
e.printStackTrace()
}
imageReader = null imageReader = null
try { try { cameraDevice?.close() } catch (_: Exception) {}
cameraDevice?.close()
} catch (e: Exception) {
e.printStackTrace()
}
cameraDevice = null cameraDevice = null
latestJpeg = null
} }
} }

View File

@@ -3,12 +3,14 @@ package com.usbwebcam
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.net.NetworkInterface
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private var mjpegServer: MjpegServer? = null private var mjpegServer: MjpegServer? = null
@@ -28,29 +30,24 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
val btnStart = findViewById<Button>(R.id.btn_start) findViewById<Button>(R.id.btn_start).setOnClickListener {
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()) { if (checkPermission()) {
startCamera() startCamera()
} }
} }
btnStop.setOnClickListener { findViewById<Button>(R.id.btn_stop).setOnClickListener {
stopCamera() stopCamera()
} }
btnCapture.setOnClickListener { findViewById<Button>(R.id.btn_capture).setOnClickListener {
cameraHelper?.captureAndSave() val saved = cameraHelper?.captureAndSave() == true
Toast.makeText(this, "图片已保存到相册", Toast.LENGTH_SHORT).show() val msg = if (saved) "图片已保存到相册" else "保存失败,请先启动服务"
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
} }
if (ContextCompat.checkSelfPermission( if (ContextCompat.checkSelfPermission(
this, this, Manifest.permission.CAMERA
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) { ) {
startCamera() startCamera()
@@ -59,8 +56,7 @@ class MainActivity : AppCompatActivity() {
private fun checkPermission(): Boolean { private fun checkPermission(): Boolean {
return if (ContextCompat.checkSelfPermission( return if (ContextCompat.checkSelfPermission(
this, this, Manifest.permission.CAMERA
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) { ) {
true true
@@ -70,6 +66,19 @@ class MainActivity : AppCompatActivity() {
} }
} }
private fun getDeviceIp(): String {
try {
NetworkInterface.getNetworkInterfaces()?.toList()?.forEach { intf ->
intf.inetAddresses?.toList()?.forEach { addr ->
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) {
return addr.hostAddress ?: "未知"
}
}
}
} catch (_: Exception) {}
return "未知"
}
private fun startCamera() { private fun startCamera() {
if (cameraHelper != null) return if (cameraHelper != null) return
@@ -78,16 +87,22 @@ class MainActivity : AppCompatActivity() {
mjpegServer?.updateFrame(frame) mjpegServer?.updateFrame(frame)
} }
// Start the server and update UI once started
mjpegServer?.start { mjpegServer?.start {
runOnUiThread { runOnUiThread {
findViewById<TextView>(R.id.tv_status).text = """ val ip = getDeviceIp()
服务运行中 findViewById<TextView>(R.id.tv_status).text =
端口: 8080 "● 服务运行中\n\n" +
IP: 无需IP (ADB模式) "端口: 8080\n" +
USB连接命令: "设备IP: $ip\n\n" +
adb forward tcp:8080 tcp:8080 "USB连接 (推荐):\n" +
""".trimIndent() " adb forward tcp:8080 tcp:8080\n\n" +
"WiFi连接:\n" +
" http://$ip:8080"
findViewById<TextView>(R.id.tv_ip).apply {
text = "在电脑端浏览器打开上述地址即可查看画面"
visibility = View.VISIBLE
}
findViewById<Button>(R.id.btn_start).isEnabled = false findViewById<Button>(R.id.btn_start).isEnabled = false
findViewById<Button>(R.id.btn_stop).isEnabled = true findViewById<Button>(R.id.btn_stop).isEnabled = true
} }
@@ -104,6 +119,7 @@ class MainActivity : AppCompatActivity() {
runOnUiThread { runOnUiThread {
findViewById<TextView>(R.id.tv_status).text = "服务已停止" findViewById<TextView>(R.id.tv_status).text = "服务已停止"
findViewById<TextView>(R.id.tv_ip).visibility = View.GONE
findViewById<Button>(R.id.btn_start).isEnabled = true findViewById<Button>(R.id.btn_start).isEnabled = true
findViewById<Button>(R.id.btn_stop).isEnabled = false findViewById<Button>(R.id.btn_stop).isEnabled = false
} }

View File

@@ -2,50 +2,82 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="horizontal"
android:padding="24dp" android:padding="24dp"
android:gravity="center"> android:background="#FAFAFA">
<!-- 左侧:状态信息 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center_vertical"
android:paddingEnd="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="USB 摄像头服务"
android:textSize="22sp"
android:textColor="#212121"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<TextView <TextView
android:id="@+id/tv_status" android:id="@+id/tv_status"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="等待启动..." android:text="等待启动..."
android:textSize="16sp" android:textSize="16sp"
android:textColor="#333" android:textColor="#424242"
android:layout_marginBottom="32dp" /> android:lineSpacingExtra="4dp"
android:background="#FFFFFF"
android:padding="16dp"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/tv_ip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="#757575"
android:visibility="gone" />
</LinearLayout>
<!-- 右侧:按钮区 -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="horizontal" android:orientation="vertical"
android:gravity="center"> android:gravity="center">
<Button <Button
android:id="@+id/btn_start" android:id="@+id/btn_start"
android:layout_width="120dp" android:layout_width="160dp"
android:layout_height="60dp" android:layout_height="56dp"
android:text="启动" android:text="启动服务"
android:textSize="18sp" android:textSize="18sp"
android:layout_marginEnd="16dp" /> android:layout_marginBottom="12dp" />
<Button <Button
android:id="@+id/btn_stop" android:id="@+id/btn_stop"
android:layout_width="120dp" android:layout_width="160dp"
android:layout_height="60dp" android:layout_height="56dp"
android:text="停止" android:text="停止服务"
android:textSize="18sp" android:textSize="18sp"
android:enabled="false" /> android:enabled="false"
android:layout_marginBottom="24dp" />
</LinearLayout>
<Button <Button
android:id="@+id/btn_capture" android:id="@+id/btn_capture"
android:layout_width="200dp" android:layout_width="160dp"
android:layout_height="70dp" android:layout_height="56dp"
android:text="拍照保存" android:text="拍照保存"
android:textSize="20sp" android:textSize="18sp"
android:layout_marginTop="24dp"
android:backgroundTint="#4CAF50" /> android:backgroundTint="#4CAF50" />
</LinearLayout>
</LinearLayout> </LinearLayout>

Binary file not shown.