Compare commits

..

2 Commits

17 changed files with 148 additions and 1905 deletions

73
.gitignore vendored
View File

@@ -1,71 +1,6 @@
# Built application files
*.apk
*.ap_
*.aab
# Ignore Windows reserved files
NUL
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
# Android/ Gradle build outputs (optional for CI, can be kept locally if desired)
**/build/
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/
# Keystore files
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (API key)
google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastjson
fastjson/
# Test folder
test/
tests/
# Python
__pycache__/
*.py[cod]
*$py.class
# Airtest
*.log
*.png
report/

102
README.md
View File

@@ -2,14 +2,13 @@
CheckShot 是一个面向 Android 的图片处理与检查工具,包含水印处理、拼图合成、以及丰富的设置能力,配合 AirTest 自动化测试用例,帮助实现端到端的图像工作流。
## 核心功能
核心功能
- 水印设置
- 时间水印:拍摄后在左下角叠加时间戳,格式固定为 "yyyy年-MM月-dd日 HH:mm:ss"。
- 地点水印:优先通过 Geocoder 联网解析地址,失败时回落显示经纬度。可在设置中配置校准方式。
- 样式:提供三种预设样式(默认/简约/醒目),并可在设置中预览和应用。
- 多图拼图(合成)模块
- 布局规则:支持 2x2 和 3x3 两种网格布局,图片自动缩放裁剪以适配网格。
- 布局规则:核心布局为 2x2 网格,且支持扩展布局如 1+3、3+1、1+2、2+1、单图等,图片自动缩放裁剪以适配网格。
- 核心能力:图片拼接、模板化布局编辑(替换/删除图片)、合成质量控制(分辨率/清晰度)。
- 交互:支持替换网格中的图片、删除图片、添加新图片、设置合成质量和文本水印文本。
- 设置与通用配置
@@ -17,112 +16,27 @@ CheckShot 是一个面向 Android 的图片处理与检查工具,包含水印
- 测试与自动化
- 集成 AirTest 测试用例,覆盖水印、相册、拼图、设置等场景,便于回归验证。
---
## 通用拼图布局组件技术规格
### 1. 逻辑结构
采用“上-中-下”三段式布局:
```
┌─────────────────────────┐
│ 标题区 (Title) │ 高度自适应,支持 1 行文本
├─────────────────────────┤
│ │
│ 图片网格区 │ 2x2 或 3x3 栅格布局
│ (Grid Area) │
│ │
├─────────────────────────┤
│ 底部文字区 (Text) │ 200dp 高度,包含标题+内容
└─────────────────────────┘
```
### 2. 核心布局参数化
| 参数 | 说明 | 当前值 |
|------|------|--------|
| Grid_Columns | 列数 | 2 或 3 |
| Grid_Rows | 行数 | 2 或 3 |
| S | 单元格间距 | 4dp |
| P | 容器内边距 | 16dp |
布局模板自动匹配:根据图片数量自动选择最接近的栅格模板。
### 3. 内容渲染规则
- **标题区**:位于顶部,高度自适应,支持 1 行文本
- **图片网格区**:位于栅格正下方,由图片组成
- **内容区**:位于底部,由 Title加粗和 Content常规组成
---
### 4. 缩放算法规格
#### 等比缩放 (Proportional Scaling)
- 锁定原始宽高比,严禁 X 或 Y 轴独立拉伸
- 计算缩放比例:`k = max(Wt / W_orig, Ht / H_orig)`
其中:
- `Wt` = 目标单元格宽度(物理像素)
- `Ht` = 目标单元格高度(物理像素)
- `W_orig` = 原始图片宽度
- `H_orig` = 原始图片高度
#### 填充模式
- **Aspect Fill**图片须完全覆盖单元格区域不允许出现留白Gap-free
#### 采样策略
- **下采样 (Downsampling)**针对大图缩小采用双线性Bilinear过滤避免出现锯齿或摩尔纹
#### 对齐规则
- **Center Aligned**:初始加载时,图片几何中心与单元格几何中心重合
#### 分辨率限制
- **Max DPI 限制**:缩小后的图片逻辑像素密度不低于 320dpi确保在高分屏下的视觉清晰度
---
### 5. 布局通用适配规则
1. 系统根据当前选定的布局模板2x2 或 3x3计算出单个单元格的目标物理像素 (Wt, Ht)
2. 原始图片输入后,自动计算缩放比例 k = max(Wt/W_orig, Ht/H_orig)
3. 图片按 k 倍数进行等比缩小
4. 缩小后的图片根据单元格中心进行溢出裁切,确保视觉上的严丝合缝
---
## 架构与实现要点
架构与实现要点
- Watermark时间文本来自系统时间地点文本通过 LocationHelper/Geocoder 获取。
- Puzzle通过 Bitmap 拼接与 Canvas/Matrix 实现多种布局,支持图片替换、删除与质量控制。
- Settings使用 DataStore 保存用户偏好。
## 快速开始
快速开始
- 构建与运行
- 构建调试 APK`./gradlew assembleDebug`
- 在设备/模拟器上安装:`adb install -r app/build/outputs/apk/debug/app-debug.apk`
- AirTest 测试
- 运行测试用例(需安装 AirTest 及依赖):`airtest run test/airtest/... --device Android:///`
## 项目结构与升级
- 运行测试用例(需安装 AirTest 及依赖):`airtest run test/airtest/... --device Android:///`
项目结构与升级
- 主要代码位于:
- app/src/main/... 业务代码与 UI 组件
- app/src/test/... 单元测试
- test/airtest/... AirTest 的 UI 测试用例
- README 作为版本演进的记录,后续变更请同步更新。
## 持续集成与发布
持续集成与发布
- 建议在 CI 中执行编译、单元测试、AirTest 测试并在通过后打包发行版。
## 联系与贡献
联系与贡献
- 如需进一步定制文档结构,或需要将某些部分拆分为单独的开发指南,请告知偏好。

View File

@@ -60,7 +60,8 @@ enum class ImageQuality(val quality: Int, val displayName: String) {
/**
* 合成布局类型
*/
enum class MergeLayoutType(val rows: Int, val cols: Int, val displayName: String, val maxImages: Int) {
Grid2x2(2, 2, "2x2网格", 4),
Grid3x3(3, 3, "3x3网格", 9)
enum class MergeLayoutType(val rows: Int, val cols: Int, val displayName: String) {
Grid2x2(2, 2, "2x2网格"),
Grid1x3(1, 3, "1+3布局"),
Grid3x1(3, 1, "3+1布局")
}

View File

@@ -2,8 +2,6 @@ package com.inspection.camera.ui.camera
import android.Manifest
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
@@ -76,7 +74,6 @@ import com.inspection.camera.util.PermissionManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.concurrent.Executors
@@ -122,23 +119,6 @@ fun CameraScreen(
}
}
// 检查是否有权限被永久拒绝
val hasPermanentlyDeniedPermission = remember(permissionsState) {
permissionsState.permissions.any { permissionState ->
val status = permissionState.status
status is com.google.accompanist.permissions.PermissionStatus.Denied &&
!status.shouldShowRationale
}
}
// 打开应用设置页面
val openAppSettings = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
}
context.startActivity(intent)
}
// 加载配置
LaunchedEffect(Unit) {
preferencesManager.watermarkStyle.collect { style ->
@@ -159,17 +139,17 @@ fun CameraScreen(
}
}
// 获取位置10秒超时
// 获取位置
LaunchedEffect(permissionsState.allPermissionsGranted, hasLocationPermission) {
isLocationLoading = true
if (permissionsState.allPermissionsGranted && hasLocationPermission) {
try {
Log.d("CameraScreen", "Getting location with 10s timeout...")
val result = withTimeoutOrNull(10000) {
locationHelper.getLocationInfo()
}
locationText = result ?: "定位失败"
Log.d("CameraScreen", "Getting location...")
locationText = locationHelper.getLocationInfo()
Log.d("CameraScreen", "Location result: $locationText")
if (locationText.isEmpty()) {
locationText = "定位失败"
}
} catch (e: Exception) {
Log.e("CameraScreen", "Location error", e)
locationText = "定位失败"
@@ -197,13 +177,10 @@ fun CameraScreen(
flashMode = flashMode,
watermarkStyle = currentWatermarkStyle,
imageQuality = currentImageQuality,
locationText = getValidLocationTextForPhoto(locationText, manualAddress, locationHelper),
locationText = if (locationText.isNotBlank()) locationText else "未知地点",
onComplete = { uri ->
capturedImages.add(uri)
isCapturing = false
},
onError = {
isCapturing = false
}
)
}
@@ -221,9 +198,7 @@ fun CameraScreen(
PermissionRequest(
onRequestPermission = { permissionsState.launchMultiplePermissionRequest() },
showDialog = showPermissionDeniedDialog,
onDismissDialog = { showPermissionDeniedDialog = false },
hasPermanentlyDeniedPermission = hasPermanentlyDeniedPermission,
openAppSettings = openAppSettings
onDismissDialog = { showPermissionDeniedDialog = false }
)
}
@@ -303,7 +278,7 @@ private fun CameraContent(
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
@@ -311,10 +286,8 @@ private fun CameraContent(
preview,
imageCapture
)
Log.d("CameraScreen", "Camera initialized successfully")
} catch (e: Exception) {
Log.e("CameraScreen", "Camera binding failed", e)
imageCapture = null
}
}, ContextCompat.getMainExecutor(context))
@@ -427,9 +400,7 @@ private fun BottomControls(
private fun PermissionRequest(
onRequestPermission: () -> Unit,
showDialog: Boolean,
onDismissDialog: () -> Unit,
hasPermanentlyDeniedPermission: Boolean,
openAppSettings: () -> Unit
onDismissDialog: () -> Unit
) {
Column(
modifier = Modifier
@@ -444,57 +415,16 @@ private fun PermissionRequest(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (hasPermanentlyDeniedPermission)
"权限被永久拒绝,请在设置中手动开启权限"
else
"请授予权限以使用拍照和地点水印功能",
text = "请授予权限以使用拍照和地点水印功能",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(24.dp))
if (hasPermanentlyDeniedPermission) {
Button(onClick = openAppSettings) {
Text("打开设置")
}
} else {
Button(onClick = onRequestPermission) {
Text("授予权限")
}
Button(onClick = onRequestPermission) {
Text("授予权限")
}
}
}
private suspend fun getValidLocationTextForPhoto(
currentLocationText: String,
manualAddress: String,
locationHelper: LocationHelper
): String {
Log.d("CameraScreen", "getValidLocationTextForPhoto called, currentLocationText=$currentLocationText")
// 检查当前定位文本是否有效
val invalidTexts = listOf("正在定位...", "定位失败", "请授予定位权限")
val isInvalid = currentLocationText.isBlank() || invalidTexts.contains(currentLocationText)
if (!isInvalid) {
Log.d("CameraScreen", "Using current location text: $currentLocationText")
return currentLocationText
}
// 使用手动地址
if (manualAddress.isNotBlank()) {
Log.d("CameraScreen", "Using manual address: $manualAddress")
return manualAddress
}
// 尝试快速获取当前位置(使用缓存)
Log.d("CameraScreen", "Requesting new location...")
val location = locationHelper.getCurrentLocation()
val result = location?.let {
"${"%.4f".format(it.latitude)}, ${"%.4f".format(it.longitude)}"
} ?: "未知地点"
Log.d("CameraScreen", "Got location result: $result")
return result
}
private fun capturePhoto(
context: Context,
imageCapture: ImageCapture,
@@ -502,10 +432,8 @@ private fun capturePhoto(
watermarkStyle: WatermarkStyle,
imageQuality: ImageQuality,
locationText: String,
onComplete: (Uri) -> Unit,
onError: () -> Unit = {}
onComplete: (Uri) -> Unit
) {
Log.d("CameraScreen", "capturePhoto called, locationText=$locationText")
val photoFile = File(
context.cacheDir,
"photo_${System.currentTimeMillis()}.jpg"
@@ -519,17 +447,9 @@ private fun capturePhoto(
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
Log.d("CameraScreen", "Photo saved, adding watermark with locationText=$locationText")
val bitmap = BitmapFactory.decodeFile(photoFile.absolutePath)
if (bitmap == null) {
Log.e("CameraScreen", "Failed to decode photo file: ${photoFile.absolutePath}")
onError()
return
}
try {
if (bitmap != null) {
val timeText = ImageProcessor.getCurrentTimeText()
Log.d("CameraScreen", "Adding watermark: timeText=$timeText, locationText=$locationText")
val watermarkedBitmap = ImageProcessor.addWatermark(
bitmap,
timeText,
@@ -544,22 +464,12 @@ private fun capturePhoto(
bitmap.recycle()
watermarkedBitmap.recycle()
if (uri != null) {
onComplete(uri)
} else {
Log.e("CameraScreen", "Failed to save image to gallery")
onError()
}
} catch (e: Exception) {
Log.e("CameraScreen", "Error processing image", e)
bitmap.recycle()
onError()
uri?.let { onComplete(it) }
}
}
override fun onError(exception: ImageCaptureException) {
Log.e("CameraScreen", "Photo capture failed", exception)
onError()
}
}
)

View File

@@ -69,7 +69,6 @@ fun GalleryScreen(
var selectedImages by remember { mutableStateOf<Set<Uri>>(emptySet()) }
var showDeleteDialog by remember { mutableStateOf(false) }
var isSelectionMode by remember { mutableStateOf(false) }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
// 加载图片
LaunchedEffect(Unit) {
@@ -150,9 +149,6 @@ fun GalleryScreen(
} else {
selectedImages + uri
}
} else {
// 非选择模式下,点击打开大图查看器
selectedImageUri = uri
}
},
onLongClick = {
@@ -193,32 +189,6 @@ fun GalleryScreen(
}
)
}
// 大图查看对话框
if (selectedImageUri != null) {
AlertDialog(
onDismissRequest = { selectedImageUri = null },
title = { Text("图片预览") },
text = {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(selectedImageUri)
.crossfade(true)
.build(),
contentDescription = "大图预览",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
},
confirmButton = {
TextButton(onClick = { selectedImageUri = null }) {
Text("关闭")
}
}
)
}
}
@Composable

View File

@@ -2,492 +2,37 @@ package com.inspection.camera.ui.merge
import android.graphics.Bitmap
import android.net.Uri
// java.io.File and FileOutputStream will be referenced with fully qualified names to avoid ambiguity
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.inspection.camera.data.PreferencesManager
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 com.inspection.camera.ui.theme.Primary
import com.inspection.camera.util.ImageProcessor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.inspection.camera.util.PuzzleMerge
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MergeScreen(
imageUris: List<Uri>,
onNavigateBack: () -> Unit,
onMergeComplete: (Uri) -> Unit,
preferencesManager: PreferencesManager
) {
fun MergeScreen(imageUris: List<Uri>) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var mergedBitmap by remember { mutableStateOf<Bitmap?>(null) }
data class ImageWithCache(val uri: Uri, val cachePath: String?)
val images = remember { mutableStateListOf<ImageWithCache>() }
var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) }
var imageQuality by remember { mutableStateOf(ImageQuality.Standard) }
var titleStyle by remember { mutableStateOf(WatermarkStyle.Default) }
var contentStyle by remember { mutableStateOf(WatermarkStyle.Default) }
var showPreview by remember { mutableStateOf(false) }
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
var showSaveDialog by remember { mutableStateOf(false) }
var selectedImageIndex by remember { mutableStateOf(-1) }
// 将 URI 图片复制到缓存目录
suspend fun copyImageToCache(uri: android.net.Uri): String? {
return withContext(Dispatchers.IO) {
try {
val input = context.contentResolver.openInputStream(uri) ?: return@withContext null
val cacheDir = context.cacheDir
val imgDir = java.io.File(cacheDir, "merge_images")
if (!imgDir.exists()) {
imgDir.mkdirs()
}
val fileName = "img_${System.nanoTime()}.jpg"
val outFile = java.io.File(imgDir, fileName)
java.io.FileOutputStream(outFile).use { output ->
input.use { inStream -> inStream.copyTo(output) }
}
outFile.absolutePath
} catch (e: Exception) {
null
}
}
LaunchedEffect(imageUris) {
mergedBitmap = PuzzleMerge.mergeToBitmap(context, imageUris.take(4), 1000)
}
// 图片选择器
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (uris.isNotEmpty()) {
scope.launch {
if (selectedImageIndex >= 0 && selectedImageIndex < images.size) {
// 替换指定位置的图片
val path = copyImageToCache(uris.first())
if (path != null) {
images[selectedImageIndex] = ImageWithCache(uris.first(), path)
}
} else {
// 添加新图片
uris.take(layoutType.maxImages - images.size).forEach { uri ->
val path = copyImageToCache(uri)
if (path != null) {
images.add(ImageWithCache(uri, path))
}
}
}
}
}
selectedImageIndex = -1
}
// 加载用户配置
LaunchedEffect(Unit) {
preferencesManager.mergeLayout.collect { layoutType = it }
}
LaunchedEffect(Unit) {
preferencesManager.imageQuality.collect { imageQuality = it }
}
LaunchedEffect(Unit) {
preferencesManager.titleStyle.collect { titleStyle = it }
}
LaunchedEffect(Unit) {
preferencesManager.contentStyle.collect { contentStyle = it }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("图片合成") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Primary,
titleContentColor = Color.White,
navigationIconContentColor = Color.White
)
Column(modifier = Modifier.fillMaxSize()) {
mergedBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "拼图合成预览",
modifier = Modifier
.fillMaxSize()
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// 布局选择
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MergeLayoutType.entries.forEach { layout ->
LayoutOption(
layout = layout,
isSelected = layoutType == layout,
onClick = {
layoutType = layout
if (images.size > layout.maxImages) {
while (images.size > layout.maxImages) {
images.removeAt(images.size - 1)
}
}
}
)
}
}
// 质量选择
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("质量:", style = MaterialTheme.typography.bodySmall)
ImageQuality.entries.forEach { quality ->
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(if (imageQuality == quality) Primary else Color.LightGray)
.clickable { imageQuality = quality }
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = quality.displayName,
color = if (imageQuality == quality) Color.White else Color.Black,
style = MaterialTheme.typography.bodySmall
)
}
}
}
// 图片网格
LazyVerticalGrid(
columns = GridCells.Fixed(layoutType.cols),
contentPadding = PaddingValues(16.dp),
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(images) { index, imageWithCache ->
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray)
.clickable {
selectedImageIndex = index
imagePickerLauncher.launch("image/*")
}
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(imageWithCache.uri)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
// 删除按钮
IconButton(
onClick = { images.removeAt(index) },
modifier = Modifier
.align(Alignment.TopEnd)
.size(32.dp)
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
) {
Icon(
Icons.Default.Close,
contentDescription = "删除",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
// 替换图标
Icon(
Icons.Default.Refresh,
contentDescription = "替换",
tint = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(32.dp)
.padding(4.dp)
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
.padding(4.dp)
)
}
}
// 添加图片按钮
if (images.size < layoutType.maxImages) {
item {
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray.copy(alpha = 0.5f))
.clickable {
imagePickerLauncher.launch("image/*")
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Add,
contentDescription = "添加图片",
tint = Color.Gray,
modifier = Modifier.size(48.dp)
)
}
}
}
}
// 文字编辑区
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("标题") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = content,
onValueChange = { content = it },
label = { Text("内容") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4
)
}
// 底部按钮
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Button(
onClick = {
scope.launch {
previewBitmap = withContext(Dispatchers.Default) {
val imageItems = images.map { img ->
ImageItem(
uri = img.uri,
path = img.cachePath ?: img.uri.toString()
)
}
ImageProcessor.mergeImages(
context,
imageItems,
layoutType,
imageQuality,
title,
content,
titleStyle,
contentStyle
)
}
showPreview = true
}
},
modifier = Modifier.weight(1f)
) {
Text("预览")
}
Button(
onClick = { showSaveDialog = true },
modifier = Modifier.weight(1f),
enabled = images.isNotEmpty()
) {
Text("保存")
}
}
}
}
// 预览对话框
if (showPreview && previewBitmap != null) {
AlertDialog(
onDismissRequest = { showPreview = false },
title = { Text("预览") },
text = {
Image(
bitmap = previewBitmap!!.asImageBitmap(),
contentDescription = "预览",
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(onClick = { showPreview = false }) {
Text("关闭")
}
}
)
}
// 保存确认对话框
if (showSaveDialog) {
AlertDialog(
onDismissRequest = { showSaveDialog = false },
title = { Text("保存合成图片") },
text = { Text("确定要将合成后的图片保存到相册吗?") },
confirmButton = {
TextButton(onClick = {
scope.launch {
val bitmap = withContext(Dispatchers.Default) {
val imageItems = images.map { img ->
ImageItem(
uri = img.uri,
path = img.cachePath ?: img.uri.toString()
)
}
ImageProcessor.mergeImages(
context,
imageItems,
layoutType,
imageQuality,
title,
content,
titleStyle,
contentStyle
)
}
val fileName = ImageProcessor.generateFileName(title.ifBlank { "合成" })
val uri = ImageProcessor.saveToGallery(context, bitmap, fileName)
uri?.let { onMergeComplete(it) }
showSaveDialog = false
}
}) {
Text("保存")
}
},
dismissButton = {
TextButton(onClick = { showSaveDialog = false }) {
Text("取消")
}
}
)
}
}
@Composable
private fun LayoutOption(
layout: MergeLayoutType,
isSelected: Boolean,
onClick: () -> Unit
) {
val displayText = when (layout) {
MergeLayoutType.Grid2x2 -> "2x2"
MergeLayoutType.Grid3x3 -> "3x3"
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(if (isSelected) Primary else Color.LightGray)
.clickable(onClick = onClick)
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayText,
color = if (isSelected) Color.White else Color.Black
)
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
}

View File

@@ -33,44 +33,6 @@ object ImageProcessor {
private val dateFormat = SimpleDateFormat("yyyy年-MM月-dd日 HH:mm:ss", Locale.getDefault())
private val fileNameFormat = SimpleDateFormat("yyyyMMddHHmm", Locale.getDefault())
/**
* 尝试加载图片 Bitmap
*/
private fun tryLoadBitmap(context: Context, imageItem: ImageItem): Bitmap? {
android.util.Log.d("ImageProcessor", "Loading image, URI: ${imageItem.uri}, path: ${imageItem.path}")
// 优先尝试 URI 加载
try {
val uri = imageItem.uri
android.util.Log.d("ImageProcessor", "Trying to open URI: $uri")
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val bitmap = BitmapFactory.decodeStream(inputStream)
android.util.Log.d("ImageProcessor", "URI loaded bitmap: ${bitmap != null}")
return bitmap
}
} catch (e: Exception) {
android.util.Log.e("ImageProcessor", "URI load failed: ${e.message}")
}
// 尝试从文件路径加载
if (imageItem.path.isNotEmpty()) {
try {
val file = java.io.File(imageItem.path)
android.util.Log.d("ImageProcessor", "Trying file: ${file.absolutePath}, exists: ${file.exists()}")
if (file.exists()) {
val bitmap = BitmapFactory.decodeFile(imageItem.path)
android.util.Log.d("ImageProcessor", "File loaded bitmap: ${bitmap != null}")
return bitmap
}
} catch (e: Exception) {
android.util.Log.e("ImageProcessor", "File load failed: ${e.message}")
}
}
android.util.Log.w("ImageProcessor", "Failed to load image")
return null
}
/**
* 获取当前时间戳文本
*/
@@ -99,90 +61,65 @@ object ImageProcessor {
locationText: String,
style: WatermarkStyle
): Bitmap {
android.util.Log.d("ImageProcessor", "addWatermark called, timeText=$timeText, locationText=$locationText")
val result = sourceBitmap.copy(Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(result)
// 使用固定的字体大小,基于图片宽度比例
val baseFontSize = result.width / 40f // 字体大小为图片宽度的1/40
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = baseFontSize
textSize = style.fontSize * result.density
color = style.textColor.toArgb()
typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.CENTER
}
val watermarkText = "$timeText $locationText"
val textWidth = paint.measureText(watermarkText)
val textHeight = paint.fontMetrics.let { it.descent - it.ascent }
android.util.Log.d("ImageProcessor", "Watermark: width=$textWidth, height=$textHeight, text=$watermarkText")
// 计算位置(底部中央
val padding = result.width / 30f // 边距为图片宽度的1/30
val x = result.width / 2f
// 计算位置(左下角
val padding = 20f * result.density
val x = padding
val y = result.height - padding
// 绘制背景
// 通过ARGB判断来决定是否绘制背景避免混用 Android Color 与 Compose Color 的类型问题
val bgColorInt = style.backgroundColor.toArgb()
android.util.Log.d("ImageProcessor", "Background color int: $bgColorInt")
if (bgColorInt != 0) {
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = bgColorInt
}
val bgRect = RectF(
x - textWidth / 2 - padding / 2,
y - textHeight - padding / 2,
x + textWidth / 2 + padding / 2,
y + padding / 2
x - 10,
y - textHeight - 10,
x + textWidth + 10,
y + 10
)
canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint)
android.util.Log.d("ImageProcessor", "Background drawn at x=$x, y=$y")
}
// 绘制文字
canvas.drawText(watermarkText, x, y, paint)
android.util.Log.d("ImageProcessor", "Text drawn at x=$x, y=$y")
return result
}
/**
* 合成多张图片(支持 URI
* 合成多张图片
*/
fun mergeImages(
context: Context,
images: List<ImageItem>,
layoutType: MergeLayoutType,
quality: ImageQuality,
title: String = "",
content: String = "",
titleStyle: WatermarkStyle = WatermarkStyle.Default,
contentStyle: WatermarkStyle = WatermarkStyle.Default
quality: ImageQuality
): Bitmap {
if (images.isEmpty() && title.isBlank() && content.isBlank()) {
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 = outputWidth / cols // 保持正方形格子
// 底部文字区域高度
val textAreaHeight = if (title.isNotBlank() || content.isNotBlank()) {
200 // 200像素高度的文字区域
} else {
0
}
// 图片区域高度
val imageAreaHeight = cellHeight * rows
val outputHeight = imageAreaHeight + textAreaHeight
val cellHeight = outputHeight / rows
val result = Bitmap.createBitmap(outputWidth, outputHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(result)
@@ -190,7 +127,6 @@ object ImageProcessor {
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// 绘制图片网格
images.forEachIndexed { index, imageItem ->
if (index >= rows * cols) return@forEachIndexed
@@ -201,10 +137,10 @@ object ImageProcessor {
val top = row * cellHeight
try {
val sourceBitmap = tryLoadBitmap(context, imageItem)
sourceBitmap ?: return@forEachIndexed
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)
@@ -214,6 +150,7 @@ object ImageProcessor {
}
sourceBitmap.recycle()
} catch (e: Exception) {
// 加载失败绘制占位
val placeholderPaint = Paint().apply {
color = Color.LTGRAY
}
@@ -224,97 +161,6 @@ object ImageProcessor {
}
}
// 绘制底部文字区域
if (textAreaHeight > 0) {
val textTop = imageAreaHeight
// 绘制白色背景
canvas.drawRect(
0f, textTop.toFloat(),
outputWidth.toFloat(), outputHeight.toFloat(),
Paint().apply { color = Color.WHITE }
)
// 绘制分割线
canvas.drawLine(
0f, textTop.toFloat(),
outputWidth.toFloat(), textTop.toFloat(),
Paint().apply {
color = Color.LTGRAY
strokeWidth = 2f
}
)
// 绘制标题
if (title.isNotBlank()) {
val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = titleStyle.fontSize * 2f // 放大标题
color = titleStyle.textColor.toArgb()
typeface = Typeface.DEFAULT_BOLD
textAlign = Paint.Align.CENTER
}
// 标题背景
val titleBgPaint = Paint().apply {
color = titleStyle.backgroundColor.toArgb()
}
val titleWidth = titlePaint.measureText(title)
val titleHeight = titlePaint.fontMetrics.let { it.descent - it.ascent }
val titleBgRect = RectF(
(outputWidth - titleWidth) / 2 - 20,
textTop + 20f,
(outputWidth + titleWidth) / 2 + 20,
textTop + 20f + titleHeight + 20
)
canvas.drawRoundRect(titleBgRect, 8f, 8f, titleBgPaint)
// 标题文字
canvas.drawText(title, outputWidth / 2f, textTop + 20f + titleHeight, titlePaint)
}
// 绘制内容
if (content.isNotBlank()) {
val contentPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = contentStyle.fontSize * 1.5f // 放大内容
color = contentStyle.textColor.toArgb()
typeface = Typeface.DEFAULT
}
val padding = 40f
val contentMaxWidth = outputWidth - padding * 2
val contentLines = wrapText(content, contentPaint, contentMaxWidth)
val lineHeight = contentPaint.fontMetrics.let { it.descent - it.ascent }
// 内容背景
val contentBgPaint = Paint().apply {
color = contentStyle.backgroundColor.toArgb()
}
// 从标题下方开始绘制内容
val contentStartY = if (title.isNotBlank()) {
textTop + 80f + lineHeight
} else {
textTop + 20f + lineHeight
}
val totalContentHeight = contentLines.size * lineHeight + 20
val contentBgRect = RectF(
padding - 10,
contentStartY - lineHeight - 10,
outputWidth - padding + 10,
contentStartY + totalContentHeight
)
canvas.drawRoundRect(contentBgRect, 8f, 8f, contentBgPaint)
// 内容文字
var y = contentStartY
contentLines.forEach { line ->
canvas.drawText(line, padding, y, contentPaint)
y += lineHeight
}
}
}
return result
}

View File

@@ -12,11 +12,8 @@ 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 android.location.LocationManager
import android.location.LocationListener
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -33,157 +30,14 @@ class LocationHelper(private val context: Context) {
Geocoder(context, Locale.getDefault())
}
// 位置缓存
private var lastLocation: Location? = null
private var lastLocationTime: Long = 0
private val LOCATION_CACHE_VALID_MS = 30000 // 30秒缓存有效
/**
* 检查定位服务是否启用GPS或网络
*/
fun isLocationEnabled(): Boolean {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
/**
* 获取当前位置(带缓存)
* 获取当前位置
*/
@SuppressLint("MissingPermission")
suspend fun getCurrentLocation(): Location? {
// 检查缓存是否有效
if (lastLocation != null && System.currentTimeMillis() - lastLocationTime < LOCATION_CACHE_VALID_MS) {
Log.d("LocationHelper", "Using cached location: $lastLocation")
return lastLocation
}
// 检查定位服务是否启用
if (!isLocationEnabled()) {
Log.w("LocationHelper", "Location services disabled")
return null
}
var result: Location? = null
// 并行尝试多种定位方式,使用最快的
result = tryHmsLocation()
if (result == null) {
result = tryGmsLocation()
}
if (result == null) {
result = getNetworkLocationFallback()
}
// 更新缓存
if (result != null) {
lastLocation = result
lastLocationTime = System.currentTimeMillis()
Log.d("LocationHelper", "Location obtained and cached: $result")
}
return result
}
@SuppressLint("MissingPermission")
private suspend fun tryGmsLocation(): Location? {
return try {
Log.d("LocationHelper", "Requesting GMS last location...")
// 先尝试获取最后已知位置(最快)
val lastLocation = fusedLocationClient.lastLocation.await()
if (lastLocation != null) {
Log.d("LocationHelper", "Got GMS last location: $lastLocation")
return lastLocation
}
// 最后位置为空,请求新位置(快速低精度)
Log.d("LocationHelper", "Last location null, requesting fresh location...")
requestFastLocation()
fusedLocationClient.lastLocation.await()
} catch (e: Exception) {
Log.e("LocationHelper", "GMS location failed", e)
null
}
}
// HMS 位置服务反射实现(无编译时依赖)
@Suppress("MissingPermission")
private suspend fun tryHmsLocation(): Location? {
Log.d("LocationHelper", "Attempt HMS location via reflection...")
return try {
val servicesClass = Class.forName("com.huawei.hms.location.LocationServices")
val getClient = servicesClass.getMethod("getFusedLocationProviderClient", Context::class.java)
val client = getClient.invoke(null, context)
val clientClass = client!!.javaClass
val getLastLocation = clientClass.getMethod("getLastLocation")
val task = getLastLocation.invoke(client)
val taskClass = task!!.javaClass
val latch = java.util.concurrent.CountDownLatch(1)
var result: Location? = null
val onSuccess = java.lang.reflect.Proxy.newProxyInstance(
LocationHelper::class.java.classLoader,
arrayOf(Class.forName("com.huawei.hmf.tasks.OnSuccessListener")),
java.lang.reflect.InvocationHandler { _, method, args ->
if (method.name == "onSuccess" && args != null) {
result = args[0] as Location
latch.countDown()
}
null
}
)
val onFailure = java.lang.reflect.Proxy.newProxyInstance(
LocationHelper::class.java.classLoader,
arrayOf(Class.forName("com.huawei.hmf.tasks.OnFailureListener")),
java.lang.reflect.InvocationHandler { _, method, args ->
if (method.name == "onFailure" && args != null) {
latch.countDown()
}
null
}
)
val addSuccess = taskClass.getMethod("addOnSuccessListener", Class.forName("com.huawei.hmf.tasks.OnSuccessListener"))
val addFailure = taskClass.getMethod("addOnFailureListener", Class.forName("com.huawei.hmf.tasks.OnFailureListener"))
addSuccess.invoke(task, onSuccess)
addFailure.invoke(task, onFailure)
latch.await(5000, java.util.concurrent.TimeUnit.MILLISECONDS)
result
} catch (t: Throwable) {
Log.e("LocationHelper", "HMS location reflection failed", t)
Log.e("LocationHelper", "HMS location fallback failed", t)
null
}
}
@SuppressLint("MissingPermission")
private suspend fun requestFastLocation(): Location? {
return try {
Log.d("LocationHelper", "Creating fast location request...")
// 使用平衡功耗精度,更快获取位置
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, 5000)
.setWaitForAccurateLocation(false)
.setMinUpdateIntervalMillis(1000)
.setMaxUpdateDelayMillis(8000)
.build()
withTimeoutOrNull(8000) {
suspendCancellableCoroutine { continuation ->
Log.d("LocationHelper", "Requesting fast location updates...")
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
Log.d("LocationHelper", "Got fast location result: ${result.lastLocation}")
fusedLocationClient.removeLocationUpdates(this)
continuation.resume(result.lastLocation)
}
}
fusedLocationClient.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
continuation.invokeOnCancellation {
Log.d("LocationHelper", "Fast location request cancelled")
fusedLocationClient.removeLocationUpdates(callback)
}
}
}
} catch (e: Exception) {
Log.e("LocationHelper", "Request fast location failed", e)
null
}
}
@@ -194,9 +48,7 @@ class LocationHelper(private val context: Context) {
@Suppress("DEPRECATION")
suspend fun getAddressFromLocation(latitude: Double, longitude: Double): String {
return try {
Log.d("LocationHelper", "Getting address for: $latitude, $longitude")
val addresses = geocoder.getFromLocation(latitude, longitude, 1)
Log.d("LocationHelper", "Geocoder results: $addresses")
if (!addresses.isNullOrEmpty()) {
val address = addresses[0]
buildString {
@@ -208,7 +60,6 @@ class LocationHelper(private val context: Context) {
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
}
} catch (e: Exception) {
Log.e("LocationHelper", "Geocoder error", e)
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
}
}
@@ -228,33 +79,6 @@ class LocationHelper(private val context: Context) {
Log.d("LocationHelper", "Network location: $location")
return location?.let { getAddressFromLocation(it.latitude, it.longitude) } ?: ""
}
// 备选方案:当设备没有 Google Play Services 时,尝试使用 Android 原生 LocationManager 获取网络定位
internal suspend fun getNetworkLocationFallback(): Location? {
return try {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val provider = LocationManager.NETWORK_PROVIDER
val enabled = locationManager.isProviderEnabled(provider)
if (!enabled) return null
suspendCancellableCoroutine { cont ->
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
locationManager.removeUpdates(this)
cont.resume(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: android.os.Bundle?) { /* no-op */ }
override fun onProviderEnabled(provider: String) { /* no-op */ }
override fun onProviderDisabled(provider: String) { /* no-op */ }
}
locationManager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper())
cont.invokeOnCancellation { locationManager.removeUpdates(listener) }
}
} catch (e: Exception) {
Log.e("LocationHelper", "Network fallback location failed", e)
null
}
}
}
private suspend fun <T> com.google.android.gms.tasks.Task<T>.await(): T {

View File

@@ -0,0 +1,59 @@
package com.inspection.camera.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.net.Uri
object PuzzleMerge {
// Merge up to 4 images into a single 2x2 bitmap
fun mergeToBitmap(context: Context, imageUris: List<Uri>, targetSize: Int = 1000): Bitmap? {
if (imageUris.isEmpty()) return null
val size = targetSize
val half = size / 2
val merged = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(merged)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val targets = arrayOf(
Rect(0, 0, half, half), // TL
Rect(half, 0, size, half), // TR
Rect(0, half, half, size), // BL
Rect(half, half, size, size) // BR
)
val toUse = imageUris.take(4)
for (i in toUse.indices) {
val bmp = loadBitmap(context, toUse[i], half, half)
if (bmp != null) {
val scaled = Bitmap.createScaledBitmap(bmp, half, half, true)
canvas.drawBitmap(scaled, null, targets[i], paint)
bmp.recycle()
scaled.recycle()
}
}
// Optional: fill empty cells with placeholder color if needed
return merged
}
// Load bitmap from URI with sampling to fit within maxW x maxH
private fun loadBitmap(context: Context, uri: Uri, maxW: Int, maxH: Int): Bitmap? {
return try {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri).use { ins ->
BitmapFactory.decodeStream(ins, null, opts)
}
val inSample = maxOf(1, max(opts.outWidth / maxW, opts.outHeight / maxH))
val opts2 = BitmapFactory.Options().apply { inSampleSize = inSample }
context.contentResolver.openInputStream(uri).use { ins ->
BitmapFactory.decodeStream(ins, null, opts2)
}
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
"""
AirTest Script: Puzzle Merge - 2x2 large image composition
"""
from airtest.core.api import *
auto_setup(__file__)
def main():
start_app("com.inspection.camera")
sleep(2)
width, height = device().get_current_resolution()
# 进入拼图页入口(假设在屏幕右侧)
touch((width * 2 / 3, height - 150))
sleep(2)
snapshot("puzzle_merge_page.png")
print("Saved puzzle_merge_page.png")
if __name__ == "__main__":
main()

View File

@@ -1,116 +0,0 @@
# -*- coding: utf-8 -*-
"""
Android 模拟器图像识别测试脚本
"""
from airtest.core.api import *
import sys
# 初始化设备连接
def init_device():
# 连接 Android 模拟器
dev = connect_device("android://127.0.0.1:5037/emulator-5554")
print(f"已连接设备: {dev}")
return dev
# 截取当前屏幕
def capture_screen(filename="screen.png"):
dev = device()
screen = dev.snapshot()
# numpy.ndarray 转换为 PIL Image 并保存
from PIL import Image
import numpy as np
img = Image.fromarray(screen)
img.save(filename)
print(f"截图已保存: {filename}")
return screen
# 测试1: 打开相机应用
def test_open_camera():
print("测试1: 打开相机应用...")
# 使用 ADB 启动相机
shell("am start -n com.inspection.camera/.ui.MainActivity")
sleep(2)
# 截图
capture_screen("test_open_camera.png")
print("相机已打开")
return True
# 测试2: 模拟点击屏幕中心(测试触摸功能)
def test_touch():
print("测试2: 测试触摸功能...")
# 点击屏幕中心
touch([540, 960]) # 模拟器分辨率 1080x1920 的中心点
sleep(1)
capture_screen("test_touch.png")
print("触摸测试完成")
return True
# 测试3: 滑动测试
def test_swipe():
print("测试3: 测试滑动功能...")
# 从上往下滑动
swipe([540, 300], [540, 900])
sleep(1)
capture_screen("test_swipe.png")
print("滑动测试完成")
return True
# 测试4: 查找屏幕上是否有特定文字使用OCR
def test_ocr():
print("测试4: OCR文字识别测试...")
try:
from airtest.aircv import aircv
import numpy as np
# 截图
screen = device().snapshot()
# 使用 PIL 显示图像信息
from PIL import Image
img = Image.fromarray(screen)
print(f"屏幕分辨率: {img.size}")
print(f"屏幕模式: {img.mode}")
capture_screen("test_ocr.png")
print("OCR 测试完成")
return True
except Exception as e:
print(f"OCR测试出错: {e}")
return False
# 主测试函数
def main():
print("=" * 50)
print("Android 模拟器图像识别测试")
print("=" * 50)
try:
# 初始化设备
init_device()
# 执行测试
test_open_camera()
test_touch()
test_swipe()
test_ocr()
print("\n" + "=" * 50)
print("所有测试完成!")
print("=" * 50)
except Exception as e:
print(f"测试出错: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -1,116 +0,0 @@
# -*- coding: utf-8 -*-
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
"""
测试拍照功能
"""
from airtest.core.api import *
from PIL import Image
import os
import time
def init_device():
# dev = connect_device("android://127.0.0.1:5037/emulator-5554") # 模拟器
dev = connect_device("android://127.0.0.1:5037/APH0219A29002701") # 真机
print(f"已连接设备: {dev}")
return dev
def capture_screen(filename="screen.png"):
screen = device().snapshot()
img = Image.fromarray(screen)
img.save(filename)
print(f"截图已保存: {filename}")
return screen
def test_camera():
print("=" * 50)
print("测试:拍照功能")
print("=" * 50)
# 启动相机应用
print("1. 启动相机应用...")
shell("am start -n com.inspection.camera/.ui.MainActivity")
sleep(3)
capture_screen("01_app_loaded.png")
print(" 相机应用已启动")
# 点击拍照按钮 (屏幕底部中央 - 2400高底部区域约在2200)
print("2. 点击拍照按钮...")
touch([540, 2200]) # 拍照按钮位置
sleep(3)
capture_screen("02_after_capture.png")
print(" 拍照完成")
# 检查照片是否生成
print("3. 检查照片是否生成...")
try:
# 创建Pictures目录如果不存在
shell("mkdir -p /storage/emulated/0/Pictures/inspection")
sleep(1)
# 列出Pictures目录下的文件
result = shell("ls -la /storage/emulated/0/Pictures/inspection/")
print(f" Pictures目录内容: {result}")
# 检查是否有新的照片文件
files_output = shell("ls /storage/emulated/0/Pictures/inspection/").strip()
if files_output and "No such file" not in files_output:
files = files_output.split('\n')
print(f" 照片文件列表: {files}")
if files and files[0]:
latest_photo = files[-1].strip()
print(f" [OK] 照片已生成: {latest_photo}")
# 拉取最新照片到本地
pull_cmd = f"/storage/emulated/0/Pictures/inspection/{latest_photo}"
os.system(f'"C:\\Users\\xiaji\\AppData\\Local\\Android\\Sdk\\platform-tools\\adb.exe" -s emulator-5554 pull "{pull_cmd}" . 2>nul')
if os.path.exists(latest_photo):
print(f" [OK] 照片已保存到本地: {latest_photo}")
print("\n" + "=" * 50)
print("测试结果: 拍照功能正常!")
print("=" * 50)
return True
else:
print(" [X] 照片未保存到本地")
return False
else:
print(" [X] 未找到照片文件")
return False
else:
# 检查DCIM目录
dcim_result = shell("ls /storage/emulated/0/DCIM/Camera/")
print(f" DCIM目录内容: {dcim_result}")
if dcim_result and "No such file" not in dcim_result and dcim_result.strip():
print(f" [OK] 照片已生成在DCIM目录")
print("\n" + "=" * 50)
print("测试结果: 拍照功能正常!")
print("=" * 50)
return True
print(" [X] 未找到任何照片")
return False
except Exception as e:
print(f" [X] 检查照片时出错: {e}")
return False
if __name__ == "__main__":
try:
init_device()
result = test_camera()
if result:
print("\n测试通过!")
else:
print("\n测试失败!")
except Exception as e:
print(f"测试出错: {e}")
import traceback
traceback.print_exc()

View File

@@ -1,57 +0,0 @@
# -*- coding: utf-8 -*-
"""
Gallery Test - Test if clicking thumbnail opens full image
"""
from airtest.core.api import *
from PIL import Image
import numpy as np
def init_device():
dev = connect_device("android://127.0.0.1:5037/emulator-5554")
print(f"Device connected")
return dev
def capture_screen(filename="screen.png"):
screen = device().snapshot()
img = Image.fromarray(screen)
img.save(filename)
print(f"Screenshot: {filename}")
return screen
# Main
print("=" * 50)
print("Gallery Test - Click Thumbnail")
print("=" * 50)
init_device()
sleep(2)
# Go to gallery tab
touch([270, 2300]) # Gallery tab
sleep(3)
capture_screen("gallery1_main.png")
# Click on first thumbnail (latest image)
touch([540, 600])
sleep(2)
capture_screen("gallery2_after_click.png")
# Check if screen changed (full image viewer opened)
# Compare two screenshots
img1 = np.array(Image.open("gallery1_main.png"))
img2 = np.array(Image.open("gallery2_after_click.png"))
# Calculate similarity
diff = np.abs(img1.astype(float) - img2.astype(float)).mean()
print(f"Screen difference: {diff:.2f}")
# Check if there's any UI change
# If diff > 50, it means screen changed significantly
if diff > 50:
print("RESULT: Screen changed after clicking thumbnail")
print(" (Full image viewer may have opened)")
else:
print("RESULT: Screen did NOT change after clicking thumbnail")
print(" (Click is NOT working - BUG!)")
print("=" * 50)

View File

@@ -1,63 +0,0 @@
# -*- coding: utf-8 -*-
"""
Gallery Test - Test clicking thumbnail to view full image
"""
from airtest.core.api import *
from PIL import Image
import numpy as np
def init_device():
dev = connect_device("android://127.0.0.1:5037/emulator-5554")
print(f"Device connected")
return dev
def capture_screen(filename="screen.png"):
screen = device().snapshot()
img = Image.fromarray(screen)
img.save(filename)
print(f"Screenshot: {filename}")
return screen
def calculate_similarity(img1_path, img2_path):
img1 = np.array(Image.open(img1_path))
img2 = np.array(Image.open(img2_path))
diff = np.abs(img1.astype(float) - img2.astype(float)).mean()
return diff
print("=" * 50)
print("Gallery Test - Click Thumbnail to View Full Image")
print("=" * 50)
init_device()
sleep(2)
# Step 1: Open Gallery app
print("Step 1: Open Gallery app")
touch([270, 2300]) # Gallery tab
sleep(3)
capture_screen("gallery_home.png")
# Step 2: Verify thumbnails are displayed
print("Step 2: Verify thumbnails are displayed")
thumbnails_visible = exists(Template(r"tpl_thumbnail.png"))
print(f"Thumbnails visible: {thumbnails_visible}")
# Step 3: Click on a thumbnail to view full image
print("Step 3: Click on first thumbnail")
touch([540, 600]) # First thumbnail position
sleep(2)
capture_screen("full_image.png")
# Step 4: Verify full image viewer opened
print("Step 4: Verify full image viewer opened")
diff = calculate_similarity("gallery_home.png", "full_image.png")
print(f"Screen difference: {diff:.2f}")
# Step 5: Test result
print("=" * 50)
if diff > 50:
print("PASS: Clicking thumbnail opens full image viewer")
else:
print("FAIL: Clicking thumbnail does NOT open full image")
print(" (This is a BUG - thumbnail click is not working)")
print("=" * 50)

View File

@@ -1,113 +0,0 @@
# -*- coding: utf-8 -*-
from airtest.core.api import *
from poco.drivers.android.uiautomation import AndroidUiautomationPoco
import time
# 初始化设备
init_device("Android", uuid="emulator-5554")
poco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)
print("=" * 50)
print("开始测试拍照功能 - 位置水印测试")
print("=" * 50)
# 启动应用
print("\n[1] 启动应用...")
home()
start_app("com.inspection.camera")
sleep(5)
# 等待应用加载
print("[2] 等待应用加载...")
snapshot("app_loaded.png")
print("[INFO] 已保存截图: app_loaded.png")
# 等待定位完成,检测定位文本
print("\n[3] 等待定位完成最多15秒...")
location_text = ""
for i in range(15):
try:
# 尝试通过poco获取定位文本UI中的卡片文本
# 定位文本可能位于某个Card内我们尝试查找包含"定位"的文本元素
elements = poco(textMatches=".*定位.*")
if elements.exists():
location_text = elements.get_text()
print(f"[INFO] 检测到定位文本: {location_text}")
if location_text != "正在定位...":
break
except Exception as e:
pass
sleep(1)
print(f"等待 {i+1}/15 秒")
# 如果未找到定位文本,尝试其他选择器
if not location_text:
try:
# 尝试查找包含"定位"或"位置"的文本
for text in ["定位", "位置", "地点"]:
elements = poco(textMatches=f".*{text}.*")
if elements.exists():
location_text = elements.get_text()
print(f"[INFO] 通过关键词 '{text}' 找到定位文本: {location_text}")
break
except Exception as e:
print(f"[WARNING] 查找定位文本失败: {e}")
print(f"\n[INFO] 最终定位文本: '{location_text}'")
# 验证定位是否失败(超时后应显示"定位失败"
if location_text == "定位失败":
print("[SUCCESS] 定位超时后正确显示'定位失败'")
else:
print(f"[WARNING] 定位文本不是'定位失败',而是: {location_text}")
# 点击拍照按钮(屏幕中央下方)
print("\n[4] 点击拍照按钮...")
shell("input tap 540 2100")
sleep(3)
snapshot("after_capture.png")
print("[INFO] 拍照后截图: after_capture.png")
# 检查保存的图片文件
print("\n[4.5] 检查保存的图片文件...")
sleep(2) # 等待文件保存
# 列出图片目录
output = shell("ls -t /sdcard/Pictures/InspectionCamera/*.jpg 2>/dev/null | head -1")
if output and output.strip():
latest_image = output.strip()
print(f"[INFO] 找到最新图片: {latest_image}")
# 获取文件大小
size_output = shell(f"du -h {latest_image} 2>/dev/null | cut -f1")
if size_output:
print(f"[INFO] 文件大小: {size_output.strip()}")
else:
print("[WARNING] 未找到保存的图片文件,可能路径不同")
# 尝试其他可能路径
shell("ls -l /sdcard/Pictures/ 2>/dev/null")
shell("ls -l /sdcard/DCIM/ 2>/dev/null")
# 打开相册
print("\n[5] 打开相册...")
shell("am start -n com.google.android.apps.photos/.home.HomeActivity")
sleep(3)
snapshot("gallery.png")
print("[INFO] 相册截图: gallery.png")
# 点击最新图片(假设第一张缩略图位于屏幕中央)
print("\n[6] 点击最新图片...")
touch([540, 600])
sleep(2)
snapshot("gallery_detail.png")
print("[INFO] 图片详情截图: gallery_detail.png")
print("\n[7] 测试完成,请检查截图文件中的水印")
print("\n" + "=" * 50)
print("测试完成!")
print("=" * 50)
print("\n请检查截图文件:")
print("1. app_loaded.png - 应用界面")
print("2. after_capture.png - 拍照后")
print("3. gallery.png - 相册")
print("4. gallery_detail.png - 图片详情")
print("\n检查照片左下角是否有时间+位置水印")
print("=" * 50)

View File

@@ -1,227 +0,0 @@
# -*- coding: utf-8 -*-
"""
Puzzle/Merge Function Test Script
Test flow:
1. Click "Puzzle" in bottom navigation
2. Select 2x2 layout
3. Select 4 images
4. Click "Preview"
5. Confirm merge result
6. Return to main page
7. Enter gallery
8. Check latest merge result
"""
from airtest.core.api import *
from PIL import Image
import numpy as np
import os
# Initialize device
def init_device():
dev = connect_device("android://127.0.0.1:5037/emulator-5554")
print(f"Device connected: {dev}")
return dev
# Capture screen
def capture_screen(filename="screen.png"):
screen = device().snapshot()
img = Image.fromarray(screen)
img.save(filename)
print(f"Screenshot saved: {filename}")
return screen
# Wait for app launch
def wait_app_launch():
print("Waiting for app launch...")
sleep(3)
# Test 1: Click "Puzzle" tab in bottom navigation
def click_merge_tab():
print("Test 1: Click 'Puzzle' tab...")
# Bottom navigation: Camera(0), Gallery(1), Puzzle(2), Settings(3)
# Click puzzle button at bottom right
touch([810, 2300])
sleep(2)
capture_screen("01_click_merge.png")
print("Clicked puzzle")
return True
# Test 2: Select 2x2 layout
def select_2x2_layout():
print("Test 2: Select 2x2 layout...")
touch([180, 200])
sleep(1)
capture_screen("02_select_2x2.png")
print("Selected 2x2 layout")
return True
# Test 3: Select 4 images
def select_4_images():
print("Test 3: Select 4 images...")
# Click add image button (first empty cell)
touch([270, 500])
sleep(2)
# This will open system image picker
capture_screen("03_before_select_image.png")
# Click "Gallery" option
touch([540, 800])
sleep(1)
# Select first image
touch([200, 400])
sleep(1)
# Second image
touch([540, 400])
sleep(1)
# Third image
touch([880, 400])
sleep(1)
# Fourth image
touch([200, 700])
sleep(1)
# Confirm selection (click OK button)
touch([900, 2300])
sleep(2)
capture_screen("04_selected_4_images.png")
print("Selected 4 images")
return True
# Test 4: Click preview button
def click_preview_button():
print("Test 4: Click preview button...")
touch([300, 2200])
sleep(3)
capture_screen("05_preview.png")
print("Clicked preview")
return True
# Test 5: Close preview dialog
def close_preview():
print("Test 5: Close preview dialog...")
touch([540, 1500])
sleep(1)
capture_screen("06_preview_closed.png")
print("Closed preview")
return True
# Test 6: Click save button
def click_save_button():
print("Test 6: Click save button...")
touch([800, 2200])
sleep(2)
capture_screen("07_save_dialog.png")
return True
# Test 7: Confirm save
def confirm_save():
print("Test 7: Confirm save...")
touch([700, 1400])
sleep(3)
capture_screen("08_saved.png")
print("Saved")
return True
# Test 8: Return to home page
def go_back_home():
print("Test 8: Return to home page...")
touch([100, 100])
sleep(2)
capture_screen("09_back_home.png")
print("Returned to home")
return True
# Test 9: Click "Gallery" tab
def click_gallery_tab():
print("Test 9: Click 'Gallery' tab...")
touch([270, 2300])
sleep(3)
capture_screen("10_gallery.png")
print("Clicked gallery")
return True
# Test 10: View latest image
def view_latest_image():
print("Test 10: View latest image...")
touch([540, 600])
sleep(2)
capture_screen("11_latest_image.png")
print("Opened latest image")
return True
# 测试11: 检查图片是否为纯白色
def check_white_image():
print("Test 11: Check if image is pure white...")
# 读取截图
img = Image.open("11_latest_image.png")
img_array = np.array(img)
# 检查是否为纯白色 (RGB都接近255)
# 取图片中心区域进行判断
h, w = img_array.shape[:2]
center_region = img_array[h//4:3*h//4, w//4:3*w//4]
# 计算平均颜色
avg_color = np.mean(center_region, axis=(0, 1))
print(f"Image center average color: {avg_color}")
# 判断是否接近白色 (R, G, B 都 > 250)
is_white = all(c > 250 for c in avg_color[:3])
if is_white:
print("WARNING: Image is pure white, merge function may not work!")
else:
print("OK: Image is not pure white, merge function works")
return not is_white
# Main test function
def main():
print("=" * 60)
print("Puzzle/Merge Function Automated Test")
print("=" * 60)
try:
# Initialize device
init_device()
# Wait for app launch
wait_app_launch()
# Execute test steps
click_merge_tab()
select_2x2_layout()
select_4_images()
click_preview_button()
close_preview()
click_save_button()
confirm_save()
go_back_home()
click_gallery_tab()
view_latest_image()
# Check result
result = check_white_image()
print("\n" + "=" * 60)
if result:
print("TEST PASSED! Merge function works normally")
else:
print("TEST FAILED! Merge result is pure white")
print("=" * 60)
except Exception as e:
print(f"Test error: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@@ -1,89 +0,0 @@
# -*- coding: utf-8 -*-
"""
Simple test to verify merge works
"""
from airtest.core.api import *
from PIL import Image
import numpy as np
def init_device():
dev = connect_device("android://127.0.0.1:5037/emulator-5554")
print(f"Device connected")
return dev
def capture_screen(filename="screen.png"):
screen = device().snapshot()
img = Image.fromarray(screen)
img.save(filename)
print(f"Screenshot: {filename}")
return screen
# Main
print("=" * 50)
print("Simple Merge Test")
print("=" * 50)
init_device()
sleep(2)
# Go to merge screen
touch([810, 2300]) # Puzzle tab
sleep(2)
capture_screen("step1_merge.png")
# Select 2x2
touch([180, 200])
sleep(1)
# Add images
touch([270, 500])
sleep(2)
# Select 4 images from gallery
touch([540, 800]) # Gallery tab in picker
sleep(1)
touch([200, 400]) # Image 1
sleep(0.5)
touch([540, 400]) # Image 2
sleep(0.5)
touch([880, 400]) # Image 3
sleep(0.5)
touch([200, 700]) # Image 4
sleep(0.5)
touch([900, 2300]) # Confirm
sleep(2)
capture_screen("step2_selected.png")
# Click preview
touch([300, 2200]) # Preview button
sleep(3)
capture_screen("step3_preview.png")
# Check preview result
img = Image.open("step3_preview.png")
img_array = np.array(img)
h, w = img_array.shape[:2]
center_region = img_array[h//4:3*h//4, w//4:3*w//4]
avg_color = np.mean(center_region, axis=(0, 1))
print(f"Preview average color: {avg_color}")
# Close preview
touch([540, 1500])
sleep(1)
# Save
touch([800, 2200]) # Save button
sleep(2)
touch([700, 1400]) # Confirm save
sleep(3)
capture_screen("step4_saved.png")
print("\n" + "=" * 50)
if avg_color[0] < 250 and avg_color[1] < 250 and avg_color[2] < 250:
print("RESULT: Merge works! Preview shows merged images.")
else:
print("RESULT: Merge may have issues.")
print("=" * 50)