package <%= java_package %> import android.app.Activity import android.content.pm.PackageManager import android.content.res.Configuration import android.net.Uri import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.core.content.FileProvider import java.io.File class MainActivity : ComponentActivity() { companion object { private const val TAG = "<%= display_name %>" init { System.loadLibrary("<%= app_name %>") } } external fun nativeSetActivity(activity: Activity) external fun nativeStartBeam() // ── Camera launchers ────────────────────────────────────────────────── private var cameraPhotoUri: Uri? = null private val cameraPhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> MobBridge.handleCameraPhotoResult(if (success) cameraPhotoUri else null) } private val cameraVideoLauncher = registerForActivityResult(ActivityResultContracts.CaptureVideo()) { success -> MobBridge.handleCameraVideoResult(if (success) cameraPhotoUri else null) } fun launchCameraPhoto() { val file = File(cacheDir, "mob_cam_${System.currentTimeMillis()}.jpg") cameraPhotoUri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) cameraPhotoLauncher.launch(cameraPhotoUri!!) } fun launchCameraVideo() { val file = File(cacheDir, "mob_cam_${System.currentTimeMillis()}.mp4") cameraPhotoUri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file) cameraVideoLauncher.launch(cameraPhotoUri!!) } // ── Photo picker launcher ───────────────────────────────────────────── private val photosPickerLauncher = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris -> MobBridge.handlePhotosResult(uris) } fun launchPhotosPicker(max: Int) { photosPickerLauncher.launch( androidx.activity.result.PickVisualMediaRequest( androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageAndVideo ) ) } // ── File picker launcher ────────────────────────────────────────────── private val filePickerLauncher = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> MobBridge.handleFilesResult(uris) } fun launchFilePicker() { filePickerLauncher.launch(arrayOf("*/*")) } // ── QR scanner launcher ─────────────────────────────────────────────── // For QR scanning we use an intent to a helper activity (MobScannerActivity) // that uses CameraX + ML Kit. It returns the scanned value as a result string. private val scannerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val value = result.data?.getStringExtra("scan_value") val type = result.data?.getStringExtra("scan_type") ?: "qr" MobBridge.handleScanResult(value, type) } fun launchQrScanner() { val intent = android.content.Intent(this, MobScannerActivity::class.java) scannerLauncher.launch(intent) } // ── Permission result ───────────────────────────────────────────────── override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == 9001) { val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } MobBridge.onPermissionResult(granted) } } override fun onCreate(savedInstanceState: Bundle?) { // Edge-to-edge: lets the content draw behind the (transparent) status // and navigation bars instead of being letterboxed by opaque system // bars. Must be called BEFORE super.onCreate() per AndroidX docs. enableEdgeToEdge() super.onCreate(savedInstanceState) MobBridge.init(this) // Forward launcher-supplied env vars into the BEAM process. Set BEFORE // nativeStartBeam below so the BEAM (and Mob.Dist in particular) sees // them when it reads getenv()/System.get_env/1. // // mob_node_suffix — appended to the configured node name // (`_android` → `_android_`). // Lets multiple Android phones running the same // app coexist in Mac's shared EPMD. // mob_dist_port — Erlang dist listen port (default 9100). intent?.extras?.getString("mob_node_suffix")?.takeIf { it.isNotEmpty() }?.let { suffix -> android.system.Os.setenv("MOB_NODE_SUFFIX", suffix, true) Log.i(TAG, "onCreate: MOB_NODE_SUFFIX=$suffix") } intent?.extras?.getInt("mob_dist_port", -1)?.takeIf { it > 0 }?.let { port -> android.system.Os.setenv("MOB_DIST_PORT", port.toString(), true) Log.i(TAG, "onCreate: MOB_DIST_PORT=$port") } // Check if launched from a notification tap intent?.extras?.getString("mob_notification_json")?.let { json -> MobBridge.setLaunchNotification(json) } setContent { val state by MobBridge.rootState val themeColors by MobBridge.themeColors BackHandler(enabled = state.node != null) { MobBridge.nativeHandleBack() } // Material 3 chrome (NavigationBar, Button, …) pulls its colours // from `MaterialTheme.colorScheme`. We want those to match the // BEAM-side `Mob.Theme` so the system widgets don't clash with // mob's own primitives. `themeColors` updates via // `:mob_nif.set_theme/1` whenever `Mob.Theme.set(...)` runs. // // There's a brief window at launch — Compose evaluates setContent // before the BEAM finishes `mount/3` and pushes the first theme // — so the null branch falls back to Material's stock dark // scheme, which usually reads as a fine placeholder until the // real palette arrives. val colorScheme = themeColors?.let { tc -> darkColorScheme( primary = colorFromMap(tc, "primary", 0xFF6750A4), onPrimary = colorFromMap(tc, "on_primary", 0xFFFFFFFF), secondary = colorFromMap(tc, "secondary", 0xFF625B71), onSecondary = colorFromMap(tc, "on_secondary", 0xFFFFFFFF), background = colorFromMap(tc, "background", 0xFF1C1B1F), onBackground = colorFromMap(tc, "on_background", 0xFFE6E1E5), surface = colorFromMap(tc, "surface", 0xFF1C1B1F), onSurface = colorFromMap(tc, "on_surface", 0xFFE6E1E5), // Mob's `surface_raised` / `muted` map onto Material 3's // surfaceVariant / onSurfaceVariant — same role. surfaceVariant = colorFromMap(tc, "surface_raised", 0xFF49454F), onSurfaceVariant = colorFromMap(tc, "muted", 0xFFCAC4D0), outline = colorFromMap(tc, "border", 0xFF938F99), error = colorFromMap(tc, "error", 0xFFF2B8B5), onError = colorFromMap(tc, "on_error", 0xFFFFFFFF), ) } ?: darkColorScheme() MaterialTheme(colorScheme = colorScheme) { AnimatedContent( targetState = state, contentKey = { it.navKey }, transitionSpec = { when (targetState.transition) { "push" -> slideInHorizontally(animationSpec = tween(300)) { it } togetherWith slideOutHorizontally(animationSpec = tween(300)) { -it / 3 } "pop" -> slideInHorizontally(animationSpec = tween(300)) { -it / 3 } togetherWith slideOutHorizontally(animationSpec = tween(300)) { it } "reset" -> fadeIn(animationSpec = tween(250)) togetherWith fadeOut(animationSpec = tween(250)) else -> EnterTransition.None togetherWith ExitTransition.None } }, label = "nav" ) { s -> s.node?.let { RenderNode(it, modifier = Modifier.fillMaxSize().safeDrawingPadding()) } } } } // If the project ships embedded Python (mix mob.enable pythonx), the // APK contains assets/python/{stdlib,lib-dynload}/. Extract those to // filesDir on first launch and tell the BEAM where they landed via // env vars consumed by .PythonPaths. Idempotent — re-launches // skip extraction once the marker file is present. extractPythonAssetsIfNeeded() Log.i(TAG, "onCreate — handing off to BEAM") nativeSetActivity(this) Thread({ nativeStartBeam() }, "beam-main").start() } private fun extractPythonAssetsIfNeeded() { val pythonRoot = File(filesDir, "python") val marker = File(pythonRoot, ".extracted") // libpython3.13.so is auto-extracted by the APK installer to the // app's nativeLibraryDir — point Pythonx.init/4 at it. val libPython = File(applicationInfo.nativeLibraryDir, "libpython3.13.so") if (libPython.exists()) { android.system.Os.setenv("MOB_PYTHON_DL", libPython.absolutePath, true) } // Skip extraction if no assets ship Python (project doesn't use Pythonx) // or if extraction has already happened. val assetList = try { assets.list("python") ?: emptyArray() } catch (_: Throwable) { emptyArray() } if (assetList.isEmpty() || marker.exists()) { if (pythonRoot.exists()) { android.system.Os.setenv("MOB_PYTHON_HOME", pythonRoot.absolutePath, true) } return } Log.i(TAG, "extractPythonAssets: extracting assets/python → ${pythonRoot.absolutePath}") copyAssetTree("python", pythonRoot) flattenLibDynload(pythonRoot) marker.createNewFile() android.system.Os.setenv("MOB_PYTHON_HOME", pythonRoot.absolutePath, true) Log.i(TAG, "extractPythonAssets: done") } // Chaquopy ships lib-dynload//*.so per architecture; CPython expects // them flat in lib-dynload/. Move the device's primary-abi `.so` files // up one level and discard the rest. private fun flattenLibDynload(pythonRoot: File) { val libDynload = File(pythonRoot, "lib/python3.13/lib-dynload") if (!libDynload.isDirectory) return val deviceAbi = android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: return val abiDir = File(libDynload, deviceAbi) if (!abiDir.isDirectory) return abiDir.listFiles()?.forEach { src -> val dst = File(libDynload, src.name) if (!dst.exists()) src.renameTo(dst) } // Drop the other-abi dirs to free space. libDynload.listFiles { f -> f.isDirectory }?.forEach { it.deleteRecursively() } } private fun copyAssetTree(srcPath: String, destDir: File) { val children = assets.list(srcPath) ?: emptyArray() if (children.isEmpty()) { // Leaf: copy the file destDir.parentFile?.mkdirs() assets.open(srcPath).use { input -> destDir.outputStream().use { output -> input.copyTo(output) } } return } destDir.mkdirs() for (child in children) { copyAssetTree("$srcPath/$child", File(destDir, child)) } } // Called when a notification is tapped and the activity already exists at // the top of the stack (singleTop launch mode). If BEAM is running, deliver // the notification directly; otherwise store it for delivery on boot. override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) setIntent(intent) intent.extras?.getString("mob_notification_json")?.let { json -> val pid = MobBridge.notifyPid if (pid != 0L) { MobBridge.nativeDeliverNotification(pid, json) } else { MobBridge.setLaunchNotification(json) } } } // Manifest declares `android:configChanges` including `uiMode`, so a // dark/light toggle delivers here instead of recreating the activity. // Forward to MobBridge so Mob.Device :appearance subscribers can react // (e.g. re-resolve Mob.Theme.Adaptive without an app restart). override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val nightMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK val scheme = if (nightMode == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" MobBridge.notifyColorSchemeChanged(scheme) } // Pulls an ARGB long out of the BEAM-pushed theme map, falling back to // a Material 3 stock dark value when the key isn't present (e.g. a // custom theme that doesn't define every Material 3 slot). private fun colorFromMap(map: Map, key: String, fallback: Long): Color = Color(map[key] ?: fallback) }