Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library

Both flavors build from one JS tree: `lite` hands out the free apk
(com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label
"RecipesCam Pro"). Release is signed from android/keystore.properties, both
files gitignored - lose either and no later build can install over this one.
The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs
rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor
alone would ship a Pro apk that thinks it is Lite.

The dev scaffolding goes: the three src/dev probes and their App effects, the
EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix
side-by-side install hook. 'balanced' stays the shipped capture mode, which is
what those probes measured on the 12S Ultra.

Looking at the picture: WB now converts kelvin through the Planckian locus into
a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old
one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a
recipe loads every knob it carries over the defaults and stays editable, and
RESET hands the panel back to the sim's own values. The in-app library picker,
the ultra-wide native module and recipe share/import land here too.

CLARITY and LIGHT GLOW existed only on the camera worklet and the export
engine, so a photo opened from the library ignored both. They now run in all
three library preview branches (libPhoto), and the preview clips them to the
drawn image - the export engine always clipped its bloom to the canvas, the
preview drew it over the whole rect, so the bloom bled past the photo onto the
black letterbox and over the frame's mat.
This commit is contained in:
2026-09-14 19:48:44 +07:00
parent 99405514c6
commit 97949e449d
49 changed files with 2050 additions and 659 deletions
@@ -0,0 +1,15 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'com.locphamtran.recipescamera'
version = '0.1.0'
android {
namespace "com.locphamtran.recipescamera.ultrawide"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
}
@@ -0,0 +1,712 @@
package com.locphamtran.recipescamera.ultrawide
import android.content.Context
import android.graphics.ImageFormat
import android.graphics.Rect
import android.graphics.SurfaceTexture
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.params.MeteringRectangle
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.util.Log
import android.util.Size
import android.view.Surface
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.views.ExpoView
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs
import kotlin.math.min
private const val TAG = "RecipesCamUltraWide"
// Camera2 open is exclusive: while the main (CameraX) session still holds the
// HAL the ultra-wide open fails with ERROR_CAMERA_IN_USE, and the release takes
// a few hundred ms after the preview is switched off — so retry instead of
// surfacing an error to JS.
private const val OPEN_RETRY_MS = 80L
private const val OPEN_MAX_ATTEMPTS = 25
private const val CAPTURE_TIMEOUT_MS = 6000L
// Ceiling for closeAsync(): the platform is supposed to report the device gone,
// but a release that is never signalled must not strand the caller.
private const val RELEASE_MAX_MS = 300L
// Tap-to-focus metering window, as a fraction of a side of the sensor's active
// array. 16% of the frame is roughly what a finger covers when it taps.
private const val METER_FRACTION = 0.08f
private fun cameraManager(ctx: Context) =
ctx.getSystemService(Context.CAMERA_SERVICE) as CameraManager
/**
* The Xiaomi 12S Ultra hides its ultra-wide (and tele) logical cameras from
* `cameraIdList` — CameraX therefore reports a single back device with
* minZoomRatio 1.0 and no 0.5x. Camera2 can still open the hidden id directly,
* so detect it as "the widest back lens that is NOT in cameraIdList": back
* facing, smallest LENS_INFO_AVAILABLE_FOCAL_LENGTHS.
*
* Returns "" when the device has no such camera (single-lens / front-only).
*/
internal fun findUltraWideId(ctx: Context): String {
val cm = cameraManager(ctx)
val exposed = cm.cameraIdList.toHashSet()
var best = ""
var bestFocal = Float.MAX_VALUE
// Ids are sparse on these devices (0/1 exposed, 2/3/6/8/9 hidden) but the
// space is not bounded by the API — 16 is well above any shipped layout.
for (id in 0 until 16) {
val key = id.toString()
if (exposed.contains(key)) continue
val cs = try {
cm.getCameraCharacteristics(key)
} catch (e: Throwable) {
continue // no such camera id
}
if (cs.get(CameraCharacteristics.LENS_FACING) != CameraCharacteristics.LENS_FACING_BACK) continue
val focals = cs.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) ?: continue
val f = focals.minOrNull() ?: continue
if (f > 0f && f < bestFocal) {
bestFocal = f
best = key
}
}
return best
}
/** Largest 4:3 size no bigger than [maxArea]; any size if the camera has no 4:3. */
private fun pick4x3(sizes: Array<Size>?, maxArea: Int): Size? {
if (sizes == null || sizes.isEmpty()) return null
var best: Size? = null
var bestArea = -1
for (s in sizes) {
if (s.width <= 0 || s.height <= 0) continue
if (abs(s.width.toDouble() / s.height - 4.0 / 3.0) > 0.02) continue
val area = s.width * s.height
if (area > maxArea || area <= bestArea) continue
bestArea = area
best = s
}
return best ?: sizes.maxByOrNull { it.width * it.height }
}
/**
* Live ultra-wide feed. A TextureView in a Camera2 session: the frames arrive
* as a landscape 4:3 buffer and are cover-scaled + rotated onto the portrait
* container by the view transform (no GL, no JS frame hop).
*/
class UltraWidePreviewView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
companion object {
@Volatile private var live: UltraWidePreviewView? = null
fun current(): UltraWidePreviewView? = live
}
private val texture = TextureView(context)
// Freezes the outgoing frame instead of letting it vanish with the session —
// see holdFrame().
private val holdView = ImageView(context)
// One thread owns every camera callback and the JPEG write — the main thread
// only ever sets props.
private var camThread: HandlerThread? = null
private val camHandler: Handler
get() {
val t = camThread ?: HandlerThread("uw-camera").also { it.start(); camThread = it }
return Handler(t.looper)
}
private var active = false
// Handoff cover — see setCover().
private var cover = false
// Bumped on every activate/deactivate: a retry or capture callback carrying a
// stale generation must not touch the current session.
private var generation = 0
private var cameraId = ""
private var device: CameraDevice? = null
private var session: CameraCaptureSession? = null
private var reader: ImageReader? = null
private var previewSize: Size? = null
private var photoSize: Size? = null
// Preview stream of the live session, kept so a tap can re-issue the request
// with a metering region.
private var previewSurface: Surface? = null
// Active pixel array of the sensor: the coordinate space Camera2 wants for
// CONTROL_AF_REGIONS / CONTROL_AE_REGIONS.
private var activeArray: Rect? = null
private var sensorOrientation = 90
private var busy = false
private var pending: Promise? = null
// Held by closeAsync() until the HAL has really let go of the lens; see
// release().
private var closedPromise: Promise? = null
private val mainHandler = Handler(Looper.getMainLooper())
// Frames drawn by the current session: leaving 0.5x while the lens is still
// opening must not hold a never-painted (black) frame over the viewfinder.
private var frameCount = 0
init {
// The frame is laid out at the buffer's own size and only then turned onto
// the viewfinder box, so the overflow (if the frame is wider than the box)
// has to be clipped away.
clipChildren = true
val fill = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
addView(texture, fill)
holdView.visibility = View.GONE
holdView.scaleType = ImageView.ScaleType.FIT_XY
addView(holdView, fill)
live = this
texture.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(st: SurfaceTexture, width: Int, height: Int) {
val p = previewSize
if (p != null) st.setDefaultBufferSize(p.width, p.height)
if (active) openCamera()
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, width: Int, height: Int) {}
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean = true
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {
frameCount++
// First frame of a fresh session: only now may the held frame go, see
// syncViews().
if (frameCount == 1) syncViews()
}
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
applyPreviewTransform()
}
// ReactViewGroup lays nobody out, and a TextureView stretches its buffer onto
// whatever box it is given. So the texture is laid out 1:1 with the frame and
// framed with the plain view properties, which act in the container's space:
// unlike the setTransform matrix (which works inside the already-stretched
// texture space) they cannot come out sideways or sheared.
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val p = previewSize
if (p == null) {
texture.layout(0, 0, r - l, b - t)
holdView.layout(0, 0, r - l, b - t)
} else {
texture.layout(0, 0, p.width, p.height)
holdView.layout(0, 0, p.width, p.height)
}
applyPreviewTransform()
}
/**
* Frame the ultra-wide exactly like the main preview: the full-width 3:4 box.
* The preview stream of this (hidden) ultra-wide id already arrives the right
* way up — same orientation as its JPEG, merely squeezed into the 4:3 buffer —
* so there is nothing to rotate: stretching the buffer onto the 3:4 box both
* un-squeezes it and fills the box, which is what the main preview shows too.
* Rotation and scale act about the texture's own centre, so aligning the two
* centres is enough.
*/
private fun applyPreviewTransform() {
val p = previewSize ?: return
val cw = width.toFloat()
val ch = height.toFloat()
if (cw <= 0f || ch <= 0f) return
val pw = p.width.toFloat()
val ph = p.height.toFloat()
// The sizing must happen here, not only in onLayout: openCamera() resolves
// the preview size *after* the first layout pass, so that pass laid the
// texture out at the container size and nothing put it back.
val boxH = min(ch, cw * 4f / 3f)
for (v in listOf<View>(texture, holdView)) {
v.layout(0, 0, p.width, p.height)
v.rotation = 0f
v.scaleX = cw / pw
v.scaleY = boxH / ph
v.translationX = cw / 2f - pw / 2f
v.translationY = ch / 2f - ph / 2f
}
}
/**
* Whether the ultra-wide stays on screen. The two back lenses are mutually
* exclusive, so leaving 0.5x closes this session and only then can the main
* one reopen — on screen that is a frozen stale frame, then the reopening
* session's out-of-focus, shifted first frames. JS keeps this true across
* that window so the held frame (holdFrame) covers it, and drops it once the
* main preview is live.
*
* Deliberately plain visibility toggles: a GONE TextureView stops being
* updated, so gating the first frame of a fresh session behind one would
* leave the view hidden forever (measured: the session opens on camera id 2
* and nothing is ever drawn).
*/
fun setCover(next: Boolean) {
if (next == cover) return
cover = next
syncViews()
}
private fun syncViews() {
texture.visibility = if (active) View.VISIBLE else View.GONE
// Held frame stays up until the fresh session has painted. Dissolving before
// that reveals the surface the session is still rebuilding — measured black
// for the rest of the open on this device, right where the 0.5x -> 1x swap
// is supposed to look seamless. During a rebind frameCount is 0, so the
// gate is "this session has drawn something", not "is active".
// A session the caller handed back through release() is gone: its last
// frameCount must not read as fresh, or the held frame would drop off a
// dead (black) texture.
val fresh = active && device != null && frameCount > 0
val holding = !fresh && (cover || frameCount == 0) && holdView.drawable != null
if (holding) {
holdView.visibility = View.VISIBLE
} else if (holdView.visibility == View.VISIBLE) {
// The incoming preview is already settled by now — dissolve rather than
// cut, so the swap between two different lenses reads as one motion.
holdView.animate().alpha(0f).setDuration(160).withEndAction {
holdView.visibility = View.GONE
holdView.alpha = 1f
holdView.setImageDrawable(null)
}
}
}
/**
* Copy the frame that is on screen into the hold layer. Closing the device
* abandons the texture's buffer queue: the last frame does NOT stay on screen
* (measured — the ultra-wide content disappears within one frame of the
* close), so it has to be copied out first.
*/
private fun holdFrame() {
// No frame drawn by this session yet (left again while the lens was still
// opening): the texture has nothing worth holding, and copying it out would
// paste a black frame over the viewfinder. Same once the device is gone —
// release() already froze that frame, and the dead texture now reads black.
if (device == null || frameCount == 0) return
val bmp = try {
texture.bitmap
} catch (e: Throwable) {
null
}
if (bmp == null) return
holdView.animate().cancel()
holdView.alpha = 1f
holdView.setImageBitmap(bmp)
}
fun setActive(next: Boolean) {
if (next == active) return
active = next
generation++
if (next) {
frameCount = 0
// openCamera() resolves the sizes itself, so only the surface needs to be
// ready here — the texture is usually already available, in which case its
// listener has long since fired (while inactive) and will not fire again.
val st = texture.surfaceTexture
if (st != null) {
val p = previewSize
if (p != null) st.setDefaultBufferSize(p.width, p.height)
openCamera()
}
// Otherwise the surface listener opens it once the texture is ready.
} else {
// Always copy the outgoing frame before the device closes — `setCover`
// decides whether it is drawn, and the two props may land in either order.
holdFrame()
closeAll()
}
syncViews()
}
/**
* Hand the lens back: freeze the outgoing frame, close the session and resolve
* once the platform says the device is gone. The caller re-activates the 1x
* preview only on that signal, because CameraX opens the main lens the moment
* it is switched back on and the HAL refuses that open while this one still
* holds the back camera slot — it is retried on a fixed ~520ms timeout, which
* is exactly the cost this handshake removes.
*/
fun release(promise: Promise) {
if (device == null && session == null) {
promise.resolve(null)
return
}
holdFrame()
closedPromise = promise
closeAll()
// frameCount stays put: it is what tells syncViews this lens did paint,
// so the held frame above keeps covering the main preview until the caller
// drops it (holding on a never-painted session would freeze that frame).
syncViews()
camHandler.postDelayed({ onReleased() }, RELEASE_MAX_MS)
}
/** Back to 0.5x while a release() was still in flight: take the lens again. */
fun reopen() {
if (!active || device != null) return
frameCount = 0
openCamera()
}
private fun onReleased() {
val p = closedPromise ?: return
closedPromise = null
mainHandler.post { p.resolve(null) }
}
private fun openCamera(attempt: Int = 0) {
if (!active) return
val ctx = context.applicationContext
val id = cameraId.ifEmpty { findUltraWideId(ctx) }
if (id.isEmpty()) {
Log.w(TAG, "no ultra-wide camera on this device")
return
}
cameraId = id
val gen = generation
try {
val cs = cameraManager(ctx).getCameraCharacteristics(id)
val map = cs.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
sensorOrientation = cs.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 90
val ps = pick4x3(map?.getOutputSizes(SurfaceTexture::class.java), 1920 * 1440)
val fs = pick4x3(map?.getOutputSizes(ImageFormat.JPEG), Int.MAX_VALUE)
activeArray = cs.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE)
if (ps == null || fs == null) {
Log.w(TAG, "ultra-wide $id has no preview/jpeg size")
return
}
if (ps != previewSize) {
previewSize = ps
texture.surfaceTexture?.setDefaultBufferSize(ps.width, ps.height)
applyPreviewTransform()
}
photoSize = fs
} catch (e: Throwable) {
Log.w(TAG, "characteristics($id) failed: ${e.message}")
return
}
cameraManager(ctx).openCamera(id, object : CameraDevice.StateCallback() {
override fun onOpened(dev: CameraDevice) {
if (gen != generation) {
dev.close()
return
}
device = dev
createSession(dev, gen)
}
override fun onDisconnected(dev: CameraDevice) {
dev.close()
if (device === dev) device = null
if (gen == generation && active) camHandler.postDelayed({ openCamera() }, OPEN_RETRY_MS)
}
override fun onError(dev: CameraDevice, error: Int) {
dev.close()
if (device === dev) device = null
Log.w(TAG, "openCamera($id) error $error (attempt $attempt)")
if (gen == generation && active && attempt < OPEN_MAX_ATTEMPTS) {
camHandler.postDelayed({ openCamera(attempt + 1) }, OPEN_RETRY_MS)
}
}
override fun onClosed(dev: CameraDevice) {
onReleased()
}
}, camHandler)
}
private fun createSession(dev: CameraDevice, gen: Int) {
val p = previewSize ?: return
val f = photoSize ?: return
val st = texture.surfaceTexture ?: return
st.setDefaultBufferSize(p.width, p.height)
val surface = Surface(st)
previewSurface?.release()
previewSurface = surface
val ir = ImageReader.newInstance(f.width, f.height, ImageFormat.JPEG, 2)
ir.setOnImageAvailableListener({ r -> onJpegAvailable(r) }, camHandler)
reader = ir
val builder = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
builder.addTarget(surface)
builder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
try {
dev.createCaptureSession(
listOf(surface, ir.surface),
object : CameraCaptureSession.StateCallback() {
override fun onConfigured(s: CameraCaptureSession) {
if (gen != generation) {
s.close()
return
}
session = s
try {
s.setRepeatingRequest(builder.build(), null, camHandler)
} catch (e: Throwable) {
Log.w(TAG, "repeating request failed: ${e.message}")
}
}
override fun onConfigureFailed(s: CameraCaptureSession) {
Log.w(TAG, "session config failed")
if (gen == generation && active) camHandler.postDelayed({ openCamera() }, OPEN_RETRY_MS)
}
},
camHandler
)
} catch (e: Throwable) {
Log.w(TAG, "createCaptureSession failed: ${e.message}")
}
}
fun capture(promise: Promise) {
val dev = device
val s = session
val ir = reader
if (dev == null || s == null || ir == null) {
promise.reject("ERR_NOT_READY", "Ultra-wide camera is not ready", null)
return
}
if (busy) {
promise.reject("ERR_BUSY", "A capture is already in flight", null)
return
}
busy = true
pending = promise
val gen = generation
try {
val req = dev.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
req.addTarget(ir.surface)
req.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
req.set(CaptureRequest.JPEG_ORIENTATION, sensorOrientation)
s.capture(req.build(), null, camHandler)
} catch (e: Throwable) {
fail("ERR_CAPTURE", "Capture request failed: ${e.message}")
return
}
camHandler.postDelayed({
if (busy && gen == generation) fail("ERR_TIMEOUT", "Ultra-wide capture timed out")
}, CAPTURE_TIMEOUT_MS)
}
private fun onJpegAvailable(r: ImageReader) {
val image = try {
r.acquireNextImage()
} catch (e: Throwable) {
null
}
if (image == null) {
fail("ERR_IMAGE", "No image from the ultra-wide capture")
return
}
val out = File(context.cacheDir, "uw_${System.currentTimeMillis()}.jpg")
try {
val buf = image.planes[0].buffer
val bytes = ByteArray(buf.remaining())
buf.get(bytes)
FileOutputStream(out).use { it.write(bytes) }
} catch (e: Throwable) {
fail("ERR_WRITE", "Could not write the ultra-wide photo: ${e.message}")
return
} finally {
image.close()
}
val p = pending
pending = null
busy = false
Log.i(TAG, "captured ${out.absolutePath} (${out.length()} bytes)")
p?.resolve(out.absolutePath)
}
private fun fail(code: String, message: String) {
val p = pending
pending = null
busy = false
p?.reject(code, message, null)
}
/**
* Tap-to-focus + spot metering on the native lens. Camera2 wants metering
* regions in active-array coordinates, so the tap travels back through the
* preview transform. This lens is a landscape 4000x3000 sensor
* (SENSOR_ORIENTATION 90) whose stream reaches the portrait box already
* rotated by the HAL, so the tap's vertical fraction is the sensor's x and
* its horizontal fraction, flipped, is the sensor's y. Measured on device by
* metering 25 taps and ranking the settled frame brightness against the
* scene: this axis order scores +0.72, every other one +0.20 or worse.
*/
private fun sensorRegion(x: Float, y: Float): Rect? {
val arr = activeArray ?: return null
val cw = width.toFloat()
val ch = height.toFloat()
if (cw <= 0f || ch <= 0f) return null
val boxH = min(ch, cw * 4f / 3f)
val u = (x / cw).coerceIn(0f, 1f)
val v = ((y - (ch - boxH) / 2f) / boxH).coerceIn(0f, 1f)
val px = arr.left + v * arr.width()
val py = arr.top + (1f - u) * arr.height()
val hw = arr.width() * METER_FRACTION
val hh = arr.height() * METER_FRACTION
return Rect(
(px - hw).toInt().coerceIn(arr.left, arr.right - 1),
(py - hh).toInt().coerceIn(arr.top, arr.bottom - 1),
(px + hw).toInt().coerceIn(arr.left + 1, arr.right),
(py + hh).toInt().coerceIn(arr.top + 1, arr.bottom)
)
}
/** Preview request that meters [region] only; null = whole-frame continuous. */
private fun meter(region: Rect?, trigger: Boolean = false): CaptureRequest? {
val dev = device ?: return null
val ps = previewSurface ?: return null
val b = dev.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
b.addTarget(ps)
if (region == null) {
b.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
return b.build()
}
val regions = arrayOf(MeteringRectangle(region, MeteringRectangle.METERING_WEIGHT_MAX))
// AF_MODE_AUTO holds the lens where the scan leaves it, so the tapped plane
// stays in focus until the next tap or the unlock. Triggering inside the
// repeating request itself would restart the scan every frame and never
// lock, which is why the trigger goes out as one extra frame.
b.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_AUTO)
b.set(CaptureRequest.CONTROL_AF_REGIONS, regions)
b.set(CaptureRequest.CONTROL_AE_REGIONS, regions)
if (trigger) {
b.set(CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER_START)
b.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START)
}
return b.build()
}
fun focusAt(x: Float, y: Float) {
val s = session ?: return
val region = sensorRegion(x, y) ?: return
val hold = meter(region) ?: return
val trigger = meter(region, trigger = true) ?: return
try {
s.setRepeatingRequest(hold, null, camHandler)
s.capture(trigger, null, camHandler)
} catch (e: Throwable) {
Log.w(TAG, "focusAt failed: ${e.message}")
}
}
/** Back to whole-frame continuous AF/AE (the tap again to unlock gesture). */
fun resetFocus() {
val s = session ?: return
val back = meter(null) ?: return
try {
s.setRepeatingRequest(back, null, camHandler)
} catch (e: Throwable) {
Log.w(TAG, "resetFocus failed: ${e.message}")
}
}
private fun closeAll() {
fail("ERR_CLOSED", "Ultra-wide camera closed")
try {
session?.close()
} catch (e: Throwable) {
Log.w(TAG, "session close: ${e.message}")
}
session = null
try {
reader?.close()
} catch (e: Throwable) {
Log.w(TAG, "reader close: ${e.message}")
}
reader = null
try {
device?.close()
} catch (e: Throwable) {
Log.w(TAG, "device close: ${e.message}")
}
device = null
try {
previewSurface?.release()
} catch (e: Throwable) {
Log.w(TAG, "surface release: ${e.message}")
}
previewSurface = null
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
closeAll()
if (live === this) live = null
}
}
class UltraWideModule : Module() {
private val mainHandler = Handler(Looper.getMainLooper())
override fun definition() = ModuleDefinition {
Name("RecipesCamUltraWide")
AsyncFunction("availableAsync") { ->
val ctx = appContext.reactContext?.applicationContext ?: return@AsyncFunction ""
val id = findUltraWideId(ctx)
id
}
View(UltraWidePreviewView::class) {
Prop("active") { view: UltraWidePreviewView, active: Boolean -> view.setActive(active) }
Prop("cover") { view: UltraWidePreviewView, cover: Boolean -> view.setCover(cover) }
}
AsyncFunction("captureAsync") { promise: Promise ->
val view = UltraWidePreviewView.current()
if (view == null) {
promise.reject("ERR_NO_VIEW", "Ultra-wide preview is not mounted", null)
} else {
view.capture(promise)
}
}
// Tap coordinates arrive in dp, like every other React Native touch point;
// the view transform works in pixels.
AsyncFunction("focusAsync") { x: Double, y: Double ->
val density = appContext.reactContext?.resources?.displayMetrics?.density ?: 1f
UltraWidePreviewView.current()?.focusAt((x * density).toFloat(), (y * density).toFloat())
}
AsyncFunction("resetFocusAsync") {
UltraWidePreviewView.current()?.resetFocus()
}
// Resolves once the ultra-wide has actually released the back camera slot,
// so the caller can re-activate the 1x preview without the open being
// refused (see UltraWidePreviewView.release).
// Expo dispatches async functions on its own queue, not the main thread,
// and ViewRootImpl kills the process outright when another thread touches a
// view — so the calls that reach into the view hierarchy are posted here.
AsyncFunction("closeAsync") { promise: Promise ->
val view = UltraWidePreviewView.current()
if (view == null) promise.resolve(null) else mainHandler.post { view.release(promise) }
}
AsyncFunction("reopenAsync") {
val view = UltraWidePreviewView.current()
if (view != null) mainHandler.post { view.reopen() }
}
}
}
@@ -0,0 +1,8 @@
{
"platforms": ["android"],
"android": {
"modules": [
"com.locphamtran.recipescamera.ultrawide.UltraWideModule"
]
}
}
+47
View File
@@ -0,0 +1,47 @@
import { requireNativeModule, requireNativeViewManager } from 'expo-modules-core';
import type { ComponentType } from 'react';
import type { ViewProps } from 'react-native';
export interface UltraWideModule {
/** Hidden back ultra-wide camera id ('' when this device has none). */
availableAsync(): Promise<string>;
/** Still capture from the ultra-wide lens; resolves to an absolute file path. */
captureAsync(): Promise<string>;
/**
* Tap-to-focus + spot metering on the ultra-wide lens, at a point in the
* preview view (React Native dp, same space as a touch's locationX/Y). The
* main CameraX session is stopped while 0.5x is live, so this is the only
* focus path that exists in that mode.
*/
focusAsync(x: number, y: number): Promise<void>;
/** Back to whole-frame continuous AF/AE. */
resetFocusAsync(): Promise<void>;
/**
* Closes the native session and resolves once the HAL has really released the
* back camera slot. Leaving 0.5x must wait for this before switching the 1x
* preview back on: the two back lenses cannot open at once, so an early 1x
* open is refused and only retried ~520ms later.
*/
closeAsync(): Promise<void>;
/** Takes the ultra-wide again after a closeAsync whose handoff was cancelled. */
reopenAsync(): Promise<void>;
}
export interface UltraWidePreviewProps extends ViewProps {
/** Opens the camera while true, closes it on false/unmount. */
active: boolean;
/**
* Draws this view while true, session or not. While the session is open the
* first frame gates it (the previous session's last frame must not show), and
* after it closes the texture holds its last frame — which is what lets the
* caller keep the ultra-wide on screen until the main preview is live again.
*/
cover: boolean;
}
export const UltraWidePreview = requireNativeViewManager<UltraWidePreviewProps>(
'RecipesCamUltraWide',
'UltraWidePreviewView'
) as ComponentType<UltraWidePreviewProps>;
export default requireNativeModule('RecipesCamUltraWide') as UltraWideModule;
@@ -0,0 +1,8 @@
{
"name": "recipescam-ultrawide",
"version": "0.1.0",
"description": "Native Camera2 ultra-wide (0.5x) preview + still capture for Recipescam.",
"main": "index.ts",
"license": "MIT",
"private": true
}