Packages
mob_new
0.4.19
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.45
0.1.44
0.1.43
0.1.42
0.1.40
0.1.33
0.1.30
0.1.29
0.1.28
0.1.27
0.1.26
0.1.25
0.1.24
0.1.23
0.1.21
0.1.20
0.1.19
0.1.18
0.1.17
0.1.16
0.1.15
0.1.14
0.1.12
0.1.11
0.1.10
0.1.9
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
Project generator for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
priv/templates/mob.new/android/app/src/main/java/MainActivity.kt.eex
package <%= java_package %>
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
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 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()
external fun nativeNotifyOrientation(orient: String)
external fun nativeNotifyConnectivity(
online: Boolean,
transport: String,
expensive: Boolean,
validated: Boolean,
)
// ── Network connectivity ──────────────────────────────────────────────
// A ConnectivityManager.NetworkCallback drives Mob.Device.network_state/0
// and the :network subscription. registerDefaultNetworkCallback fires an
// initial onCapabilitiesChanged, so the BEAM-side cache is seeded at start.
private var connectivityManager: ConnectivityManager? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private fun registerNetworkCallback() {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
connectivityManager = cm
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
pushConnectivity(caps)
}
override fun onLost(network: Network) {
// Don't blindly report offline: on a wifi->cellular handoff the
// lost default's onLost can arrive after the new default has
// settled, which would leave us stuck offline. Re-check the
// current active network before concluding there's no path.
val active = connectivityManager?.activeNetwork
val caps = active?.let { connectivityManager?.getNetworkCapabilities(it) }
pushConnectivity(caps)
}
}
networkCallback = callback
try {
cm.registerDefaultNetworkCallback(callback)
} catch (_: Throwable) {
}
}
private fun pushConnectivity(caps: NetworkCapabilities?) {
if (caps == null) {
notifyConnectivitySafe(false, "none", false, false)
return
}
val transport = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "wired"
else -> "other"
}
val expensive = !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
// Android actively probes for real internet reachability — false on a
// captive portal / before validation. iOS has no equivalent (:unavailable).
val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
notifyConnectivitySafe(true, transport, expensive, validated)
}
private fun notifyConnectivitySafe(
online: Boolean,
transport: String,
expensive: Boolean,
validated: Boolean,
) {
try {
nativeNotifyConnectivity(online, transport, expensive, validated)
} catch (_: Throwable) {
}
}
// ── File picker launcher ──────────────────────────────────────────────
private val filePickerLauncher =
registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
MobBridge.handleFilesResult(uris)
}
fun launchFilePicker() {
filePickerLauncher.launch(arrayOf("*/*"))
}
// ── Permission result ─────────────────────────────────────────────────
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, 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)
registerNetworkCallback()
// Register activated plugins' Kotlin bridge classes (generated by
// mob_dev at build time). Each register() caches its own jclass +
// method IDs natively so the plugin's NIF can call into it; the
// Activity is then handed to any bridge implementing
// io.mob.plugin.MobActivityAware. Must run before the BEAM starts.
io.mob.plugin.MobPluginBootstrap.registerAll(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
// (`<app>_android` → `<app>_android_<suffix>`).
// 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 <App>.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/<abi>/*.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 = io.mob.plugin.MobNotifyHub.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)
// Forward the new orientation to the BEAM so Mob.Device.orientation/0 and
// the :display subscription reflect a rotation (mob_send_orientation_changed).
val orient = when (display?.rotation) {
android.view.Surface.ROTATION_90 -> "landscape_left"
android.view.Surface.ROTATION_270 -> "landscape_right"
android.view.Surface.ROTATION_180 -> "portrait_upside_down"
else -> "portrait"
}
try {
nativeNotifyOrientation(orient)
} catch (_: Throwable) {
}
}
override fun onDestroy() {
super.onDestroy()
val cm = connectivityManager
val cb = networkCallback
if (cm != null && cb != null) {
try {
cm.unregisterNetworkCallback(cb)
} catch (_: Throwable) {
}
}
}
// 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<String, Long>, key: String, fallback: Long): Color =
Color(map[key] ?: fallback)
}