From ce8c4f51fd759cd442b7d526408fdcad51221976 Mon Sep 17 00:00:00 2001 From: xiajiid Date: Sun, 8 Feb 2026 12:15:14 +0800 Subject: [PATCH] Add camera functionality with CameraX, permissions, watermarks, and settings page --- app/build.gradle | 14 + app/src/main/AndroidManifest.xml | 19 +- .../java/com/example/app/CameraActivity.kt | 283 ++++++++++++++++++ .../main/java/com/example/app/MainActivity.kt | 5 + .../java/com/example/app/SettingsFragment.kt | 121 ++++++++ .../res/drawable/circle_button_background.xml | 8 + app/src/main/res/drawable/ic_camera.xml | 13 + app/src/main/res/drawable/ic_settings.xml | 10 + app/src/main/res/layout/activity_main.xml | 77 +++-- app/src/main/res/layout/fragment_settings.xml | 143 +++++++++ 10 files changed, 674 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/example/app/CameraActivity.kt create mode 100644 app/src/main/java/com/example/app/SettingsFragment.kt create mode 100644 app/src/main/res/drawable/circle_button_background.xml create mode 100644 app/src/main/res/drawable/ic_camera.xml create mode 100644 app/src/main/res/drawable/ic_settings.xml create mode 100644 app/src/main/res/layout/fragment_settings.xml diff --git a/app/build.gradle b/app/build.gradle index 9d8b776..d2f2d3d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,6 +37,20 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + + // CameraX core library + def camerax_version = "1.3.0" + implementation "androidx.camera:camera-core:${camerax_version}" + implementation "androidx.camera:camera-camera2:${camerax_version}" + implementation "androidx.camera:camera-lifecycle:${camerax_version}" + implementation "androidx.camera:camera-video:${camerax_version}" + + // CameraX View class + implementation "androidx.camera:camera-view:${camerax_version}" + + // CameraX Extensions (Effects) - Optional + implementation "androidx.camera:camera-extensions:${camerax_version}" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 103c677..67973a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,17 +1,32 @@ + + + + + + + + + + + + android:theme="@style/Theme.LogCam" + android:requestLegacyExternalStorage="true" + tools:targetApi="q"> + android:exported="true" + android:screenOrientation="portrait"> diff --git a/app/src/main/java/com/example/app/CameraActivity.kt b/app/src/main/java/com/example/app/CameraActivity.kt new file mode 100644 index 0000000..ea684e6 --- /dev/null +++ b/app/src/main/java/com/example/app/CameraActivity.kt @@ -0,0 +1,283 @@ +package com.example.app + +import android.Manifest +import android.annotation.SuppressLint +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.location.Geocoder +import android.location.Location +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Looper +import android.provider.MediaStore +import android.util.Log +import android.widget.ImageButton +import android.widget.LinearLayout +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.snackbar.Snackbar +import java.io.IOException +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.math.roundToInt + +class CameraActivity : AppCompatActivity() { + + companion object { + private const val TAG = "LogCam" + private const val REQUEST_CODE_PERMISSIONS = 10 + private val REQUIRED_PERMISSIONS = arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION + ) + } + + private lateinit var viewFinder: PreviewView + private lateinit var captureButton: ImageButton + private lateinit var settingsButton: ImageButton + private lateinit var photoPreviewLayout: LinearLayout + private var imageCapture: ImageCapture? = null + private lateinit var cameraExecutor: ExecutorService + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + // 初始化视图 + viewFinder = findViewById(R.id.viewFinder) + captureButton = findViewById(R.id.captureButton) + settingsButton = findViewById(R.id.settingsButton) + photoPreviewLayout = findViewById(R.id.photoPreviewLayout) + + // 请求权限 + if (allPermissionsGranted()) { + startCamera() + } else { + ActivityCompat.requestPermissions( + this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS + ) + } + + // 设置点击监听器 + captureButton.setOnClickListener { takePhoto() } + settingsButton.setOnClickListener { openSettings() } + + // 初始化线程池 + cameraExecutor = Executors.newSingleThreadExecutor() + } + + private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { + ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED + } + + private fun startCamera() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(this) + + cameraProviderFuture.addListener({ + val 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 + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + this, cameraSelector, preview, imageCapture + ) + + } catch (exc: Exception) { + Log.e(TAG, "相机绑定失败", exc) + } + }, ContextCompat.getMainExecutor(this)) + } + + private fun takePhoto() { + val imageCapture = imageCapture ?: return + + val outputFileOptions = ImageCapture.OutputFileOptions.Builder( + createImageUri() + ).build() + + imageCapture.takePicture( + outputFileOptions, + ContextCompat.getMainExecutor(this), + object : ImageCapture.OnImageSavedCallback { + override fun onError(exception: ImageCaptureException) { + Log.e(TAG, "照片拍摄失败: ${exception.message}", exception) + Toast.makeText( + baseContext, + "照片拍摄失败: ${exception.message}", + Toast.LENGTH_SHORT + ).show() + } + + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + // 添加水印到已保存的照片 + val savedUri = output.savedUri ?: return + addWatermarkToImage(savedUri) + + Toast.makeText( + baseContext, + "照片已保存", + Toast.LENGTH_SHORT + ).show() + + // 刷新相册 + refreshGallery(savedUri) + } + } + ) + } + + private fun createImageUri(): Uri { + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, "巡检_${System.currentTimeMillis()}.jpg") + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis()) + } + } + return contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) + ?: throw IOException("无法创建图片URI") + } + + private fun addWatermarkToImage(imageUri: Uri) { + try { + val inputStream = contentResolver.openInputStream(imageUri) + val originalBitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + val watermarkedBitmap = addWatermark(originalBitmap) + + // 将带水印的图片保存回原URI + val outputStream = contentResolver.openOutputStream(imageUri, "rwt") // truncate and write + watermarkedBitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) + outputStream?.close() + + // 回收位图以释放内存 + if (watermarkedBitmap != originalBitmap) { + originalBitmap.recycle() + } + watermarkedBitmap.recycle() + } catch (e: Exception) { + Log.e(TAG, "添加水印失败: ${e.message}") + } + } + + private fun addWatermark(bitmap: Bitmap): Bitmap { + val watermarkedBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true) + val canvas = Canvas(watermarkedBitmap) + val paint = Paint().apply { + color = Color.WHITE + textSize = 48f + typeface = Typeface.DEFAULT_BOLD + textAlign = Paint.Align.LEFT + } + + // 绘制时间水印 + val timestamp = formatDate(System.currentTimeMillis()) + canvas.drawText(timestamp, 50f, watermarkedBitmap.height - 100f, paint) + + // 绘制位置水印 + getCurrentLocation()?.let { location -> + val address = getAddressFromLocation(location.latitude, location.longitude) + canvas.drawText(address, 50f, watermarkedBitmap.height - 50f, paint) + } + + return watermarkedBitmap + } + + private fun refreshGallery(uri: Uri) { + // 通知相册更新 + val contentValues = ContentValues().apply { + put(MediaStore.Images.Media.IS_PENDING, 0) + } + contentResolver.update(uri, contentValues, null, null) + } + + // 将时间戳转换为指定格式的日期时间字符串 + fun formatDate(timestamp: Long): String { + val sdf = SimpleDateFormat("yyyy年-MM月-dd日 HH:mm:ss", Locale.getDefault()) + return sdf.format(Date(timestamp)) + } + + // 使用Geocoder将经纬度转换为地址 + private fun getAddressFromLocation(latitude: Double, longitude: Double): String? { + return try { + val geocoder = Geocoder(this, Locale.getDefault()) + val addresses = geocoder.getFromLocation(latitude, longitude, 1) + if (addresses?.isEmpty() == false) { + val address = addresses[0] + val addressLine = address.getAddressLine(0) + addressLine ?: "${latitude}, ${longitude}" + } else { + "${latitude}, ${longitude}" + } + } catch (e: Exception) { + Log.e(TAG, "无法解析地址: ${e.message}") + "${latitude}, ${longitude}" + } + } + + @SuppressLint("MissingPermission") + private fun getCurrentLocation(): Location? { + // 这里应该实现获取当前位置的逻辑 + // 简化实现,返回null,实际应用中需要使用LocationManager或FusedLocationProviderClient + return null + } + + private fun openSettings() { + // 打开设置页面 + val settingsFragment = SettingsFragment() + settingsFragment.show(supportFragmentManager, "SettingsFragment") + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_PERMISSIONS) { + if (allPermissionsGranted()) { + startCamera() + } else { + Toast.makeText(this, "权限被拒绝,相机功能不可用", Toast.LENGTH_SHORT).show() + // 可以在这里提供降级功能,比如手动输入位置信息 + } + } + } + + override fun onDestroy() { + super.onDestroy() + cameraExecutor.shutdown() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/app/MainActivity.kt b/app/src/main/java/com/example/app/MainActivity.kt index e675925..c81cc19 100644 --- a/app/src/main/java/com/example/app/MainActivity.kt +++ b/app/src/main/java/com/example/app/MainActivity.kt @@ -1,5 +1,6 @@ package com.example.app +import android.content.Intent import android.os.Bundle import androidx.appcompat.app.AppCompatActivity @@ -7,5 +8,9 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + // 直接启动相机Activity + startActivity(Intent(this, CameraActivity::class.java)) + finish() // 关闭MainActivity,避免返回键回到空白页面 } } \ No newline at end of file diff --git a/app/src/main/java/com/example/app/SettingsFragment.kt b/app/src/main/java/com/example/app/SettingsFragment.kt new file mode 100644 index 0000000..573f9b7 --- /dev/null +++ b/app/src/main/java/com/example/app/SettingsFragment.kt @@ -0,0 +1,121 @@ +package com.example.app + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.app.ActivityCompat +import androidx.fragment.app.DialogFragment + +class SettingsFragment : DialogFragment() { + + private lateinit var locationCalibrationRadioGroup: RadioGroup + private lateinit var manualLatInput: EditText + private lateinit var manualLngInput: EditText + private lateinit var distanceInput: EditText + private lateinit var saveButton: Button + private lateinit var cancelButton: Button + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_settings, container, false) + + initViews(view) + setupClickListeners() + + return view + } + + private fun initViews(view: View) { + locationCalibrationRadioGroup = view.findViewById(R.id.locationCalibrationRadioGroup) + manualLatInput = view.findViewById(R.id.manualLatInput) + manualLngInput = view.findViewById(R.id.manualLngInput) + distanceInput = view.findViewById(R.id.distanceInput) + saveButton = view.findViewById(R.id.saveButton) + cancelButton = view.findViewById(R.id.cancelButton) + } + + private fun setupClickListeners() { + locationCalibrationRadioGroup.setOnCheckedChangeListener { _, checkedId -> + val selectedMethod = when (checkedId) { + R.id.latLngDistanceCalibration -> "经纬度+距离校准" + R.id.onlineQueryCalibration -> "联网查询校准" + else -> "默认" + } + Log.d("SettingsFragment", "选择了校准方式: $selectedMethod") + } + + saveButton.setOnClickListener { + saveSettings() + dismiss() + } + + cancelButton.setOnClickListener { + dismiss() + } + } + + private fun saveSettings() { + // 获取选择的位置校准方式 + val selectedMethodId = locationCalibrationRadioGroup.checkedRadioButtonId + val calibrationMethod = when (selectedMethodId) { + R.id.latLngDistanceCalibration -> "lat_lng_distance" + R.id.onlineQueryCalibration -> "online_query" + else -> "default" + } + + // 保存设置到SharedPreferences + val sharedPreferences = requireContext().getSharedPreferences("LogCamSettings", Context.MODE_PRIVATE) + val editor = sharedPreferences.edit() + editor.putString("calibration_method", calibrationMethod) + + // 如果选择了手动输入经纬度,则保存经纬度和距离 + if (selectedMethodId == R.id.latLngDistanceCalibration) { + try { + val latStr = manualLatInput.text.toString() + val lngStr = manualLngInput.text.toString() + val distanceStr = distanceInput.text.toString() + + if (latStr.isNotEmpty() && lngStr.isNotEmpty()) { + val latitude = latStr.toDouble() + val longitude = lngStr.toDouble() + val distance = if (distanceStr.isNotEmpty()) distanceStr.toDouble() else 0.0 + + if (latitude in -90.0..90.0 && longitude in -180.0..180.0) { + editor.putFloat("manual_latitude", latitude.toFloat()) + editor.putFloat("manual_longitude", longitude.toFloat()) + editor.putFloat("calibration_distance", distance.toFloat()) + Toast.makeText(context, "设置已保存", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "经纬度超出有效范围", Toast.LENGTH_SHORT).show() + return + } + } + } catch (e: NumberFormatException) { + Toast.makeText(context, "请输入有效的数字", Toast.LENGTH_SHORT).show() + return + } + } + + editor.apply() + } + + companion object { + const val REQUEST_CODE_LOCATION_PERMISSION = 100 + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/circle_button_background.xml b/app/src/main/res/drawable/circle_button_background.xml new file mode 100644 index 0000000..16f2a1e --- /dev/null +++ b/app/src/main/res/drawable/circle_button_background.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..b1f5323 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..c8e834a --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ea3335f..d70fcea 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,30 +4,73 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent" + android:layout_height="match_height" tools:context=".MainActivity"> - + - + + app:layout_constraintStart_toStartOf="parent"> + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..dd76643 --- /dev/null +++ b/app/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +