Zoom the FRAME preview instead of the photo, and save to DCIM/Camera

- FRAME tab now pinches the whole view (frame + photo + watermark) in both
  camera and library; one finger pans that view. Preview only: the exported
  file is identical at 1x and while the view is magnified.
- Watermark pinch/drag stay relative and keep priority while the WATERMARK
  row is open, so they still work after returning from the FRAME tab.
- Exports go through a native MediaStore insert into DCIM/Camera, falling
  back to expo-media-library when the native save fails.
This commit is contained in:
2026-09-11 23:09:26 +07:00
parent 40c91d2a75
commit 1d84fe8a4a
4 changed files with 348 additions and 69 deletions
@@ -1,5 +1,6 @@
package com.locphamtran.recipescamera.export
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
@@ -10,6 +11,8 @@ import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import expo.modules.kotlin.functions.Coroutine
@@ -74,6 +77,44 @@ class RecipescamExportModule : Module() {
Name("RecipesCamExport")
Log.i(TAG, "module defined RecipesCamExport")
// Save an encoded JPEG/PNG into DCIM/Camera. expo-media-library's JS API
// only ever inserts into DCIM (root) or Pictures/Camera, so a camera-roll
// export lands where the stock camera puts its shots by inserting the
// MediaStore row ourselves with an explicit RELATIVE_PATH. The file is
// staged IS_PENDING, copied from the cache path, then published. Returns the
// MediaStore _ID (same shape as expo's Asset.id, usable with
// deleteAssetsAsync), or "" when the insert/copy fails so JS can fall back.
AsyncFunction("saveToCameraRollAsync") Coroutine { srcPath: String, mimeType: String ->
val ctx = appContext.reactContext ?: error("react context lost")
withContext(Dispatchers.IO) {
val src = File(srcPath)
if (!src.exists()) return@withContext ""
val resolver = ctx.contentResolver
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, src.name)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/Camera")
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = try {
resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
} catch (e: Throwable) {
Log.w(TAG, "saveToCameraRollAsync insert failed: ${e.message}")
null
} ?: return@withContext ""
try {
resolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out) } }
?: error("openOutputStream returned null")
} catch (e: Throwable) {
Log.w(TAG, "saveToCameraRollAsync copy failed: ${e.message}")
resolver.delete(uri, null, null)
return@withContext ""
}
resolver.update(uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null)
uri.lastPathSegment ?: ""
}
}
// P0 spike: decode (BitmapFactory) + JPEG encode + file write, all off the
// JS/main threads. Returns per-stage ms so JS can prove the JS thread is
// never blocked while native rendering runs.
+6
View File
@@ -91,6 +91,12 @@ export interface RecipescamExportModule {
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise<ColorTimings>;
/** P1 alias: matrix + crop, no tone/cinema/enhance passes. */
processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise<ColorTimings>;
/**
* Insert an encoded image into DCIM/Camera (MediaStore RELATIVE_PATH) — where
* the stock camera writes. Returns the MediaStore _ID (expo Asset.id shape),
* or '' when the insert/copy fails, so JS falls back to MediaLibrary.
*/
saveToCameraRollAsync(srcPath: string, mimeType: string): Promise<string>;
/**
* Raw device identity the stock Xiaomi camera folds into EXIF 0x889e (the
* watermark blob HyperOS Gallery reads) but CameraX/HAL never supplies: