Current section

Files

Jump to
mob_new priv templates mob.new android app src main java MobBridge.kt.eex
Raw

priv/templates/mob.new/android/app/src/main/java/MobBridge.kt.eex

package <%= java_package %>
import android.app.Activity
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.pm.PackageManager
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.LocationListener
import android.location.LocationManager as AndroidLocationManager
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.net.Uri
import android.os.SystemClock
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import java.io.File
import java.lang.ref.WeakReference
import java.util.concurrent.Executor
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size as ComposeSize
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import android.graphics.Paint
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.QuestionMark
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.AcUnit
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.os.Build
import android.view.HapticFeedbackConstants
import org.json.JSONArray
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import org.json.JSONObject
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview as CameraPreview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.LifecycleOwner
/**
* Bridge between the BEAM and Jetpack Compose.
*
* The BEAM calls setRootJson(json) (via mob_nif's set_root/1) whenever the
* screen re-renders. MutableState triggers recomposition automatically —
* no main-thread dispatch needed for state writes.
*
* Tap events: Compose onClick calls nativeSendTap(handle), which routes
* to mob_send_tap() in mob_nif.c and sends {:tap, tag} to the registered PID.
*
* Holds the rendered tree and the nav transition to animate.
*
* navKey only increments on actual navigation transitions (push/pop/reset).
* AnimatedContent in MainActivity uses navKey as the contentKey so that
* same-screen BEAM re-renders (transition == "none") recompose the existing
* composable in place — no content swap, no focus loss, no keyboard dismissal.
*/
data class RootState(val navKey: Int, val transition: String, val node: MobNode?)
object MobBridge {
private val _rootState = mutableStateOf(RootState(0, "none", null))
val rootState: State<RootState> get() = _rootState
// Persists LazyListState across re-renders so scroll position survives data
// updates. Keyed by the on_end_reached handle integer, which is stable within
// a screen (same render-order index after each clear_taps). Cleared on
// navigation transitions (push/pop/reset) where the list is genuinely new.
private val lazyListStates = mutableMapOf<Int, LazyListState>()
fun getOrCreateLazyListState(handle: Int): LazyListState =
lazyListStates.getOrPut(handle) { LazyListState() }
private var activityRef: WeakReference<Activity>? = null
/** Called from mob_nif.c via JNI — initialise anything activity-scoped. */
@JvmStatic
fun init(activity: Activity) {
activityRef = WeakReference(activity)
extractOtpIfNeeded(activity)
copyMobLogos(activity)
}
/**
* Extracts the bundled OTP runtime from `assets/otp.zip` into `<filesDir>/otp/`.
*
* Release builds (assembleRelease / bundleRelease) ship the OTP tree as an
* asset zip because Play Store installs can't `adb push` it. The extractor
* runs once on first launch (and again after each app update, keyed by
* PackageInfo.lastUpdateTime).
*
* Debug builds skip extraction — `mix mob.deploy --native --android` pushes
* the OTP tree directly to `<filesDir>/otp/` via adb, so no asset zip exists
* and this method becomes a no-op.
*
* Pattern mirrors elixir-desktop's `Bridge.kt:unpackZip()`.
*/
private fun extractOtpIfNeeded(activity: Activity) {
val assetList = try { activity.assets.list("") ?: emptyArray() } catch (_: Exception) { return }
if ("otp.zip" !in assetList) return
val otpDir = java.io.File(activity.filesDir, "otp")
val marker = java.io.File(otpDir, ".installed_version")
val expectedVersion = activity.packageManager
.getPackageInfo(activity.packageName, 0).lastUpdateTime.toString()
if (marker.exists() && marker.readText() == expectedVersion) return
if (otpDir.exists()) otpDir.deleteRecursively()
otpDir.mkdirs()
activity.assets.open("otp.zip").use { input ->
java.util.zip.ZipInputStream(java.io.BufferedInputStream(input)).use { zis ->
val buffer = ByteArray(8192)
while (true) {
val entry = zis.nextEntry ?: break
val out = java.io.File(otpDir, entry.name)
if (entry.isDirectory) {
out.mkdirs()
} else {
out.parentFile?.mkdirs()
java.io.FileOutputStream(out).use { fos ->
var n = zis.read(buffer)
while (n != -1) { fos.write(buffer, 0, n); n = zis.read(buffer) }
}
}
zis.closeEntry()
}
}
}
marker.writeText(expectedVersion)
android.util.Log.i("MobBridge", "extracted OTP runtime, version=$expectedVersion")
}
/** Extracts Mob logo PNGs from APK assets to the OTP root so Elixir can reference them. */
private fun copyMobLogos(activity: Activity) {
val otpDir = java.io.File(activity.filesDir, "otp").also { it.mkdirs() }
listOf("mob_logo_dark.png", "mob_logo_light.png").forEach { name ->
try {
activity.assets.open(name).use { input ->
java.io.File(otpDir, name).outputStream().use { output -> input.copyTo(output) }
}
} catch (e: Exception) {
android.util.Log.w("MobBridge", "Could not copy logo asset $name: $e")
}
}
}
/**
* Called from nif_color_scheme via JNI. Returns "light" or "dark" based on
* the current night-mode UI configuration. Falls back to "light" if the
* activity is gone (shouldn't happen in practice).
*/
@JvmStatic
fun getColorScheme(): String {
val activity = activityRef?.get() ?: return "light"
val nightMode =
activity.resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK
return if (nightMode == android.content.res.Configuration.UI_MODE_NIGHT_YES) "dark"
else "light"
}
/** Called from nif_exit_app via JNI — backgrounds the app without killing it. */
@JvmStatic
fun moveToBack() {
activityRef?.get()?.let { activity ->
activity.runOnUiThread { activity.moveTaskToBack(true) }
}
}
/**
* Called from MainActivity.onConfigurationChanged when uiMode flips.
* Forwards to the BEAM via JNI so Mob.Device :appearance subscribers
* see {:mob_device, :color_scheme_changed, :light | :dark}.
*/
@JvmStatic
fun notifyColorSchemeChanged(scheme: String) {
nativeNotifyColorScheme(scheme)
}
/** JNI bridge — implemented in beam_jni.c. */
@JvmStatic external fun nativeNotifyColorScheme(scheme: String)
/** Called from mob_nif.c's nif_set_root — updates Compose state. */
@JvmStatic
fun setRootJson(json: String, transition: String) {
// Navigation transitions mean a genuinely different screen — old list state
// is no longer relevant and would scroll the wrong list to a stale position.
val newKey = if (transition != "none") {
lazyListStates.clear()
_rootState.value.navKey + 1
} else {
_rootState.value.navKey
}
_rootState.value = RootState(newKey, transition, JSONObject(json).toMobNode())
}
/** Called from Compose onClick — routes tap back to BEAM via C. */
@JvmStatic
external fun nativeSendTap(handle: Int)
/** Called from Compose onChange — routes change value back to BEAM via C. */
@JvmStatic
external fun nativeSendChangeStr(handle: Int, value: String)
@JvmStatic
external fun nativeSendChangeBool(handle: Int, value: Boolean)
@JvmStatic
external fun nativeSendChangeFloat(handle: Int, value: Float)
@JvmStatic external fun nativeSendFocus(handle: Int)
@JvmStatic external fun nativeSendBlur(handle: Int)
@JvmStatic external fun nativeSendSubmit(handle: Int)
/** Called from BackHandler in MainActivity when the system back gesture fires. */
@JvmStatic external fun nativeHandleBack()
// ── Native delivery stubs — implemented in beam_jni.c ────────────────────
@JvmStatic external fun nativeDeliverAtom2(pid: Long, a1: String, a2: String)
@JvmStatic external fun nativeDeliverAtom3(pid: Long, a1: String, a2: String, a3: String)
@JvmStatic external fun nativeDeliverLocation(pid: Long, lat: Double, lon: Double, acc: Double, alt: Double)
@JvmStatic external fun nativeDeliverMotion(pid: Long, ax: Double, ay: Double, az: Double,
gx: Double, gy: Double, gz: Double, ts: Long)
@JvmStatic external fun nativeDeliverFileResult(pid: Long, event: String, sub: String, json: String?)
@JvmStatic external fun nativeDeliverPushToken(pid: Long, token: String)
@JvmStatic external fun nativeDeliverNotification(pid: Long, json: String)
@JvmStatic external fun nativeSetLaunchNotification(json: String?)
@JvmStatic external fun nativeDeliverWebViewMessage(pid: Long, json: String)
@JvmStatic external fun nativeDeliverWebViewBlocked(pid: Long, url: String)
@JvmStatic external fun nativeDeliverAlertAction(action: String)
// ── Pending callback PIDs ──────────────────────────────────────────────
var pendingPermissionPid: Long = 0
var pendingPermissionCap: String = ""
@Volatile var notifyPid: Long = 0
var pendingCameraPid: Long = 0
var pendingCameraIsVideo: Boolean = false
var pendingPhotosPid: Long = 0
var pendingFilesPid: Long = 0
var pendingScanPid: Long = 0
// ── Permissions ────────────────────────────────────────────────────────
@JvmStatic
fun request_permission(pid: Long, cap: String) {
pendingPermissionPid = pid
pendingPermissionCap = cap
val activity = activityRef?.get() ?: run {
nativeDeliverAtom3(pid, "permission", cap, "denied"); return
}
val perms = when (cap) {
"camera" -> arrayOf(android.Manifest.permission.CAMERA)
"microphone" -> arrayOf(android.Manifest.permission.RECORD_AUDIO)
"photo_library" -> if (android.os.Build.VERSION.SDK_INT >= 33)
arrayOf(android.Manifest.permission.READ_MEDIA_IMAGES, android.Manifest.permission.READ_MEDIA_VIDEO)
else arrayOf(android.Manifest.permission.READ_EXTERNAL_STORAGE)
"location" -> arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION)
"notifications" -> if (android.os.Build.VERSION.SDK_INT >= 33)
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS)
else { nativeDeliverAtom3(pid, "permission", "notifications", "granted"); return }
else -> { nativeDeliverAtom3(pid, "permission", cap, "denied"); return }
}
if (perms.all { ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED }) {
nativeDeliverAtom3(pid, "permission", cap, "granted")
} else {
ActivityCompat.requestPermissions(activity, perms, PERM_REQUEST_CODE)
}
}
@JvmStatic
fun onPermissionResult(granted: Boolean) {
nativeDeliverAtom3(pendingPermissionPid, "permission", pendingPermissionCap, if (granted) "granted" else "denied")
}
// ── Biometric ─────────────────────────────────────────────────────────
@JvmStatic
fun biometric_authenticate(pid: Long, reason: String) {
val activity = activityRef?.get() as? FragmentActivity ?: run {
nativeDeliverAtom2(pid, "biometric", "not_available"); return
}
val mgr = BiometricManager.from(activity)
if (mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
!= BiometricManager.BIOMETRIC_SUCCESS) {
nativeDeliverAtom2(pid, "biometric", "not_available"); return
}
val executor: Executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
nativeDeliverAtom2(pid, "biometric", "success")
}
override fun onAuthenticationFailed() {
nativeDeliverAtom2(pid, "biometric", "failure")
}
override fun onAuthenticationError(code: Int, msg: CharSequence) {
nativeDeliverAtom2(pid, "biometric", if (code == BiometricPrompt.ERROR_CANCELED ||
code == BiometricPrompt.ERROR_USER_CANCELED) "failure" else "not_available")
}
})
activity.runOnUiThread {
prompt.authenticate(BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate").setSubtitle(reason)
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK).build())
}
}
// ── Location ──────────────────────────────────────────────────────────
private var locationClient: FusedLocationProviderClient? = null
private var locationCallback: LocationCallback? = null
@JvmStatic
fun location_get_once(pid: Long, accuracy: String) {
val activity = activityRef?.get() ?: run {
nativeDeliverAtom3(pid, "location", "error", "unavailable"); return
}
if (ContextCompat.checkSelfPermission(activity, android.Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
nativeDeliverAtom3(pid, "location", "error", "permission_denied"); return
}
val client = LocationServices.getFusedLocationProviderClient(activity)
client.lastLocation.addOnSuccessListener { loc ->
if (loc != null) {
nativeDeliverLocation(pid, loc.latitude, loc.longitude,
loc.accuracy.toDouble(), loc.altitude)
} else {
val req = LocationRequest.Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, 1000).setMaxUpdates(1).build()
val cb = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { l ->
nativeDeliverLocation(pid, l.latitude, l.longitude, l.accuracy.toDouble(), l.altitude)
}
client.removeLocationUpdates(this)
}
}
client.requestLocationUpdates(req, cb, activity.mainLooper)
}
}
}
@JvmStatic
fun location_start(pid: Long, accuracy: String) {
val activity = activityRef?.get() ?: return
if (ContextCompat.checkSelfPermission(activity, android.Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
nativeDeliverAtom3(pid, "location", "error", "permission_denied"); return
}
val priority = when (accuracy) {
"high" -> Priority.PRIORITY_HIGH_ACCURACY
"low" -> Priority.PRIORITY_LOW_POWER
else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
}
val client = LocationServices.getFusedLocationProviderClient(activity)
locationClient = client
val req = LocationRequest.Builder(priority, 5000).build()
val cb = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { l ->
nativeDeliverLocation(pid, l.latitude, l.longitude, l.accuracy.toDouble(), l.altitude)
}
}
}
locationCallback = cb
client.requestLocationUpdates(req, cb, activity.mainLooper)
}
@JvmStatic
fun location_stop() {
locationCallback?.let { locationClient?.removeLocationUpdates(it) }
locationCallback = null
}
// ── Camera ────────────────────────────────────────────────────────────
@JvmStatic
fun camera_capture_photo(pid: Long, quality: String) {
pendingCameraPid = pid
pendingCameraIsVideo = false
activityRef?.get()?.let { (it as? MainActivity)?.launchCameraPhoto() }
?: nativeDeliverAtom2(pid, "camera", "cancelled")
}
@JvmStatic
fun camera_capture_video(pid: Long, maxDuration: String) {
pendingCameraPid = pid
pendingCameraIsVideo = true
activityRef?.get()?.let { (it as? MainActivity)?.launchCameraVideo() }
?: nativeDeliverAtom2(pid, "camera", "cancelled")
}
@JvmStatic
fun handleCameraPhotoResult(uri: Uri?) {
val pid = pendingCameraPid
if (uri == null) { nativeDeliverAtom2(pid, "camera", "cancelled"); return }
val activity = activityRef?.get() ?: return
Thread {
try {
val tmp = File(activity.cacheDir, "mob_photo_${System.currentTimeMillis()}.jpg")
activity.contentResolver.openInputStream(uri)?.use { it.copyTo(tmp.outputStream()) }
val json = """[{"path":"${tmp.absolutePath}","width":0,"height":0}]"""
nativeDeliverFileResult(pid, "camera", "photo", json)
} catch (e: Exception) {
nativeDeliverAtom2(pid, "camera", "cancelled")
}
}.start()
}
@JvmStatic
fun handleCameraVideoResult(uri: Uri?) {
val pid = pendingCameraPid
if (uri == null) { nativeDeliverAtom2(pid, "camera", "cancelled"); return }
val activity = activityRef?.get() ?: return
Thread {
try {
val tmp = File(activity.cacheDir, "mob_video_${System.currentTimeMillis()}.mp4")
activity.contentResolver.openInputStream(uri)?.use { it.copyTo(tmp.outputStream()) }
val json = """[{"path":"${tmp.absolutePath}","duration":0.0}]"""
nativeDeliverFileResult(pid, "camera", "video", json)
} catch (e: Exception) {
nativeDeliverAtom2(pid, "camera", "cancelled")
}
}.start()
}
// ── Photos picker ─────────────────────────────────────────────────────
@JvmStatic
fun photos_pick(pid: Long, maxStr: String) {
pendingPhotosPid = pid
activityRef?.get()?.let { (it as? MainActivity)?.launchPhotosPicker(maxStr.toIntOrNull() ?: 1) }
?: nativeDeliverAtom2(pid, "photos", "cancelled")
}
@JvmStatic
fun handlePhotosResult(uris: List<Uri>) {
val pid = pendingPhotosPid
if (uris.isEmpty()) { nativeDeliverAtom2(pid, "photos", "cancelled"); return }
val activity = activityRef?.get() ?: return
Thread {
try {
val items = uris.mapIndexed { i, uri ->
val ext = if (uri.toString().contains("video")) "mp4" else "jpg"
val tmp = File(activity.cacheDir, "mob_pick_${System.currentTimeMillis()}_$i.$ext")
activity.contentResolver.openInputStream(uri)?.use { it.copyTo(tmp.outputStream()) }
val type = if (ext == "mp4") "video" else "image"
"""{"path":"${tmp.absolutePath}","type":"$type","width":0,"height":0}"""
}
val json = "[${items.joinToString(",")}]"
nativeDeliverFileResult(pid, "photos", "picked", json)
} catch (e: Exception) {
nativeDeliverAtom2(pid, "photos", "cancelled")
}
}.start()
}
// ── File picker ───────────────────────────────────────────────────────
@JvmStatic
fun files_pick(pid: Long, typesJson: String) {
pendingFilesPid = pid
activityRef?.get()?.let { (it as? MainActivity)?.launchFilePicker() }
?: nativeDeliverAtom2(pid, "files", "cancelled")
}
@JvmStatic
fun handleFilesResult(uris: List<Uri>) {
val pid = pendingFilesPid
if (uris.isEmpty()) { nativeDeliverAtom2(pid, "files", "cancelled"); return }
val activity = activityRef?.get() ?: return
Thread {
try {
val items = uris.mapIndexed { i, uri ->
val name = uri.lastPathSegment ?: "file_$i"
val tmp = File(activity.cacheDir, "mob_file_${System.currentTimeMillis()}_$name")
activity.contentResolver.openInputStream(uri)?.use { it.copyTo(tmp.outputStream()) }
val size = tmp.length()
val mime = activity.contentResolver.getType(uri) ?: "application/octet-stream"
"""{"path":"${tmp.absolutePath}","name":"$name","mime":"$mime","size":$size}"""
}
val json = "[${items.joinToString(",")}]"
nativeDeliverFileResult(pid, "files", "picked", json)
} catch (e: Exception) {
nativeDeliverAtom2(pid, "files", "cancelled")
}
}.start()
}
// ── Audio recording ───────────────────────────────────────────────────
private var audioRecorder: MediaRecorder? = null
private var audioPath: String? = null
private var audioStartMs: Long = 0
private var audioPid: Long = 0
@JvmStatic
fun audio_start_recording(pid: Long, optsJson: String) {
audioPid = pid
val activity = activityRef?.get() ?: return
activity.runOnUiThread {
try {
val tmp = File(activity.cacheDir, "mob_audio_${System.currentTimeMillis()}.m4a")
audioPath = tmp.absolutePath
audioStartMs = SystemClock.elapsedRealtime()
val rec = if (android.os.Build.VERSION.SDK_INT >= 31)
MediaRecorder(activity)
else @Suppress("DEPRECATION") MediaRecorder()
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setOutputFile(audioPath)
rec.prepare()
rec.start()
audioRecorder = rec
} catch (e: Exception) {
nativeDeliverAtom3(pid, "audio", "error", "setup_failed")
}
}
}
@JvmStatic
fun audio_stop_recording() {
val rec = audioRecorder ?: return
val pid = audioPid
val path = audioPath ?: return
val duration = (SystemClock.elapsedRealtime() - audioStartMs) / 1000.0
audioRecorder = null
try {
rec.stop()
rec.release()
val json = """[{"path":"$path","duration":$duration}]"""
nativeDeliverFileResult(pid, "audio", "recorded", json)
} catch (e: Exception) {
nativeDeliverAtom3(pid, "audio", "error", "stop_failed")
}
}
// ── Audio playback ─────────────────────────────────────────────────────
private var audioPlayer: MediaPlayer? = null
private var playbackPid: Long = 0
private var playbackPath: String? = null
@JvmStatic
fun audio_play(pid: Long, path: String, optsJson: String) {
playbackPid = pid
playbackPath = path
val activity = activityRef?.get() ?: return
activity.runOnUiThread {
try {
audioPlayer?.release()
audioPlayer = null
val opts = org.json.JSONObject(optsJson)
val loop = opts.optBoolean("loop", false)
val volume = opts.optDouble("volume", 1.0).toFloat()
val player = MediaPlayer()
player.setDataSource(path)
player.isLooping = loop
player.setVolume(volume, volume)
player.setOnCompletionListener {
val p = playbackPid
val pp = playbackPath ?: ""
audioPlayer = null
playbackPath = null
val json = """[{"path":"$pp"}]"""
nativeDeliverFileResult(p, "audio", "playback_finished", json)
}
player.setOnErrorListener { _, _, _ ->
val p = playbackPid
audioPlayer = null
playbackPath = null
nativeDeliverAtom3(p, "audio", "playback_error", "player_error")
true
}
player.prepare()
player.start()
audioPlayer = player
} catch (e: Exception) {
nativeDeliverAtom3(pid, "audio", "playback_error", "setup_failed")
}
}
}
@JvmStatic
fun audio_stop_playback() {
val player = audioPlayer ?: return
audioPlayer = null
playbackPath = null
try { player.stop(); player.release() } catch (_: Exception) {}
}
@JvmStatic
fun audio_set_volume(volStr: String) {
val vol = volStr.toFloatOrNull() ?: 1.0f
audioPlayer?.setVolume(vol, vol)
}
// ── Background keep-alive ─────────────────────────────────────────────
@JvmStatic
fun background_keep_alive() {
val activity = activityRef?.get() ?: return
val intent = Intent(activity, BeamForegroundService::class.java).apply {
action = BeamForegroundService.ACTION_START
}
if (Build.VERSION.SDK_INT >= 26) {
activity.startForegroundService(intent)
} else {
activity.startService(intent)
}
}
@JvmStatic
fun background_stop() {
val activity = activityRef?.get() ?: return
val intent = Intent(activity, BeamForegroundService::class.java).apply {
action = BeamForegroundService.ACTION_STOP
}
activity.startService(intent)
}
// ── Storage ───────────────────────────────────────────────────────────
@JvmStatic
fun storage_dir(type: String): String? {
val ctx = activityRef?.get() ?: return null
return when (type) {
"temp" -> ctx.cacheDir.absolutePath + "/mob_temp"
"documents" -> ctx.filesDir.absolutePath + "/documents"
"cache" -> ctx.cacheDir.absolutePath
"app_support" -> ctx.filesDir.absolutePath
"icloud" -> null
else -> null
}.also { path -> path?.let { java.io.File(it).mkdirs() } }
}
@JvmStatic
fun storage_external_files_dir(type: String): String? {
val ctx = activityRef?.get() ?: return null
val envType = when (type) {
"documents" -> android.os.Environment.DIRECTORY_DOCUMENTS
"pictures" -> android.os.Environment.DIRECTORY_PICTURES
"movies" -> android.os.Environment.DIRECTORY_MOVIES
"music" -> android.os.Environment.DIRECTORY_MUSIC
"downloads" -> android.os.Environment.DIRECTORY_DOWNLOADS
"dcim" -> android.os.Environment.DIRECTORY_DCIM
else -> android.os.Environment.DIRECTORY_DOCUMENTS
}
return ctx.getExternalFilesDir(envType)?.absolutePath
}
@JvmStatic
fun storage_save_to_media_store(pid: Long, path: String, type: String) {
val ctx = activityRef?.get() ?: run {
nativeDeliverAtom3(pid, "storage", "error", "no_context")
return
}
val file = java.io.File(path)
val ext = file.extension.lowercase()
val mimeType = when {
type == "image" || ext in listOf("jpg","jpeg","png","gif","webp","heic") -> "image/*"
type == "video" || ext in listOf("mp4","mov","m4v","avi","mkv") -> "video/*"
type == "audio" || ext in listOf("m4a","mp3","aac","wav","flac","ogg") -> "audio/*"
else -> "*/*"
}
val collection = when {
mimeType.startsWith("image") -> android.provider.MediaStore.Images.Media.getContentUri(
android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY)
mimeType.startsWith("video") -> android.provider.MediaStore.Video.Media.getContentUri(
android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY)
mimeType.startsWith("audio") -> android.provider.MediaStore.Audio.Media.getContentUri(
android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY)
else -> android.provider.MediaStore.Files.getContentUri(
android.provider.MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
try {
val values = android.content.ContentValues().apply {
put(android.provider.MediaStore.MediaColumns.DISPLAY_NAME, file.name)
put(android.provider.MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(android.provider.MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = ctx.contentResolver.insert(collection, values)
?: throw Exception("insert returned null")
ctx.contentResolver.openOutputStream(uri)?.use { out ->
file.inputStream().use { it.copyTo(out) }
}
values.clear()
values.put(android.provider.MediaStore.MediaColumns.IS_PENDING, 0)
ctx.contentResolver.update(uri, values, null, null)
val json = org.json.JSONArray().apply {
put(org.json.JSONObject().put("path", path))
}.toString()
nativeDeliverFileResult(pid, "storage", "saved_to_library", json)
} catch (e: Exception) {
nativeDeliverAtom3(pid, "storage", "error", "save_failed")
}
}
// ── Camera preview ────────────────────────────────────────────────────
internal var previewCameraProvider: ProcessCameraProvider? = null
@JvmStatic
fun camera_start_preview(pid: Long, optsJson: String) {
try {
val facing = JSONObject(optsJson).optString("facing", "back")
_previewFacing.value = facing
} catch (_: Exception) {}
}
@JvmStatic
fun camera_stop_preview() {
_previewFacing.value = null
previewCameraProvider?.unbindAll()
previewCameraProvider = null
}
private val _previewFacing = mutableStateOf<String?>(null)
val previewFacing: State<String?> get() = _previewFacing
// ── Alerts / action sheets / toasts ───────────────────────────────────
@JvmStatic
fun alert_show(title: String, message: String, buttonsJson: String) {
val activity = activityRef?.get() ?: return
val buttons = parseButtonsJson(buttonsJson)
activity.runOnUiThread {
val builder = android.app.AlertDialog.Builder(activity)
if (title.isNotEmpty()) builder.setTitle(title)
if (message.isNotEmpty()) builder.setMessage(message)
val positives = buttons.filter { it["style"] != "cancel" }
val cancels = buttons.filter { it["style"] == "cancel" }
positives.firstOrNull()?.let { btn ->
val action = btn["action"] ?: "dismiss"
builder.setPositiveButton(btn["label"]) { _, _ -> nativeDeliverAlertAction(action) }
}
positives.getOrNull(1)?.let { btn ->
val action = btn["action"] ?: "dismiss"
builder.setNeutralButton(btn["label"]) { _, _ -> nativeDeliverAlertAction(action) }
}
cancels.firstOrNull()?.let { btn ->
val action = btn["action"] ?: "dismiss"
builder.setNegativeButton(btn["label"]) { _, _ -> nativeDeliverAlertAction(action) }
}
builder.setOnCancelListener { nativeDeliverAlertAction("dismiss") }
builder.show()
}
}
@JvmStatic
fun action_sheet_show(title: String, buttonsJson: String) {
val activity = activityRef?.get() ?: return
val buttons = parseButtonsJson(buttonsJson)
val nonCancel = buttons.filter { it["style"] != "cancel" }
val cancel = buttons.firstOrNull { it["style"] == "cancel" }
val labels = nonCancel.map { it["label"] ?: "" }.toTypedArray()
activity.runOnUiThread {
val builder = android.app.AlertDialog.Builder(activity)
if (title.isNotEmpty()) builder.setTitle(title)
builder.setItems(labels) { _, which ->
val action = nonCancel[which]["action"] ?: "dismiss"
nativeDeliverAlertAction(action)
}
cancel?.let { btn ->
val action = btn["action"] ?: "dismiss"
builder.setNegativeButton(btn["label"]) { _, _ -> nativeDeliverAlertAction(action) }
}
builder.setOnCancelListener { nativeDeliverAlertAction("dismiss") }
builder.show()
}
}
@JvmStatic
fun toast_show(message: String, duration: String) {
val activity = activityRef?.get() ?: return
activity.runOnUiThread {
val dur = if (duration == "long") android.widget.Toast.LENGTH_LONG
else android.widget.Toast.LENGTH_SHORT
android.widget.Toast.makeText(activity, message, dur).show()
}
}
private fun parseButtonsJson(json: String): List<Map<String, String>> {
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
mapOf("label" to obj.optString("label"),
"style" to obj.optString("style"),
"action" to obj.optString("action"))
}
} catch (e: Exception) { emptyList() }
}
// ── WebView ────────────────────────────────────────────────────────────
@Volatile var webView: android.webkit.WebView? = null
@JvmStatic
fun webview_eval_js(code: String) {
val wv = webView ?: return
activityRef?.get()?.runOnUiThread { wv.evaluateJavascript(code, null) }
}
@JvmStatic
fun webview_post_message(json: String) {
val escaped = json.replace("\\", "\\\\").replace("'", "\\'")
webview_eval_js("window.mob&&window.mob._dispatch('$escaped')")
}
@JvmStatic
fun webview_can_go_back(): Boolean {
val wv = webView ?: return false
val latch = java.util.concurrent.CountDownLatch(1)
var result = false
activityRef?.get()?.runOnUiThread {
result = wv.canGoBack()
latch.countDown()
} ?: latch.countDown()
latch.await(1, java.util.concurrent.TimeUnit.SECONDS)
return result
}
@JvmStatic
fun webview_go_back() {
val wv = webView ?: return
activityRef?.get()?.runOnUiThread { wv.goBack() }
}
// ── Motion sensors ─────────────────────────────────────────────────────
private var sensorManager: SensorManager? = null
private var sensorListener: SensorEventListener? = null
private var motionPid: Long = 0
private var accelData = floatArrayOf(0f, 0f, 0f)
private var gyroData = floatArrayOf(0f, 0f, 0f)
@JvmStatic
fun motion_start(pid: Long, intervalMsStr: String) {
motionPid = pid
val intervalMs = intervalMsStr.toLongOrNull() ?: 100L
val activity = activityRef?.get() ?: return
val sm = activity.getSystemService(android.content.Context.SENSOR_SERVICE) as SensorManager
sensorManager = sm
val listener = object : SensorEventListener {
var lastSendMs = 0L
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_ACCELEROMETER -> accelData = event.values.copyOf()
Sensor.TYPE_GYROSCOPE -> gyroData = event.values.copyOf()
}
val now = System.currentTimeMillis()
if (now - lastSendMs >= intervalMs) {
lastSendMs = now
nativeDeliverMotion(pid,
accelData[0].toDouble(), accelData[1].toDouble(), accelData[2].toDouble(),
gyroData[0].toDouble(), gyroData[1].toDouble(), gyroData[2].toDouble(),
now)
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
sensorListener = listener
sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)?.let {
sm.registerListener(listener, it, SensorManager.SENSOR_DELAY_NORMAL)
}
sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE)?.let {
sm.registerListener(listener, it, SensorManager.SENSOR_DELAY_NORMAL)
}
}
@JvmStatic
fun motion_stop() {
sensorListener?.let { sensorManager?.unregisterListener(it) }
sensorListener = null
}
// ── QR scanner ────────────────────────────────────────────────────────
@JvmStatic
fun scanner_scan(pid: Long, formatsJson: String) {
pendingScanPid = pid
activityRef?.get()?.let { (it as? MainActivity)?.launchQrScanner() }
?: nativeDeliverAtom2(pid, "scan", "cancelled")
}
@JvmStatic
fun handleScanResult(value: String?, type: String?) {
val pid = pendingScanPid
if (value == null) { nativeDeliverAtom2(pid, "scan", "cancelled"); return }
val safeValue = value.replace("\"", "\\\"")
val safeType = (type ?: "qr").replace("\"", "\\\"")
val json = """[{"type":"$safeType","value":"$safeValue"}]"""
nativeDeliverFileResult(pid, "scan", "result", json)
}
// ── Local notifications ────────────────────────────────────────────────
const val NOTIF_CHANNEL_ID = "mob_notifications"
private const val PERM_REQUEST_CODE = 9001
@JvmStatic
fun notify_schedule(pid: Long, optsJson: String) {
val activity = activityRef?.get() ?: return
try {
val opts = org.json.JSONObject(optsJson)
val id = opts.getString("id")
val title = opts.getString("title")
val body = opts.getString("body")
val triggerAt = opts.getLong("trigger_at") * 1000L // to ms
// Ensure channel exists
val nm = activity.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as NotificationManager
if (android.os.Build.VERSION.SDK_INT >= 26) {
nm.createNotificationChannel(
NotificationChannel(NOTIF_CHANNEL_ID, "Notifications", NotificationManager.IMPORTANCE_DEFAULT))
}
val intent = android.content.Intent(activity, NotificationReceiver::class.java).apply {
putExtra("title", title)
putExtra("body", body)
putExtra("id", id)
putExtra("data", opts.optJSONObject("data")?.toString() ?: "{}")
}
val pi = PendingIntent.getBroadcast(activity, id.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val am = activity.getSystemService(android.content.Context.ALARM_SERVICE) as AlarmManager
if (android.os.Build.VERSION.SDK_INT >= 23) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
} else {
am.setExact(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}
} catch (e: Exception) {
android.util.Log.e("MobBridge", "notify_schedule failed: ${e.message}")
}
}
@JvmStatic
fun notify_cancel(id: String) {
val activity = activityRef?.get() ?: return
val intent = android.content.Intent(activity, NotificationReceiver::class.java)
val pi = PendingIntent.getBroadcast(activity, id.hashCode(), intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE)
pi?.let {
val am = activity.getSystemService(android.content.Context.ALARM_SERVICE) as AlarmManager
am.cancel(it)
}
}
@JvmStatic
fun notify_register_push(pid: Long, arg: String?) {
notifyPid = pid
// Deliver any token that refreshed while no screen was active.
MobFirebaseService.pendingToken?.let { token ->
MobFirebaseService.pendingToken = null
nativeDeliverPushToken(pid, token)
return
}
com.google.firebase.messaging.FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (task.isSuccessful) nativeDeliverPushToken(pid, task.result)
}
}
@JvmStatic
fun setLaunchNotification(json: String?) {
nativeSetLaunchNotification(json)
}
/**
* Called from nif_safe_area via JNI — returns [top, right, bottom, left] in dp.
* Reads the window's system bar insets on the UI thread.
*/
@JvmStatic
fun getSafeArea(): FloatArray {
val activity = activityRef?.get() ?: return FloatArray(4)
val density = activity.resources.displayMetrics.density
val result = FloatArray(4)
val latch = java.util.concurrent.CountDownLatch(1)
activity.runOnUiThread {
val insets = activity.window.decorView.rootWindowInsets
if (insets != null) {
result[0] = insets.systemWindowInsetTop / density
result[1] = insets.systemWindowInsetRight / density
result[2] = insets.systemWindowInsetBottom / density
result[3] = insets.systemWindowInsetLeft / density
}
latch.countDown()
}
try { latch.await() } catch (e: InterruptedException) { Thread.currentThread().interrupt() }
return result
}
/** Called from nif_haptic via JNI — fires haptic feedback on the UI thread. */
@JvmStatic
fun haptic(type: String) {
activityRef?.get()?.let { activity ->
activity.runOnUiThread {
val view = activity.window.decorView
val constant = when (type) {
"light" -> HapticFeedbackConstants.VIRTUAL_KEY
"medium" -> HapticFeedbackConstants.CLOCK_TICK
"heavy" -> HapticFeedbackConstants.LONG_PRESS
"success" -> if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.CONFIRM
else HapticFeedbackConstants.CLOCK_TICK
"error" -> if (Build.VERSION.SDK_INT >= 30) HapticFeedbackConstants.REJECT
else HapticFeedbackConstants.LONG_PRESS
"warning" -> HapticFeedbackConstants.CLOCK_TICK
else -> HapticFeedbackConstants.VIRTUAL_KEY
}
@Suppress("DEPRECATION")
view.performHapticFeedback(constant, HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING)
}
}
}
/** Called from nif_clipboard_put via JNI — writes text to the system clipboard. */
@JvmStatic
fun clipboardPut(text: String) {
activityRef?.get()?.let { activity ->
activity.runOnUiThread {
val cm = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("mob", text))
}
}
}
/**
* Called from nif_clipboard_get via JNI — returns clipboard text or null.
* Blocks the calling thread until the UI thread has read the clipboard.
*/
@JvmStatic
fun clipboardGet(): String? {
val activity = activityRef?.get() ?: return null
val result = arrayOfNulls<String>(1)
val latch = java.util.concurrent.CountDownLatch(1)
activity.runOnUiThread {
try {
val cm = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
result[0] = cm.primaryClip?.getItemAt(0)?.coerceToText(activity)?.toString()
} finally {
latch.countDown()
}
}
try { latch.await() } catch (e: InterruptedException) { Thread.currentThread().interrupt() }
return result[0]
}
/** Called from nif_share_text via JNI — opens the system share sheet. */
@JvmStatic
fun shareText(text: String) {
activityRef?.get()?.let { activity ->
activity.runOnUiThread {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, text)
}
activity.startActivity(Intent.createChooser(intent, null))
}
}
}
/** Called from nif_open_url via JNI — hands a URL to the OS to open in the
* default browser/handler. Fire-and-forget; failures are silently ignored. */
@JvmStatic
fun openUrl(url: String) {
activityRef?.get()?.let { activity ->
activity.runOnUiThread {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity.startActivity(intent)
} catch (e: Exception) {
android.util.Log.w("MobBridge", "openUrl failed for $url: ${e.message}")
}
}
}
}
}
// ── Composables ───────────────────────────────────────────────────────────────
// ── Native view component registry ───────────────────────────────────────────
// Register platform-native Composables by name at app startup. The name is the
// Elixir module with "Elixir." stripped and "." replaced with "_":
// MyApp.ChartComponent → "MyApp_ChartComponent"
//
// MobNativeViewRegistry.register("MyApp_ChartComponent") { props, send ->
// ChartView(data = props["data"]) { index ->
// send("tapped", mapOf("index" to index))
// }
// }
typealias MobNativeSend = (event: String, payload: Map<String, Any>) -> Unit
typealias MobNativeViewFactory = @Composable (props: Map<String, Any?>, send: MobNativeSend) -> Unit
object MobNativeViewRegistry {
private val factories = mutableMapOf<String, MobNativeViewFactory>()
fun register(name: String, factory: MobNativeViewFactory) {
factories[name] = factory
}
@Composable
fun render(node: MobNode) {
val name = node.props["module"] as? String ?: return
val factory = factories[name] ?: return
val handle = (node.props["component_handle"] as? Number)?.toInt() ?: return
val send: MobNativeSend = { event, payload ->
try {
val json = org.json.JSONObject(payload).toString()
nativeDeliverComponentEvent(handle, event, json)
} catch (_: Exception) {}
}
factory(node.props, send)
}
external fun nativeDeliverComponentEvent(handle: Int, event: String, payloadJson: String)
}
/** Renders a MobNode tree produced by Mob.Renderer. */
@Composable
fun RenderNode(node: MobNode, modifier: Modifier = Modifier) {
val ox = floatProp(node.props, "offset_x") ?: 0f
val oy = floatProp(node.props, "offset_y") ?: 0f
if (ox != 0f || oy != 0f) {
Box(modifier = Modifier.offset(x = ox.dp, y = oy.dp)) {
RenderNodeInner(node, modifier)
}
} else {
RenderNodeInner(node, modifier)
}
}
@Composable
private fun RenderNodeInner(node: MobNode, modifier: Modifier) {
// Apply on_tap as a clickable modifier for any node type except button —
// button installs its own onClick via the Button composable. Mirrors iOS,
// where most node types pick up onTapGesture via .ifLet(node.onTap).
val tapHandle = intProp(node.props, "on_tap")
val tapModifier = if (tapHandle != null && node.type != "button") {
modifier.clickable { MobBridge.nativeSendTap(tapHandle) }
} else modifier
val m = tapModifier.then(nodeModifier(node.props))
when (node.type) {
"column" -> Column(modifier = m) {
node.children.forEach { child ->
val w = floatProp(child.props, "weight")
RenderNode(child, if (w != null) Modifier.weight(w) else Modifier)
}
}
"row" -> Row(modifier = m, verticalAlignment = rowAlignProp(node.props)) {
node.children.forEach { child ->
val w = floatProp(child.props, "weight")
RenderNode(child, if (w != null) Modifier.weight(w) else Modifier)
}
}
// Box defaults to fillMaxWidth (matching iOS .frame(maxWidth: .infinity))
// when no explicit width is set; otherwise it uses the explicit
// width applied via nodeModifier above.
// contentAlignment derives from the "align" prop ("center" /
// "top_leading" / etc.) — defaults to TopStart for back-compat.
"box" -> {
val hasWidth = floatProp(node.props, "width") != null
val boxModifier = if (hasWidth) m else m.fillMaxWidth()
Box(modifier = boxModifier, contentAlignment = boxAlignProp(node.props)) {
node.children.forEach { RenderNode(it) }
}
}
"scroll" -> {
val scrollState = rememberScrollState()
if (node.props["axis"] == "horizontal") {
Row(modifier = m.horizontalScroll(scrollState)) {
node.children.forEach { RenderNode(it) }
}
} else {
Column(modifier = m.verticalScroll(scrollState).imePadding()) {
node.children.forEach { RenderNode(it) }
}
}
}
"text" -> MobText(node, m)
"button" -> MobButton(node, m)
"tab_bar" -> MobTabBar(node, m)
"text_field" -> MobTextField(node, m)
"toggle" -> MobToggle(node, m)
"slider" -> MobSlider(node, m)
"divider" -> MobDivider(node, m)
"spacer" -> MobSpacer(node, m)
"progress" -> MobProgress(node, m)
"image" -> MobImage(node, m)
"icon" -> MobIcon(node, m)
"lazy_list" -> MobLazyList(node, m)
"video" -> MobVideoPlayer(node, m)
"camera_preview" -> MobCameraPreview(node, m)
"web_view" -> MobWebView(node, m)
"native_view" -> MobNativeViewRegistry.render(node)
"canvas" -> MobCanvas(node, m)
}
}
@Composable
private fun MobText(node: MobNode, modifier: Modifier) {
val text = node.props["text"] as? String ?: ""
val color = colorProp(node.props, "text_color")
val fontSize = sizeProp(node.props, "text_size")
val fontWeight = fontWeightProp(node.props)
val fontStyle = if (boolProp(node.props, "italic") == true) FontStyle.Italic else FontStyle.Normal
val textAlign = textAlignProp(node.props)
val letterSpacing = floatProp(node.props, "letter_spacing")
val lineHeightMul = floatProp(node.props, "line_height")
val fontFamily = fontFamilyProp(node.props)
val tapHandle = intProp(node.props, "on_tap")
val resolvedLineHeight = if (lineHeightMul != null && fontSize != TextUnit.Unspecified)
(lineHeightMul * fontSize.value).sp else TextUnit.Unspecified
val tappableModifier = if (tapHandle != null)
modifier.clickable { MobBridge.nativeSendTap(tapHandle) } else modifier
// text_align is a no-op when the Text wraps to its content width — the
// alignment only matters if the Text is wider than its content. Apply
// fillMaxWidth in that case so center/right alignment behaves like iOS.
val textModifier = if (textAlign != null && boolProp(node.props, "fill_width") != false &&
floatProp(node.props, "width") == null) {
tappableModifier.fillMaxWidth()
} else tappableModifier
Text(
text = text,
modifier = textModifier,
color = color,
fontSize = fontSize,
fontWeight = fontWeight,
fontStyle = fontStyle,
textAlign = textAlign,
lineHeight = resolvedLineHeight,
letterSpacing = letterSpacing?.sp ?: TextUnit.Unspecified,
fontFamily = fontFamily,
)
}
@Composable
private fun MobButton(node: MobNode, modifier: Modifier) {
val label = node.props["text"] as? String ?: ""
val tapHandle = intProp(node.props, "on_tap")
val bgColor = colorProp(node.props, "background")
val cornerRad = floatProp(node.props, "corner_radius") ?: 0f
val fillWidth = boolProp(node.props, "fill_width") ?: false
val colors = if (bgColor != Color.Unspecified)
ButtonDefaults.buttonColors(containerColor = bgColor)
else
ButtonDefaults.buttonColors()
// fill_width and corner_radius are driven by Elixir props (set in component
// defaults but overridable per-node). Shape overrides M3's stadium default.
Button(
onClick = { tapHandle?.let { MobBridge.nativeSendTap(it) } },
modifier = if (fillWidth) modifier.fillMaxWidth() else modifier,
colors = colors,
shape = RoundedCornerShape(cornerRad.dp),
) {
val textColor = colorProp(node.props, "text_color")
val fontSize = sizeProp(node.props, "text_size")
Text(text = label, color = textColor, fontSize = fontSize,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@Composable
private fun MobTextField(node: MobNode, modifier: Modifier) {
val changeHandle = intProp(node.props, "on_change")
val focusHandle = intProp(node.props, "on_focus")
val blurHandle = intProp(node.props, "on_blur")
val submitHandle = intProp(node.props, "on_submit")
val placeholder = node.props["placeholder"] as? String ?: ""
val keyboardController = LocalSoftwareKeyboardController.current
val keyboardType = when (node.props["keyboard"] as? String) {
"number" -> KeyboardType.Number
"decimal" -> KeyboardType.Decimal
"email" -> KeyboardType.Email
"phone" -> KeyboardType.Phone
"url" -> KeyboardType.Uri
else -> KeyboardType.Text
}
val imeAction = when (node.props["return_key"] as? String) {
"next" -> ImeAction.Next
"go" -> ImeAction.Go
"search" -> ImeAction.Search
"send" -> ImeAction.Send
else -> ImeAction.Done
}
var localValue by remember(node.props["value"]) {
mutableStateOf(node.props["value"] as? String ?: "")
}
// Only fill width when explicitly asked. The unconditional fillMaxWidth
// we used to apply broke layouts like ImperialInput's row of three
// text_fields — the first field swallowed all the row's width and the
// siblings got 0 px (silently invisible).
val fillWidth = boolProp(node.props, "fill_width") ?: false
val tfModifier = if (fillWidth) modifier.fillMaxWidth() else modifier
TextField(
value = localValue,
onValueChange = { new ->
localValue = new
changeHandle?.let { MobBridge.nativeSendChangeStr(it, new) }
},
placeholder = { Text(placeholder) },
modifier = tfModifier
.onFocusChanged { state ->
if (state.isFocused) focusHandle?.let { MobBridge.nativeSendFocus(it) }
else blurHandle?.let { MobBridge.nativeSendBlur(it) }
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),
keyboardActions = KeyboardActions(onAny = {
submitHandle?.let { MobBridge.nativeSendSubmit(it) }
// dismiss for terminal actions; Next intentionally keeps keyboard open
if (imeAction != ImeAction.Next) keyboardController?.hide()
}),
)
}
@Composable
private fun MobToggle(node: MobNode, modifier: Modifier) {
val handle = intProp(node.props, "on_change")
val checked = boolProp(node.props, "value") ?: false
val color = colorProp(node.props, "color")
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
node.props["label"]?.let {
Text(text = it as String, modifier = Modifier.weight(1f))
}
Switch(
checked = checked,
onCheckedChange = { new -> handle?.let { MobBridge.nativeSendChangeBool(it, new) } },
colors = if (color != Color.Unspecified)
SwitchDefaults.colors(checkedThumbColor = color)
else
SwitchDefaults.colors(),
)
}
}
@Composable
private fun MobSlider(node: MobNode, modifier: Modifier) {
val handle = intProp(node.props, "on_change")
val minVal = floatProp(node.props, "min") ?: 0f
val maxVal = floatProp(node.props, "max") ?: 1f
val color = colorProp(node.props, "color")
var localVal by remember(node.props["value"]) {
mutableStateOf(floatProp(node.props, "value") ?: minVal)
}
Slider(
value = localVal,
onValueChange = { new ->
localVal = new
handle?.let { MobBridge.nativeSendChangeFloat(it, new) }
},
valueRange = minVal..maxVal,
modifier = modifier.fillMaxWidth(),
colors = if (color != Color.Unspecified)
SliderDefaults.colors(thumbColor = color, activeTrackColor = color)
else
SliderDefaults.colors(),
)
}
@Composable
private fun MobDivider(node: MobNode, modifier: Modifier) {
val thickness = floatProp(node.props, "thickness") ?: 1f
val color = colorProp(node.props, "color")
HorizontalDivider(
modifier = modifier,
thickness = thickness.dp,
color = if (color != Color.Unspecified) color else DividerDefaults.color,
)
}
@Composable
private fun MobSpacer(node: MobNode, modifier: Modifier) {
val size = floatProp(node.props, "size")
// size() sets both width and height so Spacer works as a gap in both Column and Row.
Spacer(modifier = if (size != null) modifier.size(size.dp) else modifier)
}
@Composable
private fun MobProgress(node: MobNode, modifier: Modifier) {
val value = floatProp(node.props, "value")
val color = colorProp(node.props, "color")
val trackColor = if (color != Color.Unspecified) color else Color.Unspecified
if (value != null) {
LinearProgressIndicator(
progress = { value },
modifier = modifier.fillMaxWidth(),
color = if (trackColor != Color.Unspecified) trackColor else Color.Unspecified,
)
} else {
LinearProgressIndicator(
modifier = modifier.fillMaxWidth(),
color = if (trackColor != Color.Unspecified) trackColor else Color.Unspecified,
)
}
}
@Composable
private fun MobImage(node: MobNode, modifier: Modifier) {
val src = node.props["src"] as? String
val contentScale = when (node.props["content_mode"] as? String) {
"fill" -> ContentScale.Crop
"stretch" -> ContentScale.FillBounds
else -> ContentScale.Fit
}
val cornerRadius = floatProp(node.props, "corner_radius") ?: 0f
val fixedWidth = floatProp(node.props, "width")
val fixedHeight = floatProp(node.props, "height")
// Coil's AsyncImage expects a URL string for remote images or a File object for
// local paths. Passing a bare path string as a model causes it to treat it as a
// relative URL and fail silently. Detect local paths and wrap in File.
val model: Any? = when {
src == null -> null
src.startsWith("http://") || src.startsWith("https://") -> src
else -> java.io.File(src)
}
var m = modifier
if (fixedWidth != null) m = m.width(fixedWidth.dp)
if (fixedHeight != null) m = m.height(fixedHeight.dp)
if (cornerRadius > 0f) m = m.clip(RoundedCornerShape(cornerRadius.dp))
AsyncImage(
model = model,
contentDescription = null,
contentScale = contentScale,
modifier = m,
)
}
@Composable
private fun MobIcon(node: MobNode, modifier: Modifier) {
val name = node.props["name"] as? String ?: "questionmark"
val tint = colorProp(node.props, "text_color")
val fontSizeSp = sizeProp(node.props, "text_size")
val sizeDp = if (fontSizeSp != androidx.compose.ui.unit.TextUnit.Unspecified)
fontSizeSp.value.dp else 24.dp
val description = node.props["text"] as? String
val onTap = (node.props["on_tap"] as? Number)?.toInt()
val baseMod = if (onTap != null) modifier.clickable { MobBridge.nativeSendTap(onTap) } else modifier
Icon(
imageVector = materialIconFor(name),
contentDescription = description,
tint = if (tint == Color.Unspecified) Color.Unspecified else tint,
modifier = baseMod.size(sizeDp),
)
}
// Logical icon name → Material icon. Names mirror MobRootView.swift's
// sfSymbolName/1 so the same `name:` prop renders an Apple-styled icon on iOS
// and a Material-styled icon on Android. Unknown names fall back to a "?".
private fun materialIconFor(logical: String): androidx.compose.ui.graphics.vector.ImageVector =
when (logical) {
"settings" -> Icons.Filled.Settings
"back" -> Icons.Filled.ArrowBack
"forward" -> Icons.Filled.ArrowForward
"close" -> Icons.Filled.Close
"add" -> Icons.Filled.Add
"remove" -> Icons.Filled.Remove
"edit" -> Icons.Filled.Edit
"check" -> Icons.Filled.Check
"chevron_right" -> Icons.Filled.ChevronRight
"chevron_left" -> Icons.Filled.ChevronLeft
"chevron_up" -> Icons.Filled.KeyboardArrowUp
"chevron_down" -> Icons.Filled.ExpandMore
"info" -> Icons.Filled.Info
"warning" -> Icons.Filled.Warning
"error" -> Icons.Filled.Error
"search" -> Icons.Filled.Search
"trash" -> Icons.Filled.Delete
"share" -> Icons.Filled.Share
"more" -> Icons.Filled.MoreVert
"menu" -> Icons.Filled.Menu
"refresh" -> Icons.Filled.Refresh
"favorite" -> Icons.Filled.FavoriteBorder
"favorite_filled" -> Icons.Filled.Favorite
"star" -> Icons.Filled.StarBorder
"star_filled" -> Icons.Filled.Star
"user" -> Icons.Filled.Person
"home" -> Icons.Filled.Home
"expand_more" -> Icons.Filled.ExpandMore
"expand_less" -> Icons.Filled.ExpandLess
else -> Icons.Filled.QuestionMark
}
@Composable
private fun MobCameraPreview(node: MobNode, modifier: Modifier) {
val facingStr = (node.props["facing"] as? String) ?: "back"
val cameraSelector = if (facingStr == "front")
CameraSelector.DEFAULT_FRONT_CAMERA
else
CameraSelector.DEFAULT_BACK_CAMERA
val context = LocalContext.current
val lifecycleOwner = context as LifecycleOwner
AndroidView(
modifier = modifier,
factory = { ctx ->
PreviewView(ctx).also { previewView ->
val providerFuture = ProcessCameraProvider.getInstance(ctx)
providerFuture.addListener({
val provider = providerFuture.get()
MobBridge.previewCameraProvider = provider
val preview = CameraPreview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
try {
provider.unbindAll()
provider.bindToLifecycle(lifecycleOwner, cameraSelector, preview)
} catch (_: Exception) {}
}, context.mainExecutor)
}
}
)
}
private val MOB_JS_SHIM = """
(function(){
if(window.mob)return;
var _h=[];
window.mob={
send:function(d){MobNative.postMessage(JSON.stringify(d));},
onMessage:function(h){_h.push(h);return function(){_h=_h.filter(function(x){return x!==h;});};},
_dispatch:function(j){try{var d=JSON.parse(j);_h.forEach(function(h){h(d);});}catch(e){}}
};
})();
""".trimIndent()
@Composable
private fun MobWebView(node: MobNode, modifier: Modifier) {
val url = node.props["url"] as? String ?: return
val allowStr = node.props["allow"] as? String ?: ""
val allowList = allowStr.split(",").filter { it.isNotEmpty() }
val title = node.props["title"] as? String
Column(modifier = modifier) {
if (title != null) {
Text(text = title, fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
}
AndroidView(
modifier = Modifier.weight(1f),
factory = { ctx ->
android.webkit.WebView(ctx).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
addJavascriptInterface(object : Any() {
@android.webkit.JavascriptInterface
fun postMessage(json: String) {
MobBridge.nativeDeliverWebViewMessage(0L, json)
}
}, "MobNative")
webViewClient = object : android.webkit.WebViewClient() {
override fun onPageFinished(view: android.webkit.WebView, pageUrl: String) {
view.evaluateJavascript(MOB_JS_SHIM, null)
}
override fun shouldOverrideUrlLoading(
view: android.webkit.WebView,
request: android.webkit.WebResourceRequest
): Boolean {
if (allowList.isEmpty()) return false
val reqUrl = request.url.toString()
if (allowList.any { reqUrl.startsWith(it) }) return false
MobBridge.nativeDeliverWebViewBlocked(0L, reqUrl)
return true
}
}
MobBridge.webView = this
loadUrl(url)
}
},
update = { wv -> MobBridge.webView = wv }
)
}
}
// ── Canvas (Mob.Canvas declarative draw spec) ───────────────────────────────
// Renders the node.props["draw"] list via Compose Canvas. Each op is a
// Map<String, Any?> with an "op" key plus op-specific fields, pre-resolved
// by the Elixir renderer (color tokens already converted to ARGB integers).
@Composable
private fun MobCanvas(node: MobNode, modifier: Modifier) {
val width = floatProp(node.props, "width") ?: 0f
val height = floatProp(node.props, "height") ?: 0f
@Suppress("UNCHECKED_CAST")
val ops: List<Map<String, Any?>> = when (val raw = node.props["draw"]) {
is JSONArray -> (0 until raw.length()).map { i -> jsonObjectToMap(raw.getJSONObject(i)) }
is List<*> -> raw as List<Map<String, Any?>>
else -> emptyList()
}
val sized = if (width > 0f && height > 0f) {
modifier.size(width.dp, height.dp)
} else {
modifier
}
Canvas(modifier = sized) {
ops.forEach { op -> drawCanvasOp(op) }
}
}
private fun DrawScope.drawCanvasOp(op: Map<String, Any?>) {
val opName = op["op"] as? String ?: return
val color = canvasColor(op["color"])
val opacity = (op["opacity"] as? Double)?.toFloat() ?: 1f
val isFill = (op["fill"] as? Boolean) ?: false
val stroke = canvasStroke(op)
when (opName) {
"line" -> drawLine(
color = color,
start = Offset(canvasFloat(op["x1"]), canvasFloat(op["y1"])),
end = Offset(canvasFloat(op["x2"]), canvasFloat(op["y2"])),
strokeWidth = stroke.width,
cap = stroke.cap,
pathEffect = stroke.pathEffect,
alpha = opacity
)
"circle" -> {
val center = Offset(canvasFloat(op["x"]), canvasFloat(op["y"]))
val radius = canvasFloat(op["r"])
if (isFill) {
drawCircle(color = color, radius = radius, center = center, alpha = opacity)
} else {
drawCircle(color = color, radius = radius, center = center, alpha = opacity, style = stroke)
}
}
"ellipse" -> {
val cx = canvasFloat(op["x"])
val cy = canvasFloat(op["y"])
val rx = canvasFloat(op["rx"])
val ry = canvasFloat(op["ry"])
val topLeft = Offset(cx - rx, cy - ry)
val size = ComposeSize(rx * 2, ry * 2)
if (isFill) {
drawOval(color = color, topLeft = topLeft, size = size, alpha = opacity)
} else {
drawOval(color = color, topLeft = topLeft, size = size, alpha = opacity, style = stroke)
}
}
"arc" -> {
// Mob.Canvas arc: degrees, 0° to the right, sweeping clockwise.
// Compose drawArc takes startAngle + sweepAngle in degrees, same convention.
val cx = canvasFloat(op["x"])
val cy = canvasFloat(op["y"])
val r = canvasFloat(op["r"])
val startDeg = canvasFloat(op["start_deg"])
val endDeg = canvasFloat(op["end_deg"])
val sweep = endDeg - startDeg
drawArc(
color = color,
startAngle = startDeg,
sweepAngle = sweep,
useCenter = false,
topLeft = Offset(cx - r, cy - r),
size = ComposeSize(r * 2, r * 2),
alpha = opacity,
style = stroke
)
}
"rect" -> {
val topLeft = Offset(canvasFloat(op["x"]), canvasFloat(op["y"]))
val size = ComposeSize(canvasFloat(op["w"]), canvasFloat(op["h"]))
val radius = canvasFloat(op["radius"])
if (radius > 0f) {
val cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius, radius)
if (isFill) {
drawRoundRect(color = color, topLeft = topLeft, size = size,
cornerRadius = cornerRadius, alpha = opacity)
} else {
drawRoundRect(color = color, topLeft = topLeft, size = size,
cornerRadius = cornerRadius, alpha = opacity, style = stroke)
}
} else {
if (isFill) {
drawRect(color = color, topLeft = topLeft, size = size, alpha = opacity)
} else {
drawRect(color = color, topLeft = topLeft, size = size, alpha = opacity, style = stroke)
}
}
}
"path" -> {
@Suppress("UNCHECKED_CAST")
val pts = op["points"] as? List<List<Any?>> ?: return
if (pts.isEmpty()) return
val closed = (op["closed"] as? Boolean) ?: false
val path = Path().apply {
moveTo(canvasFloat(pts[0].getOrNull(0)), canvasFloat(pts[0].getOrNull(1)))
for (i in 1 until pts.size) {
lineTo(canvasFloat(pts[i].getOrNull(0)), canvasFloat(pts[i].getOrNull(1)))
}
if (closed || isFill) close()
}
if (isFill) {
drawPath(path = path, color = color, alpha = opacity)
} else {
drawPath(path = path, color = color, alpha = opacity, style = stroke)
}
}
"text" -> {
val str = op["text"] as? String ?: return
val size = canvasFloat(op["size"])
val anchor = op["anchor"] as? String ?: "start"
val weight = op["weight"] as? String
// Compose has no DrawScope text primitive prior to TextMeasurer; the
// simplest cross-version path is the platform Canvas via drawIntoCanvas.
// Anchor is handled by measuring with Paint and offsetting x.
drawIntoCanvas { canvas ->
val paint = Paint().apply {
isAntiAlias = true
textSize = size
this.color = color.toArgb()
alpha = (opacity * 255).toInt().coerceIn(0, 255)
typeface = canvasTypeface(weight)
}
val measured = paint.measureText(str)
val x = canvasFloat(op["x"])
val y = canvasFloat(op["y"])
val drawX = when (anchor) {
"center" -> x - measured / 2f
"end" -> x - measured
else -> x
}
// Compose Canvas positions text by baseline; offset by font ascent
// so y is the top edge (matches SwiftUI Canvas convention).
val baseline = y - paint.fontMetrics.ascent
canvas.nativeCanvas.drawText(str, drawX, baseline, paint)
}
}
"image" -> {
// Image asset rendering deferred — needs context-aware loading from the
// app's drawable resources or asset catalog. Tracked separately.
}
}
}
// Canvas coordinates from BEAM are in dp (matching the canvas's declared
// width/height). Compose's DrawScope works in pixels, so convert dp→px here.
private fun DrawScope.canvasFloat(v: Any?): Float {
val dp = when (v) {
is Float -> v
is Double -> v.toFloat()
is Int -> v.toFloat()
is Long -> v.toFloat()
else -> 0f
}
return dp.dp.toPx()
}
private fun canvasColor(v: Any?): Color = when (v) {
is Long -> Color(v.toInt())
is Int -> Color(v)
is Double -> Color(v.toLong().toInt())
is String -> {
// Hex string fallback ("#rrggbb"). Pre-resolved ARGB integers are the
// hot path; this is for raw color strings that bypass theme resolution.
if (v.startsWith("#") && v.length == 7) {
val rgb = v.substring(1).toLong(16)
val r = ((rgb shr 16) and 0xFF).toInt()
val g = ((rgb shr 8) and 0xFF).toInt()
val b = (rgb and 0xFF).toInt()
Color(red = r, green = g, blue = b)
} else Color.Black
}
else -> Color.Black
}
private fun DrawScope.canvasStroke(op: Map<String, Any?>): Stroke {
val width = canvasFloat(op["width"]).let { if (it > 0f) it else 1f }
val cap = when (op["cap"] as? String) {
"round" -> StrokeCap.Round
"square" -> StrokeCap.Square
else -> StrokeCap.Butt
}
val join = when (op["join"] as? String) {
"round" -> StrokeJoin.Round
"bevel" -> StrokeJoin.Bevel
else -> StrokeJoin.Miter
}
@Suppress("UNCHECKED_CAST")
val dashList = (op["dash"] as? List<Any?>)?.map { canvasFloat(it) }?.toFloatArray()
val pathEffect = if (dashList != null && dashList.isNotEmpty()) {
PathEffect.dashPathEffect(dashList, 0f)
} else null
return Stroke(width = width, cap = cap, join = join, pathEffect = pathEffect)
}
private fun canvasTypeface(weight: String?): Typeface = when (weight) {
"bold", "semibold", "medium" -> Typeface.DEFAULT_BOLD
else -> Typeface.DEFAULT
}
@Composable
private fun MobVideoPlayer(node: MobNode, modifier: Modifier) {
val src = node.props["src"] as? String ?: return
val autoplay = boolProp(node.props, "autoplay") ?: false
val context = LocalContext.current
// ExoPlayer / Media3 video player.
// Requires: implementation 'androidx.media3:media3-exoplayer:1.3.0'
// implementation 'androidx.media3:media3-ui:1.3.0'
// Stubbed until Media3 dependency is added to build.gradle.
// Replace this Box with the full player implementation when the dep is present:
androidx.compose.foundation.layout.Box(
modifier = modifier
.background(Color.Black),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text("Video: $src", color = Color.White, fontSize = 12.sp)
}
}
@Composable
private fun MobLazyList(node: MobNode, modifier: Modifier) {
val handle = intProp(node.props, "on_end_reached")
// Use a persistent LazyListState keyed by handle so scroll position survives
// BEAM re-renders. rememberLazyListState() would reset to 0 on every data
// update because AnimatedContent creates a fresh composition for each new
// RootState, even when only list items changed (no navigation).
val listState = remember(handle) {
if (handle != null) MobBridge.getOrCreateLazyListState(handle)
else LazyListState()
}
val reachedEnd by remember {
derivedStateOf {
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
val total = listState.layoutInfo.totalItemsCount
total > 0 && lastVisible >= total - 1
}
}
LaunchedEffect(reachedEnd) {
if (reachedEnd) handle?.let { MobBridge.nativeSendTap(it) }
}
LazyColumn(state = listState, modifier = modifier.fillMaxWidth()) {
items(node.children) { child -> RenderNode(child) }
}
}
@Composable
private fun MobTabBar(node: MobNode, modifier: Modifier) {
val tabs = tabDefsProp(node.props)
val activeId = (node.props["active"] as? String) ?: tabs.firstOrNull()?.get("id") ?: ""
val handle = intProp(node.props, "on_tab_select")
val activeIdx = tabs.indexOfFirst { it["id"] == activeId }.coerceAtLeast(0)
Scaffold(
modifier = modifier,
bottomBar = {
NavigationBar {
tabs.forEachIndexed { index, tab ->
NavigationBarItem(
selected = index == activeIdx,
onClick = { handle?.let { MobBridge.nativeSendChangeStr(it, tab["id"] ?: "") } },
label = { Text(tab["label"] ?: "") },
icon = { Icon(materialIconForLogical(tab["icon"] ?: ""), contentDescription = tab["label"]) }
)
}
}
}
) { innerPadding ->
if (activeIdx < node.children.size) {
RenderNode(node.children[activeIdx], Modifier.padding(innerPadding))
}
}
}
// ── Modifier helpers ──────────────────────────────────────────────────────────
private fun nodeModifier(props: Map<String, Any?>): Modifier {
var m: Modifier = Modifier
val cornerRadius = floatProp(props, "corner_radius") ?: 0f
val shape = if (cornerRadius > 0f) RoundedCornerShape(cornerRadius.dp) else null
// Background must come before padding so it fills the full area (including
// padding space). If background were applied after padding, it would only
// draw behind the inner content area — making empty boxes invisible.
// When a corner radius is present, clip the background to that shape so
// rectangular bleed doesn't show through the rounded corners.
longColorProp(props, "background")?.let { bg ->
m = if (shape != null) m.background(bg, shape) else m.background(bg)
}
// Border (opt-in: requires both border_color and border_width). Drawn on
// the same shape as the background so rounded boxes get a proper outline.
val borderColor = longColorProp(props, "border_color")
val borderWidth = floatProp(props, "border_width") ?: 0f
if (borderColor != null && borderWidth > 0f) {
m = if (shape != null) m.border(borderWidth.dp, borderColor, shape)
else m.border(borderWidth.dp, borderColor)
}
val uniform = intProp(props, "padding")
val top = intProp(props, "padding_top")
val right = intProp(props, "padding_right")
val bottom = intProp(props, "padding_bottom")
val left = intProp(props, "padding_left")
val hasEdge = top != null || right != null || bottom != null || left != null
m = when {
hasEdge -> m.padding(
top = (top ?: uniform ?: 0).dp,
end = (right ?: uniform ?: 0).dp,
bottom = (bottom ?: uniform ?: 0).dp,
start = (left ?: uniform ?: 0).dp,
)
uniform != null -> m.padding(uniform.dp)
else -> m
}
// Clip children to shape after padding so the rounded mask covers the
// entire padded area, not just the inner content.
if (shape != null) m = m.clip(shape)
if (boolProp(props, "fill_width") == true) m = m.fillMaxWidth()
// fill_height: true stretches the node to the parent's vertical bounds.
// Used by full-screen overlay boxes whose center alignment needs the
// viewport as its reference frame, not the contained children's height.
if (boolProp(props, "fill_height") == true) m = m.fillMaxHeight()
// Explicit width / height — when set, the node uses that exact size
// instead of stretching to fill its parent. Used by SquareTriangle's
// ring cells (110x110 boxes acting as rings via corner_radius +
// border).
floatProp(props, "width")?.let { w -> m = m.width(w.dp) }
floatProp(props, "height")?.let { h -> m = m.height(h.dp) }
// Per-node offset is NOT applied here. Compose's Modifier.offset on the
// node's own modifier chain didn't displace siblings reliably when stacking
// multiple offset boxes inside a parent Box. RenderNode wraps the whole
// node in an outer Box(Modifier.offset(...)) instead — see RenderNode.
return m
}
// ── Typography helpers ────────────────────────────────────────────────────────
private fun fontWeightProp(props: Map<String, Any?>): FontWeight? =
when (props["font_weight"] as? String) {
"bold" -> FontWeight.Bold
"semibold" -> FontWeight.SemiBold
"medium" -> FontWeight.Medium
"light" -> FontWeight.Light
"thin" -> FontWeight.Thin
else -> null
}
private fun textAlignProp(props: Map<String, Any?>): TextAlign? =
when (props["text_align"] as? String) {
"center" -> TextAlign.Center
"right" -> TextAlign.End
else -> null
}
// Recursively convert org.json.JSONObject/JSONArray trees to plain Kotlin
// Map<String, Any?> / List<Any?> so they're usable by code that expects
// Kotlin collections (e.g. MobCanvas's "draw" op list).
private fun jsonObjectToMap(obj: JSONObject): Map<String, Any?> {
val result = mutableMapOf<String, Any?>()
for (key in obj.keys()) {
result[key] = jsonValueToKotlin(obj.get(key))
}
return result
}
private fun jsonValueToKotlin(v: Any?): Any? = when (v) {
is JSONObject -> jsonObjectToMap(v)
is JSONArray -> (0 until v.length()).map { i -> jsonValueToKotlin(v.get(i)) }
JSONObject.NULL -> null
else -> v
}
private fun fontFamilyProp(props: Map<String, Any?>): FontFamily? {
val name = props["font"] as? String ?: return null
return try { FontFamily(Typeface.create(name, Typeface.NORMAL)) }
catch (_: Exception) { null }
}
// ── Tab bar helpers ───────────────────────────────────────────────────────────
// Logical icon name → Material Icon. Mirrors the iOS sfSymbolName/2 lookup so
// the same Elixir tab declaration ("history", "qr_code", etc.) renders a
// platform-native icon on each side. Falls back to a visible Star for unknown
// names so missing mappings show up in the UI rather than failing silently.
private fun materialIconForLogical(logical: String): androidx.compose.ui.graphics.vector.ImageVector =
when (logical) {
"home" -> Icons.Filled.Home
"history" -> Icons.Filled.History
"list" -> Icons.Filled.List
"qr_code" -> Icons.Filled.QrCode
"link" -> Icons.Filled.Link
"snowflake" -> Icons.Filled.AcUnit
"star" -> Icons.Filled.Star
"settings" -> Icons.Filled.Settings
"search" -> Icons.Filled.Search
"user" -> Icons.Filled.Person
else -> Icons.Filled.Star
}
private fun tabDefsProp(props: Map<String, Any?>): List<Map<String, String>> {
return when (val raw = props["tabs"]) {
is JSONArray -> (0 until raw.length()).map { i ->
val obj = raw.getJSONObject(i)
mapOf("id" to obj.optString("id"), "label" to obj.optString("label"), "icon" to obj.optString("icon"))
}
else -> emptyList()
}
}
// ── Prop extraction ───────────────────────────────────────────────────────────
// Vertical alignment for Row from the `align:` prop — :top / :center / :bottom
// (atom from Elixir arrives as a String). Defaults to CenterVertically because
// that matches the most common visual expectation (e.g. icon next to title);
// opt back into Top with align: :top.
private fun rowAlignProp(props: Map<String, Any?>): Alignment.Vertical =
when (props["align"] as? String) {
"top" -> Alignment.Top
"bottom" -> Alignment.Bottom
else -> Alignment.CenterVertically
}
// 2D alignment for Box content — same string keys as iOS so the
// renderer doesn't have to discriminate per platform.
private fun boxAlignProp(props: Map<String, Any?>): Alignment =
when (props["align"] as? String) {
"center" -> Alignment.Center
"top" -> Alignment.TopCenter
"top_center" -> Alignment.TopCenter
"top_trailing" -> Alignment.TopEnd
"leading" -> Alignment.CenterStart
"trailing" -> Alignment.CenterEnd
"bottom" -> Alignment.BottomCenter
"bottom_leading" -> Alignment.BottomStart
"bottom_center" -> Alignment.BottomCenter
"bottom_trailing" -> Alignment.BottomEnd
else -> Alignment.TopStart
}
private fun colorProp(props: Map<String, Any?>, key: String): Color =
longColorProp(props, key) ?: Color.Unspecified
private fun longColorProp(props: Map<String, Any?>, key: String): Color? =
when (val v = props[key]) {
is Long -> Color(v.toInt())
is Int -> Color(v)
is Double -> Color(v.toLong().toInt())
else -> null
}
private fun sizeProp(props: Map<String, Any?>, key: String): TextUnit =
when (val v = props[key]) {
is Double -> v.toFloat().sp
is Float -> v.sp
is Int -> v.sp
is Long -> v.toFloat().sp
else -> TextUnit.Unspecified
}
private fun intProp(props: Map<String, Any?>, key: String): Int? =
when (val v = props[key]) {
is Int -> v
is Long -> v.toInt()
is Double -> v.toInt()
else -> null
}
private fun floatProp(props: Map<String, Any?>, key: String): Float? =
when (val v = props[key]) {
is Double -> v.toFloat()
is Float -> v
is Int -> v.toFloat()
is Long -> v.toFloat()
else -> null
}
private fun boolProp(props: Map<String, Any?>, key: String): Boolean? =
when (val v = props[key]) {
is Boolean -> v
is String -> v == "true"
else -> null
}
// ── Notification broadcast receiver ─────────────────────────────────────────
// Receives alarms from AlarmManager and posts the notification to the system tray.
// Also delivers the event to the running BEAM screen process if one is registered.
class NotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
val title = intent.getStringExtra("title") ?: ""
val body = intent.getStringExtra("body") ?: ""
val id = intent.getStringExtra("id") ?: "mob"
val dataStr = intent.getStringExtra("data") ?: "{}"
val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as NotificationManager
val notif = NotificationCompat.Builder(context, MobBridge.NOTIF_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.build()
nm.notify(id.hashCode(), notif)
// Deliver to BEAM if a screen is running
val json = """{"id":"$id","title":"$title","body":"$body","source":"local","data":$dataStr}"""
// We can't deliver directly here since we don't have a pid.
// The notification delegate in iOS style is set up differently on Android.
// Delivery happens via MobBridge when the notification is tapped (MainActivity intent).
// For foreground delivery, the screen registers via Mob.Permissions before scheduling.
}
}