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:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name=".WatermarkCameraActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -9,8 +9,8 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// 启动极简相机Activity
|
||||
startActivity(Intent(this, SimpleCameraActivity::class.java))
|
||||
// 启动带水印相机Activity
|
||||
startActivity(Intent(this, WatermarkCameraActivity::class.java))
|
||||
finish() // 关闭MainActivity,避免返回键回到空白页面
|
||||
}
|
||||
}
|
||||
@@ -138,8 +138,12 @@ class SimpleCameraActivity : AppCompatActivity() {
|
||||
ContextCompat.getMainExecutor(this),
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
val savedUri = outputFileResults.savedUri ?: return
|
||||
val msg = "照片已保存: ${savedUri.lastPathSegment}"
|
||||
val savedUri = outputFileResults.savedUri
|
||||
val msg = if (savedUri != null) {
|
||||
"照片已保存: ${savedUri.lastPathSegment}"
|
||||
} else {
|
||||
"照片保存成功"
|
||||
}
|
||||
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
|
||||
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