🌱

expo-cultiva-scanner

Módulo Nativo Expo · FincaApp — Escaneo QR de parcelas agrícolas
Expo SDK 52 Swift 5.9 Kotlin 1.9 TypeScript 5.x iOS + Android

1 Visión general del módulo

expo-cultiva-scanner permite a FincaApp leer códigos QR de etiquetas físicas en parcelas agrícolas accediendo directamente a las APIs de cámara nativas iOS y Android, sin depender de librerías JS que pierden rendimiento.

📷

getDeviceInfo()

Función síncrona. Devuelve marca y modelo del dispositivo del agricultor.

startScan()

AsyncFunction en hilo de fondo. Inicia el motor de detección QR nativo.

📡

onQRDetected

Evento nativo → JS con code y timestamp en tiempo real.

🔦

ScannerView

Vista nativa con prop torchEnabled para linterna del dispositivo.

🔑

Config Plugin

Inyecta permisos de cámara en Info.plist y AndroidManifest automáticamente.

🔗

Autolinking

Registro en expo-module.config.json para vinculación automática.

React Native JS
FincaApp UI
Expo Modules API
TypeScript bindings
Swift Module
AVFoundation QR
Kotlin Module
ML Kit Barcode
Comunicación bidireccional: funciones síncronas/asíncronas + eventos nativo→JS

2 Scaffolding del módulo

Usamos create-expo-module en modo local (solo para FincaApp) con las features exactas que necesitamos.

$ npx create-expo-module@latest \
  --local \
  --platform ios,android \
  --features AsyncFunction,Event,View,ViewEvent \
  cultiva-scanner
1

Local module — vive en modules/cultiva-scanner/ dentro del repo de FincaApp. Sin package.json propio.

2

--features genera código de ejemplo para AsyncFunction, Event, View y ViewEvent (que implica View, correcto).

3

Reemplazamos el código generado con la implementación real de AVFoundation (iOS) y ML Kit (Android).

3 Swift — iOS Module

El módulo Swift usa el DSL de Expo Modules Core. La función startScan() corre en hilo de fondo y dispara eventos al detectar QR.

Swift ios/CultivaScannerModule.swift
import ExpoModulesCore
import AVFoundation

public class CultivaScannerModule: Module {
  private var captureSession: AVCaptureSession?

  public func definition() -> ModuleDefinition {
    Name("CultivaScanner")

    // Eventos declarados antes de usarlos
    Events("onQRDetected", "onScanError")

    // Constante: versión del motor de detección
    Constant("ENGINE_VERSION") { "avfoundation-1.0" }

    // Función síncrona: info del dispositivo
    Function("getDeviceInfo") { () -> [String: String] in
      return [
        "brand": "Apple",
        "model": UIDevice.current.model,
        "systemVersion": UIDevice.current.systemVersion
      ]
    }

    // AsyncFunction: inicia el escaneo QR en hilo de fondo
    AsyncFunction("startScan") { () -> Void in
      let session = AVCaptureSession()
      guard let device = AVCaptureDevice.default(for: .video),
            let input = try? AVCaptureDeviceInput(device: device)
      else {
        throw Exception(name: "CameraUnavailable",
                         description: "No se pudo acceder a la cámara")
      }
      session.addInput(input)

      let output = AVCaptureMetadataOutput()
      session.addOutput(output)
      output.setMetadataObjectsDelegate(self, queue: .main)
      output.metadataObjectTypes = [.qr]

      self.captureSession = session
      session.startRunning()
    }

    // AsyncFunction: detiene el escaneo
    AsyncFunction("stopScan") { () -> Void in
      self.captureSession?.stopRunning()
      self.captureSession = nil
    }

    // Ciclo de vida: limpiar al cerrar el módulo
    OnDestroy {
      self.captureSession?.stopRunning()
    }
  }
}

// Delegate: recibe resultados QR desde AVFoundation
extension CultivaScannerModule: AVCaptureMetadataOutputObjectsDelegate {
  public func metadataOutput(
    _ output: AVCaptureMetadataOutput,
    didOutput objects: [AVMetadataObject],
    from connection: AVCaptureConnection
  ) {
    guard let qr = objects.first as? AVMetadataMachineReadableCodeObject,
          let value = qr.stringValue else { return }

    sendEvent("onQRDetected", [
      "code": value,
      "timestamp": Date().timeIntervalSince1970 * 1000
    ])
  }
}
Nota clave: La función startScan() es AsyncFunction (no bloquea el hilo JS). El delegate de AVFoundation llama a sendEvent desde el main thread, que es correcto para UI.

4 Kotlin — Android Module

La versión Android usa ML Kit Barcode Scanning. El AsyncFunction Kotlin soporta coroutines nativas.

Kotlin android/src/main/java/expo/modules/cultivascanner/CultivaScannerModule.kt
package expo.modules.cultivascanner

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.os.Build
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScanner
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GcodeBarcodeScanning
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.tasks.await
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine

class CultivaScannerModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("CultivaScanner")

    // Eventos declarados
    Events("onQRDetected", "onScanError")

    Constant("ENGINE_VERSION") { "mlkit-barcode-1.0" }

    // Función síncrona: info del dispositivo
    Function("getDeviceInfo") {
      mapOf(
        "brand" to Build.BRAND,
        "model" to Build.MODEL,
        "sdkVersion" to Build.VERSION.SDK_INT.toString()
      )
    }

    // AsyncFunction con Coroutine: inicia escaneo QR
    AsyncFunction("startScan") Coroutine {
      withContext(Dispatchers.Main) {
        val options = GmsBarcodeScannerOptions.Builder()
          .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
          .build()

        val scanner = GcodeBarcodeScanning
          .getClient(appContext.reactContext!!, options)

        val code = suspendCancellableCoroutine { cont ->
          scanner.startScan()
            .addOnSuccessListener { barcode ->
              cont.resume(barcode.rawValue ?: "")
            }
            .addOnFailureListener { e ->
              sendEvent("onScanError", bundleOf("message" to e.message))
              cont.resume("")
            }
        }

        if (code.isNotEmpty()) {
          sendEvent("onQRDetected",
            bundleOf(
              "code" to code,
              "timestamp" to System.currentTimeMillis()
            )
          )
        }
      }
    }

    AsyncFunction("stopScan") { /* ML Kit gestiona su ciclo de vida internamente */ }
  }
}

5 Vista nativa — ScannerView

La vista nativa expone la cámara como componente React Native con soporte para la linterna vía prop torchEnabled.

Swift ios/CultivaScannerView.swift
import ExpoModulesCore
import AVFoundation

class CultivaScannerView: ExpoView {
  private var previewLayer: AVCaptureVideoPreviewLayer?
  var torchEnabled: Bool = false {
    didSet { applyTorch() }
  }

  private func applyTorch() {
    guard let device = AVCaptureDevice.default(for: .video),
          device.hasTorch else { return }
    try? device.lockForConfiguration()
    device.torchMode = torchEnabled ? .on : .off
    device.unlockForConfiguration()
  }

  override func layoutSubviews() {
    super.layoutSubviews()
    previewLayer?.frame = bounds
  }
}

class CultivaScannerViewModule: Module {
  public func definition() -> ModuleDefinition {
    Name("CultivaScannerView")

    View(CultivaScannerView.self) {
      Prop("torchEnabled") { (view: CultivaScannerView, value: Bool) in
        view.torchEnabled = value
      }

      Events("onViewReady")
    }
  }
}

6 TypeScript API

Capa de TypeScript que expone el módulo nativo con tipos correctos. Se importa directamente desde la app.

TypeScript src/index.ts
import { requireNativeModule, NativeModulesProxy } from "expo";
import { useEvent } from "expo";
import { ViewProps } from "react-native";

const CultivaScanner = requireNativeModule("CultivaScanner");

// --- Tipos ---

export interface DeviceInfo {
  brand: string;
  model: string;
  systemVersion?: string;
  sdkVersion?: string;
}

export interface QRDetectedEvent {
  code: string;
  timestamp: number;
}

export interface ScannerViewProps extends ViewProps {
  torchEnabled?: boolean;
  onViewReady?: () => void;
}

// --- API ---

export function getDeviceInfo(): DeviceInfo {
  return CultivaScanner.getDeviceInfo();
}

export async function startScan(): Promise<void> {
  return CultivaScanner.startScan();
}

export async function stopScan(): Promise<void> {
  return CultivaScanner.stopScan();
}

export const ENGINE_VERSION: string = CultivaScanner.ENGINE_VERSION;

// Hook para suscribirse al evento QR
export function useQRDetected(
  callback: (event: QRDetectedEvent) => void
) {
  return useEvent(CultivaScanner, "onQRDetected", callback);
}

7 Config Plugin — Permisos de cámara

El plugin modifica automáticamente Info.plist (iOS) y AndroidManifest.xml al hacer expo prebuild.

TypeScript plugin/src/index.ts
import {
  ConfigPlugin,
  createRunOncePlugin,
  withInfoPlist,
  withAndroidManifest
} from "expo/config-plugins";

const withCultivaScanner: ConfigPlugin<{ cameraUsageDescription?: string }> =
  (config, { cameraUsageDescription = "FincaApp necesita la cámara para escanear parcelas" } = {}) => {

    // iOS: añade NSCameraUsageDescription en Info.plist
    config = withInfoPlist(config, (mod) => {
      mod.modResults."NSCameraUsageDescription" = cameraUsageDescription;
      return mod;
    });

    // Android: añade permiso CAMERA en AndroidManifest.xml
    config = withAndroidManifest(config, (mod) => {
      const manifest = mod.modResults.manifest;
      const permissions = manifest["uses-permission"] || [];
      const cameraPermission = "android.permission.CAMERA";

      const alreadyAdded = permissions.some(
        (p: any) => p.$["android:name"] === cameraPermission
      );

      if (!alreadyAdded) {
        permissions.push({ $: { "android:name": cameraPermission } });
        manifest["uses-permission"] = permissions;
      }
      return mod;
    });

    return config;
  };

export default createRunOncePlugin(
  withCultivaScanner,
  "expo-cultiva-scanner",
  "1.0.0"
);
Activación en app.json: Añadir el plugin en "plugins": [["expo-cultiva-scanner", { "cameraUsageDescription": "Escaneo de parcelas" }]] y ejecutar expo prebuild.

8 expo-module.config.json

Registro del módulo para el sistema de autolinking de Expo. iOS usa el nombre de clase; Android el nombre completo con paquete.

JSON expo-module.config.json
{
  "platforms": ["android", "apple"],
  "apple": {
    "modules": ["CultivaScannerModule", "CultivaScannerViewModule"]
  },
  "android": {
    "modules": [
      "expo.modules.cultivascanner.CultivaScannerModule",
      "expo.modules.cultivascanner.CultivaScannerViewModule"
    ]
  },
  "plugin": "plugin/src/index"
}
CampoDescripciónValor
platformsPlataformas soportadas["android", "apple"]
apple.modulesClases Swift registradas (solo nombre)2 módulos
android.modulesClases Kotlin (fully-qualified)2 módulos
pluginRuta al config pluginplugin/src/index

9 Uso en FincaApp

Ejemplo de pantalla React Native que usa el hook useQRDetected y la vista nativa ScannerView.

TypeScript · React Native screens/ScanParcelaScreen.tsx
import React, { useState, useEffect } from "react";
import { View, Text, StyleSheet, Pressable } from "react-native";
import {
  startScan, stopScan, getDeviceInfo,
  useQRDetected, ScannerView, ENGINE_VERSION
} from "../modules/cultiva-scanner/src";

export function ScanParcelaScreen() {
  const [scanning, setScanning] = useState(false);
  const [torch, setTorch] = useState(false);
  const [lastCode, setLastCode] = useState<string | null>(null);
  const device = getDeviceInfo();

  // Suscripción al evento QR nativo
  useQRDetected((event) => {
    setLastCode(event.code);
    setScanning(false);
    // → navegar a detalles de parcela por código QR
  });

  const handleStart = async () => {
    setScanning(true);
    await startScan();
  };

  return (
    <View style={styles.container}>
      <Text style={styles.deviceInfo}>
        {device.brand} {device.model} · Motor: {ENGINE_VERSION}
      </Text>

      {/* Vista nativa con linterna */}
      <ScannerView
        style={styles.scanner}
        torchEnabled={torch}
        onViewReady={() => console.log("Vista lista")}
      />

      <Pressable
        style={styles.btn}
        onPress={scanning ? stopScan : handleStart}
      >
        <Text>{scanning ? "Detener" : "Escanear QR parcela"}</Text>
      </Pressable>

      {lastCode && (
        <Text style={styles.result}>Parcela: {lastCode}</Text>
      )}
    </View>
  );
}