Sell PRO as an in-app unlock, take shared photos, and let FX run negative

- One free Play app (com.locphamtran.recipescamera) with an expo-iap PRO
  unlock, replacing the paid apk for the store channel
- Android SEND/SEND_MULTIPLE share target feeds photos into the editor,
  on cold start and on resume
- BACK button in the TopBar and press-and-hold PEEK replace the chip row
- NOISE REDUCTION and SHARPENING accept -10: a negative SHARPENING softens
  the frame before the grain pass, a negative NOISE REDUCTION re-grains it,
  in the Skia engine, the live preview and the Kotlin export path alike
This commit is contained in:
2026-09-16 06:48:22 +07:00
parent 27a9dfb65f
commit a85ebf0a81
21 changed files with 860 additions and 183 deletions
@@ -2,6 +2,7 @@ package com.locphamtran.recipescamera.export
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
@@ -11,6 +12,7 @@ import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.net.Uri
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
@@ -77,6 +79,23 @@ class RecipescamExportModule : Module() {
Name("RecipesCamExport")
Log.i(TAG, "module defined RecipesCamExport")
// System share sheet intake. AndroidManifest declares this app a share
// target (ACTION_SEND / ACTION_SEND_MULTIPLE, image/*), and the shared photo
// exists only as the intent's EXTRA_STREAM — a content:// Uri that no JS API
// here can read (expo-linking surfaces VIEW data, never stream extras, and on
// 13+ the sender's uri is short-lived and redacted). So the stream is copied
// natively into the cache and handed to JS as an ordinary file that the normal
// library pipeline (path + assetId -> EXIF/MediaStore GPS) reads.
AsyncFunction("takeSharedImage") Coroutine { ->
// Read live, never cached: launchMode=singleTask re-delivers a share that
// arrives while we are alive through onNewIntent -> setIntent, so the
// activity's intent is always the current one and no native hook is needed.
val ctx = appContext.reactContext
val activity = appContext.currentActivity
if (ctx == null || activity == null) emptyMap<String, String>()
else withContext(Dispatchers.IO) { consumeSharedImage(ctx, activity.intent) }
}
// 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
@@ -337,6 +356,9 @@ private suspend fun processPipeline(
val aspect = adjust["aspect"] as? String
val dpi = (adjust["dpi"] as? Number)?.toInt() ?: 0
val sharpen = (adjust["sharpen"] as? Number)?.toDouble() ?: 0.0
// Negative SHARPENING knob (engine step 4): pre-grain gaussian soften, must
// run before the grain pass or the blur eats the re-grained noise.
val soften = (adjust["soften"] as? Number)?.toDouble() ?: 0.0
require(matrixList.size == 20) { "color matrix needs 20 floats, got ${matrixList.size}" }
val matrix = FloatArray(20) { i -> (matrixList[i] as? Number)?.toFloat() ?: 0f }
val cinemaFlat = FloatArray(16) { i -> (cinemaList?.getOrNull(i) as? Number)?.toFloat() ?: 0f }
@@ -434,11 +456,13 @@ private suspend fun processPipeline(
Log.i(TAG, "centerPixel rgb=(${android.graphics.Color.red(center)},${android.graphics.Color.green(center)},${android.graphics.Color.blue(center)})")
bmp.recycle()
// 5. Denoise / clarity / grain (exportEngine #4 + #6), CPU passes on the
// matrix output. Engine chain: denoise blur -> clarity conv (or mist blur),
// then a grain Overlay rect on top.
val needEnhance = denoise > 0 || clarity != 0.0
val needGrain = grain > 0
// 5. Denoise / clarity / soften / grain (exportEngine #4 + #6), CPU passes
// on the matrix output. Engine chain: denoise blur -> clarity conv (or mist
// blur) -> soften blur for a negative SHARPENING knob, then a grain Overlay
// rect on top. A negative NOISE REDUCTION knob adds re-grain instead of
// blurring, so a -10/-10 photo comes out soft AND grainy.
val needEnhance = denoise > 0 || clarity != 0.0 || soften > 0
val needGrain = grain > 0 || denoise < 0
if (needEnhance || needGrain) {
val t6 = System.nanoTime()
val wO = out.width
@@ -456,10 +480,16 @@ private suspend fun processPipeline(
val mistSigma = (abs(clarity) / 10.0) * 4.0 // bloom/mist
p = boxBlur(p, wO, hO, max(1, mistSigma.roundToInt()))
}
// Negative SHARPENING: same 3px-max gaussian the engine puts in the
// filter chain before grain (MakeBlur sigma = |knob|/10*3).
if (soften > 0) p = boxBlur(p, wO, hO, max(1, soften.roundToInt()))
if (needEnhance) timings["clarityMs"] = ((System.nanoTime() - t6) / 1_000_000).toInt()
val t7 = System.nanoTime()
if (grain > 0) {
val opacity = (grain / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
// Engine #6 grain amount: positive grain plus the re-grain a negative
// NOISE REDUCTION knob asks for, clamped to the 0..10 knob range.
val g = min(10.0, max(0.0, grain) + max(0.0, -denoise))
if (g > 0) {
val opacity = (g / 20.0).toFloat().coerceIn(0f, 1f) // engine: grain/20
p = grainOverlay(p, wO, hO, opacity)
timings["grainMs"] = ((System.nanoTime() - t7) / 1_000_000).toInt()
Log.i(TAG, "grain applied opacity=$opacity")
@@ -1216,3 +1246,71 @@ private fun parityCompare(ctx: Context, pathA: String, pathB: String, outPath: S
"gt32pct" to (gt[2] * 100.0 / total),
)
}
// ---- system share intake (AndroidManifest ACTION_SEND) ---------------------
/**
* The photo a share handed this launch, copied out of the sender's content://
* stream into our own cache and returned as { path, assetId, type } — all empty
* when this launch was not a share, or when the share was already taken.
*
* The copy is what lets the rest of the app work unchanged: every other entry
* point (LibraryPickerModal, the export pipeline, the EXIF read) wants a real
* file, and the sender's read grant only ever covers this one activity.
*
* ponytail: only the FIRST stream is read, so ACTION_SEND_MULTIPLE's 2nd..nth
* images are dropped — the editor works on one photo at a time; add when a
* multi-photo queue exists.
*/
private fun consumeSharedImage(ctx: Context, intent: Intent?): Map<String, String> {
if (intent == null) return emptyMap()
val action = intent.action
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return emptyMap()
val uri = sharedStreamUri(intent) ?: return emptyMap()
// Consumed: the activity keeps this intent for its whole life and JS re-asks
// on every foreground return, so the extra goes — otherwise every resume
// re-imported the same photo and threw the edits already made on it away.
intent.removeExtra(Intent.EXTRA_STREAM)
intent.clipData = null
// The sender's declared type, not the file name: several apps share HEIC or
// PNG streams with no usable name at all.
val type = ctx.contentResolver.getType(uri) ?: intent.type ?: "image/*"
val ext = when {
type.contains("png") -> "png"
type.contains("webp") -> "webp"
type.contains("heic") || type.contains("heif") -> "heic"
else -> "jpg"
}
val dst = File(File(ctx.cacheDir, "shared").apply { mkdirs() }, "shared-${System.currentTimeMillis()}.$ext")
try {
ctx.contentResolver.openInputStream(uri).use { input ->
requireNotNull(input) { "unreadable share stream" }
dst.outputStream().use { input.copyTo(it) }
}
} catch (e: Throwable) {
Log.w(TAG, "shared image copy failed: ${e.message}")
dst.delete()
return emptyMap()
}
// MediaStore ids are the trailing number of the uri — "content://media/
// external/images/media/12345", or the document form ".../image%3A12345"
// (percent-decoded by Uri itself). The library pipeline uses it for the
// MediaStore location lookup when the file carries no EXIF GPS.
val id = (uri.lastPathSegment ?: "").substringAfterLast(':')
val assetId = if (id.isNotEmpty() && id.all { c -> c.isDigit() }) id else ""
Log.i(TAG, "shared image -> ${dst.absolutePath} (type=$type, assetId=$assetId)")
return mapOf("path" to dst.absolutePath, "assetId" to assetId, "type" to type)
}
/**
* The sender's stream. Every API level puts it in EXTRA_STREAM — one Uri for
* SEND, a ParcelableArrayList for SEND_MULTIPLE — and a few senders fill in
* only clipData, so all three shapes are accepted.
*/
@Suppress("DEPRECATION")
private fun sharedStreamUri(intent: Intent): Uri? {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let { return it }
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.firstOrNull()?.let { return it }
val clip = intent.clipData
return if (clip != null && clip.itemCount > 0) clip.getItemAt(0).uri else null
}
+12 -1
View File
@@ -42,7 +42,7 @@ export interface PhotoAdjust {
toneSh?: number;
/** 16 flat floats from cinemaShader.ts getCinemaUniforms(season).flat; null/omitted or flat[0]=0 = skip. */
cinema?: number[] | null;
/** ColorAdjustments.denoise 0..10 (0 = skip). */
/** ColorAdjustments.denoise -10..10; >0 blur, <0 re-grain (0 = skip). */
denoise?: number;
/** ColorAdjustments.clarity -10..10; >0 conv sharpen, <0 mist blur (0 = skip). */
clarity?: number;
@@ -52,6 +52,8 @@ export interface PhotoAdjust {
quality?: number;
/** Final screen-sharpen amount (exportEngine #9 screenSharpenImage); >0 applies. */
sharpen?: number;
/** Soften blur radius for a negative sharpening knob (exportEngine #4); >0 applies. */
soften?: number;
/** DPI stamped into the JPEG header after encode (patchJpegDpi port); 0 = skip. */
dpi?: number;
/** Composition aspect-ratio crop computed after decode (center-largest, engine #1b). */
@@ -104,6 +106,15 @@ export interface RecipescamExportModule {
*/
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
/**
* The image a system share handed this launch (AndroidManifest
* ACTION_SEND — see takeSharedImage in the Kotlin module): the sender's
* stream is copied into the cache and returned as an ordinary file.
* `path` is "" when this launch was not a share, or when the share was
* already taken — the intent is read and cleared exactly once.
*/
takeSharedImage(): Promise<{ path: string; assetId: string; type: string }>;
/** P0 probe helpers — removed before ship. */
ping(): Promise<string>;
materializeBenchAsset(assetName: string): Promise<string>;