diff --git a/resources/androidstudio/app/src/main/cpp/php_bridge.c b/resources/androidstudio/app/src/main/cpp/php_bridge.c
index d1ad509..c3e49a4 100644
--- a/resources/androidstudio/app/src/main/cpp/php_bridge.c
+++ b/resources/androidstudio/app/src/main/cpp/php_bridge.c
@@ -18,6 +18,7 @@ jobject g_bridge_instance = NULL;
extern jint InitializeBridgeJNI(JNIEnv* env);
static void safe_php_embed_shutdown(void);
static void worker_embed_shutdown(void);
+static void ephemeral_embed_shutdown(void);
int android_header_handler(sapi_header_struct *sapi_header, sapi_header_op_enum op, sapi_headers_struct *sapi_headers);
// Global state
@@ -69,6 +70,11 @@ static void (*jni_output_callback_ptr)(const char *) = NULL;
static int worker_initialized = 0;
static pthread_mutex_t g_worker_mutex = PTHREAD_MUTEX_INITIALIZER;
+// Ephemeral state — generic background TSRM context for plugin use
+static int ephemeral_initialized = 0;
+static int ephemeral_cold_booted = 0;
+static pthread_mutex_t g_ephemeral_mutex = PTHREAD_MUTEX_INITIALIZER;
+
/**
* Configure the embed SAPI module with host-registered functions.
* Must be called before each php_embed_init().
@@ -1050,6 +1056,174 @@ JNIEXPORT void JNICALL native_worker_shutdown(JNIEnv *env, jobject thiz) {
pthread_mutex_unlock(&g_worker_mutex);
}
+// ============================================================================
+// Ephemeral PHP Runtime — separate TSRM context for plugin background work
+// ============================================================================
+// Generic background PHP context that any plugin can use (e.g. background tasks,
+// scheduled jobs). Supports both hot path (app alive) and cold path (WorkManager
+// cold start after app killed).
+
+static int ephemeral_embed_init(void) {
+ if (php_initialized) {
+ // Hot path: persistent runtime is alive, allocate a TSRM thread context
+ LOGI("ephemeral_embed_init: hot path — using existing TSRM");
+
+ ts_resource(0);
+ setup_embed_module();
+
+ if (php_embed_module.startup(&php_embed_module) == FAILURE) {
+ LOGE("ephemeral_embed_init: module startup failed");
+ return FAILURE;
+ }
+
+ if (php_request_startup() == FAILURE) {
+ LOGE("ephemeral_embed_init: request startup failed");
+ return FAILURE;
+ }
+
+ ephemeral_cold_booted = 0;
+
+ LOGI("ephemeral_embed_init: hot path ready");
+ return SUCCESS;
+ }
+
+ // Cold path: WorkManager started the process after app was killed.
+ // No persistent runtime exists — do a full php_embed_init().
+ LOGI("ephemeral_embed_init: cold path — full PHP bootstrap");
+
+ setenv("NATIVEPHP_RUNNING", "true", 1);
+ setenv("APP_URL", "http://127.0.0.1", 1);
+ setenv("ASSET_URL", "http://127.0.0.1/_assets/", 1);
+ setenv("APP_RUNNING_IN_CONSOLE", "true", 1);
+ setenv("PHP_SELF", "/ephemeral", 1);
+ setenv("HTTP_HOST", "127.0.0.1", 1);
+
+ setup_embed_module();
+ if (php_embed_init(0, NULL) != SUCCESS) {
+ LOGE("ephemeral_embed_init: cold path php_embed_init() FAILED");
+ return FAILURE;
+ }
+ sapi_module.header_handler = android_header_handler;
+ ephemeral_cold_booted = 1;
+
+ LOGI("ephemeral_embed_init: cold path ready");
+ return SUCCESS;
+}
+
+static void ephemeral_embed_shutdown(void) {
+ if (ephemeral_cold_booted) {
+ LOGI("ephemeral_embed_shutdown: cold path — full php_embed_shutdown");
+ safe_php_embed_shutdown();
+ } else {
+ LOGI("ephemeral_embed_shutdown: hot path — thread cleanup only");
+ php_request_shutdown(NULL);
+ ts_free_thread();
+ }
+ LOGI("ephemeral_embed_shutdown: done");
+}
+
+JNIEXPORT jint JNICALL native_ephemeral_boot(JNIEnv *env, jobject thiz, jstring jBootstrapPath) {
+ pthread_mutex_lock(&g_ephemeral_mutex);
+
+ if (ephemeral_initialized) {
+ LOGI("ephemeral_boot: already initialized, skipping");
+ pthread_mutex_unlock(&g_ephemeral_mutex);
+ return 0;
+ }
+
+ const char *bootstrapPath = (*env)->GetStringUTFChars(env, jBootstrapPath, NULL);
+ LOGI("ephemeral_boot: initializing with bootstrap=%s", bootstrapPath);
+
+ clear_collected_output();
+
+ if (ephemeral_embed_init() != SUCCESS) {
+ LOGE("ephemeral_boot: ephemeral_embed_init() FAILED");
+ (*env)->ReleaseStringUTFChars(env, jBootstrapPath, bootstrapPath);
+ pthread_mutex_unlock(&g_ephemeral_mutex);
+ return -1;
+ }
+
+ zend_first_try {
+ zend_activate_modules();
+ zend_file_handle fileHandle;
+ zend_stream_init_filename(&fileHandle, bootstrapPath);
+ php_execute_script(&fileHandle);
+ } zend_end_try();
+
+ char *ephemeral_boot_output = get_collected_output();
+ if (ephemeral_boot_output && strstr(ephemeral_boot_output, "FATAL") != NULL) {
+ LOGE("ephemeral_boot: bootstrap produced errors: %.200s", ephemeral_boot_output);
+ }
+
+ ephemeral_initialized = 1;
+ LOGI("ephemeral_boot: ephemeral PHP interpreter ready");
+
+ (*env)->ReleaseStringUTFChars(env, jBootstrapPath, bootstrapPath);
+ pthread_mutex_unlock(&g_ephemeral_mutex);
+ return 0;
+}
+
+JNIEXPORT jstring JNICALL native_ephemeral_artisan(JNIEnv *env, jobject thiz, jstring jCommand) {
+ pthread_mutex_lock(&g_ephemeral_mutex);
+
+ if (!ephemeral_initialized) {
+ LOGE("ephemeral_artisan: ephemeral runtime not initialized!");
+ pthread_mutex_unlock(&g_ephemeral_mutex);
+ return (*env)->NewStringUTF(env, "Ephemeral runtime not initialized.");
+ }
+
+ const char *command = (*env)->GetStringUTFChars(env, jCommand, NULL);
+ LOGI("ephemeral_artisan: %s", command);
+
+ clear_collected_output();
+
+ setenv("APP_RUNNING_IN_CONSOLE", "true", 1);
+
+ char eval_code[4096];
+ snprintf(eval_code, sizeof(eval_code),
+ "try {\n"
+ " echo \\Native\\Mobile\\Runtime::artisan('%s');\n"
+ "} catch (\\Throwable $e) {\n"
+ " echo 'Ephemeral artisan error: ' . $e->getMessage();\n"
+ "}\n",
+ command);
+
+ zend_first_try {
+ zend_eval_string(eval_code, NULL, "ephemeral_artisan");
+ } zend_end_try();
+
+ setenv("APP_RUNNING_IN_CONSOLE", "false", 1);
+
+ (*env)->ReleaseStringUTFChars(env, jCommand, command);
+
+ char *ephemeral_output = get_collected_output();
+ jstring result = (*env)->NewStringUTF(env, ephemeral_output ? ephemeral_output : "");
+ pthread_mutex_unlock(&g_ephemeral_mutex);
+ return result;
+}
+
+JNIEXPORT void JNICALL native_ephemeral_shutdown(JNIEnv *env, jobject thiz) {
+ pthread_mutex_lock(&g_ephemeral_mutex);
+
+ if (!ephemeral_initialized) {
+ LOGI("ephemeral_shutdown: not initialized, nothing to do");
+ pthread_mutex_unlock(&g_ephemeral_mutex);
+ return;
+ }
+
+ LOGI("ephemeral_shutdown: shutting down ephemeral interpreter");
+
+ zend_first_try {
+ zend_eval_string("\\Native\\Mobile\\Runtime::shutdown();", NULL, "ephemeral_shutdown");
+ } zend_end_try();
+
+ ephemeral_embed_shutdown();
+ ephemeral_initialized = 0;
+
+ LOGI("ephemeral_shutdown: done");
+ pthread_mutex_unlock(&g_ephemeral_mutex);
+}
+
static JNINativeMethod gMethods[] = {
// PHPBridge
{"nativeExecuteScript", "(Ljava/lang/String;)Ljava/lang/String;", (void *) native_execute_script},
@@ -1078,6 +1252,11 @@ static JNINativeMethod gMethods[] = {
{"nativeWorkerBoot","(Ljava/lang/String;)I",(void *) native_worker_boot},
{"nativeWorkerArtisan","(Ljava/lang/String;)Ljava/lang/String;",(void *) native_worker_artisan},
{"nativeWorkerShutdown","()V",(void *) native_worker_shutdown},
+
+ // Ephemeral runtime (background tasks via WorkManager) methods
+ {"nativeEphemeralBoot","(Ljava/lang/String;)I",(void *) native_ephemeral_boot},
+ {"nativeEphemeralArtisan","(Ljava/lang/String;)Ljava/lang/String;",(void *) native_ephemeral_artisan},
+ {"nativeEphemeralShutdown","()V",(void *) native_ephemeral_shutdown},
};
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/PHPBridge.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/PHPBridge.kt
index 16983c8..500641b 100644
--- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/PHPBridge.kt
+++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/bridge/PHPBridge.kt
@@ -63,6 +63,11 @@ class PHPBridge(private val context: Context) {
external fun nativeWorkerArtisan(command: String): String
external fun nativeWorkerShutdown()
+ // Ephemeral runtime JNI methods — generic background TSRM context for plugin use
+ external fun nativeEphemeralBoot(bootstrapPath: String): Int
+ external fun nativeEphemeralArtisan(command: String): String
+ external fun nativeEphemeralShutdown()
+
@Volatile
private var runtimeInitialized = false
@@ -285,7 +290,9 @@ class PHPBridge(private val context: Context) {
}
fun getLastPostData(): String? {
- return lastPostData
+ val data = lastPostData
+ lastPostData = null // Clear after consumption to prevent stale data
+ return data
}
fun getLaravelPath(): String {
diff --git a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt
index 5aee8de..dc7b952 100644
--- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt
+++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/MainActivity.kt
@@ -278,6 +278,14 @@ class MainActivity : FragmentActivity(), WebViewProvider {
super.onNewIntent(intent)
handleDeepLinkIntent(intent)
+ // If deep link didn't fire but we have a notification URL, navigate via Inertia
+ if (intent.data == null) {
+ val notificationUrl = intent.getStringExtra("notification_url")
+ if (!notificationUrl.isNullOrEmpty()) {
+ navigateWithInertia(notificationUrl)
+ }
+ }
+
// Post lifecycle event for plugins
intent.data?.let { uri ->
NativePHPLifecycle.post(
@@ -298,7 +306,7 @@ class MainActivity : FragmentActivity(), WebViewProvider {
}
private fun handleDeepLinkIntent(intent: Intent?) {
- // Check for notification URL extra (from local notification taps)
+ // Check for notification URL extra (from local notification taps or foreground push)
val notificationUrl = intent?.getStringExtra("notification_url")
if (!notificationUrl.isNullOrEmpty()) {
Log.d("DeepLink", "🔔 Notification URL: $notificationUrl")
@@ -312,6 +320,30 @@ class MainActivity : FragmentActivity(), WebViewProvider {
return
}
+ // Check for deep link URL from FCM data payload (background/killed push notifications)
+ val fcmUrl = intent?.getStringExtra("url") ?: intent?.getStringExtra("link")
+ if (!fcmUrl.isNullOrEmpty()) {
+ Log.d("DeepLink", "🔔 FCM deep link URL: $fcmUrl")
+ val uri = android.net.Uri.parse(fcmUrl)
+ val scheme = uri.scheme
+ val route = if (scheme != null && scheme != "http" && scheme != "https") {
+ val host = uri.host ?: ""
+ val path = uri.path ?: ""
+ val query = uri.query?.let { "?$it" } ?: ""
+ if (host.isNotEmpty()) "/$host$path$query" else "$path$query"
+ } else {
+ fcmUrl
+ }
+ pendingDeepLink = route
+ if (::laravelEnv.isInitialized && ::webViewManager.isInitialized) {
+ val fullUrl = "http://127.0.0.1$route"
+ Log.d("DeepLink", "🚀 Loading FCM deep link immediately: $fullUrl")
+ webView.loadUrl(fullUrl)
+ pendingDeepLink = null
+ }
+ return
+ }
+
val uri = intent?.data ?: return
Log.d("DeepLink", "🌐 Received deep link: $uri")
diff --git a/resources/stubs/android/PluginBridgeFunctionRegistration.kt.stub b/resources/stubs/android/PluginBridgeFunctionRegistration.kt.stub
index 7cf04a3..ed52365 100644
--- a/resources/stubs/android/PluginBridgeFunctionRegistration.kt.stub
+++ b/resources/stubs/android/PluginBridgeFunctionRegistration.kt.stub
@@ -11,5 +11,7 @@ import com.nativephp.mobile.bridge.BridgeFunctionRegistry
fun registerPluginBridgeFunctions(activity: FragmentActivity, context: Context) {
val registry = BridgeFunctionRegistry.shared
+{{ INIT_FUNCTIONS }}
+
{{ REGISTRATIONS }}
}
\ No newline at end of file
diff --git a/resources/xcode/NativePHP/AppDelegate.swift b/resources/xcode/NativePHP/AppDelegate.swift
index 0295973..01c1bca 100644
--- a/resources/xcode/NativePHP/AppDelegate.swift
+++ b/resources/xcode/NativePHP/AppDelegate.swift
@@ -107,4 +107,18 @@ class AppDelegate: NSObject, UIApplicationDelegate {
DeepLinkRouter.shared.handle(url: url)
return true
}
+
+ // Handle remote notifications (data-only messages, silent push)
+ func application(
+ _ application: UIApplication,
+ didReceiveRemoteNotification userInfo: [AnyHashable: Any],
+ fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
+ ) {
+ NotificationCenter.default.post(
+ name: .didReceiveRemoteNotification,
+ object: nil,
+ userInfo: ["payload": userInfo]
+ )
+ completionHandler(.newData)
+ }
}
diff --git a/resources/xcode/NativePHP/DeepLinkRouter.swift b/resources/xcode/NativePHP/DeepLinkRouter.swift
index f29a887..06c86ae 100644
--- a/resources/xcode/NativePHP/DeepLinkRouter.swift
+++ b/resources/xcode/NativePHP/DeepLinkRouter.swift
@@ -67,10 +67,13 @@ final class DeepLinkRouter {
DebugLogger.shared.log("🔗 Normalized to: \(newURLString)")
- // 3. Either redirect immediately or store for later
+ // 3. Either navigate immediately or store for later
if isWebViewReady && isPhpReady {
- DebugLogger.shared.log("🔗 Both ready, redirecting immediately")
- redirectToURL(newURLString)
+ // App is already running — use Inertia router for SPA navigation
+ // This prevents Inertia from returning raw JSON on subsequent navigations
+ // (e.g. second OAuth login after logout)
+ DebugLogger.shared.log("🔗 Both ready, navigating with Inertia")
+ navigateWithInertia(normalizedRoute)
} else {
DebugLogger.shared.log("🔗 Not ready, storing as pending URL")
// Store the URL to handle once both WebView and PHP are ready
@@ -87,4 +90,18 @@ final class DeepLinkRouter {
)
DebugLogger.shared.log("🔗 redirectToURL() notification posted successfully")
}
+
+ /// Navigate using Inertia router when the app is already running.
+ /// Uses the path (not the full php:// URL) so window.router.visit() works correctly.
+ private func navigateWithInertia(_ path: String) {
+ DebugLogger.shared.log("🔗 navigateWithInertia() posting notification for path: \(path)")
+ DispatchQueue.main.async {
+ NotificationCenter.default.post(
+ name: .navigateWithInertiaNotification,
+ object: nil,
+ userInfo: ["path": path]
+ )
+ }
+ DebugLogger.shared.log("🔗 navigateWithInertia() notification posted")
+ }
}
diff --git a/src/Facades/PushNotifications.php b/src/Facades/PushNotifications.php
index af81018..b8a317a 100644
--- a/src/Facades/PushNotifications.php
+++ b/src/Facades/PushNotifications.php
@@ -9,6 +9,7 @@
* @method static PendingPushNotificationEnrollment enroll()
* @method static string|null checkPermission()
* @method static string|null getToken()
+ * @method static void clearBadge()
*
* @see \Native\Mobile\PushNotifications
*/
diff --git a/src/Plugins/Compilers/AndroidPluginCompiler.php b/src/Plugins/Compilers/AndroidPluginCompiler.php
index a65b5da..b80f87b 100644
--- a/src/Plugins/Compilers/AndroidPluginCompiler.php
+++ b/src/Plugins/Compilers/AndroidPluginCompiler.php
@@ -146,7 +146,7 @@ public function compile(): void
return;
}
- // Check if there are any plugins with Android bridge functions
+ // Check if there are any plugins with Android bridge functions or init functions
$hasAndroidFunctions = $allPlugins->filter(function (Plugin $p) {
foreach ($p->getBridgeFunctions() as $function) {
if (! empty($function['android'])) {
@@ -157,6 +157,10 @@ public function compile(): void
return false;
})->isNotEmpty();
+ $hasInitFunctions = $allPlugins->filter(function (Plugin $p) {
+ return $p->getAndroidInitFunction() !== null;
+ })->isNotEmpty();
+
// Ensure generated directory exists
$this->files->ensureDirectoryExists($this->generatedPath);
@@ -164,8 +168,8 @@ public function compile(): void
$allPlugins->filter(fn (Plugin $p) => $p->hasAndroidCode())
->each(fn (Plugin $plugin) => $this->copyPluginSources($plugin));
- // Generate the registration file
- if ($hasAndroidFunctions) {
+ // Generate the bridge function registration file
+ if ($hasAndroidFunctions || $hasInitFunctions) {
$this->generateBridgeFunctionRegistration($allPlugins);
} else {
$this->generateEmptyRegistration();
@@ -265,8 +269,10 @@ protected function sanitizeKotlinName(string $name): string
protected function generateBridgeFunctionRegistration(Collection $plugins): void
{
$registrations = [];
+ $initFunctions = [];
foreach ($plugins as $plugin) {
+ // Collect bridge function registrations
foreach ($plugin->getBridgeFunctions() as $function) {
if (empty($function['android'])) {
continue;
@@ -279,9 +285,18 @@ protected function generateBridgeFunctionRegistration(Collection $plugins): void
'params' => $function['android_params'] ?? ['activity'],
];
}
+
+ // Collect init functions
+ $initFunction = $plugin->getAndroidInitFunction();
+ if ($initFunction) {
+ $initFunctions[] = [
+ 'function' => $initFunction,
+ 'plugin' => $plugin->name,
+ ];
+ }
}
- $content = $this->renderRegistrationTemplate($registrations);
+ $content = $this->renderRegistrationTemplate($registrations, $initFunctions);
$path = $this->generatedPath.'/PluginBridgeFunctionRegistration.kt';
$this->files->put($path, $content);
@@ -291,7 +306,7 @@ protected function generateBridgeFunctionRegistration(Collection $plugins): void
/**
* Render the Kotlin registration file
*/
- protected function renderRegistrationTemplate(array $registrations): string
+ protected function renderRegistrationTemplate(array $registrations, array $initFunctions = []): string
{
// Build imports from the android class paths in nativephp.json
$imports = collect($registrations)
@@ -302,6 +317,18 @@ protected function renderRegistrationTemplate(array $registrations): string
->map(fn ($package) => "import {$package}")
->implode("\n");
+ // Add imports for init functions (top-level Kotlin functions need full path import)
+ $initImports = collect($initFunctions)
+ ->pluck('function')
+ ->unique()
+ ->sort()
+ ->map(fn ($func) => "import {$func}")
+ ->implode("\n");
+
+ if ($initImports) {
+ $imports = $imports ? $imports."\n".$initImports : $initImports;
+ }
+
$registerCalls = collect($registrations)
->map(function ($reg) {
$className = $this->extractClassName($reg['class']);
@@ -312,9 +339,20 @@ protected function renderRegistrationTemplate(array $registrations): string
})
->implode("\n\n");
+ $initCalls = collect($initFunctions)
+ ->map(function ($init) {
+ // Extract just the function name from the full path
+ $parts = explode('.', $init['function']);
+ $funcName = end($parts);
+
+ return " // Plugin: {$init['plugin']}\n {$funcName}(context)";
+ })
+ ->implode("\n\n");
+
return Stub::make('android/PluginBridgeFunctionRegistration.kt.stub')
->replaceAll([
'IMPORTS' => $imports,
+ 'INIT_FUNCTIONS' => $initCalls,
'REGISTRATIONS' => $registerCalls,
])
->render();
diff --git a/src/Plugins/Compilers/IOSPluginCompiler.php b/src/Plugins/Compilers/IOSPluginCompiler.php
index 8878986..9cf888d 100644
--- a/src/Plugins/Compilers/IOSPluginCompiler.php
+++ b/src/Plugins/Compilers/IOSPluginCompiler.php
@@ -367,6 +367,10 @@ protected function injectPlistEntries(string $plist, array $entries): string
$arrayContent .= "\n\t\t{$item}";
}
$entry = "\n\t{$key}\n\t{$arrayContent}\n\t";
+ } elseif (is_bool($value)) {
+ // Handle boolean values
+ $boolTag = $value ? '' : '';
+ $entry = "\n\t{$key}\n\t{$boolTag}";
} else {
// Handle string values - substitute placeholders
$value = $this->substituteEnvPlaceholders($value);
diff --git a/src/PushNotifications.php b/src/PushNotifications.php
index a2636d2..90fafb3 100644
--- a/src/PushNotifications.php
+++ b/src/PushNotifications.php
@@ -64,4 +64,16 @@ public function getToken(): ?string
return null;
}
+
+ /**
+ * Clear the app icon badge number
+ */
+ public function clearBadge(): void
+ {
+ if (! function_exists('nativephp_call')) {
+ return;
+ }
+
+ nativephp_call('PushNotification.ClearBadge', '{}');
+ }
}