Initial commit: Android Inspection Camera project

This commit is contained in:
2025-12-24 17:52:55 +08:00
commit aadfd5a296
33 changed files with 2850 additions and 0 deletions

View File

@@ -0,0 +1,339 @@
package com.inspection.camera.util
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.compose.ui.graphics.toArgb
import com.inspection.camera.data.models.ImageItem
import com.inspection.camera.data.models.ImageQuality
import com.inspection.camera.data.models.MergeLayoutType
import com.inspection.camera.data.models.WatermarkStyle
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 图片处理工具类
*/
object ImageProcessor {
private val dateFormat = SimpleDateFormat("yyyy年-MM月-dd日 HH:mm:ss", Locale.getDefault())
private val fileNameFormat = SimpleDateFormat("yyyyMMddHHmm", Locale.getDefault())
/**
* 获取当前时间戳文本
*/
fun getCurrentTimeText(): String {
return dateFormat.format(Date())
}
/**
* 生成文件名
*/
fun generateFileName(theme: String): String {
val timeStr = fileNameFormat.format(Date())
return if (theme.isNotBlank()) {
"巡检报告_${theme}_$timeStr.jpg"
} else {
"巡检报告_$timeStr.jpg"
}
}
/**
* 添加水印到图片
*/
fun addWatermark(
sourceBitmap: Bitmap,
timeText: String,
locationText: String,
style: WatermarkStyle
): Bitmap {
val result = sourceBitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(result)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = style.fontSize * result.density
color = style.textColor.toArgb()
typeface = Typeface.DEFAULT_BOLD
}
val watermarkText = "$timeText $locationText"
val textWidth = paint.measureText(watermarkText)
val textHeight = paint.fontMetrics.let { it.descent - it.ascent }
// 计算位置(左下角)
val padding = 20f * result.density
val x = padding
val y = result.height - padding
// 绘制背景
if (style.backgroundColor != android.graphics.Color.TRANSPARENT) {
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = style.backgroundColor.toArgb()
}
val bgRect = RectF(
x - 10,
y - textHeight - 10,
x + textWidth + 10,
y + 10
)
canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint)
}
// 绘制文字
canvas.drawText(watermarkText, x, y, paint)
return result
}
/**
* 合成多张图片
*/
fun mergeImages(
images: List<ImageItem>,
layoutType: MergeLayoutType,
quality: ImageQuality
): Bitmap {
if (images.isEmpty()) {
return Bitmap.createBitmap(1920, 1080, Bitmap.Config.ARGB_8888)
}
val cols = layoutType.cols
val rows = layoutType.rows
val imageCount = images.size.coerceAtMost(rows * cols)
val outputWidth = 1920
val outputHeight = 1080
val cellWidth = outputWidth / cols
val cellHeight = outputHeight / rows
val result = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
canvas.drawColor(Color.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
images.forEachIndexed { index, imageItem ->
if (index >= rows * cols) return@forEachIndexed
val col = index % cols
val row = index / cols
val left = col * cellWidth
val top = row * cellHeight
try {
val inputStream = imageItem.uri.path?.let { path ->
imageItem.uri.let { uri ->
inputStream
}
}
val sourceBitmap = BitmapFactory.decodeFile(imageItem.path)
?: return@forEachIndexed
// 缩放并居中裁剪
val scaledBitmap = scaleAndCropBitmap(sourceBitmap, cellWidth, cellHeight)
val dstRect = Rect(left, top, left + cellWidth, top + cellHeight)
canvas.drawBitmap(scaledBitmap, null, dstRect, paint)
if (scaledBitmap != sourceBitmap) {
scaledBitmap.recycle()
}
sourceBitmap.recycle()
} catch (e: Exception) {
// 加载失败绘制占位
val placeholderPaint = Paint().apply {
color = Color.LTGRAY
}
canvas.drawRect(
RectF(left.toFloat(), top.toFloat(), (left + cellWidth).toFloat(), (top + cellHeight).toFloat()),
placeholderPaint
)
}
}
return result
}
/**
* 缩放并居中裁剪Bitmap
*/
private fun scaleAndCropBitmap(source: Bitmap, targetWidth: Int, targetHeight: Int): Bitmap {
val sourceWidth = source.width.toFloat()
val sourceHeight = source.height.toFloat()
val scale = maxOf(
targetWidth.toFloat() / sourceWidth,
targetHeight.toFloat() / sourceHeight
)
val scaledWidth = sourceWidth * scale
val scaledHeight = sourceHeight * scale
val xOffset = (scaledWidth - targetWidth) / 2
val yOffset = (scaledHeight - targetHeight) / 2
val matrix = Matrix()
matrix.postScale(scale, scale)
matrix.postTranslate(-xOffset, -yOffset)
return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
}
/**
* 添加文字到图片
*/
fun addTextToBitmap(
sourceBitmap: Bitmap,
title: String,
content: String,
titleStyle: WatermarkStyle,
contentStyle: WatermarkStyle
): Bitmap {
val result = sourceBitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(result)
val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = titleStyle.fontSize * result.density
color = titleStyle.textColor.toArgb()
typeface = Typeface.DEFAULT_BOLD
}
val contentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = contentStyle.fontSize * result.density
color = contentStyle.textColor.toArgb()
typeface = Typeface.DEFAULT
}
val padding = 20f * result.density
val titleHeight = titlePaint.fontMetrics.let { it.descent - it.ascent }
val lineHeight = contentPaint.fontMetrics.let { it.descent - it.ascent }
// 顶部标题
var y = padding + titleHeight
if (title.isNotBlank()) {
val bgPaint = Paint().apply {
color = titleStyle.backgroundColor.toArgb()
}
val titleWidth = titlePaint.measureText(title)
val bgRect = RectF(
padding - 10,
padding - 10,
padding + titleWidth + 10,
padding + titleHeight + 10
)
canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint)
canvas.drawText(title, padding, y, titlePaint)
y += titleHeight + 20
}
// 底部内容
val contentMaxWidth = result.width - padding * 2
val contentLines = wrapText(content, contentPaint, contentMaxWidth)
var lastY = result.height - padding
// 先计算内容总高度
val contentTotalHeight = contentLines.size * lineHeight + 40 * result.density
// 从底部向上绘制
y = lastY - contentTotalHeight + lineHeight + 20
if (content.isNotBlank()) {
val bgPaint = Paint().apply {
color = contentStyle.backgroundColor.toArgb()
}
val maxLineWidth = contentLines.maxOf { contentPaint.measureText(it) }
val bgRect = RectF(
padding - 10,
y - lineHeight - 10,
padding + maxLineWidth + 10,
lastY - 20
)
canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint)
contentLines.forEach { line ->
canvas.drawText(line, padding, y, contentPaint)
y += lineHeight
}
}
return result
}
/**
* 文本自动换行
*/
private fun wrapText(text: String, paint: Paint, maxWidth: Float): List<String> {
val words = text.split("")
val lines = mutableListOf<String>()
var currentLine = StringBuilder()
words.forEach { word ->
val testLine = if (currentLine.isEmpty()) word else "$currentLine$word"
if (paint.measureText(testLine) <= maxWidth) {
currentLine = StringBuilder(testLine)
} else {
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
currentLine = StringBuilder(word)
}
}
if (currentLine.isNotEmpty()) {
lines.add(currentLine.toString())
}
return lines
}
/**
* 保存图片到相册
*/
fun saveToGallery(
context: Context,
bitmap: Bitmap,
fileName: String,
quality: Int = 85
): Uri? {
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, Environment.DIRECTORY_PICTURES + "/InspectionCamera")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
return uri?.let {
resolver.openOutputStream(it)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(it, contentValues, null, null)
}
it
}
}
}

View File

@@ -0,0 +1,89 @@
package com.inspection.camera.util
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.os.Looper
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* 位置服务帮助类
*/
class LocationHelper(private val context: Context) {
private val fusedLocationClient: FusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(context)
private val geocoder: Geocoder by lazy {
Geocoder(context, Locale.getDefault())
}
/**
* 获取当前位置
*/
@SuppressLint("MissingPermission")
suspend fun getCurrentLocation(): Location? {
return try {
fusedLocationClient.lastLocation.await()
} catch (e: Exception) {
null
}
}
/**
* 根据经纬度获取地址
*/
@Suppress("DEPRECATION")
suspend fun getAddressFromLocation(latitude: Double, longitude: Double): String {
return try {
val addresses = geocoder.getFromLocation(latitude, longitude, 1)
if (!addresses.isNullOrEmpty()) {
val address = addresses[0]
buildString {
address.locality?.let { append(it) }
address.subLocality?.let { if (isNotEmpty()) append(" "); append(it) }
address.thoroughfare?.let { if (isNotEmpty()) append(" "); append(it) }
}.ifEmpty { "${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}" }
} else {
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
}
} catch (e: Exception) {
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
}
}
/**
* 获取位置信息(地址或经纬度)
*/
suspend fun getLocationInfo(useNetwork: Boolean = true): String {
if (!useNetwork) {
val location = getCurrentLocation() ?: return ""
return "${"%.4f".format(location.latitude)}, ${"%.4f".format(location.longitude)}"
}
val location = getCurrentLocation() ?: return ""
return getAddressFromLocation(location.latitude, location.longitude)
}
}
private suspend fun <T> com.google.android.gms.tasks.Task<T>.await(): T {
return suspendCancellableCoroutine { continuation ->
addOnSuccessListener { result ->
continuation.resume(result)
}
addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
}
}

View File

@@ -0,0 +1,45 @@
package com.inspection.camera.util
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.ContextCompat
/**
* 权限管理工具
*/
object PermissionManager {
val cameraPermissions = arrayOf(Manifest.permission.CAMERA)
val locationPermissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
fun hasCameraPermission(context: Context): Boolean {
return cameraPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
fun hasLocationPermission(context: Context): Boolean {
return locationPermissions.any {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
fun requestCameraPermission(
launcher: ActivityResultLauncher<Array<String>>,
onResult: (Boolean) -> Unit
) {
launcher.launch(cameraPermissions)
}
fun requestLocationPermission(
launcher: ActivityResultLauncher<Array<String>>,
onResult: (Boolean) -> Unit
) {
launcher.launch(locationPermissions)
}
}