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:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user