fix: chèn frame và lưu ảnh vào thư viện

This commit is contained in:
2026-07-06 11:44:11 +07:00
parent 4e3c6f35c7
commit bc3b3533a7
6 changed files with 652 additions and 91 deletions
@@ -39,7 +39,12 @@ class FrameItemAdapter(
holder.txtFrameName.text = holder.itemView.context.getString(R.string.default_frame)
} else {
try {
val inputStream = holder.itemView.context.assets.open("frames/${item.imageFileName}")
val path = if (item.imageFileName.startsWith("frames/")) {
item.imageFileName
} else {
"frames/${item.imageFileName}"
}
val inputStream = holder.itemView.context.assets.open(path)
val bitmap = BitmapFactory.decodeStream(inputStream)
holder.imgFrameThumb.setImageBitmap(bitmap)
} catch (e: Exception) {
@@ -19,6 +19,8 @@ import androidx.constraintlayout.widget.ConstraintLayout
import com.photobooth.app.databinding.ActivityMainBinding
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import android.os.Build
import androidx.camera.core.ImageCaptureException
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
@@ -75,8 +77,7 @@ class MainActivity : AppCompatActivity() {
private fun initFrameData() {
allFrames = listOf(
FrameItem("DEFAULT", "Mặc định", ""),
FrameItem("instax_mini_single", "Instax Mini", "frames/instax_mini_single.png"),
FrameItem("photobooth_4strip", "Photobooth 4-Strip", "frames/photobooth_4strip.png")
FrameItem("instax_mini_single", "Instax Mini", "frames/instaxframe.png")
)
// Initialize default Color Presets
@@ -248,86 +249,82 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show()
}
}
adjustViewFinderBounds()
}
private fun applyPresetFilter(preset: ColorPreset) {
currentSelectedPreset = preset
if (preset.id == "DEFAULT") {
binding.imgFilterOverlay.visibility = android.view.View.GONE
binding.viewFinder.setLayerType(android.view.View.LAYER_TYPE_NONE, null)
private fun adjustViewFinderBounds() {
val framePath = currentSelectedFramePath
if (framePath == null || framePath.isEmpty()) {
// Reset viewFinder to match_parent
val params = binding.viewFinder.layoutParams as android.widget.FrameLayout.LayoutParams
params.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
params.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
params.topMargin = 0
params.bottomMargin = 0
params.leftMargin = 0
params.rightMargin = 0
binding.viewFinder.layoutParams = params
// Also reset filter overlay
val filterParams = binding.imgFilterOverlay.layoutParams as android.widget.FrameLayout.LayoutParams
filterParams.width = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
filterParams.height = android.widget.FrameLayout.LayoutParams.MATCH_PARENT
filterParams.topMargin = 0
filterParams.bottomMargin = 0
filterParams.leftMargin = 0
filterParams.rightMargin = 0
binding.imgFilterOverlay.layoutParams = filterParams
} else {
binding.imgFilterOverlay.visibility = android.view.View.VISIBLE
val isBeingEdited = editingPreset != null
val useCalculation = isBeingEdited || (preset.id != "instax_faded_warm_01" && preset.id != "instax_bw_cool" && preset.id != "instax_cool_summer")
val filterColor = if (useCalculation) {
// 1. ALPHA (Độ đậm/Mờ của lớp filter): Được quyết định bởi Saturation, Vibrance và hiệu ứng nâng cao
// Quy đổi dải từ -1.0 -> 1.0 sang mức độ trong suốt hợp lý (từ 15 đến 140 trong hệ 255)
val baseAlpha = 60f
val saturationImpact = (preset.basic.saturation + preset.basic.vibrance) * 25f
val contrastImpact = preset.basic.contrast * 15f
val alpha = (baseAlpha + saturationImpact + contrastImpact).toInt().coerceIn(15, 140)
// 2. BASE COLOR (Màu nền mặc định ban đầu trước khi tinh chỉnh)
// Bắt đầu từ một màu trung tính hoặc lấy màu gốc của Theme nếu có
var baseR = 140f
var baseG = 130f
var baseB = 120f
// 3. BRIGHTNESS (Độ sáng): Bắt buộc phải cộng/trừ ĐỀU vào cả 3 kênh R, G, B để dịch chuyển Exposure
val brightnessOffset = preset.basic.brightness * 80f // Nhân hệ số để biên độ thay đổi từ -100 đến +100 rõ rệt
baseR += brightnessOffset
baseG += brightnessOffset
baseB += brightnessOffset
// 4. TEMPERATURE (Nhiệt màu): + là Ấm (Tăng Đỏ, Giảm Lam), - là Lạnh (Giảm Đỏ, Tăng Lam)
val tempOffset = preset.basic.temperature * 50f
baseR += tempOffset
baseB -= tempOffset
// 5. TINT (Sắc độ): + là Ám hồng (Tăng Đỏ & Lam, Giảm Lục), - là Ám xanh (Giảm Đỏ & Lam, Tăng Lục)
val tintOffset = preset.basic.tint * 40f
baseG -= tintOffset
baseR += (tintOffset * 0.5f)
baseB += (tintOffset * 0.5f)
// 6. TONE CURVE & SHADOWS TINT (Ám màu vùng tối đặc trưng của Instax)
baseR += (preset.toneCurve.shadowsTintR * 35f)
baseG += (preset.toneCurve.shadowsTintG * 35f)
baseB += (preset.toneCurve.shadowsTintB * 35f)
// Nâng đáy màu đen (Faded Black) tạo độ sờn ảnh
val fadeOffset = preset.toneCurve.fadedBlackLevel * 30f
baseR += fadeOffset
baseG += fadeOffset
baseB += fadeOffset
// 7. CLARITY & DEHAZE (Điều tiết thêm độ tương phản cục bộ của màu)
val advancedOffset = (preset.advanced.clarity - preset.advanced.dehaze) * 20f
baseR += advancedOffset
baseG += advancedOffset
baseB += advancedOffset
// 8. ÉP BIÊN ĐỘ (COERCE) để đảm bảo giá trị RGB luôn nằm trong giới hạn vật lý 0 -> 255
val r = baseR.toInt().coerceIn(0, 255)
val g = baseG.toInt().coerceIn(0, 255)
val b = baseB.toInt().coerceIn(0, 255)
android.graphics.Color.argb(alpha, r, g, b)
} else {
// Khối xử lý cho các mẫu mặc định cố định không sửa đổi
when (preset.id) {
"instax_faded_warm_01" -> android.graphics.Color.argb(55, 255, 152, 0)
"instax_bw_cool" -> android.graphics.Color.argb(80, 128, 128, 128)
"instax_cool_summer" -> android.graphics.Color.argb(45, 0, 188, 212)
else -> android.graphics.Color.argb(0, 0, 0, 0)
binding.cameraContainer.post {
val containerWidth = binding.cameraContainer.width
val containerHeight = binding.cameraContainer.height
if (containerWidth > 0 && containerHeight > 0) {
// We know instaxframe is 1024 x 1207
val imgW = 1024f
val imgH = 1207f
val aspect = imgW / imgH
val containerAspect = containerWidth.toFloat() / containerHeight.toFloat()
var targetWidth = containerWidth
var targetHeight = containerHeight
if (containerAspect > aspect) {
// Container is wider than the image aspect -> height is matched, width is scaled
targetWidth = (containerHeight * aspect).toInt()
} else {
// Container is taller than the image aspect -> width is matched, height is scaled
targetHeight = (containerWidth / aspect).toInt()
}
val verticalMargin = (containerHeight - targetHeight) / 2
val horizontalMargin = (containerWidth - targetWidth) / 2
// Adjust viewFinder params
val params = binding.viewFinder.layoutParams as android.widget.FrameLayout.LayoutParams
params.width = targetWidth
params.height = targetHeight
params.topMargin = verticalMargin
params.bottomMargin = verticalMargin
params.leftMargin = horizontalMargin
params.rightMargin = horizontalMargin
binding.viewFinder.layoutParams = params
// Adjust filter overlay params
val filterParams = binding.imgFilterOverlay.layoutParams as android.widget.FrameLayout.LayoutParams
filterParams.width = targetWidth
filterParams.height = targetHeight
filterParams.topMargin = verticalMargin
filterParams.bottomMargin = verticalMargin
filterParams.leftMargin = horizontalMargin
filterParams.rightMargin = horizontalMargin
binding.imgFilterOverlay.layoutParams = filterParams
}
}
binding.imgFilterOverlay.setBackgroundColor(filterColor)
applyColorMatrixToViewFinder(preset)
}
}
private fun applyColorMatrixToViewFinder(preset: ColorPreset) {
private fun getColorMatrixForPreset(preset: ColorPreset): android.graphics.ColorMatrix {
val finalMatrix = android.graphics.ColorMatrix()
// 1. Saturation & Vibrance (Sắc độ bão hòa màu thực tế)
@@ -384,6 +381,93 @@ class MainActivity : AppCompatActivity() {
))
finalMatrix.postConcat(tintMatrix)
return finalMatrix
}
private fun getFilterColorForPreset(preset: ColorPreset): Int {
if (preset.id == "DEFAULT") return android.graphics.Color.TRANSPARENT
val isBeingEdited = editingPreset != null
val useCalculation = isBeingEdited || (preset.id != "instax_faded_warm_01" && preset.id != "instax_bw_cool" && preset.id != "instax_cool_summer")
return if (useCalculation) {
// 1. ALPHA (Độ đậm/Mờ của lớp filter): Được quyết định bởi Saturation, Vibrance và hiệu ứng nâng cao
// Quy đổi dải từ -1.0 -> 1.0 sang mức độ trong suốt hợp lý (từ 15 đến 140 trong hệ 255)
val baseAlpha = 60f
val saturationImpact = (preset.basic.saturation + preset.basic.vibrance) * 25f
val contrastImpact = preset.basic.contrast * 15f
val alpha = (baseAlpha + saturationImpact + contrastImpact).toInt().coerceIn(15, 140)
// 2. BASE COLOR (Màu nền mặc định ban đầu trước khi tinh chỉnh)
// Bắt đầu từ một màu trung tính hoặc lấy màu gốc của Theme nếu có
var baseR = 140f
var baseG = 130f
var baseB = 120f
// 3. BRIGHTNESS (Độ sáng): Bắt buộc phải cộng/trừ ĐỀU vào cả 3 kênh R, G, B để dịch chuyển Exposure
val brightnessOffset = preset.basic.brightness * 80f // Nhân hệ số để biên độ thay đổi từ -100 đến +100 rõ rệt
baseR += brightnessOffset
baseG += brightnessOffset
baseB += brightnessOffset
// 4. TEMPERATURE (Nhiệt màu): + là Ấm (Tăng Đỏ, Giảm Lam), - là Lạnh (Giảm Đỏ, Tăng Lam)
val tempOffset = preset.basic.temperature * 50f
baseR += tempOffset
baseB -= tempOffset
// 5. TINT (Sắc độ): + là Ám hồng (Tăng Đỏ & Lam, Giảm Lục), - là Ám xanh (Giảm Đỏ & Lam, Tăng Lục)
val tintOffset = preset.basic.tint * 40f
baseG -= tintOffset
baseR += (tintOffset * 0.5f)
baseB += (tintOffset * 0.5f)
// 6. TONE CURVE & SHADOWS TINT (Ám màu vùng tối đặc trưng của Instax)
baseR += (preset.toneCurve.shadowsTintR * 35f)
baseG += (preset.toneCurve.shadowsTintG * 35f)
baseB += (preset.toneCurve.shadowsTintB * 35f)
// Nâng đáy màu đen (Faded Black) tạo độ sờn ảnh
val fadeOffset = preset.toneCurve.fadedBlackLevel * 30f
baseR += fadeOffset
baseG += fadeOffset
baseB += fadeOffset
// 7. CLARITY & DEHAZE (Điều tiết thêm độ tương phản cục bộ của màu)
val advancedOffset = (preset.advanced.clarity - preset.advanced.dehaze) * 20f
baseR += advancedOffset
baseG += advancedOffset
baseB += advancedOffset
// 8. ÉP BIÊN ĐỘ (COERCE) để đảm bảo giá trị RGB luôn nằm trong giới hạn vật lý 0 -> 255
val r = baseR.toInt().coerceIn(0, 255)
val g = baseG.toInt().coerceIn(0, 255)
val b = baseB.toInt().coerceIn(0, 255)
android.graphics.Color.argb(alpha, r, g, b)
} else {
// Khối xử lý cho các mẫu mặc định cố định không sửa đổi
when (preset.id) {
"instax_faded_warm_01" -> android.graphics.Color.argb(55, 255, 152, 0)
"instax_bw_cool" -> android.graphics.Color.argb(80, 128, 128, 128)
"instax_cool_summer" -> android.graphics.Color.argb(45, 0, 188, 212)
else -> android.graphics.Color.argb(0, 0, 0, 0)
}
}
}
private fun applyPresetFilter(preset: ColorPreset) {
currentSelectedPreset = preset
if (preset.id == "DEFAULT") {
binding.imgFilterOverlay.visibility = android.view.View.GONE
binding.viewFinder.setLayerType(android.view.View.LAYER_TYPE_NONE, null)
} else {
binding.imgFilterOverlay.visibility = android.view.View.VISIBLE
val filterColor = getFilterColorForPreset(preset)
binding.imgFilterOverlay.setBackgroundColor(filterColor)
applyColorMatrixToViewFinder(preset)
}
}
private fun applyColorMatrixToViewFinder(preset: ColorPreset) {
val finalMatrix = getColorMatrixForPreset(preset)
val paint = android.graphics.Paint().apply {
colorFilter = android.graphics.ColorMatrixColorFilter(finalMatrix)
}
@@ -507,6 +591,7 @@ class MainActivity : AppCompatActivity() {
if (isCountingDown) return
val wrapper = binding.buttonsWrapper
TransitionManager.beginDelayedTransition(wrapper)
val density = resources.displayMetrics.density
if (!isFrameModeOpen) {
isFrameModeOpen = true
@@ -515,14 +600,16 @@ class MainActivity : AppCompatActivity() {
// Hide preset button
binding.btnMainPreset.visibility = android.view.View.GONE
// Translate Frame button to left edge anchor
// Translate Frame button to left edge anchor with 12.5dp margin
val params = binding.btnMainFrame.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.endToStart = ConstraintLayout.LayoutParams.UNSET
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
params.leftMargin = (12.5f * density).toInt()
binding.btnMainFrame.layoutParams = params
// Show horizontal timeline
// Show horizontal timeline & solid dock background
binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE
binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE
binding.rvHorizontalTimeline.adapter = frameItemAdapter
} else {
@@ -533,12 +620,12 @@ class MainActivity : AppCompatActivity() {
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.endToStart = binding.btnMainPreset.id
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
params.leftMargin = 0
binding.btnMainFrame.layoutParams = params
// Show preset button
// Show preset button & hide timeline/dock background
binding.btnMainPreset.visibility = android.view.View.VISIBLE
// Hide timeline
binding.viewLeftDockBackground.visibility = android.view.View.GONE
binding.rvHorizontalTimeline.visibility = android.view.View.GONE
}
}
@@ -547,6 +634,7 @@ class MainActivity : AppCompatActivity() {
if (isCountingDown) return
val wrapper = binding.buttonsWrapper
TransitionManager.beginDelayedTransition(wrapper)
val density = resources.displayMetrics.density
if (!isPresetModeOpen) {
isPresetModeOpen = true
@@ -555,14 +643,16 @@ class MainActivity : AppCompatActivity() {
// Hide frame button
binding.btnMainFrame.visibility = android.view.View.GONE
// Translate Preset button to left edge anchor (occupying Frame button start spot)
// Translate Preset button to left edge anchor with 12.5dp margin
val params = binding.btnMainPreset.layoutParams as ConstraintLayout.LayoutParams
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
params.startToEnd = ConstraintLayout.LayoutParams.UNSET
params.endToEnd = ConstraintLayout.LayoutParams.UNSET
params.leftMargin = (12.5f * density).toInt()
binding.btnMainPreset.layoutParams = params
// Show horizontal timeline
// Show horizontal timeline & solid dock background
binding.viewLeftDockBackground.visibility = android.view.View.VISIBLE
binding.rvHorizontalTimeline.visibility = android.view.View.VISIBLE
presetAdapter.resetSelection()
binding.rvHorizontalTimeline.adapter = presetAdapter
@@ -574,12 +664,12 @@ class MainActivity : AppCompatActivity() {
params.startToStart = ConstraintLayout.LayoutParams.UNSET
params.startToEnd = binding.btnMainFrame.id
params.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
params.leftMargin = 0
binding.btnMainPreset.layoutParams = params
// Show frame button
// Show frame button & hide timeline/dock background
binding.btnMainFrame.visibility = android.view.View.VISIBLE
// Hide timeline
binding.viewLeftDockBackground.visibility = android.view.View.GONE
binding.rvHorizontalTimeline.visibility = android.view.View.GONE
}
}
@@ -962,14 +1052,262 @@ class MainActivity : AppCompatActivity() {
}
private fun takePicture() {
val imageCapture = this.imageCapture ?: return
// Show progress bar
binding.progressBar.visibility = android.view.View.VISIBLE
// Setup output file
val tempFile = java.io.File.createTempFile("raw_capture_", ".jpg", cacheDir)
val outputOptions = ImageCapture.OutputFileOptions.Builder(tempFile).build()
imageCapture.takePicture(
outputOptions,
cameraExecutor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
processAndSaveCapturedPhoto(tempFile)
}
override fun onError(exception: ImageCaptureException) {
runOnUiThread {
binding.progressBar.visibility = android.view.View.GONE
Toast.makeText(this@MainActivity, "Lỗi chụp ảnh: ${exception.message}", Toast.LENGTH_SHORT).show()
}
if (tempFile.exists()) {
tempFile.delete()
}
}
}
)
}
private fun processAndSaveCapturedPhoto(file: java.io.File) {
try {
val toneG = android.media.ToneGenerator(android.media.AudioManager.STREAM_ALARM, 100)
toneG.startTone(android.media.ToneGenerator.TONE_PROP_ACK, 250)
// 1. Decode captured file to Bitmap
val rawBitmap = android.graphics.BitmapFactory.decodeFile(file.absolutePath)
if (rawBitmap == null) {
runOnUiThread {
binding.progressBar.visibility = android.view.View.GONE
Toast.makeText(this, "Không thể đọc dữ liệu ảnh chụp", Toast.LENGTH_SHORT).show()
}
file.delete()
return
}
// 2. Adjust Orientation (EXIF + Mirroring for Front Camera)
var processedBitmap = rawBitmap
try {
val exif = android.media.ExifInterface(file.absolutePath)
val orientation = exif.getAttributeInt(
android.media.ExifInterface.TAG_ORIENTATION,
android.media.ExifInterface.ORIENTATION_NORMAL
)
val matrix = android.graphics.Matrix()
var rotation = 0f
when (orientation) {
android.media.ExifInterface.ORIENTATION_ROTATE_90 -> rotation = 90f
android.media.ExifInterface.ORIENTATION_ROTATE_180 -> rotation = 180f
android.media.ExifInterface.ORIENTATION_ROTATE_270 -> rotation = 270f
}
if (rotation != 0f) {
matrix.postRotate(rotation)
}
// Front camera mirroring
if (lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
matrix.postScale(-1f, 1f)
}
if (rotation != 0f || lensFacing == CameraSelector.DEFAULT_FRONT_CAMERA) {
processedBitmap = android.graphics.Bitmap.createBitmap(
rawBitmap, 0, 0, rawBitmap.width, rawBitmap.height, matrix, true
)
if (processedBitmap != rawBitmap) {
rawBitmap.recycle()
}
}
} catch (exifEx: Exception) {
// Ignore exif exceptions
}
// 3. Apply Current Preset Filters (ColorMatrix + Semi-transparent overlay color)
val preset = currentSelectedPreset ?: colorPresets[0]
val filteredBitmap = android.graphics.Bitmap.createBitmap(
processedBitmap.width, processedBitmap.height, android.graphics.Bitmap.Config.ARGB_8888
)
val canvas = android.graphics.Canvas(filteredBitmap)
val paint = android.graphics.Paint().apply {
isAntiAlias = true
isFilterBitmap = true
}
if (preset.id != "DEFAULT") {
val finalMatrix = getColorMatrixForPreset(preset)
paint.colorFilter = android.graphics.ColorMatrixColorFilter(finalMatrix)
}
canvas.drawBitmap(processedBitmap, 0f, 0f, paint)
processedBitmap.recycle()
val overlayColor = getFilterColorForPreset(preset)
if (overlayColor != android.graphics.Color.TRANSPARENT) {
val overlayPaint = android.graphics.Paint().apply {
color = overlayColor
style = android.graphics.Paint.Style.FILL
}
canvas.drawRect(
0f, 0f, filteredBitmap.width.toFloat(), filteredBitmap.height.toFloat(), overlayPaint
)
}
// 4. Film Grain effect
if (preset.grain.grainAmount > 0f) {
val grainAmount = preset.grain.grainAmount
val grainSize = preset.grain.grainSize.coerceAtLeast(0.01f)
val width = filteredBitmap.width
val height = filteredBitmap.height
val pixels = IntArray(width * height)
filteredBitmap.getPixels(pixels, 0, width, 0, 0, width, height)
val random = java.util.Random()
val sizeFactor = (grainSize * 12).toInt().coerceAtLeast(1)
for (y in 0 until height step sizeFactor) {
for (x in 0 until width step sizeFactor) {
val noise = (random.nextGaussian() * 127 * grainAmount).toInt()
for (ny in 0 until sizeFactor) {
if (y + ny >= height) break
for (nx in 0 until sizeFactor) {
if (x + nx >= width) break
val index = (y + ny) * width + (x + nx)
val color = pixels[index]
val r = (android.graphics.Color.red(color) + noise).coerceIn(0, 255)
val g = (android.graphics.Color.green(color) + noise).coerceIn(0, 255)
val b = (android.graphics.Color.blue(color) + noise).coerceIn(0, 255)
pixels[index] = android.graphics.Color.rgb(r, g, b)
}
}
}
}
filteredBitmap.setPixels(pixels, 0, width, 0, 0, width, height)
}
// 5. Merge Instax Frame Overlay
var finalBitmap = filteredBitmap
val framePath = currentSelectedFramePath
if (framePath != null && framePath.isNotEmpty()) {
try {
val frameInputStream = assets.open(framePath)
val frameBitmap = android.graphics.BitmapFactory.decodeStream(frameInputStream)
if (frameBitmap != null) {
val W_frame = frameBitmap.width
val H_frame = frameBitmap.height
val R = W_frame.toFloat() / H_frame.toFloat()
val W_photo = filteredBitmap.width
val H_photo = filteredBitmap.height
val photoRatio = W_photo.toFloat() / H_photo.toFloat()
var cropX = 0
var cropY = 0
var cropW = W_photo
var cropH = H_photo
if (photoRatio > R) {
cropW = (H_photo * R).toInt()
cropX = (W_photo - cropW) / 2
} else {
cropH = (W_photo / R).toInt()
cropY = (H_photo - cropH) / 2
}
val croppedPhoto = android.graphics.Bitmap.createBitmap(
filteredBitmap, cropX, cropY, cropW, cropH
)
val resizedPhoto = android.graphics.Bitmap.createScaledBitmap(
croppedPhoto, W_frame, H_frame, true
)
if (croppedPhoto != filteredBitmap) {
croppedPhoto.recycle()
}
filteredBitmap.recycle()
val mergeBitmap = android.graphics.Bitmap.createBitmap(
W_frame, H_frame, android.graphics.Bitmap.Config.ARGB_8888
)
val mergeCanvas = android.graphics.Canvas(mergeBitmap)
mergeCanvas.drawBitmap(resizedPhoto, 0f, 0f, null)
mergeCanvas.drawBitmap(frameBitmap, 0f, 0f, null)
resizedPhoto.recycle()
frameBitmap.recycle()
finalBitmap = mergeBitmap
}
} catch (e: Exception) {
// ignore
}
}
// 6. Save image to system Gallery
val savedSuccess = saveBitmapToGallery(finalBitmap, "RetroPhoto")
finalBitmap.recycle()
runOnUiThread {
binding.progressBar.visibility = android.view.View.GONE
if (savedSuccess) {
Toast.makeText(this, "Đã lưu vào thư viện!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Lỗi khi lưu ảnh vào Gallery", Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
// Fallback
runOnUiThread {
binding.progressBar.visibility = android.view.View.GONE
Toast.makeText(this, "Lỗi xử lý ảnh: ${e.message}", Toast.LENGTH_SHORT).show()
}
} finally {
if (file.exists()) {
file.delete()
}
}
val successMsg = getString(R.string.toast_capture_success)
Toast.makeText(this, successMsg, Toast.LENGTH_SHORT).show()
}
private fun saveBitmapToGallery(bitmap: android.graphics.Bitmap, title: String): Boolean {
val filename = "${title}_${System.currentTimeMillis()}.jpg"
var success = false
val contentResolver = contentResolver
val imageDetails = android.content.ContentValues().apply {
put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, filename)
put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/RetroPhotobooth")
put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
}
}
val imageUri = contentResolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageDetails)
if (imageUri != null) {
try {
contentResolver.openOutputStream(imageUri).use { outputStream ->
if (outputStream != null) {
bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 95, outputStream)
success = true
}
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
imageDetails.clear()
imageDetails.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(imageUri, imageDetails, null, null)
}
} catch (e: Exception) {
contentResolver.delete(imageUri, null, null)
success = false
}
}
return success
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
@@ -999,6 +1337,10 @@ class MainActivity : AppCompatActivity() {
companion object {
private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
private val REQUIRED_PERMISSIONS = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
arrayOf(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
arrayOf(Manifest.permission.CAMERA)
}
}
}
@@ -82,6 +82,7 @@
android:id="@+id/cameraContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#000000"
app:layout_constraintDimensionRatio="3:4"
app:layout_constraintTop_toBottomOf="@id/topBar"
app:layout_constraintBottom_toTopOf="@id/panelSelectionContainer">
@@ -149,6 +150,14 @@
android:paddingEnd="16dp"
android:visibility="gone" />
<!-- Khung nền đen che các item cuộn sang bên trái -->
<View
android:id="@+id/viewLeftDockBackground"
android:layout_width="75dp"
android:layout_height="match_parent"
android:background="#000000"
android:visibility="gone" />
<!-- LỚP TRÊN: Chứa 2 nút chức năng chính (Gốc ở giữa, động chuyển sang trái) -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/buttonsWrapper"
@@ -357,4 +366,17 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- Biểu tượng loading xoay tròn khi đang xử lý ảnh -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="64dp"
android:layout_height="64dp"
android:visibility="gone"
android:elevation="10dp"
android:indeterminateTint="@color/theme_primary"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>