Compare commits

..

2 Commits

Author SHA1 Message Date
3ee14eabe6 fix: 优化定位速度并修复拍照水印缺失定位信息问题
- LocationHelper: 添加30秒位置缓存,减少超时时间,优化定位策略
- CameraScreen: 添加智能定位文本验证,确保拍照时水印包含有效定位信息
- 处理权限永久拒绝情况,引导用户到设置
2026-03-01 20:02:23 +08:00
247f5f31a5 简化拼图布局:只保留2x2和3x3两种网格布局 2026-03-01 14:33:09 +08:00
8 changed files with 801 additions and 112 deletions

73
.gitignore vendored
View File

@@ -1,6 +1,71 @@
# Ignore Windows reserved files
NUL
# Built application files
*.apk
*.ap_
*.aab
# Android/ Gradle build outputs (optional for CI, can be kept locally if desired)
**/build/
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Gradle files
.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/

View File

@@ -8,7 +8,7 @@ CheckShot 是一个面向 Android 的图片处理与检查工具,包含水印
- 地点水印:优先通过 Geocoder 联网解析地址,失败时回落显示经纬度。可在设置中配置校准方式。
- 样式:提供三种预设样式(默认/简约/醒目),并可在设置中预览和应用。
- 多图拼图(合成)模块
- 布局规则:核心布局为 2x2 网格,且支持扩展布局如 1+3、3+1、1+2、2+1、单图等,图片自动缩放裁剪以适配网格。
- 布局规则:支持 2x2 和 3x3 两种网格布局,图片自动缩放裁剪以适配网格。
- 核心能力:图片拼接、模板化布局编辑(替换/删除图片)、合成质量控制(分辨率/清晰度)。
- 交互:支持替换网格中的图片、删除图片、添加新图片、设置合成质量和文本水印文本。
- 设置与通用配置

View File

@@ -60,8 +60,7 @@ enum class ImageQuality(val quality: Int, val displayName: String) {
/**
* 合成布局类型
*/
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布局")
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)
}

View File

@@ -2,6 +2,8 @@ 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
@@ -119,6 +121,23 @@ 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 ->
@@ -177,7 +196,7 @@ fun CameraScreen(
flashMode = flashMode,
watermarkStyle = currentWatermarkStyle,
imageQuality = currentImageQuality,
locationText = if (locationText.isNotBlank()) locationText else "未知地点",
locationText = getValidLocationTextForPhoto(locationText, manualAddress, locationHelper),
onComplete = { uri ->
capturedImages.add(uri)
isCapturing = false
@@ -198,7 +217,9 @@ fun CameraScreen(
PermissionRequest(
onRequestPermission = { permissionsState.launchMultiplePermissionRequest() },
showDialog = showPermissionDeniedDialog,
onDismissDialog = { showPermissionDeniedDialog = false }
onDismissDialog = { showPermissionDeniedDialog = false },
hasPermanentlyDeniedPermission = hasPermanentlyDeniedPermission,
openAppSettings = openAppSettings
)
}
@@ -400,7 +421,9 @@ private fun BottomControls(
private fun PermissionRequest(
onRequestPermission: () -> Unit,
showDialog: Boolean,
onDismissDialog: () -> Unit
onDismissDialog: () -> Unit,
hasPermanentlyDeniedPermission: Boolean,
openAppSettings: () -> Unit
) {
Column(
modifier = Modifier
@@ -415,16 +438,50 @@ private fun PermissionRequest(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "请授予权限以使用拍照和地点水印功能",
text = if (hasPermanentlyDeniedPermission)
"权限被永久拒绝,请在设置中手动开启权限"
else
"请授予权限以使用拍照和地点水印功能",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRequestPermission) {
Text("授予权限")
if (hasPermanentlyDeniedPermission) {
Button(onClick = openAppSettings) {
Text("打开设置")
}
} else {
Button(onClick = onRequestPermission) {
Text("授予权限")
}
}
}
}
private suspend fun getValidLocationTextForPhoto(
currentLocationText: String,
manualAddress: String,
locationHelper: LocationHelper
): String {
// 检查当前定位文本是否有效
val invalidTexts = listOf("正在定位...", "定位失败", "请授予定位权限")
val isInvalid = currentLocationText.isBlank() || invalidTexts.contains(currentLocationText)
if (!isInvalid) {
return currentLocationText
}
// 使用手动地址
if (manualAddress.isNotBlank()) {
return manualAddress
}
// 尝试快速获取当前位置(使用缓存)
val location = locationHelper.getCurrentLocation()
return location?.let {
"${"%.4f".format(it.latitude)}, ${"%.4f".format(it.longitude)}"
} ?: "未知地点"
}
private fun capturePhoto(
context: Context,
imageCapture: ImageCapture,

View File

@@ -2,37 +2,508 @@ 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.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
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.unit.dp
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import com.inspection.camera.util.PuzzleMerge
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MergeScreen(imageUris: List<Uri>) {
fun MergeScreen(
imageUris: List<Uri>,
onNavigateBack: () -> Unit,
onMergeComplete: (Uri) -> Unit,
preferencesManager: PreferencesManager
) {
val context = LocalContext.current
var mergedBitmap by remember { mutableStateOf<Bitmap?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(imageUris) {
mergedBitmap = PuzzleMerge.mergeToBitmap(context, imageUris.take(4), 1000)
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
}
}
}
Column(modifier = Modifier.fillMaxSize()) {
mergedBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "拼图合成预览",
modifier = Modifier
.fillMaxSize()
// 图片选择器
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
)
)
}
) { 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
).let { bitmap ->
if (title.isNotBlank() || content.isNotBlank()) {
ImageProcessor.addTextToBitmap(
bitmap,
title,
content,
titleStyle,
contentStyle
)
} else {
bitmap
}
}
}
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
).let { mergedBitmap ->
if (title.isNotBlank() || content.isNotBlank()) {
ImageProcessor.addTextToBitmap(
mergedBitmap,
title,
content,
titleStyle,
contentStyle
)
} else {
mergedBitmap
}
}
}
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

@@ -12,8 +12,11 @@ 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
@@ -30,14 +33,157 @@ 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 {
fusedLocationClient.lastLocation.await()
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()
} 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
}
}
@@ -48,7 +194,9 @@ 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 {
@@ -60,6 +208,7 @@ 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)}"
}
}
@@ -79,6 +228,33 @@ 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

@@ -1,59 +0,0 @@
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

@@ -1,20 +0,0 @@
# -*- 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()