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', '{}'); + } }