Add WatermarkCameraActivity with timestamp watermark and development plan
This commit is contained in:
102
DEVELOPMENT_PLAN.md
Normal file
102
DEVELOPMENT_PLAN.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# 巡检相机增量开发计划
|
||||||
|
|
||||||
|
## 开发原则
|
||||||
|
- **增量开发**:每次只添加一个功能,测试通过后再添加下一个
|
||||||
|
- **DRY原则**:避免重复代码,保持代码简洁
|
||||||
|
- **测试驱动**:每个功能都要在真机上测试验证
|
||||||
|
|
||||||
|
## 功能列表(按优先级排序)
|
||||||
|
|
||||||
|
### ✅ 阶段1:基础功能(已完成)
|
||||||
|
1. 相机预览
|
||||||
|
2. 拍照保存
|
||||||
|
3. 权限处理
|
||||||
|
|
||||||
|
### 🔄 阶段2:水印功能(当前阶段)
|
||||||
|
1. 时间戳水印
|
||||||
|
2. 水印样式可配置(位置、颜色、大小)
|
||||||
|
3. 水印预览
|
||||||
|
|
||||||
|
### 📋 阶段3:多图管理
|
||||||
|
1. 拍摄多张照片
|
||||||
|
2. 照片预览网格
|
||||||
|
3. 照片选择/删除
|
||||||
|
|
||||||
|
### 🎨 阶段4:图片合成
|
||||||
|
1. 2x2网格合成
|
||||||
|
2. 合成图片添加标题
|
||||||
|
3. 合成图片质量优化
|
||||||
|
|
||||||
|
### ⚙️ 阶段5:设置功能
|
||||||
|
1. 水印设置
|
||||||
|
2. 图片质量设置
|
||||||
|
3. 存储路径设置
|
||||||
|
|
||||||
|
### 📱 阶段6:高级功能
|
||||||
|
1. 图片编辑(裁剪、旋转)
|
||||||
|
2. 批量处理
|
||||||
|
3. 分享功能
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
### 已修复问题
|
||||||
|
1. ✅ 极简相机基础功能正常
|
||||||
|
2. ✅ 修复错误提示问题("拍照失败:processing failed")
|
||||||
|
|
||||||
|
### 当前测试版本
|
||||||
|
- **WatermarkCameraActivity**:带时间戳水印的相机
|
||||||
|
- 测试重点:水印添加是否正常,性能是否稳定
|
||||||
|
|
||||||
|
## 测试步骤
|
||||||
|
|
||||||
|
### 水印相机测试
|
||||||
|
1. 下载最新APK安装
|
||||||
|
2. 启动应用,授予相机权限
|
||||||
|
3. 拍照测试
|
||||||
|
4. 检查:
|
||||||
|
- 照片是否保存成功
|
||||||
|
- 水印是否正确添加(左下角时间戳)
|
||||||
|
- 水印是否清晰可见
|
||||||
|
- 应用是否稳定无闪退
|
||||||
|
|
||||||
|
### 问题反馈
|
||||||
|
如果发现问题,请提供:
|
||||||
|
1. 手机型号和Android版本
|
||||||
|
2. 问题描述(闪退时机、错误提示)
|
||||||
|
3. 如果有ADB日志,提供logcat输出
|
||||||
|
|
||||||
|
## 代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/com/example/app/
|
||||||
|
├── MainActivity.kt # 启动入口
|
||||||
|
├── SimpleCameraActivity.kt # 极简相机(基础功能)
|
||||||
|
├── WatermarkCameraActivity.kt # 带水印相机(当前测试)
|
||||||
|
├── CameraActivity.kt # 完整功能相机(待重构)
|
||||||
|
└── ImageCompositor.kt # 图片合成工具
|
||||||
|
|
||||||
|
app/src/main/res/layout/
|
||||||
|
├── activity_main.xml # 主布局
|
||||||
|
├── activity_simple_camera.xml # 极简相机布局
|
||||||
|
└── (后续添加更多布局)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 后续计划
|
||||||
|
|
||||||
|
根据水印相机的测试结果:
|
||||||
|
1. **如果正常**:添加多图管理功能
|
||||||
|
2. **如果有问题**:修复水印功能,优化性能
|
||||||
|
|
||||||
|
## 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 调试版构建
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
# 发布版构建(需要配置签名)
|
||||||
|
./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## 版本控制
|
||||||
|
|
||||||
|
每个功能阶段都会创建独立的Activity,便于测试和回滚。最终会将所有功能整合到主CameraActivity中。
|
||||||
@@ -30,6 +30,10 @@
|
|||||||
android:name=".SimpleCameraActivity"
|
android:name=".SimpleCameraActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
<activity
|
||||||
|
android:name=".WatermarkCameraActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -9,8 +9,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
// 启动极简相机Activity
|
// 启动带水印相机Activity
|
||||||
startActivity(Intent(this, SimpleCameraActivity::class.java))
|
startActivity(Intent(this, WatermarkCameraActivity::class.java))
|
||||||
finish() // 关闭MainActivity,避免返回键回到空白页面
|
finish() // 关闭MainActivity,避免返回键回到空白页面
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,8 +138,12 @@ class SimpleCameraActivity : AppCompatActivity() {
|
|||||||
ContextCompat.getMainExecutor(this),
|
ContextCompat.getMainExecutor(this),
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||||
val savedUri = outputFileResults.savedUri ?: return
|
val savedUri = outputFileResults.savedUri
|
||||||
val msg = "照片已保存: ${savedUri.lastPathSegment}"
|
val msg = if (savedUri != null) {
|
||||||
|
"照片已保存: ${savedUri.lastPathSegment}"
|
||||||
|
} else {
|
||||||
|
"照片保存成功"
|
||||||
|
}
|
||||||
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
|
||||||
Log.d(TAG, msg)
|
Log.d(TAG, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
248
app/src/main/java/com/example/app/WatermarkCameraActivity.kt
Normal file
248
app/src/main/java/com/example/app/WatermarkCameraActivity.kt
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package com.example.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageCapture
|
||||||
|
import androidx.camera.core.ImageCaptureException
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
class WatermarkCameraActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WatermarkCamera"
|
||||||
|
private const val REQUEST_CODE_PERMISSIONS = 10
|
||||||
|
private val REQUIRED_PERMISSIONS = arrayOf(
|
||||||
|
Manifest.permission.CAMERA
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var viewFinder: PreviewView
|
||||||
|
private lateinit var captureButton: FloatingActionButton
|
||||||
|
private var imageCapture: ImageCapture? = null
|
||||||
|
private lateinit var cameraExecutor: ExecutorService
|
||||||
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_simple_camera)
|
||||||
|
|
||||||
|
// 初始化视图
|
||||||
|
viewFinder = findViewById(R.id.viewFinder)
|
||||||
|
captureButton = findViewById(R.id.captureButton)
|
||||||
|
|
||||||
|
// 初始化线程池
|
||||||
|
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
// 请求权限
|
||||||
|
if (allPermissionsGranted()) {
|
||||||
|
startCamera()
|
||||||
|
} else {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置拍照按钮点击监听器
|
||||||
|
captureButton.setOnClickListener { takePhoto() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
cameraExecutor.shutdown()
|
||||||
|
cameraProvider?.unbindAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
|
||||||
|
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startCamera() {
|
||||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
|
||||||
|
|
||||||
|
cameraProviderFuture.addListener({
|
||||||
|
try {
|
||||||
|
cameraProvider = cameraProviderFuture.get()
|
||||||
|
|
||||||
|
// 预览
|
||||||
|
val preview = Preview.Builder()
|
||||||
|
.build()
|
||||||
|
.also {
|
||||||
|
it.setSurfaceProvider(viewFinder.surfaceProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
imageCapture = ImageCapture.Builder()
|
||||||
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
|
cameraProvider?.unbindAll()
|
||||||
|
cameraProvider?.bindToLifecycle(
|
||||||
|
this, cameraSelector, preview, imageCapture
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "相机启动成功")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "相机初始化失败: ${e.message}", e)
|
||||||
|
Toast.makeText(this, "相机启动失败: ${e.message}", Toast.LENGTH_LONG).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}, ContextCompat.getMainExecutor(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun takePhoto() {
|
||||||
|
val imageCapture = this.imageCapture ?: run {
|
||||||
|
Toast.makeText(this, "相机未准备好", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||||
|
|
||||||
|
val contentValues = ContentValues().apply {
|
||||||
|
put(MediaStore.MediaColumns.DISPLAY_NAME, "巡检_$timestamp")
|
||||||
|
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/LogCam")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentResolver = contentResolver
|
||||||
|
val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
|
||||||
|
|
||||||
|
if (uri == null) {
|
||||||
|
Toast.makeText(this, "创建文件失败", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputOptions = ImageCapture.OutputFileOptions.Builder(contentResolver, uri, contentValues).build()
|
||||||
|
|
||||||
|
imageCapture.takePicture(
|
||||||
|
outputOptions,
|
||||||
|
ContextCompat.getMainExecutor(this),
|
||||||
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
|
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||||
|
val savedUri = outputFileResults.savedUri
|
||||||
|
if (savedUri != null) {
|
||||||
|
// 添加水印
|
||||||
|
addWatermarkToImage(savedUri)
|
||||||
|
val msg = "照片已保存并添加水印: ${savedUri.lastPathSegment}"
|
||||||
|
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
Log.d(TAG, msg)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(baseContext, "照片保存成功", Toast.LENGTH_SHORT).show()
|
||||||
|
Log.d(TAG, "照片保存成功")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
Log.e(TAG, "拍照失败: ${exception.message}", exception)
|
||||||
|
Toast.makeText(baseContext, "拍照失败: ${exception.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addWatermarkToImage(imageUri: Uri) {
|
||||||
|
try {
|
||||||
|
// 读取图片
|
||||||
|
val inputStream = contentResolver.openInputStream(imageUri)
|
||||||
|
val originalBitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
|
inputStream?.close()
|
||||||
|
|
||||||
|
if (originalBitmap == null) {
|
||||||
|
Log.e(TAG, "无法读取图片: $imageUri")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加水印
|
||||||
|
val watermarkedBitmap = addWatermark(originalBitmap)
|
||||||
|
|
||||||
|
// 保存回原文件
|
||||||
|
val outputStream: OutputStream? = contentResolver.openOutputStream(imageUri, "w")
|
||||||
|
if (outputStream != null) {
|
||||||
|
watermarkedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)
|
||||||
|
outputStream.close()
|
||||||
|
Log.d(TAG, "水印添加成功: $imageUri")
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "无法写入图片: $imageUri")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回收位图
|
||||||
|
originalBitmap.recycle()
|
||||||
|
watermarkedBitmap.recycle()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "添加水印失败: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addWatermark(originalBitmap: Bitmap): Bitmap {
|
||||||
|
val timestamp = SimpleDateFormat("yyyy年-MM月-dd日 HH:mm:ss", Locale.getDefault()).format(Date())
|
||||||
|
|
||||||
|
// 创建可修改的位图副本
|
||||||
|
val mutableBitmap = originalBitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||||
|
val canvas = Canvas(mutableBitmap)
|
||||||
|
|
||||||
|
// 创建水印画笔
|
||||||
|
val paint = Paint().apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
textSize = 48f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setShadowLayer(5f, 0f, 0f, Color.BLACK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算文字尺寸
|
||||||
|
val textWidth = paint.measureText(timestamp)
|
||||||
|
val textHeight = paint.descent() - paint.ascent()
|
||||||
|
|
||||||
|
// 计算水印位置(左下角,留边距)
|
||||||
|
val margin = 20f
|
||||||
|
val x = margin
|
||||||
|
val y = mutableBitmap.height - textHeight - margin
|
||||||
|
|
||||||
|
// 绘制水印
|
||||||
|
canvas.drawText(timestamp, x, y, paint)
|
||||||
|
|
||||||
|
return mutableBitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == REQUEST_CODE_PERMISSIONS) {
|
||||||
|
if (allPermissionsGranted()) {
|
||||||
|
startCamera()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, "相机权限被拒绝,应用无法使用相机功能", Toast.LENGTH_LONG).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user