Compare commits
2 Commits
44fe4d963c
...
3ee14eabe6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ee14eabe6 | |||
| 247f5f31a5 |
73
.gitignore
vendored
73
.gitignore
vendored
@@ -1,6 +1,71 @@
|
|||||||
# Ignore Windows reserved files
|
# Built application files
|
||||||
NUL
|
*.apk
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
# Android/ Gradle build outputs (optional for CI, can be kept locally if desired)
|
# Files for the ART/Dalvik VM
|
||||||
**/build/
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
.gradle/
|
.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/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ CheckShot 是一个面向 Android 的图片处理与检查工具,包含水印
|
|||||||
- 地点水印:优先通过 Geocoder 联网解析地址,失败时回落显示经纬度。可在设置中配置校准方式。
|
- 地点水印:优先通过 Geocoder 联网解析地址,失败时回落显示经纬度。可在设置中配置校准方式。
|
||||||
- 样式:提供三种预设样式(默认/简约/醒目),并可在设置中预览和应用。
|
- 样式:提供三种预设样式(默认/简约/醒目),并可在设置中预览和应用。
|
||||||
- 多图拼图(合成)模块
|
- 多图拼图(合成)模块
|
||||||
- 布局规则:核心布局为 2x2 网格,且支持扩展布局如 1+3、3+1、1+2、2+1、单图等,图片自动缩放裁剪以适配网格。
|
- 布局规则:支持 2x2 和 3x3 两种网格布局,图片自动缩放裁剪以适配网格。
|
||||||
- 核心能力:图片拼接、模板化布局编辑(替换/删除图片)、合成质量控制(分辨率/清晰度)。
|
- 核心能力:图片拼接、模板化布局编辑(替换/删除图片)、合成质量控制(分辨率/清晰度)。
|
||||||
- 交互:支持替换网格中的图片、删除图片、添加新图片、设置合成质量和文本水印文本。
|
- 交互:支持替换网格中的图片、删除图片、添加新图片、设置合成质量和文本水印文本。
|
||||||
- 设置与通用配置
|
- 设置与通用配置
|
||||||
|
|||||||
@@ -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) {
|
enum class MergeLayoutType(val rows: Int, val cols: Int, val displayName: String, val maxImages: Int) {
|
||||||
Grid2x2(2, 2, "2x2网格"),
|
Grid2x2(2, 2, "2x2网格", 4),
|
||||||
Grid1x3(1, 3, "1+3布局"),
|
Grid3x3(3, 3, "3x3网格", 9)
|
||||||
Grid3x1(3, 1, "3+1布局")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.inspection.camera.ui.camera
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
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) {
|
LaunchedEffect(Unit) {
|
||||||
preferencesManager.watermarkStyle.collect { style ->
|
preferencesManager.watermarkStyle.collect { style ->
|
||||||
@@ -177,7 +196,7 @@ fun CameraScreen(
|
|||||||
flashMode = flashMode,
|
flashMode = flashMode,
|
||||||
watermarkStyle = currentWatermarkStyle,
|
watermarkStyle = currentWatermarkStyle,
|
||||||
imageQuality = currentImageQuality,
|
imageQuality = currentImageQuality,
|
||||||
locationText = if (locationText.isNotBlank()) locationText else "未知地点",
|
locationText = getValidLocationTextForPhoto(locationText, manualAddress, locationHelper),
|
||||||
onComplete = { uri ->
|
onComplete = { uri ->
|
||||||
capturedImages.add(uri)
|
capturedImages.add(uri)
|
||||||
isCapturing = false
|
isCapturing = false
|
||||||
@@ -198,7 +217,9 @@ fun CameraScreen(
|
|||||||
PermissionRequest(
|
PermissionRequest(
|
||||||
onRequestPermission = { permissionsState.launchMultiplePermissionRequest() },
|
onRequestPermission = { permissionsState.launchMultiplePermissionRequest() },
|
||||||
showDialog = showPermissionDeniedDialog,
|
showDialog = showPermissionDeniedDialog,
|
||||||
onDismissDialog = { showPermissionDeniedDialog = false }
|
onDismissDialog = { showPermissionDeniedDialog = false },
|
||||||
|
hasPermanentlyDeniedPermission = hasPermanentlyDeniedPermission,
|
||||||
|
openAppSettings = openAppSettings
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +421,9 @@ private fun BottomControls(
|
|||||||
private fun PermissionRequest(
|
private fun PermissionRequest(
|
||||||
onRequestPermission: () -> Unit,
|
onRequestPermission: () -> Unit,
|
||||||
showDialog: Boolean,
|
showDialog: Boolean,
|
||||||
onDismissDialog: () -> Unit
|
onDismissDialog: () -> Unit,
|
||||||
|
hasPermanentlyDeniedPermission: Boolean,
|
||||||
|
openAppSettings: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -415,16 +438,50 @@ private fun PermissionRequest(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "请授予权限以使用拍照和地点水印功能",
|
text = if (hasPermanentlyDeniedPermission)
|
||||||
|
"权限被永久拒绝,请在设置中手动开启权限"
|
||||||
|
else
|
||||||
|
"请授予权限以使用拍照和地点水印功能",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Button(onClick = onRequestPermission) {
|
if (hasPermanentlyDeniedPermission) {
|
||||||
Text("授予权限")
|
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(
|
private fun capturePhoto(
|
||||||
context: Context,
|
context: Context,
|
||||||
imageCapture: ImageCapture,
|
imageCapture: ImageCapture,
|
||||||
|
|||||||
@@ -2,37 +2,508 @@ package com.inspection.camera.ui.merge
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
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.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.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.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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.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.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
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
|
@Composable
|
||||||
fun MergeScreen(imageUris: List<Uri>) {
|
fun MergeScreen(
|
||||||
|
imageUris: List<Uri>,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onMergeComplete: (Uri) -> Unit,
|
||||||
|
preferencesManager: PreferencesManager
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var mergedBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(imageUris) {
|
data class ImageWithCache(val uri: Uri, val cachePath: String?)
|
||||||
mergedBitmap = PuzzleMerge.mergeToBitmap(context, imageUris.take(4), 1000)
|
|
||||||
|
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 ->
|
val imagePickerLauncher = rememberLauncherForActivityResult(
|
||||||
Image(
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
bitmap = bmp.asImageBitmap(),
|
) { uris ->
|
||||||
contentDescription = "拼图合成预览",
|
if (uris.isNotEmpty()) {
|
||||||
modifier = Modifier
|
scope.launch {
|
||||||
.fillMaxSize()
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import com.google.android.gms.location.LocationRequest
|
|||||||
import com.google.android.gms.location.LocationResult
|
import com.google.android.gms.location.LocationResult
|
||||||
import com.google.android.gms.location.LocationServices
|
import com.google.android.gms.location.LocationServices
|
||||||
import com.google.android.gms.location.Priority
|
import com.google.android.gms.location.Priority
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.location.LocationListener
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
@@ -30,14 +33,157 @@ class LocationHelper(private val context: Context) {
|
|||||||
Geocoder(context, Locale.getDefault())
|
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")
|
@SuppressLint("MissingPermission")
|
||||||
suspend fun getCurrentLocation(): Location? {
|
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 {
|
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) {
|
} 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
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,7 +194,9 @@ class LocationHelper(private val context: Context) {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
suspend fun getAddressFromLocation(latitude: Double, longitude: Double): String {
|
suspend fun getAddressFromLocation(latitude: Double, longitude: Double): String {
|
||||||
return try {
|
return try {
|
||||||
|
Log.d("LocationHelper", "Getting address for: $latitude, $longitude")
|
||||||
val addresses = geocoder.getFromLocation(latitude, longitude, 1)
|
val addresses = geocoder.getFromLocation(latitude, longitude, 1)
|
||||||
|
Log.d("LocationHelper", "Geocoder results: $addresses")
|
||||||
if (!addresses.isNullOrEmpty()) {
|
if (!addresses.isNullOrEmpty()) {
|
||||||
val address = addresses[0]
|
val address = addresses[0]
|
||||||
buildString {
|
buildString {
|
||||||
@@ -60,6 +208,7 @@ class LocationHelper(private val context: Context) {
|
|||||||
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
|
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e("LocationHelper", "Geocoder error", e)
|
||||||
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
|
"${"%.4f".format(latitude)}, ${"%.4f".format(longitude)}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +228,33 @@ class LocationHelper(private val context: Context) {
|
|||||||
Log.d("LocationHelper", "Network location: $location")
|
Log.d("LocationHelper", "Network location: $location")
|
||||||
return location?.let { getAddressFromLocation(it.latitude, it.longitude) } ?: ""
|
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 {
|
private suspend fun <T> com.google.android.gms.tasks.Task<T>.await(): T {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user