fix(date-recorder): correct photo EXIF orientation and fix capture crash
- PhotoProcessor: read EXIF orientation and rotate bitmap on load so camera JPEGs display upright - CameraScreen: set target rotation so CameraX writes correct EXIF - CameraScreen: fix RejectedExecutionException by not shutting down the capture executor on dispose (CameraX may still post to it after) - CameraScreen: route capture callback through main handler for thread safety before triggering Activity navigation - CameraScreen: defer camera binding until PreviewView is attached to avoid repeated Surface abandon/recreate and camera rebind loops - CameraScreen: use an overlay (not component swap) for the capturing state so the bound ImageCapture is not torn down mid-capture - deps: add androidx.exifinterface for EXIF reading
This commit is contained in:
parent
b2942a3ca9
commit
50da18460f
@ -103,6 +103,7 @@ dependencies {
|
|||||||
implementation(libs.camera.camera2)
|
implementation(libs.camera.camera2)
|
||||||
implementation(libs.camera.lifecycle)
|
implementation(libs.camera.lifecycle)
|
||||||
implementation(libs.camera.view)
|
implementation(libs.camera.view)
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
|
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.kotlin.get()}")
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import android.graphics.Color as AndroidColor
|
|||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
@ -18,19 +19,45 @@ import java.io.FileOutputStream
|
|||||||
object PhotoProcessor {
|
object PhotoProcessor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按目标显示宽度降采样加载 Bitmap,避免大图 OOM。
|
* 按目标显示宽度降采样加载 Bitmap,避免大图 OOM,并应用 EXIF 旋转方向。
|
||||||
|
*
|
||||||
|
* 相机拍摄的 JPEG 常带有 EXIF orientation 标记(如 90/180/270),
|
||||||
|
* BitmapFactory.decodeFile 默认忽略它,导致显示方向错误。
|
||||||
|
* 这里读取 EXIF 并在加载后旋转到位,返回的 Bitmap 即为视觉正向。
|
||||||
*
|
*
|
||||||
* @param path 图片绝对路径
|
* @param path 图片绝对路径
|
||||||
* @param reqWidth 期望显示宽度(像素),实际宽度会 >= reqWidth 的最小 2 次幂采样
|
* @param reqWidth 期望显示宽度(像素),实际宽度会 >= reqWidth 的最小 2 次幂采样
|
||||||
* @return 降采样后的 Bitmap,加载失败抛异常
|
* @return 降采样并校正方向后的 Bitmap,加载失败抛异常
|
||||||
*/
|
*/
|
||||||
fun loadSampled(path: String, reqWidth: Int = 1080): Bitmap {
|
fun loadSampled(path: String, reqWidth: Int = 1080): Bitmap {
|
||||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
BitmapFactory.decodeFile(path, options)
|
BitmapFactory.decodeFile(path, options)
|
||||||
options.inSampleSize = calculateInSampleSize(options.outWidth, reqWidth)
|
options.inSampleSize = calculateInSampleSize(options.outWidth, reqWidth)
|
||||||
options.inJustDecodeBounds = false
|
options.inJustDecodeBounds = false
|
||||||
return BitmapFactory.decodeFile(path, options)
|
val bitmap = BitmapFactory.decodeFile(path, options)
|
||||||
?: error("无法加载图片: $path")
|
?: error("无法加载图片: $path")
|
||||||
|
val rotation = readExifRotation(path)
|
||||||
|
return if (rotation == 0) bitmap else rotate(bitmap, rotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 JPEG 的 EXIF orientation 标签,返回对应的顺时针旋转角度。
|
||||||
|
* 非 JPEG 或无 EXIF 时返回 0。
|
||||||
|
*/
|
||||||
|
private fun readExifRotation(path: String): Int {
|
||||||
|
return runCatching {
|
||||||
|
val orientation = ExifInterface(path)
|
||||||
|
.getAttributeInt(
|
||||||
|
ExifInterface.TAG_ORIENTATION,
|
||||||
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
|
)
|
||||||
|
when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90 -> 90
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_180 -> 180
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> 270
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}.getOrDefault(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateInSampleSize(srcWidth: Int, reqWidth: Int): Int =
|
private fun calculateInSampleSize(srcWidth: Int, reqWidth: Int): Int =
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package plus.rua.project.ui
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
@ -31,7 +32,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -98,17 +98,18 @@ fun CameraScreen(
|
|||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.semantics { testTagsAsResourceId = true }
|
.semantics { testTagsAsResourceId = true }
|
||||||
) {
|
) {
|
||||||
when {
|
if (!hasCameraPermission) {
|
||||||
!hasCameraPermission -> PermissionDeniedContent(onBack = onBack)
|
PermissionDeniedContent(onBack = onBack)
|
||||||
|
} else {
|
||||||
isCapturing -> CircularProgressIndicator(
|
// 关键:CameraPreview 必须始终留在组合中,不能被 isCapturing 替换。
|
||||||
modifier = Modifier.align(Alignment.Center),
|
// 一旦 isCapturing=true 时移除 CameraPreview,其 remember 的 imageCapture、
|
||||||
color = Color.White
|
// LaunchedEffect 绑定都会失效,相机 unbindAll + clearPipeline,
|
||||||
)
|
// 而此时 takePicture 的异步请求还在排队,导致拍照无法完成(第一下点击失效)。
|
||||||
|
// loading 指示改为叠加覆盖层。
|
||||||
else -> CameraPreview(
|
CameraPreview(
|
||||||
context = context,
|
context = context,
|
||||||
lifecycleOwner = lifecycleOwner,
|
lifecycleOwner = lifecycleOwner,
|
||||||
|
isCapturing = isCapturing,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onCaptured = { path ->
|
onCaptured = { path ->
|
||||||
isCapturing = false
|
isCapturing = false
|
||||||
@ -120,6 +121,18 @@ fun CameraScreen(
|
|||||||
},
|
},
|
||||||
setCapturing = { isCapturing = it }
|
setCapturing = { isCapturing = it }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isCapturing) {
|
||||||
|
// 半透明遮罩 + loading,覆盖在预览之上但不移除预览
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
captureError?.let { msg ->
|
captureError?.let { msg ->
|
||||||
@ -138,46 +151,61 @@ fun CameraScreen(
|
|||||||
private fun CameraPreview(
|
private fun CameraPreview(
|
||||||
context: Context,
|
context: Context,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
isCapturing: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onCaptured: (String) -> Unit,
|
onCaptured: (String) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
setCapturing: (Boolean) -> Unit
|
setCapturing: (Boolean) -> Unit
|
||||||
) {
|
) {
|
||||||
val imageCapture = remember { ImageCapture.Builder().build() }
|
val imageCapture = remember {
|
||||||
|
// 设置目标旋转角度,让 CameraX 写入正确的 EXIF orientation,
|
||||||
|
// 配合 PhotoProcessor 读取 EXIF 后即可得到正向图片。
|
||||||
|
ImageCapture.Builder()
|
||||||
|
.setTargetRotation(context.getDisplayRotation())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
// 注意:不要在 onDispose 中 shutdown executor。
|
||||||
|
// CameraX 1.5 的 takePicture 在 onImageSaved 之后内部仍可能向 executor 提交收尾任务,
|
||||||
|
// 过早 shutdown 会导致 RejectedExecutionException 崩溃。应用进程结束时线程池自然回收。
|
||||||
val executor = remember { Executors.newSingleThreadExecutor() }
|
val executor = remember { Executors.newSingleThreadExecutor() }
|
||||||
|
// 主线程 Handler,用于把拍照回调从 executor 线程切回 UI 线程后再触发跳转/状态更新
|
||||||
|
val mainHandler = remember { android.os.Handler(android.os.Looper.getMainLooper()) }
|
||||||
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
|
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
// 持有 PreviewView 引用:由 AndroidView.factory 创建后暴露给绑定逻辑。
|
||||||
onDispose { executor.shutdown() }
|
// 不用 remember{PreviewView(context)} 自建 —— AndroidView 需要自己管理 View 生命周期
|
||||||
}
|
// (attach/detach/Surface 创建),外部 remember 的 View 与 AndroidView 内部生命周期冲突,
|
||||||
|
// 会导致 Surface 反复 abandon/recreate,进而相机反复 bind/unbind。
|
||||||
|
var previewView by remember { mutableStateOf<PreviewView?>(null) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
val previewView = PreviewView(ctx).apply {
|
PreviewView(ctx).apply {
|
||||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
|
// 等真正 attach 到窗口、Surface 就绪后再绑定相机
|
||||||
|
post {
|
||||||
|
previewView = this
|
||||||
|
}
|
||||||
}
|
}
|
||||||
bindCameraUseCases(
|
|
||||||
context = ctx,
|
|
||||||
lifecycleOwner = lifecycleOwner,
|
|
||||||
previewView = previewView,
|
|
||||||
imageCapture = imageCapture,
|
|
||||||
lensFacing = lensFacing
|
|
||||||
)
|
|
||||||
previewView
|
|
||||||
},
|
|
||||||
update = { previewView ->
|
|
||||||
bindCameraUseCases(
|
|
||||||
context = context,
|
|
||||||
lifecycleOwner = lifecycleOwner,
|
|
||||||
previewView = previewView,
|
|
||||||
imageCapture = imageCapture,
|
|
||||||
lensFacing = lensFacing
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// 不在 update 里重新绑定相机,避免每次重组触发解绑重绑
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 镜头切换时重新绑定。
|
||||||
|
// 注意:previewView 首次就绪也会触发(从 null → PreviewView),这是预期的首次绑定。
|
||||||
|
LaunchedEffect(lensFacing, previewView) {
|
||||||
|
val pv = previewView ?: return@LaunchedEffect
|
||||||
|
bindCameraUseCases(
|
||||||
|
context = context,
|
||||||
|
lifecycleOwner = lifecycleOwner,
|
||||||
|
previewView = pv,
|
||||||
|
imageCapture = imageCapture,
|
||||||
|
lensFacing = lensFacing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 顶部返回按钮
|
// 顶部返回按钮
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onBack,
|
onClick = onBack,
|
||||||
@ -220,8 +248,10 @@ private fun CameraPreview(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 快门按钮:外圈白色环 + 内圈白色实心
|
// 快门按钮:外圈白色环 + 内圈白色实心。
|
||||||
|
// 关键:拍照中(isCapturing=true)禁用,防止连点导致启动多个 PhotoEditor。
|
||||||
IconButton(
|
IconButton(
|
||||||
|
enabled = !isCapturing,
|
||||||
onClick = {
|
onClick = {
|
||||||
val photoFile = createTempPhotoFile(context)
|
val photoFile = createTempPhotoFile(context)
|
||||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
||||||
@ -231,11 +261,15 @@ private fun CameraPreview(
|
|||||||
executor,
|
executor,
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
||||||
onCaptured(photoFile.absolutePath)
|
// 拍照回调运行在 executor 线程,必须切回主线程
|
||||||
|
// 再触发 Activity 跳转与状态更新,避免线程安全问题
|
||||||
|
mainHandler.post { onCaptured(photoFile.absolutePath) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(exception: ImageCaptureException) {
|
override fun onError(exception: ImageCaptureException) {
|
||||||
onError("拍照失败:${exception.message}")
|
Log.e(TAG, "onError: 拍照失败 code=${exception.imageCaptureError} msg=${exception.message}", exception)
|
||||||
|
val msg = "拍照失败:${exception.message}"
|
||||||
|
mainHandler.post { onError(msg) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -279,6 +313,23 @@ private fun PermissionDeniedContent(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Context.getDisplayRotation(): Int {
|
||||||
|
val display = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||||
|
display
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") //getDisplay 在 API30+ 弃用,旧版本必须用此 API
|
||||||
|
(getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager).defaultDisplay
|
||||||
|
}
|
||||||
|
return when (display?.rotation) {
|
||||||
|
android.view.Surface.ROTATION_90 -> 90
|
||||||
|
android.view.Surface.ROTATION_180 -> 180
|
||||||
|
android.view.Surface.ROTATION_270 -> 270
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "DateRecorder/Camera"
|
||||||
|
|
||||||
private fun bindCameraUseCases(
|
private fun bindCameraUseCases(
|
||||||
context: Context,
|
context: Context,
|
||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
|||||||
@ -26,6 +26,7 @@ versions = "0.54.0"
|
|||||||
[libraries]
|
[libraries]
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
|
androidx-benchmark-macro = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmarkMacro" }
|
||||||
|
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version = "1.4.1" }
|
||||||
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
|
camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" }
|
||||||
camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
|
camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" }
|
||||||
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
|
camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user