feat: add torch plugin for iOS and Android (#3551)

* feat(barcode-scanner): add torch toggle

* feat: torch plugin

* fix

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
0xbizkandLucas Nogueira authored and GitHub committed 2026-10-13 05:37:04 -03:00
1 parent c91a9e6af5
commit d80b1be257
48 files changed
+1861 -4

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/build
/.tauri
+44
View File
@@ -0,0 +1,44 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "app.tauri.torch"
compileSdk = 36
defaultConfig {
minSdk = 24
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
}
dependencies {
implementation("androidx.core:core-ktx:1.9.0")
implementation("androidx.appcompat:appcompat:1.6.0")
implementation("com.google.android.material:material:1.7.0")
implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
// Only compiled against: CameraX is on the runtime classpath when the barcode scanner plugin is,
// and ScannerCamera, the only class using it, is only constructed while a scan is active.
compileOnly("androidx.camera:camera-core:1.5.1")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation(project(":tauri-android"))
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
+2
View File
@@ -0,0 +1,2 @@
include ':tauri-android'
project(':tauri-android').projectDir = new File('./.tauri/tauri-api')
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,43 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.torch
import androidx.camera.core.Camera
import androidx.camera.core.TorchState
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executor
/**
* The camera held by an active barcode scan.
*
* While CameraX holds the camera, `CameraManager.setTorchMode` fails with `CAMERA_IN_USE`, so the
* torch has to go through the camera's own `CameraControl`. This is the only class referencing
* CameraX, which is only on the classpath when the barcode scanner plugin is; only construct it
* from a camera returned by [TorchPlugin.findScannerCamera].
*/
internal class ScannerCamera(camera: Any) {
private val camera = camera as Camera
val hasFlashUnit: Boolean
get() = camera.cameraInfo.hasFlashUnit()
val torchEnabled: Boolean
get() = camera.cameraInfo.torchState.value == TorchState.ON
/** Calls [callback] with `null` once the torch changed, or with the error that prevented it. */
fun setTorch(enabled: Boolean, executor: Executor, callback: (Throwable?) -> Unit) {
val future = camera.cameraControl.enableTorch(enabled)
future.addListener({
try {
future.get()
callback(null)
} catch (e: ExecutionException) {
callback(e.cause ?: e)
} catch (e: Exception) {
callback(e)
}
}, executor)
}
}
@@ -0,0 +1,159 @@
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
package app.tauri.torch
import android.app.Activity
import android.content.Context
import android.hardware.camera2.CameraAccessException
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.os.Handler
import android.os.Looper
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import app.tauri.Logger
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
private const val ERROR_NOT_AVAILABLE = "The torch is not available on this device"
private const val SCANNER_PLUGIN_CLASS = "app.tauri.barcodescanner.BarcodeScannerPlugin"
@TauriPlugin
class TorchPlugin(private val activity: Activity) : Plugin(activity) {
private val cameraManager =
activity.getSystemService(Context.CAMERA_SERVICE) as CameraManager
// the camera whose flash unit is used as the torch, or null if the device has none
private val torchCameraId: String? = findTorchCameraId()
// the system-reported state of the torch, kept in sync by torchCallback so changes made by
// the system or other apps are reflected
@Volatile
private var torchEnabled = false
private val torchCallback = object : CameraManager.TorchCallback() {
override fun onTorchModeChanged(cameraId: String, enabled: Boolean) {
if (cameraId == torchCameraId) torchEnabled = enabled
}
override fun onTorchModeUnavailable(cameraId: String) {
if (cameraId == torchCameraId) torchEnabled = false
}
}
init {
if (torchCameraId != null) {
cameraManager.registerTorchCallback(torchCallback, Handler(Looper.getMainLooper()))
}
}
override fun onDestroy(activity: AppCompatActivity) {
cameraManager.unregisterTorchCallback(torchCallback)
}
private fun findTorchCameraId(): String? {
return try {
val withFlash = cameraManager.cameraIdList.filter {
cameraManager.getCameraCharacteristics(it)
.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
}
withFlash.firstOrNull {
cameraManager.getCameraCharacteristics(it)
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
} ?: withFlash.firstOrNull()
} catch (e: Exception) {
Logger.error("Failed to look up the camera flash unit: ${e.message}")
null
}
}
// The camera of an active barcode scan, if the barcode scanner plugin is installed and scanning.
private fun findScannerCamera(): ScannerCamera? {
val camera = try {
Class.forName(SCANNER_PLUGIN_CLASS).getMethod("getActiveCamera").invoke(null)
} catch (e: Exception) {
null
} ?: return null
return ScannerCamera(camera)
}
private fun isTorchOn(): Boolean = findScannerCamera()?.torchEnabled ?: torchEnabled
private fun setTorch(invoke: Invoke, enabled: Boolean, onSuccess: () -> Unit) {
val action = if (enabled) "enable" else "disable"
val scannerCamera = findScannerCamera()
if (scannerCamera != null) {
if (!scannerCamera.hasFlashUnit) {
invoke.reject("The camera used by the barcode scanner has no flash unit")
return
}
scannerCamera.setTorch(enabled, ContextCompat.getMainExecutor(activity)) { error ->
if (error == null) {
onSuccess()
} else {
invoke.reject("Failed to $action the torch: ${error.message}")
}
}
return
}
val cameraId = torchCameraId
if (cameraId == null) {
invoke.reject(ERROR_NOT_AVAILABLE)
return
}
try {
cameraManager.setTorchMode(cameraId, enabled)
torchEnabled = enabled
onSuccess()
} catch (e: CameraAccessException) {
when (e.reason) {
CameraAccessException.CAMERA_IN_USE, CameraAccessException.MAX_CAMERAS_IN_USE ->
invoke.reject("Failed to $action the torch: the camera is in use by another app")
else -> invoke.reject("Failed to $action the torch: ${e.message}")
}
} catch (e: Exception) {
invoke.reject("Failed to $action the torch: ${e.message}")
}
}
@Command
fun enable(invoke: Invoke) {
setTorch(invoke, true) { invoke.resolve() }
}
@Command
fun disable(invoke: Invoke) {
setTorch(invoke, false) { invoke.resolve() }
}
@Command
fun toggle(invoke: Invoke) {
val enabled = !isTorchOn()
setTorch(invoke, enabled) {
val result = JSObject()
result.put("enabled", enabled)
invoke.resolve(result)
}
}
@Command
fun isAvailable(invoke: Invoke) {
val result = JSObject()
result.put("available", torchCameraId != null)
invoke.resolve(result)
}
@Command
fun isEnabled(invoke: Invoke) {
val result = JSObject()
result.put("enabled", isTorchOn())
invoke.resolve(result)
}
}