react-native-capture-studio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +86 -0
- package/android/build.gradle +139 -0
- package/android/generated/java/com/capturestudio/NativeCaptureStudioSpec.java +48 -0
- package/android/generated/jni/CMakeLists.txt +31 -0
- package/android/generated/jni/RNCaptureStudioSpec-generated.cpp +44 -0
- package/android/generated/jni/RNCaptureStudioSpec.h +31 -0
- package/android/generated/jni/react/renderer/components/RNCaptureStudioSpec/RNCaptureStudioSpecJSI.h +56 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +13 -0
- package/android/src/main/AndroidManifestNew.xml +2 -0
- package/android/src/main/java/com/capturestudio/CaptureStudioModule.kt +177 -0
- package/android/src/main/java/com/capturestudio/CaptureStudioPackage.kt +33 -0
- package/android/src/main/java/com/capturestudio/data/CameraRepository.kt +43 -0
- package/android/src/main/java/com/capturestudio/data/processing/ImageProcessingWorker.kt +126 -0
- package/android/src/main/java/com/capturestudio/data/processing/ImageProcessor.kt +244 -0
- package/android/src/main/java/com/capturestudio/domain/CaptureOptions.kt +0 -0
- package/android/src/main/java/com/capturestudio/domain/model/ImageProcessingItem.kt +18 -0
- package/android/src/main/java/com/capturestudio/domain/model/ProcessingResult.kt +14 -0
- package/android/src/main/java/com/capturestudio/ui/camera/CameraActivity.kt +55 -0
- package/android/src/main/java/com/capturestudio/ui/camera/CameraUiState.kt +7 -0
- package/android/src/main/java/com/capturestudio/ui/camera/CameraViewModel.kt +34 -0
- package/android/src/main/java/com/capturestudio/ui/camera/CameraViewModelFactory.kt +17 -0
- package/android/src/main/res/layout/activity_camera.xml +10 -0
- package/ios/CaptureStudio.h +7 -0
- package/ios/CaptureStudio.mm +186 -0
- package/ios/ImageProcessor.h +22 -0
- package/ios/ImageProcessor.mm +383 -0
- package/ios/generated/Package.swift +59 -0
- package/ios/generated/ReactAppDependencyProvider/RCTAppDependencyProvider.h +25 -0
- package/ios/generated/ReactAppDependencyProvider/RCTAppDependencyProvider.mm +40 -0
- package/ios/generated/ReactAppDependencyProvider/ReactAppDependencyProvider.podspec +34 -0
- package/ios/generated/ReactCodegen/RCTModuleProviders.h +16 -0
- package/ios/generated/ReactCodegen/RCTModuleProviders.mm +51 -0
- package/ios/generated/ReactCodegen/RCTModulesConformingToProtocolsProvider.h +18 -0
- package/ios/generated/ReactCodegen/RCTModulesConformingToProtocolsProvider.mm +54 -0
- package/ios/generated/ReactCodegen/RCTThirdPartyComponentsProvider.h +16 -0
- package/ios/generated/ReactCodegen/RCTThirdPartyComponentsProvider.mm +30 -0
- package/ios/generated/ReactCodegen/RCTUnstableModulesRequiringMainQueueSetupProvider.h +14 -0
- package/ios/generated/ReactCodegen/RCTUnstableModulesRequiringMainQueueSetupProvider.mm +19 -0
- package/ios/generated/ReactCodegen/RNCaptureStudioSpec/RNCaptureStudioSpec-generated.mm +53 -0
- package/ios/generated/ReactCodegen/RNCaptureStudioSpec/RNCaptureStudioSpec.h +70 -0
- package/ios/generated/ReactCodegen/RNCaptureStudioSpecJSI.h +56 -0
- package/ios/generated/ReactCodegen/ReactCodegen.podspec +110 -0
- package/lib/commonjs/NativeCaptureStudio.js +9 -0
- package/lib/commonjs/NativeCaptureStudio.js.map +1 -0
- package/lib/commonjs/index.js +20 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/module/NativeCaptureStudio.js +5 -0
- package/lib/module/NativeCaptureStudio.js.map +1 -0
- package/lib/module/index.js +13 -0
- package/lib/module/index.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/NativeCaptureStudio.d.ts +9 -0
- package/lib/typescript/commonjs/src/NativeCaptureStudio.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +19 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/NativeCaptureStudio.d.ts +9 -0
- package/lib/typescript/module/src/NativeCaptureStudio.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +19 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/package.json +202 -0
- package/react-native-capture-studio.podspec +48 -0
- package/react-native.config.js +12 -0
- package/src/NativeCaptureStudio.ts +11 -0
- package/src/index.tsx +30 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.capturestudio
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import java.util.HashMap
|
|
9
|
+
|
|
10
|
+
class CaptureStudioPackage : BaseReactPackage() {
|
|
11
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
12
|
+
return if (name == CaptureStudioModule.NAME) {
|
|
13
|
+
CaptureStudioModule(reactContext)
|
|
14
|
+
} else {
|
|
15
|
+
null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
|
|
20
|
+
return ReactModuleInfoProvider {
|
|
21
|
+
val moduleInfos: MutableMap<String, ReactModuleInfo> = HashMap()
|
|
22
|
+
moduleInfos[CaptureStudioModule.NAME] = ReactModuleInfo(
|
|
23
|
+
CaptureStudioModule.NAME,
|
|
24
|
+
CaptureStudioModule.NAME,
|
|
25
|
+
false, // canOverrideExistingModule
|
|
26
|
+
false, // needsEagerInit
|
|
27
|
+
false, // isCxxModule
|
|
28
|
+
true // isTurboModule
|
|
29
|
+
)
|
|
30
|
+
moduleInfos
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package com.capturestudio.data
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.camera.core.CameraSelector
|
|
5
|
+
import androidx.camera.core.Preview
|
|
6
|
+
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
7
|
+
import androidx.camera.view.PreviewView
|
|
8
|
+
import androidx.core.content.ContextCompat
|
|
9
|
+
import androidx.lifecycle.LifecycleOwner
|
|
10
|
+
|
|
11
|
+
class CameraRepository {
|
|
12
|
+
|
|
13
|
+
private var cameraProvider: ProcessCameraProvider? = null
|
|
14
|
+
|
|
15
|
+
fun startCamera(
|
|
16
|
+
context: Context,
|
|
17
|
+
lifecycleOwner: LifecycleOwner,
|
|
18
|
+
previewView: PreviewView
|
|
19
|
+
) {
|
|
20
|
+
val providerFuture = ProcessCameraProvider.getInstance(context)
|
|
21
|
+
|
|
22
|
+
providerFuture.addListener({
|
|
23
|
+
cameraProvider = providerFuture.get()
|
|
24
|
+
|
|
25
|
+
val preview = Preview.Builder().build()
|
|
26
|
+
preview.setSurfaceProvider(previewView.surfaceProvider)
|
|
27
|
+
|
|
28
|
+
val selector = CameraSelector.DEFAULT_BACK_CAMERA
|
|
29
|
+
|
|
30
|
+
cameraProvider?.unbindAll()
|
|
31
|
+
cameraProvider?.bindToLifecycle(
|
|
32
|
+
lifecycleOwner,
|
|
33
|
+
selector,
|
|
34
|
+
preview
|
|
35
|
+
)
|
|
36
|
+
}, ContextCompat.getMainExecutor(context))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fun release() {
|
|
40
|
+
cameraProvider?.unbindAll()
|
|
41
|
+
cameraProvider = null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
package com.capturestudio.data.processing
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import androidx.work.CoroutineWorker
|
|
6
|
+
import androidx.work.Data
|
|
7
|
+
import androidx.work.WorkerParameters
|
|
8
|
+
import kotlinx.coroutines.Dispatchers
|
|
9
|
+
import kotlinx.coroutines.withContext
|
|
10
|
+
import org.json.JSONArray
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* WorkManager worker that processes images in the background.
|
|
15
|
+
* Handles multiple images sequentially with compression and watermarking.
|
|
16
|
+
*/
|
|
17
|
+
class ImageProcessingWorker(
|
|
18
|
+
context: Context,
|
|
19
|
+
params: WorkerParameters
|
|
20
|
+
) : CoroutineWorker(context, params) {
|
|
21
|
+
|
|
22
|
+
companion object {
|
|
23
|
+
private const val TAG = "ImageProcessingWorker"
|
|
24
|
+
|
|
25
|
+
// Input/Output data keys
|
|
26
|
+
const val KEY_IMAGES = "images"
|
|
27
|
+
const val KEY_RESULTS = "results"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
|
31
|
+
val imagesJson = inputData.getString(KEY_IMAGES)
|
|
32
|
+
|
|
33
|
+
if (imagesJson.isNullOrEmpty()) {
|
|
34
|
+
Log.e(TAG, "No images provided")
|
|
35
|
+
return@withContext Result.failure()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
val results = processImages(imagesJson)
|
|
40
|
+
|
|
41
|
+
val outputData = Data.Builder()
|
|
42
|
+
.putString(KEY_RESULTS, results.toString())
|
|
43
|
+
.build()
|
|
44
|
+
|
|
45
|
+
Log.d(TAG, "Processing completed successfully")
|
|
46
|
+
Result.success(outputData)
|
|
47
|
+
} catch (e: Exception) {
|
|
48
|
+
Log.e(TAG, "Processing failed", e)
|
|
49
|
+
Result.failure()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Process all images from the JSON array.
|
|
55
|
+
*
|
|
56
|
+
* @param imagesJson JSON array string of image processing items
|
|
57
|
+
* @return JSONArray of processing results
|
|
58
|
+
*/
|
|
59
|
+
private fun processImages(imagesJson: String): JSONArray {
|
|
60
|
+
val imageArray = JSONArray(imagesJson)
|
|
61
|
+
val results = JSONArray()
|
|
62
|
+
|
|
63
|
+
Log.d(TAG, "Processing ${imageArray.length()} images")
|
|
64
|
+
|
|
65
|
+
for (i in 0 until imageArray.length()) {
|
|
66
|
+
val imageObject = imageArray.getJSONObject(i)
|
|
67
|
+
val result = processSingleImage(imageObject)
|
|
68
|
+
results.put(result)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return results
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Process a single image and return the result.
|
|
76
|
+
*/
|
|
77
|
+
private fun processSingleImage(imageObject: JSONObject): JSONObject {
|
|
78
|
+
val originalPath = imageObject.optString("localPath", "")
|
|
79
|
+
val localPath = originalPath.replace("file://", "")
|
|
80
|
+
val timeStamp = imageObject.optString("timeStamp", "")
|
|
81
|
+
val isForOnlyWatermark = imageObject.optBoolean("isForOnlyWatermark", false)
|
|
82
|
+
val compressJpeg = imageObject.optBoolean("compressJpegImage", false)
|
|
83
|
+
|
|
84
|
+
// Validate input
|
|
85
|
+
if (localPath.isEmpty() || localPath == "undefined") {
|
|
86
|
+
Log.w(TAG, "Skipping invalid path: $localPath")
|
|
87
|
+
return createResultJson(originalPath, false, "Invalid path")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (timeStamp.isEmpty() || timeStamp == "undefined") {
|
|
91
|
+
Log.w(TAG, "Skipping invalid timestamp for: $localPath")
|
|
92
|
+
return createResultJson(originalPath, false, "Invalid timestamp")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Process the image
|
|
96
|
+
val result = ImageProcessor.processImage(
|
|
97
|
+
imagePath = localPath,
|
|
98
|
+
timeStamp = timeStamp,
|
|
99
|
+
isForOnlyWatermark = isForOnlyWatermark,
|
|
100
|
+
compressJpeg = compressJpeg
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return if (result.isSuccess) {
|
|
104
|
+
createResultJson(originalPath, true, null)
|
|
105
|
+
} else {
|
|
106
|
+
val error = result.exceptionOrNull()?.message ?: "Unknown error"
|
|
107
|
+
Log.e(TAG, "Failed to process $localPath: $error")
|
|
108
|
+
createResultJson(originalPath, false, error)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a JSON result object.
|
|
114
|
+
*/
|
|
115
|
+
private fun createResultJson(
|
|
116
|
+
localPath: String,
|
|
117
|
+
success: Boolean,
|
|
118
|
+
error: String?
|
|
119
|
+
): JSONObject {
|
|
120
|
+
return JSONObject().apply {
|
|
121
|
+
put("localPath", localPath)
|
|
122
|
+
put("success", success)
|
|
123
|
+
put("error", error ?: JSONObject.NULL)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
package com.capturestudio.data.processing
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.BitmapFactory
|
|
5
|
+
import android.graphics.Canvas
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Matrix
|
|
8
|
+
import android.graphics.Paint
|
|
9
|
+
import android.graphics.Rect
|
|
10
|
+
import android.util.Log
|
|
11
|
+
import androidx.exifinterface.media.ExifInterface
|
|
12
|
+
import java.io.ByteArrayOutputStream
|
|
13
|
+
import java.io.File
|
|
14
|
+
import java.io.FileOutputStream
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Handles image processing operations including:
|
|
18
|
+
* - EXIF orientation correction
|
|
19
|
+
* - Watermark overlay
|
|
20
|
+
* - Compression to target file size (300-500KB)
|
|
21
|
+
*/
|
|
22
|
+
object ImageProcessor {
|
|
23
|
+
|
|
24
|
+
private const val TAG = "ImageProcessor"
|
|
25
|
+
|
|
26
|
+
// Target file size range in KB
|
|
27
|
+
private const val MIN_SIZE_KB_DEFAULT = 300
|
|
28
|
+
private const val MIN_SIZE_KB_WATERMARK_ONLY = 400
|
|
29
|
+
private const val MAX_SIZE_KB = 500
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Process a single image: rotate based on EXIF, add watermark, compress.
|
|
33
|
+
*
|
|
34
|
+
* @param imagePath Path to the image file
|
|
35
|
+
* @param timeStamp Watermark text to overlay
|
|
36
|
+
* @param isForOnlyWatermark Use higher minimum size if true
|
|
37
|
+
* @param compressJpeg Use JPEG format if true, WebP otherwise
|
|
38
|
+
* @return Result indicating success or failure with error message
|
|
39
|
+
*/
|
|
40
|
+
fun processImage(
|
|
41
|
+
imagePath: String,
|
|
42
|
+
timeStamp: String,
|
|
43
|
+
isForOnlyWatermark: Boolean,
|
|
44
|
+
compressJpeg: Boolean
|
|
45
|
+
): Result<Unit> {
|
|
46
|
+
return runCatching {
|
|
47
|
+
val file = File(imagePath)
|
|
48
|
+
if (!file.exists()) {
|
|
49
|
+
throw IllegalArgumentException("File not found: $imagePath")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Log.d(TAG, "Processing image: $imagePath")
|
|
53
|
+
|
|
54
|
+
// 1. Load bitmap
|
|
55
|
+
val originalBitmap = BitmapFactory.decodeFile(file.absolutePath)
|
|
56
|
+
?: throw IllegalStateException("Failed to decode image: $imagePath")
|
|
57
|
+
|
|
58
|
+
// 2. Get EXIF orientation and rotate if needed
|
|
59
|
+
val exif = ExifInterface(file.absolutePath)
|
|
60
|
+
val orientation = exif.getAttributeInt(
|
|
61
|
+
ExifInterface.TAG_ORIENTATION,
|
|
62
|
+
ExifInterface.ORIENTATION_NORMAL
|
|
63
|
+
)
|
|
64
|
+
val rotatedBitmap = rotateBitmapIfNeeded(originalBitmap, orientation)
|
|
65
|
+
|
|
66
|
+
// Recycle original if we created a new bitmap
|
|
67
|
+
if (rotatedBitmap !== originalBitmap) {
|
|
68
|
+
originalBitmap.recycle()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. Add watermark
|
|
72
|
+
val watermarkedBitmap = if (timeStamp.isNotEmpty()) {
|
|
73
|
+
addWatermark(rotatedBitmap, timeStamp).also {
|
|
74
|
+
if (it !== rotatedBitmap) rotatedBitmap.recycle()
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
rotatedBitmap
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. Compress to target size
|
|
81
|
+
val minSizeKB = if (isForOnlyWatermark) MIN_SIZE_KB_WATERMARK_ONLY else MIN_SIZE_KB_DEFAULT
|
|
82
|
+
compressToTargetSize(
|
|
83
|
+
bitmap = watermarkedBitmap,
|
|
84
|
+
outputPath = imagePath,
|
|
85
|
+
minKB = minSizeKB,
|
|
86
|
+
maxKB = MAX_SIZE_KB,
|
|
87
|
+
useJpeg = compressJpeg
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
watermarkedBitmap.recycle()
|
|
91
|
+
|
|
92
|
+
Log.d(TAG, "Successfully processed: $imagePath")
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Rotate bitmap based on EXIF orientation.
|
|
98
|
+
*/
|
|
99
|
+
private fun rotateBitmapIfNeeded(bitmap: Bitmap, orientation: Int): Bitmap {
|
|
100
|
+
val rotationAngle = when (orientation) {
|
|
101
|
+
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
|
102
|
+
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
|
103
|
+
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
|
104
|
+
else -> return bitmap // No rotation needed
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
val matrix = Matrix().apply {
|
|
108
|
+
postRotate(rotationAngle)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Bitmap.createBitmap(
|
|
112
|
+
bitmap,
|
|
113
|
+
0,
|
|
114
|
+
0,
|
|
115
|
+
bitmap.width,
|
|
116
|
+
bitmap.height,
|
|
117
|
+
matrix,
|
|
118
|
+
true
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Add a watermark to the bottom-right corner of the image.
|
|
124
|
+
* Yellow text on black background, matching iOS implementation.
|
|
125
|
+
*/
|
|
126
|
+
private fun addWatermark(bitmap: Bitmap, text: String): Bitmap {
|
|
127
|
+
val mutableBitmap = bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
|
128
|
+
val canvas = Canvas(mutableBitmap)
|
|
129
|
+
|
|
130
|
+
val imageWidth = mutableBitmap.width
|
|
131
|
+
val imageHeight = mutableBitmap.height
|
|
132
|
+
|
|
133
|
+
// Calculate sizes relative to image dimensions (matching iOS)
|
|
134
|
+
val textSize = imageWidth / 40f
|
|
135
|
+
val padding = imageWidth / 50f
|
|
136
|
+
|
|
137
|
+
// Text paint
|
|
138
|
+
val textPaint = Paint().apply {
|
|
139
|
+
color = Color.YELLOW
|
|
140
|
+
this.textSize = textSize
|
|
141
|
+
isAntiAlias = true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Background paint
|
|
145
|
+
val bgPaint = Paint().apply {
|
|
146
|
+
color = Color.BLACK
|
|
147
|
+
style = Paint.Style.FILL
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Measure text
|
|
151
|
+
val textBounds = Rect()
|
|
152
|
+
textPaint.getTextBounds(text, 0, text.length, textBounds)
|
|
153
|
+
|
|
154
|
+
// Calculate background dimensions
|
|
155
|
+
val bgWidth = textBounds.width() + 2 * padding
|
|
156
|
+
val bgHeight = textBounds.height() + 2 * padding
|
|
157
|
+
|
|
158
|
+
// Position at bottom-right
|
|
159
|
+
val x = imageWidth - bgWidth - padding
|
|
160
|
+
val y = imageHeight - bgHeight - padding
|
|
161
|
+
|
|
162
|
+
// Draw black background rectangle
|
|
163
|
+
canvas.drawRect(x, y, x + bgWidth, y + bgHeight, bgPaint)
|
|
164
|
+
|
|
165
|
+
// Draw text
|
|
166
|
+
canvas.drawText(
|
|
167
|
+
text,
|
|
168
|
+
x + padding,
|
|
169
|
+
y + textBounds.height() + padding,
|
|
170
|
+
textPaint
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return mutableBitmap
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compress bitmap to target file size using binary search.
|
|
178
|
+
* Tries to achieve file size between minKB and maxKB.
|
|
179
|
+
*/
|
|
180
|
+
private fun compressToTargetSize(
|
|
181
|
+
bitmap: Bitmap,
|
|
182
|
+
outputPath: String,
|
|
183
|
+
minKB: Int,
|
|
184
|
+
maxKB: Int,
|
|
185
|
+
useJpeg: Boolean
|
|
186
|
+
) {
|
|
187
|
+
val format = getCompressFormat(useJpeg)
|
|
188
|
+
Log.d(TAG, "Using compression format: $format (useJpeg=$useJpeg)")
|
|
189
|
+
|
|
190
|
+
var low = 0
|
|
191
|
+
var high = 100
|
|
192
|
+
var bestData: ByteArray? = null
|
|
193
|
+
var bestQuality = 100
|
|
194
|
+
|
|
195
|
+
// Binary search for optimal quality
|
|
196
|
+
while (low <= high) {
|
|
197
|
+
val quality = (low + high) / 2
|
|
198
|
+
val stream = ByteArrayOutputStream()
|
|
199
|
+
|
|
200
|
+
bitmap.compress(format, quality, stream)
|
|
201
|
+
val data = stream.toByteArray()
|
|
202
|
+
val sizeKB = data.size / 1024
|
|
203
|
+
|
|
204
|
+
bestData = data
|
|
205
|
+
bestQuality = quality
|
|
206
|
+
|
|
207
|
+
when {
|
|
208
|
+
sizeKB < minKB -> {
|
|
209
|
+
// Size too small, increase quality
|
|
210
|
+
low = quality + 1
|
|
211
|
+
}
|
|
212
|
+
sizeKB > maxKB -> {
|
|
213
|
+
// Size too large, decrease quality
|
|
214
|
+
high = quality - 1
|
|
215
|
+
}
|
|
216
|
+
else -> {
|
|
217
|
+
// Within range, done
|
|
218
|
+
Log.d(TAG, "Found optimal quality: $quality, size: ${sizeKB}KB")
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Write to file
|
|
225
|
+
bestData?.let { data ->
|
|
226
|
+
FileOutputStream(outputPath).use { fos ->
|
|
227
|
+
fos.write(data)
|
|
228
|
+
}
|
|
229
|
+
Log.d(TAG, "Wrote ${data.size / 1024}KB at quality $bestQuality to $outputPath")
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get the appropriate compression format based on preference.
|
|
235
|
+
*/
|
|
236
|
+
private fun getCompressFormat(useJpeg: Boolean): Bitmap.CompressFormat {
|
|
237
|
+
return if (useJpeg) {
|
|
238
|
+
Bitmap.CompressFormat.JPEG
|
|
239
|
+
} else {
|
|
240
|
+
@Suppress("DEPRECATION")
|
|
241
|
+
Bitmap.CompressFormat.WEBP
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package com.capturestudio.domain.model
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents an image to be processed with compression and watermarking.
|
|
5
|
+
*
|
|
6
|
+
* @property localPath The file path to the image (may include "file://" prefix)
|
|
7
|
+
* @property timeStamp The timestamp text to overlay as watermark
|
|
8
|
+
* @property isForOnlyWatermark If true, uses higher minimum size (400KB vs 300KB)
|
|
9
|
+
* @property compressJpegImage If true, uses JPEG format; otherwise uses WebP
|
|
10
|
+
* @property replaceOriginal If true, overwrites the original file
|
|
11
|
+
*/
|
|
12
|
+
data class ImageProcessingItem(
|
|
13
|
+
val localPath: String,
|
|
14
|
+
val timeStamp: String,
|
|
15
|
+
val isForOnlyWatermark: Boolean = false,
|
|
16
|
+
val compressJpegImage: Boolean = false,
|
|
17
|
+
val replaceOriginal: Boolean = true
|
|
18
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package com.capturestudio.domain.model
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents the result of processing a single image.
|
|
5
|
+
*
|
|
6
|
+
* @property localPath The original path of the processed image
|
|
7
|
+
* @property success Whether processing completed successfully
|
|
8
|
+
* @property error Error message if processing failed, null otherwise
|
|
9
|
+
*/
|
|
10
|
+
data class ProcessingResult(
|
|
11
|
+
val localPath: String,
|
|
12
|
+
val success: Boolean,
|
|
13
|
+
val error: String? = null
|
|
14
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package com.capturestudio.ui.camera
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Bundle
|
|
6
|
+
import androidx.activity.result.contract.ActivityResultContracts
|
|
7
|
+
import androidx.activity.viewModels
|
|
8
|
+
import androidx.appcompat.app.AppCompatActivity
|
|
9
|
+
import androidx.camera.view.PreviewView
|
|
10
|
+
import androidx.core.content.ContextCompat
|
|
11
|
+
import com.capturestudio.R
|
|
12
|
+
|
|
13
|
+
class CameraActivity : AppCompatActivity() {
|
|
14
|
+
|
|
15
|
+
private val viewModel: CameraViewModel by viewModels {
|
|
16
|
+
CameraViewModelFactory()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private lateinit var previewView: PreviewView
|
|
20
|
+
|
|
21
|
+
private val permissionLauncher =
|
|
22
|
+
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
23
|
+
if (granted) {
|
|
24
|
+
startCamera()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
29
|
+
super.onCreate(savedInstanceState)
|
|
30
|
+
setContentView(R.layout.activity_camera)
|
|
31
|
+
|
|
32
|
+
previewView = findViewById(R.id.previewView)
|
|
33
|
+
|
|
34
|
+
if (hasCameraPermission()) {
|
|
35
|
+
startCamera()
|
|
36
|
+
} else {
|
|
37
|
+
permissionLauncher.launch(Manifest.permission.CAMERA)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun startCamera() {
|
|
42
|
+
viewModel.startCamera(
|
|
43
|
+
context = this,
|
|
44
|
+
lifecycleOwner = this,
|
|
45
|
+
previewView = previewView
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun hasCameraPermission(): Boolean {
|
|
50
|
+
return ContextCompat.checkSelfPermission(
|
|
51
|
+
this,
|
|
52
|
+
Manifest.permission.CAMERA
|
|
53
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
package com.capturestudio.ui.camera
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.camera.view.PreviewView
|
|
5
|
+
import androidx.lifecycle.LifecycleOwner
|
|
6
|
+
import androidx.lifecycle.LiveData
|
|
7
|
+
import androidx.lifecycle.MutableLiveData
|
|
8
|
+
import androidx.lifecycle.ViewModel
|
|
9
|
+
import com.capturestudio.data.CameraRepository
|
|
10
|
+
|
|
11
|
+
class CameraViewModel(
|
|
12
|
+
private val cameraRepository: CameraRepository
|
|
13
|
+
) : ViewModel() {
|
|
14
|
+
|
|
15
|
+
private val _uiState = MutableLiveData(CameraUiState())
|
|
16
|
+
val uiState: LiveData<CameraUiState> = _uiState
|
|
17
|
+
|
|
18
|
+
fun startCamera(
|
|
19
|
+
context: Context,
|
|
20
|
+
lifecycleOwner: LifecycleOwner,
|
|
21
|
+
previewView: PreviewView
|
|
22
|
+
) {
|
|
23
|
+
cameraRepository.startCamera(
|
|
24
|
+
context = context,
|
|
25
|
+
lifecycleOwner = lifecycleOwner,
|
|
26
|
+
previewView = previewView
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override fun onCleared() {
|
|
31
|
+
super.onCleared()
|
|
32
|
+
cameraRepository.release()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.capturestudio.ui.camera
|
|
2
|
+
|
|
3
|
+
import androidx.lifecycle.ViewModel
|
|
4
|
+
import androidx.lifecycle.ViewModelProvider
|
|
5
|
+
import com.capturestudio.data.CameraRepository
|
|
6
|
+
|
|
7
|
+
class CameraViewModelFactory : ViewModelProvider.Factory {
|
|
8
|
+
|
|
9
|
+
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
|
10
|
+
if (modelClass.isAssignableFrom(CameraViewModel::class.java)) {
|
|
11
|
+
return CameraViewModel(
|
|
12
|
+
cameraRepository = CameraRepository()
|
|
13
|
+
) as T
|
|
14
|
+
}
|
|
15
|
+
throw IllegalArgumentException("Unknown ViewModel class")
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
android:layout_width="match_parent"
|
|
3
|
+
android:layout_height="match_parent"
|
|
4
|
+
android:background="#000">
|
|
5
|
+
|
|
6
|
+
<androidx.camera.view.PreviewView
|
|
7
|
+
android:id="@+id/previewView"
|
|
8
|
+
android:layout_width="match_parent"
|
|
9
|
+
android:layout_height="match_parent" />
|
|
10
|
+
</FrameLayout>
|