From 7767e8842077217a65267c350a94f3e6f5a7498b Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Sun, 26 Apr 2026 00:27:47 +0300 Subject: [PATCH 1/9] feat(harness): implement modular dashboard UI and view stack --- android/.build_version | 2 +- scenes/Main.tscn | 218 +++++------ scenes/view_stack/AnalyticsView.tscn | 38 ++ scenes/view_stack/CrashlyticsView.tscn | 39 ++ scenes/view_stack/MessagingView.tscn | 38 ++ scripts/Main.gd | 512 ++++++++++++++++--------- scripts/TestButton.gd | 34 ++ 7 files changed, 586 insertions(+), 295 deletions(-) create mode 100644 scenes/view_stack/AnalyticsView.tscn create mode 100644 scenes/view_stack/CrashlyticsView.tscn create mode 100644 scenes/view_stack/MessagingView.tscn create mode 100644 scripts/TestButton.gd diff --git a/android/.build_version b/android/.build_version index 48e9b82..6cec3aa 100644 --- a/android/.build_version +++ b/android/.build_version @@ -1 +1 @@ -4.5.1.stable +4.6.1.stable diff --git a/scenes/Main.tscn b/scenes/Main.tscn index 7d553df..cd10f21 100644 --- a/scenes/Main.tscn +++ b/scenes/Main.tscn @@ -1,6 +1,7 @@ -[gd_scene load_steps=2 format=3 uid="uid://bk7bnp6eunbxp"] +[gd_scene load_steps=3 format=3 uid="uid://bk7bnp6eunbxp"] [ext_resource type="Script" path="res://scripts/Main.gd" id="1"] +[ext_resource type="Script" path="res://scripts/TestButton.gd" id="2"] [node name="Main" type="Control"] layout_mode = 3 @@ -16,179 +17,154 @@ layout_mode = 1 anchors_preset = 15 anchor_right = 1.0 anchor_bottom = 1.0 -offset_left = 40.0 -offset_top = 140.0 -offset_right = -40.0 -offset_bottom = -60.0 grow_horizontal = 2 grow_vertical = 2 -theme_override_constants/separation = 20 +theme_override_constants/separation = 0 -[node name="Title" type="Label" parent="VBoxContainer"] +[node name="HeaderGroup" type="PanelContainer" parent="VBoxContainer"] +custom_minimum_size = Vector2(0, 100) layout_mode = 2 -theme_override_font_sizes/font_size = 48 -text = "Firebase Plugin Test" -horizontal_alignment = 1 -[node name="HSeparator" type="HSeparator" parent="VBoxContainer"] +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/HeaderGroup"] layout_mode = 2 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 -[node name="StatusLabel" type="Label" parent="VBoxContainer"] -custom_minimum_size = Vector2(0, 60) -layout_mode = 2 -theme_override_font_sizes/font_size = 32 -text = "Ready" -horizontal_alignment = 1 -autowrap_mode = 3 -vertical_alignment = 1 - -[node name="HSeparator2" type="HSeparator" parent="VBoxContainer"] +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/HeaderGroup/MarginContainer"] layout_mode = 2 -[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"] +[node name="BackButton" type="Button" parent="VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer"] +visible = false +custom_minimum_size = Vector2(80, 60) layout_mode = 2 -size_flags_vertical = 3 +size_flags_vertical = 4 +theme_override_font_sizes/font_size = 32 +text = "<" -[node name="ContentContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"] +[node name="ViewTitle" type="Label" parent="VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer"] layout_mode = 2 size_flags_horizontal = 3 -size_flags_vertical = 3 -theme_override_constants/separation = 15 +theme_override_font_sizes/font_size = 42 +text = "Firebase Harness" +horizontal_alignment = 1 +vertical_alignment = 1 -[node name="CoreLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="HSeparator" type="HSeparator" parent="VBoxContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "๐Ÿ”ฅ Firebase Core" -[node name="InitializeButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="ContextGroup" type="MarginContainer" parent="VBoxContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Initialize Firebase" +size_flags_vertical = 3 +theme_override_constants/margin_left = 40 +theme_override_constants/margin_top = 20 +theme_override_constants/margin_right = 40 +theme_override_constants/margin_bottom = 20 -[node name="HSeparator" type="HSeparator" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="Dashboard" type="ScrollContainer" parent="VBoxContainer/ContextGroup"] layout_mode = 2 -[node name="AnalyticsLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="List" type="VBoxContainer" parent="VBoxContainer/ContextGroup/Dashboard"] layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "๐Ÿ“Š Firebase Analytics" +size_flags_horizontal = 3 +theme_override_constants/separation = 20 -[node name="LogEventButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="InitializeButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +custom_minimum_size = Vector2(0, 120) layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Log Test Event" +theme_override_font_sizes/font_size = 36 +text = "๐Ÿ”ฅ INITIALIZE FIREBASE" +script = ExtResource("2") -[node name="LogScreenButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="HSeparator" type="HSeparator" parent="VBoxContainer/ContextGroup/Dashboard/List"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Log Screen View" +theme_override_constants/separation = 20 -[node name="HSeparator2" type="HSeparator" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="AnalyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +disabled = true +custom_minimum_size = Vector2(0, 100) layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = "ANALYTICS" +script = ExtResource("2") -[node name="CrashlyticsLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="CrashlyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +disabled = true +custom_minimum_size = Vector2(0, 100) layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "๐Ÿ› Firebase Crashlytics" +theme_override_font_sizes/font_size = 32 +text = "CRASHLYTICS" +script = ExtResource("2") -[node name="LogCrashlyticsButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="MessagingButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +disabled = true +custom_minimum_size = Vector2(0, 100) layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Log Message" +theme_override_font_sizes/font_size = 32 +text = "MESSAGING" +script = ExtResource("2") -[node name="SetUserIDButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) -layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Set User ID" -[node name="SetCustomValueButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) -layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Set Custom Value" -[node name="ForceCrashButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="ModuleContainer" type="Control" parent="VBoxContainer/ContextGroup"] +visible = false layout_mode = 2 -theme_override_colors/font_color = Color(1, 0.3, 0.3, 1) -theme_override_font_sizes/font_size = 26 -text = "โš  Force Crash (Test)" -[node name="HSeparator3" type="HSeparator" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="LogGroup" type="VBoxContainer" parent="VBoxContainer"] +custom_minimum_size = Vector2(0, 400) layout_mode = 2 +theme_override_constants/separation = 10 -[node name="MessagingLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="HSeparator" type="HSeparator" parent="VBoxContainer/LogGroup"] layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "๐Ÿ’ฌ Firebase Messaging" -[node name="RequestPermissionButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/LogGroup"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Request Notification Permission" +size_flags_vertical = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 10 -[node name="GetTokenButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="VBoxContainer" type="VBoxContainer" parent="VBoxContainer/LogGroup/MarginContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Get FCM Token" +theme_override_constants/separation = 10 -[node name="SubscribeTopicButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="LogTitle" type="Label" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Subscribe to 'test_topic'" +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“ Output Log" -[node name="UnsubscribeTopicButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="LogOutput" type="TextEdit" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Unsubscribe from 'test_topic'" +size_flags_vertical = 3 +theme_override_font_sizes/font_size = 22 +editable = false +wrap_mode = 1 -[node name="HSeparator4" type="HSeparator" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="LogControls" type="HBoxContainer" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer"] layout_mode = 2 +theme_override_constants/separation = 40 -[node name="LogLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="ClearLogButton" type="Button" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls"] +custom_minimum_size = Vector2(200, 70) layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "๐Ÿ“ Output Log" +theme_override_font_sizes/font_size = 24 +text = "Clear Logs" -[node name="LogOutput" type="TextEdit" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="Control" type="Control" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls"] layout_mode = 2 -custom_minimum_size = Vector2(0, 450) size_flags_horizontal = 3 + +[node name="CopyLogButton" type="Button" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls"] +custom_minimum_size = Vector2(200, 70) +layout_mode = 2 theme_override_font_sizes/font_size = 24 -editable = false -wrap_mode = 1 +text = "Copy Logs" + +[connection signal="pressed" from="VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer/BackButton" to="." method="show_dashboard"] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/InitializeButton" to="." method="_on_initialize_pressed"] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" to="." method="show_module" binds= ["Analytics"]] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" to="." method="show_module" binds= ["Crashlytics"]] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" to="." method="show_module" binds= ["Messaging"]] -[node name="ClearLogButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) -layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Clear Log" - -[node name="CopyLogButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) -layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Copy Log" - -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/InitializeButton" to="." method="_on_initialize_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/LogEventButton" to="." method="_on_log_event_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/LogScreenButton" to="." method="_on_log_screen_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/LogCrashlyticsButton" to="." method="_on_log_crashlytics_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/SetUserIDButton" to="." method="_on_set_user_id_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/SetCustomValueButton" to="." method="_on_set_custom_value_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/ForceCrashButton" to="." method="_on_force_crash_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/RequestPermissionButton" to="." method="_on_request_permission_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/GetTokenButton" to="." method="_on_get_token_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/SubscribeTopicButton" to="." method="_on_subscribe_topic_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/UnsubscribeTopicButton" to="." method="_on_unsubscribe_topic_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/ClearLogButton" to="." method="_on_clear_log_pressed"] -[connection signal="pressed" from="VBoxContainer/ScrollContainer/ContentContainer/CopyLogButton" to="." method="_on_copy_log_pressed"] +[connection signal="pressed" from="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls/ClearLogButton" to="." method="_on_clear_log_pressed"] +[connection signal="pressed" from="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls/CopyLogButton" to="." method="_on_copy_log_pressed"] diff --git a/scenes/view_stack/AnalyticsView.tscn b/scenes/view_stack/AnalyticsView.tscn new file mode 100644 index 0000000..09c3cae --- /dev/null +++ b/scenes/view_stack/AnalyticsView.tscn @@ -0,0 +1,38 @@ +[gd_scene load_steps=2 format=3 uid="uid://c1v8n2r5p6q7y"] + +[ext_resource type="Script" path="res://scripts/TestButton.gd" id="1"] + +[node name="AnalyticsView" type="VBoxContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 20 + +[node name="Title" type="Label" parent="."] +layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = "Analytics Module" +horizontal_alignment = 1 + +[node name="LogEventButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“Š Log Test Event" +script = ExtResource("1") + +[node name="LogScreenButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“ฑ Log Screen View" +script = ExtResource("1") + +[node name="UserPropsButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ‘ค Set User Property" +script = ExtResource("1") diff --git a/scenes/view_stack/CrashlyticsView.tscn b/scenes/view_stack/CrashlyticsView.tscn new file mode 100644 index 0000000..111458d --- /dev/null +++ b/scenes/view_stack/CrashlyticsView.tscn @@ -0,0 +1,39 @@ +[gd_scene load_steps=2 format=3 uid="uid://e4t5n8m7p2r3y"] + +[ext_resource type="Script" path="res://scripts/TestButton.gd" id="1"] + +[node name="CrashlyticsView" type="VBoxContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 20 + +[node name="Title" type="Label" parent="."] +layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = "Crashlytics Module" +horizontal_alignment = 1 + +[node name="FatalButton" type="Button" parent="."] +modulate = Color(1, 0.4, 0.4, 1) +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ’€ Force Fatal Crash" +script = ExtResource("1") + +[node name="NonFatalButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "โš ๏ธ Log Non-Fatal Error" +script = ExtResource("1") + +[node name="CustomValueButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”‘ Set Custom Value" +script = ExtResource("1") diff --git a/scenes/view_stack/MessagingView.tscn b/scenes/view_stack/MessagingView.tscn new file mode 100644 index 0000000..8a244c1 --- /dev/null +++ b/scenes/view_stack/MessagingView.tscn @@ -0,0 +1,38 @@ +[gd_scene load_steps=2 format=3 uid="uid://d3r5n7m6p1q2y"] + +[ext_resource type="Script" path="res://scripts/TestButton.gd" id="1"] + +[node name="MessagingView" type="VBoxContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 20 + +[node name="Title" type="Label" parent="."] +layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = "Cloud Messaging" +horizontal_alignment = 1 + +[node name="PermissionButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”” Request Permission" +script = ExtResource("1") + +[node name="SubscribeButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "โž• Subscribe to Topic" +script = ExtResource("1") + +[node name="UnsubscribeButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "โž– Unsubscribe from Topic" +script = ExtResource("1") diff --git a/scripts/Main.gd b/scripts/Main.gd index 339dd43..f7bee2a 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -1,21 +1,73 @@ extends Control + + # Firebase Singletons var core: Object = null var analytics: Object = null var crashlytics: Object = null var messaging: Object = null -# UI Elements -@onready var status_label: Label = $VBoxContainer/StatusLabel -@onready var log_output: TextEdit = $VBoxContainer/ScrollContainer/ContentContainer/LogOutput +# Navigation Elements +@onready var back_button: Button = $VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer/BackButton +@onready var view_title: Label = $VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer/ViewTitle + +# Views +@onready var dashboard_view: ScrollContainer = $VBoxContainer/ContextGroup/Dashboard +@onready var module_container: Control = $VBoxContainer/ContextGroup/ModuleContainer + +# Dashboard Buttons +@onready var init_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/InitializeButton +@onready var analytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton +@onready var crashlytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton +@onready var messaging_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/MessagingButton + +# Log Elements +@onready var log_output: TextEdit = $VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogOutput + +# Dashboard button paths (used by flash_status / update_btn_status) +const INIT_PATH := "VBoxContainer/ContextGroup/Dashboard/List/InitializeButton" +const ANALYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" +const CRASHLYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" +const MESSAGING_PATH := "VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" + +# Tracks the module-view button currently awaiting an async signal, per module. +# The harness only permits one in-flight call per module at a time. +var _pending_call: Dictionary = { + "Analytics": "", + "Crashlytics": "", + "Messaging": "", +} func _ready() -> void: - log_message("=== Firebase Plugin Test ===") + get_viewport().size_changed.connect(_apply_safe_area) + _apply_safe_area() + log_message("=== Firebase Test Harness ===") + show_dashboard() + enable_service_buttons(false) initialize_firebase_plugins() +func _apply_safe_area() -> void: + var os_name = OS.get_name() + if os_name != "iOS" and os_name != "Android": + return + var safe_area = DisplayServer.get_display_safe_area() + var window_size = DisplayServer.window_get_size() + if safe_area.size != Vector2i.ZERO and safe_area.size != window_size: + var top_margin = safe_area.position.y + var bottom_margin = window_size.y - (safe_area.position.y + safe_area.size.y) + var left_margin = safe_area.position.x + var right_margin = window_size.x - (safe_area.position.x + safe_area.size.x) + + if has_node("VBoxContainer"): + var vbox = $VBoxContainer + vbox.offset_top = top_margin + vbox.offset_bottom = -bottom_margin + vbox.offset_left = left_margin + vbox.offset_right = -right_margin + func initialize_firebase_plugins() -> void: - # Firebase Core + # Core if Engine.has_singleton("GodotxFirebaseCore"): core = Engine.get_singleton("GodotxFirebaseCore") core.core_initialized.connect(_on_core_initialized) @@ -24,242 +76,356 @@ func initialize_firebase_plugins() -> void: else: log_message("โœ— Firebase Core plugin not found") - # Firebase Analytics + # Analytics if Engine.has_singleton("GodotxFirebaseAnalytics"): analytics = Engine.get_singleton("GodotxFirebaseAnalytics") - analytics.analytics_initialized.connect(_on_analytics_initialized) - analytics.analytics_event_logged.connect(_on_event_logged) - analytics.analytics_error.connect(_on_error.bind("Analytics")) + analytics.analytics_initialized.connect(_on_module_init_done.bind("Analytics")) + analytics.analytics_event_logged.connect(_on_analytics_event_logged) + analytics.analytics_screen_logged.connect(_on_analytics_screen_logged) + analytics.analytics_property_set.connect(_on_analytics_property_set) + analytics.analytics_error.connect(_on_module_error.bind("Analytics")) log_message("โœ“ Firebase Analytics plugin found") else: log_message("โœ— Firebase Analytics plugin not found") - # Firebase Crashlytics + # Crashlytics if Engine.has_singleton("GodotxFirebaseCrashlytics"): crashlytics = Engine.get_singleton("GodotxFirebaseCrashlytics") - crashlytics.crashlytics_initialized.connect(_on_crashlytics_initialized) - crashlytics.crashlytics_error.connect(_on_error.bind("Crashlytics")) + crashlytics.crashlytics_initialized.connect(_on_module_init_done.bind("Crashlytics")) + crashlytics.crashlytics_non_fatal_logged.connect(_on_crashlytics_non_fatal_logged) + crashlytics.crashlytics_message_logged.connect(_on_crashlytics_message_logged) + crashlytics.crashlytics_value_set.connect(_on_crashlytics_value_set) + crashlytics.crashlytics_error.connect(_on_module_error.bind("Crashlytics")) log_message("โœ“ Firebase Crashlytics plugin found") else: log_message("โœ— Firebase Crashlytics plugin not found") - # Firebase Messaging + # Messaging if Engine.has_singleton("GodotxFirebaseMessaging"): messaging = Engine.get_singleton("GodotxFirebaseMessaging") - messaging.messaging_permission_granted.connect(_on_permission_granted) - messaging.messaging_permission_denied.connect(_on_permission_denied) - messaging.messaging_token_received.connect(_on_token_received) - messaging.messaging_apn_token_received.connect(_on_apn_token_received) - messaging.messaging_message_received.connect(_on_message_received) - messaging.messaging_error.connect(_on_error.bind("Messaging")) + messaging.messaging_initialized.connect(_on_module_init_done.bind("Messaging")) + messaging.messaging_permission_granted.connect(_on_messaging_permission_granted) + messaging.messaging_permission_denied.connect(_on_messaging_permission_denied) + messaging.messaging_token_received.connect(_on_messaging_token_received) + if OS.get_name() == "iOS": + messaging.messaging_apn_token_received.connect(_on_messaging_apn_token_received) + messaging.messaging_message_received.connect(_on_messaging_message_received) + messaging.messaging_topic_subscribed.connect(_on_messaging_topic_subscribed) + messaging.messaging_topic_unsubscribed.connect(_on_messaging_topic_unsubscribed) + messaging.messaging_error.connect(_on_module_error.bind("Messaging")) log_message("โœ“ Firebase Messaging plugin found") else: log_message("โœ— Firebase Messaging plugin not found") +# ============== NAVIGATION ============== + +func show_dashboard() -> void: + view_title.text = "Firebase Harness" + back_button.visible = false + dashboard_view.visible = true + module_container.visible = false + for module in module_container.get_children(): + module.visible = false + +func show_module(module_name: String) -> void: + dashboard_view.visible = false + module_container.visible = true + back_button.visible = true + view_title.text = "Firebase " + module_name + + for child in module_container.get_children(): + child.queue_free() + + var node_name = module_name.replace(" ", "") + "View" + var scene_path = "res://scenes/view_stack/" + node_name + ".tscn" + + if ResourceLoader.exists(scene_path): + var scene = load(scene_path) + var instance = scene.instantiate() + module_container.add_child(instance) + instance.name = node_name + _connect_module_buttons(module_name, instance) + else: + log_message("[System] Module view '" + node_name + "' not implemented") + +# ============== HELPERS ============== + func log_message(message: String) -> void: print(message) if log_output: log_output.text += message + "\n" log_output.scroll_vertical = log_output.get_line_count() -func update_status(text: String, color: Color = Color.WHITE) -> void: - if status_label: - status_label.text = text - status_label.modulate = color +func update_btn_status(path: String, status: int) -> void: + var btn = get_node_or_null(path) + if btn and btn.has_method("update_status"): + btn.update_status(status) + +func flash_status(path: String, status: int) -> void: + update_btn_status(path, status) + +func enable_service_buttons(enabled: bool) -> void: + analytics_btn.disabled = !enabled + crashlytics_btn.disabled = !enabled + messaging_btn.disabled = !enabled + +func _module_btn_path(module_name: String, btn_name: String) -> String: + return "VBoxContainer/ContextGroup/ModuleContainer/" + module_name + "View/" + btn_name + +func _connect_module_buttons(module_name: String, instance: Node) -> void: + if module_name == "Analytics": + _connect_btn(instance, "LogEventButton", _on_log_event_pressed) + _connect_btn(instance, "LogScreenButton", _on_log_screen_pressed) + _connect_btn(instance, "UserPropsButton", _on_set_user_property_pressed) + elif module_name == "Messaging": + _connect_btn(instance, "PermissionButton", _on_request_messaging_permission_pressed) + _connect_btn(instance, "SubscribeButton", _on_subscribe_topic_pressed) + _connect_btn(instance, "UnsubscribeButton", _on_unsubscribe_topic_pressed) + elif module_name == "Crashlytics": + _connect_btn(instance, "FatalButton", _on_crash_pressed) + _connect_btn(instance, "NonFatalButton", _on_non_fatal_pressed) + _connect_btn(instance, "CustomValueButton", _on_set_custom_value_pressed) + +func _connect_btn(instance: Node, btn_name: String, method: Callable) -> void: + var btn = instance.get_node_or_null(btn_name) + if btn: btn.pressed.connect(method) # ============== CORE ============== + func _on_initialize_pressed() -> void: - if core: - log_message("\n[Core] Initializing Firebase...") - update_status("Initializing...", Color.YELLOW) - core.initialize() - else: + if not core: log_message("[Core] Plugin not available") + flash_status(INIT_PATH, TestButton.Status.FAILURE) + return + log_message("\n[Core] Initializing Firebase...") + flash_status(INIT_PATH, TestButton.Status.PENDING) + init_btn.disabled = true + core.initialize() func _on_core_initialized(success: bool) -> void: - if success: - log_message("[Core] โœ“ Firebase initialized successfully!") - - # Initialize dependent modules - if crashlytics: - log_message("[Crashlytics] Initializing...") - crashlytics.initialize() - if analytics: - log_message("[Analytics] Initializing...") - analytics.initialize() - if messaging: - log_message("[Messaging] Initializing...") - messaging.initialize() - else: + init_btn.disabled = false + if not success: log_message("[Core] โœ— Firebase initialization failed") - update_status("Initialization Failed", Color.RED) + flash_status(INIT_PATH, TestButton.Status.FAILURE) + enable_service_buttons(false) + return -func _on_crashlytics_initialized(success: bool) -> void: - if success: - log_message("[Crashlytics] โœ“ Initialized") - else: - log_message("[Crashlytics] โœ— Initialization failed") + log_message("[Core] โœ“ Firebase initialized successfully!") + flash_status(INIT_PATH, TestButton.Status.SUCCESS) + _start_module_init_cascade() + +func _start_module_init_cascade() -> void: + if analytics: + log_message("[Analytics] Initializing...") + analytics.initialize() + if crashlytics: + log_message("[Crashlytics] Initializing...") + crashlytics.initialize() + if messaging: + log_message("[Messaging] Initializing...") + messaging.initialize() + +func _on_module_init_done(success: bool, module_name: String) -> void: + var module_btn: Button = null + match module_name: + "Analytics": + module_btn = analytics_btn + "Crashlytics": + module_btn = crashlytics_btn + "Messaging": + module_btn = messaging_btn -func _on_analytics_initialized(success: bool) -> void: if success: - log_message("[Analytics] โœ“ Initialized") - update_status("Firebase Ready", Color.GREEN) + log_message("[%s] โœ“ Initialized" % module_name) + if module_btn: module_btn.disabled = false else: - log_message("[Analytics] โœ— Initialization failed") + log_message("[%s] โœ— Initialization failed" % module_name) + if module_btn: module_btn.disabled = true # ============== ANALYTICS ============== + func _on_log_event_pressed() -> void: - if analytics: - var event_name = "test_button_clicked" - var params = { - "timestamp": str(Time.get_unix_time_from_system()), - "screen": "main", - "test_value": "42" - } - log_message("\n[Analytics] Logging event: " + event_name) - log_message(" Params: " + str(params)) - analytics.log_event(event_name, params) - else: + var btn_path = _module_btn_path("Analytics", "LogEventButton") + if not analytics: log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Logging event: test_event") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Analytics"] = btn_path + analytics.log_event("test_event", {"p1": "v1", "p2": 123}) func _on_log_screen_pressed() -> void: - if analytics: - var params = { - "screen_name": "main_screen", - "screen_class": "MainScene" - } - log_message("\n[Analytics] Logging screen view") - log_message(" Params: " + str(params)) - analytics.log_event("screen_view", params) - else: + var btn_path = _module_btn_path("Analytics", "LogScreenButton") + if not analytics: log_message("[Analytics] Plugin not available") - -func _on_event_logged(event_name: String) -> void: + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Logging screen: MainScene") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Analytics"] = btn_path + analytics.log_screen_view("MainScene", "GodotSampleActivity") + +func _on_analytics_event_logged(event_name: String) -> void: log_message("[Analytics] โœ“ Event logged: " + event_name) + _clear_pending("Analytics") -# ============== CRASHLYTICS ============== -func _on_log_crashlytics_pressed() -> void: - if crashlytics: - var message = "Test log message from Godot - " + str(Time.get_datetime_string_from_system()) - log_message("\n[Crashlytics] Logging message: " + message) - crashlytics.log_message(message) - log_message("[Crashlytics] โœ“ Message logged") - else: - log_message("[Crashlytics] Plugin not available") - -func _on_set_user_id_pressed() -> void: - if crashlytics: - var user_id = "test_user_" + str(randi() % 10000) - log_message("\n[Crashlytics] Setting user ID: " + user_id) - crashlytics.set_user_id(user_id) - log_message("[Crashlytics] โœ“ User ID set") - else: - log_message("[Crashlytics] Plugin not available") - -func _on_set_custom_value_pressed() -> void: - if crashlytics: - log_message("\n[Crashlytics] Setting custom values (individual + auto)...") - - # individual typed calls - crashlytics.set_custom_value_string("demo_string", "demo_value_" + str(randi() % 10000)) - crashlytics.set_custom_value_int("demo_int", randi() % 1000) - crashlytics.set_custom_value_bool("demo_bool", randi() % 2 == 0) - crashlytics.set_custom_value_float("demo_float", randf() * 100.0) - - # auto-dispatch helper - FirebaseCrashlyticsHelper.set_custom_value(crashlytics, "demo_auto_str", "auto_" + str(randi() % 1000)) - FirebaseCrashlyticsHelper.set_custom_value(crashlytics, "demo_auto_int", randi() % 100) - FirebaseCrashlyticsHelper.set_custom_value(crashlytics, "demo_auto_bool", randi() % 2 == 0) - FirebaseCrashlyticsHelper.set_custom_value(crashlytics, "demo_auto_float", randf() * 100.0) +func _on_analytics_screen_logged(screen_name: String) -> void: + log_message("[Analytics] โœ“ Screen logged: " + screen_name) + _clear_pending("Analytics") - log_message("[Crashlytics] โœ“ Custom values set") - else: - log_message("[Crashlytics] Plugin not available") +func _on_set_user_property_pressed() -> void: + var btn_path = _module_btn_path("Analytics", "UserPropsButton") + if not analytics: + log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Setting user property: test_prop = test_value") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Analytics"] = btn_path + analytics.set_user_property("test_prop", "test_value") -func _on_force_crash_pressed() -> void: - if crashlytics: - log_message("\n[Crashlytics] โš  FORCING CRASH - App will close!") - update_status("Crashing...", Color.RED) - await get_tree().create_timer(0.5).timeout - crashlytics.crash() - else: - log_message("[Crashlytics] Plugin not available") +func _on_analytics_property_set(prop_name: String) -> void: + log_message("[Analytics] โœ“ Property set: " + prop_name) + _clear_pending("Analytics") # ============== MESSAGING ============== -func _on_request_permission_pressed() -> void: - if messaging: - log_message("\n[Messaging] Requesting notification permission...") - messaging.request_permission() - log_message("[Messaging] Permission request sent") - else: - log_message("[Messaging] Plugin not available") -func _on_get_token_pressed() -> void: - if messaging: - log_message("\n[Messaging] Requesting FCM token...") - update_status("Getting Token...", Color.YELLOW) - messaging.get_token() - - # Also request APNs token (iOS only) - if OS.get_name() == "iOS": - messaging.get_apns_token() - else: +func _on_request_messaging_permission_pressed() -> void: + var btn_path = _module_btn_path("Messaging", "PermissionButton") + if not messaging: log_message("[Messaging] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Messaging] Requesting permissions...") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Messaging"] = btn_path + messaging.request_permission() func _on_subscribe_topic_pressed() -> void: - if messaging: - var topic = "test_topic" - log_message("\n[Messaging] Subscribing to topic: " + topic) - messaging.subscribe_to_topic(topic) - log_message("[Messaging] Subscribe request sent") - else: + var btn_path = _module_btn_path("Messaging", "SubscribeButton") + if not messaging: log_message("[Messaging] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Messaging] Subscribing to: test_topic") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Messaging"] = btn_path + messaging.subscribe_to_topic("test_topic") func _on_unsubscribe_topic_pressed() -> void: - if messaging: - var topic = "test_topic" - log_message("\n[Messaging] Unsubscribing from topic: " + topic) - messaging.unsubscribe_from_topic(topic) - log_message("[Messaging] Unsubscribe request sent") - else: + var btn_path = _module_btn_path("Messaging", "UnsubscribeButton") + if not messaging: log_message("[Messaging] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Messaging] Unsubscribing from: test_topic") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Messaging"] = btn_path + messaging.unsubscribe_from_topic("test_topic") + +func _on_messaging_permission_granted() -> void: + log_message("[Messaging] โœ“ Permission granted") + _clear_pending("Messaging") -func _on_permission_granted() -> void: - log_message("[Messaging] โœ“ Notification permission granted") - update_status("Permission Granted", Color.GREEN) +func _on_messaging_permission_denied() -> void: + log_message("[Messaging] โœ— Permission denied") + var path: String = _pending_call.get("Messaging", "") + if path != "": + flash_status(path, TestButton.Status.FAILURE) + _pending_call["Messaging"] = "" -func _on_permission_denied() -> void: - log_message("[Messaging] โ“˜ Notification permission denied") - log_message(" User declined or disabled notifications in system settings") - update_status("Permission Denied", Color.ORANGE) +func _on_messaging_topic_subscribed(topic: String) -> void: + log_message("[Messaging] โœ“ Subscribed to: " + topic) + _clear_pending("Messaging") -func _on_token_received(token: String) -> void: - log_message("[Messaging] โœ“ FCM Token received:") - log_message(" " + token) - update_status("Token Received", Color.GREEN) +func _on_messaging_topic_unsubscribed(topic: String) -> void: + log_message("[Messaging] โœ“ Unsubscribed from: " + topic) + _clear_pending("Messaging") -func _on_apn_token_received(token: String) -> void: - log_message("[Messaging] โœ“ APN Device Token received:") - log_message(" " + token) - update_status("APN Token Received", Color.GREEN) +func _on_messaging_token_received(token: String) -> void: + log_message("[Messaging] Token: " + token) -func _on_message_received(title: String, body: String) -> void: - log_message("[Messaging] โœ“ Message received:") - log_message(" Title: " + title) - log_message(" Body: " + body) +func _on_messaging_apn_token_received(token: String) -> void: + log_message("[Messaging] APNs Token: " + token) + +func _on_messaging_message_received(title: String, body: String) -> void: + log_message("[Messaging] Message received: " + title + " โ€” " + body) + +# ============== CRASHLYTICS ============== + +func _on_crash_pressed() -> void: + var btn_path = _module_btn_path("Crashlytics", "FatalButton") + if not crashlytics: + log_message("[Crashlytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Crashlytics] !!! FORCING FATAL CRASH !!!") + flash_status(btn_path, TestButton.Status.PENDING) + # If the crash truly propagates, the app terminates and the yellow state is lost. + # If the exception is caught by Godot's dispatcher, the button stays yellow โ€” a + # visible hint that the crash did not actually take down the process. + crashlytics.crash() + +func _on_non_fatal_pressed() -> void: + var btn_path = _module_btn_path("Crashlytics", "NonFatalButton") + if not crashlytics: + log_message("[Crashlytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Crashlytics] Logging non-fatal error") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Crashlytics"] = btn_path + crashlytics.log_non_fatal_exception("This is a test non-fatal error") + +func _on_set_custom_value_pressed() -> void: + var btn_path = _module_btn_path("Crashlytics", "CustomValueButton") + if not crashlytics: + log_message("[Crashlytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Crashlytics] Setting custom value") + flash_status(btn_path, TestButton.Status.PENDING) + crashlytics.set_custom_value_string("test_key", "test_value") + _pending_call["Crashlytics"] = btn_path + +func _on_crashlytics_non_fatal_logged(message: String) -> void: + log_message("[Crashlytics] โœ“ Non-fatal logged: " + message) + _clear_pending("Crashlytics") + +func _on_crashlytics_message_logged(message: String) -> void: + log_message("[Crashlytics] โœ“ Message logged: " + message) + _clear_pending("Crashlytics") + +func _on_crashlytics_value_set(key: String) -> void: + log_message("[Crashlytics] โœ“ Value set for: " + key) + _clear_pending("Crashlytics") + +# ============== ERRORS ============== -# ============== GENERAL ============== func _on_error(message: String, module: String) -> void: log_message("[" + module + "] โœ— Error: " + message) - update_status("Error: " + message, Color.RED) + +func _on_module_error(message: String, module_name: String) -> void: + log_message("[%s] โœ— Error: %s" % [module_name, message]) + var path: String = _pending_call.get(module_name, "") + if path != "": + flash_status(path, TestButton.Status.FAILURE) + _pending_call[module_name] = "" + +func _clear_pending(module_name: String) -> void: + var path: String = _pending_call.get(module_name, "") + if path != "": + flash_status(path, TestButton.Status.SUCCESS) + _pending_call[module_name] = "" + +# ============== LOG CONTROLS ============== func _on_clear_log_pressed() -> void: - if log_output: - log_output.text = "" + if log_output: log_output.text = "" log_message("=== Log Cleared ===") - update_status("Ready", Color.WHITE) func _on_copy_log_pressed() -> void: if log_output: DisplayServer.clipboard_set(log_output.text) - update_status("Log Copied", Color.GREEN) + log_message("[System] Log copied to clipboard") diff --git a/scripts/TestButton.gd b/scripts/TestButton.gd new file mode 100644 index 0000000..1149a50 --- /dev/null +++ b/scripts/TestButton.gd @@ -0,0 +1,34 @@ +extends Button +class_name TestButton + +enum Status { + IDLE, + PENDING, + SUCCESS, + FAILURE +} + +@export var reset_time: float = 3.0 +var _timer: SceneTreeTimer = null + +func _ready() -> void: + update_status(Status.IDLE) + +func update_status(status: int) -> void: + match status: + Status.IDLE: + self_modulate = Color.WHITE + Status.PENDING: + self_modulate = Color.YELLOW + Status.SUCCESS: + self_modulate = Color.GREEN + Status.FAILURE: + self_modulate = Color.RED + _start_reset_timer() + +func _start_reset_timer() -> void: + if _timer: + _timer = null # Cancel previous timer by letting it die + + _timer = get_tree().create_timer(reset_time) + _timer.timeout.connect(func(): update_status(Status.IDLE)) From f9eac81929155a8089fecdd494c42d4b5b32b997 Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Sun, 26 Apr 2026 00:27:58 +0300 Subject: [PATCH 2/9] feat(remote-config): implement native Remote Config for Android and iOS --- Makefile | 13 +- addons/godotx_firebase/export_plugin.gd | 109 ++++++++- scenes/view_stack/RemoteConfigView.tscn | 90 +++++++ scripts/Main.gd | 156 +++++++++++- .../firebase_remote_config/build.gradle.kts | 38 +++ .../firebase_remote_config/consumer-rules.pro | 39 +++ .../firebase_remote_config/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + source/android/firebase_remote_config/gradlew | 185 ++++++++++++++ .../firebase_remote_config/gradlew.bat | 89 +++++++ .../settings.gradle.kts | 17 ++ .../src/main/AndroidManifest.xml | 10 + .../FirebaseRemoteConfigPlugin.kt | 197 +++++++++++++++ source/ios/firebase_remote_config/.gdignore | 0 source/ios/firebase_remote_config/Podfile | 15 ++ .../Sources/godotx_firebase_remote_config.h | 34 +++ .../Sources/godotx_firebase_remote_config.mm | 228 ++++++++++++++++++ .../godotx_firebase_remote_config_module.cpp | 19 ++ .../godotx_firebase_remote_config_module.h | 7 + .../firebase_remote_config.gdip | 21 ++ source/ios/firebase_remote_config/project.yml | 77 ++++++ 22 files changed, 1345 insertions(+), 7 deletions(-) create mode 100644 scenes/view_stack/RemoteConfigView.tscn create mode 100644 source/android/firebase_remote_config/build.gradle.kts create mode 100644 source/android/firebase_remote_config/consumer-rules.pro create mode 100644 source/android/firebase_remote_config/gradle.properties create mode 100644 source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.jar create mode 100644 source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.properties create mode 100755 source/android/firebase_remote_config/gradlew create mode 100644 source/android/firebase_remote_config/gradlew.bat create mode 100644 source/android/firebase_remote_config/settings.gradle.kts create mode 100644 source/android/firebase_remote_config/src/main/AndroidManifest.xml create mode 100644 source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt create mode 100644 source/ios/firebase_remote_config/.gdignore create mode 100644 source/ios/firebase_remote_config/Podfile create mode 100644 source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h create mode 100644 source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm create mode 100644 source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.cpp create mode 100644 source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.h create mode 100644 source/ios/firebase_remote_config/firebase_remote_config.gdip create mode 100644 source/ios/firebase_remote_config/project.yml diff --git a/Makefile b/Makefile index 95674b8..669f427 100644 --- a/Makefile +++ b/Makefile @@ -25,10 +25,10 @@ TMP_DIR = /tmp # ============================================================================ # Module Configuration # ============================================================================ -APPLE_MODULES = firebase_core firebase_analytics firebase_crashlytics firebase_messaging -APPLE_MODULE_NAMES = Core Analytics Crashlytics Messaging +APPLE_MODULES = firebase_core firebase_analytics firebase_crashlytics firebase_messaging firebase_remote_config +APPLE_MODULE_NAMES = Core Analytics Crashlytics Messaging RemoteConfig -ANDROID_MODULES = firebase_core firebase_analytics firebase_crashlytics firebase_messaging +ANDROID_MODULES = firebase_core firebase_analytics firebase_crashlytics firebase_messaging firebase_remote_config # ============================================================================ # Build Configuration @@ -250,8 +250,11 @@ build-apple: setup-apple echo " - Copying frameworks from FirebaseCrashlytics..." && \ cp -a $(FIREBASE_SDK_DIR)/FirebaseCrashlytics/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ firebase_messaging) \ - echo " - Copying frameworks from FirebaseMessaging..." && \ - cp -a $(FIREBASE_SDK_DIR)/FirebaseMessaging/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ + echo " - Copying frameworks from FirebaseMessaging..." \ + && cp -a $(FIREBASE_SDK_DIR)/FirebaseMessaging/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ + firebase_remote_config) \ + echo " - Copying frameworks from FirebaseRemoteConfig..." \ + && cp -a $(FIREBASE_SDK_DIR)/FirebaseRemoteConfig/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ esac); \ echo " โœ“ $$module build complete (Debug + Release)"; \ echo ""; \ diff --git a/addons/godotx_firebase/export_plugin.gd b/addons/godotx_firebase/export_plugin.gd index 8b88add..7062818 100644 --- a/addons/godotx_firebase/export_plugin.gd +++ b/addons/godotx_firebase/export_plugin.gd @@ -54,6 +54,51 @@ class AppleExportPlugin extends EditorExportPlugin: "default_value": "res://GoogleService-Info.plist" }) + # Enable Core + options.append({ + "option": { + "name": "firebase/enable_core", + "type": TYPE_BOOL + }, + "default_value": true + }) + + # Enable Analytics + options.append({ + "option": { + "name": "firebase/enable_analytics", + "type": TYPE_BOOL + }, + "default_value": false + }) + + # Enable Crashlytics + options.append({ + "option": { + "name": "firebase/enable_crashlytics", + "type": TYPE_BOOL + }, + "default_value": false + }) + + # Enable Messaging + options.append({ + "option": { + "name": "firebase/enable_messaging", + "type": TYPE_BOOL + }, + "default_value": false + }) + + # Enable Remote Config + options.append({ + "option": { + "name": "firebase/enable_remote_config", + "type": TYPE_BOOL + }, + "default_value": false + }) + return options @@ -171,6 +216,24 @@ class AndroidExportPlugin extends EditorExportPlugin: "default_value": "25.0.1" }) + # Enable Remote Config + options.append({ + "option": { + "name": "firebase/enable_remote_config", + "type": TYPE_BOOL + }, + "default_value": false + }) + + # Remote Config version + options.append({ + "option": { + "name": "firebase/remote_config_version", + "type": TYPE_STRING + }, + "default_value": "22.0.1" + }) + return options @@ -201,6 +264,12 @@ class AndroidExportPlugin extends EditorExportPlugin: dependencies.append("com.google.firebase:firebase-messaging:" + version) print("[Firebase] Adding Messaging dependency (v%s)" % version) + # Remote Config + if get_option("firebase/enable_remote_config"): + var version = get_option("firebase/remote_config_version") + dependencies.append("com.google.firebase:firebase-config-ktx:" + version) + print("[Firebase] Adding Remote Config dependency (v%s)" % version) + return dependencies @@ -223,6 +292,9 @@ class AndroidExportPlugin extends EditorExportPlugin: if get_option("firebase/enable_messaging"): modules.append("firebase_messaging") + if get_option("firebase/enable_remote_config"): + modules.append("firebase_remote_config") + # Search for AARs in each module's directory for module in modules: var module_path: String = "res://android/" + module + "/" @@ -279,6 +351,41 @@ class AndroidExportPlugin extends EditorExportPlugin: out_file.store_buffer(content) out_file.close() + print("[Firebase] โœ“ Copied google-services.json โ†’ " + dest_res_path) - print("[Firebase] Copied Android config to " + dest_res_path) + # Patch Gradle files to declare and apply the Crashlytics Gradle plugin. + # The plugin is required at build time to inject a build UUID into the APK. + # Without it the app crashes on launch when Crashlytics is enabled. + if get_option("firebase/enable_crashlytics"): + _patch_gradle_file( + "res://android/build/settings.gradle", + "id 'com.google.gms.google-services' version '4.4.2'", + "id 'com.google.gms.google-services' version '4.4.2'\n id 'com.google.firebase.crashlytics' version '3.0.3'", + "settings.gradle" + ) + _patch_gradle_file( + "res://android/build/build.gradle", + "id 'com.google.gms.google-services'", + "id 'com.google.gms.google-services'\n id 'com.google.firebase.crashlytics'", + "build.gradle" + ) + + + func _patch_gradle_file(res_path: String, needle: String, replacement: String, label: String) -> void: + if not FileAccess.file_exists(res_path): + push_warning("[Firebase] %s not found, skipping Crashlytics Gradle plugin injection" % label) + return + var f := FileAccess.open(res_path, FileAccess.READ) + var text := f.get_as_text() + f.close() + if "firebase.crashlytics" in text: + return + var patched := text.replace(needle, replacement) + if patched == text: + push_warning("[Firebase] Could not inject Crashlytics plugin into %s โ€” pattern not found" % label) + return + var out := FileAccess.open(res_path, FileAccess.WRITE) + out.store_string(patched) + out.close() + print("[Firebase] โœ“ Injected Crashlytics Gradle plugin into %s" % label) diff --git a/scenes/view_stack/RemoteConfigView.tscn b/scenes/view_stack/RemoteConfigView.tscn new file mode 100644 index 0000000..84dec61 --- /dev/null +++ b/scenes/view_stack/RemoteConfigView.tscn @@ -0,0 +1,90 @@ +[gd_scene load_steps=2 format=3 uid="uid://d1rcviewstack"] + +[ext_resource type="Script" path="res://scripts/TestButton.gd" id="1"] + +[node name="RemoteConfigView" type="ScrollContainer"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="List" type="VBoxContainer" parent="."] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 20 + +[node name="Title" type="Label" parent="List"] +layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = "Remote Config Module" +horizontal_alignment = 1 + +[node name="FetchButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”„ Fetch & Activate" +script = ExtResource("1") + +[node name="HSeparator" type="HSeparator" parent="List"] +layout_mode = 2 + +[node name="GetStringButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“„ Get String (welcome_message)" +script = ExtResource("1") + +[node name="GetIntButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”ข Get Int (min_version)" +script = ExtResource("1") + +[node name="GetFloatButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿงช Get Float (drop_rate)" +script = ExtResource("1") + +[node name="GetBoolButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”˜ Get Bool (feature_enabled)" +script = ExtResource("1") + +[node name="GetDictButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“ Get Dictionary (game_config)" +script = ExtResource("1") + +[node name="HSeparator2" type="HSeparator" parent="List"] +layout_mode = 2 + +[node name="SetDefaultsButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ› ๏ธ Set Local Defaults" +script = ExtResource("1") + +[node name="SetIntervalButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "โšก Dev Mode (0s Interval)" +script = ExtResource("1") + +[node name="ListenerButton" type="Button" parent="List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“ก Real-time Updates: OFF" +script = ExtResource("1") diff --git a/scripts/Main.gd b/scripts/Main.gd index f7bee2a..8142f8a 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -7,6 +7,7 @@ var core: Object = null var analytics: Object = null var crashlytics: Object = null var messaging: Object = null +var remote_config: Object = null # Navigation Elements @onready var back_button: Button = $VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer/BackButton @@ -21,6 +22,7 @@ var messaging: Object = null @onready var analytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton @onready var crashlytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton @onready var messaging_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/MessagingButton +@onready var remote_config_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton # Log Elements @onready var log_output: TextEdit = $VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogOutput @@ -30,6 +32,7 @@ const INIT_PATH := "VBoxContainer/ContextGroup/Dashboard/List/InitializeButton" const ANALYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" const CRASHLYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" const MESSAGING_PATH := "VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" +const REMOTE_CONFIG_PATH := "VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton" # Tracks the module-view button currently awaiting an async signal, per module. # The harness only permits one in-flight call per module at a time. @@ -37,6 +40,7 @@ var _pending_call: Dictionary = { "Analytics": "", "Crashlytics": "", "Messaging": "", + "RemoteConfig": "", } func _ready() -> void: @@ -116,6 +120,17 @@ func initialize_firebase_plugins() -> void: log_message("โœ“ Firebase Messaging plugin found") else: log_message("โœ— Firebase Messaging plugin not found") + + # Remote Config + if Engine.has_singleton("GodotxFirebaseRemoteConfig"): + remote_config = Engine.get_singleton("GodotxFirebaseRemoteConfig") + remote_config.remote_config_initialized.connect(_on_module_init_done.bind("RemoteConfig")) + remote_config.fetch_completed.connect(_on_rc_fetch_completed) + remote_config.config_updated.connect(_on_config_updated) + remote_config.remote_config_error.connect(_on_module_error.bind("RemoteConfig")) + log_message("โœ“ Firebase Remote Config plugin found") + else: + log_message("โœ— Firebase Remote Config plugin not found") # ============== NAVIGATION ============== @@ -168,9 +183,13 @@ func enable_service_buttons(enabled: bool) -> void: analytics_btn.disabled = !enabled crashlytics_btn.disabled = !enabled messaging_btn.disabled = !enabled + remote_config_btn.disabled = !enabled func _module_btn_path(module_name: String, btn_name: String) -> String: - return "VBoxContainer/ContextGroup/ModuleContainer/" + module_name + "View/" + btn_name + var base_path = "VBoxContainer/ContextGroup/ModuleContainer/" + module_name.replace(" ", "") + "View/" + if module_name == "Remote Config": + return base_path + "List/" + btn_name + return base_path + btn_name func _connect_module_buttons(module_name: String, instance: Node) -> void: if module_name == "Analytics": @@ -185,6 +204,18 @@ func _connect_module_buttons(module_name: String, instance: Node) -> void: _connect_btn(instance, "FatalButton", _on_crash_pressed) _connect_btn(instance, "NonFatalButton", _on_non_fatal_pressed) _connect_btn(instance, "CustomValueButton", _on_set_custom_value_pressed) + elif module_name == "Remote Config": + # Note: Buttons are inside the 'List' child of the ScrollContainer + var list = instance.get_node("List") + _connect_btn(list, "FetchButton", _on_rc_fetch_pressed) + _connect_btn(list, "GetStringButton", _on_rc_get_string_pressed) + _connect_btn(list, "GetIntButton", _on_rc_get_int_pressed) + _connect_btn(list, "GetFloatButton", _on_rc_get_float_pressed) + _connect_btn(list, "GetBoolButton", _on_rc_get_bool_pressed) + _connect_btn(list, "GetDictButton", _on_rc_get_dict_pressed) + _connect_btn(list, "SetDefaultsButton", _on_rc_set_defaults_pressed) + _connect_btn(list, "SetIntervalButton", _on_rc_set_interval_pressed) + _connect_btn(list, "ListenerButton", _on_rc_listener_toggle_pressed) func _connect_btn(instance: Node, btn_name: String, method: Callable) -> void: var btn = instance.get_node_or_null(btn_name) @@ -224,6 +255,9 @@ func _start_module_init_cascade() -> void: if messaging: log_message("[Messaging] Initializing...") messaging.initialize() + if remote_config: + log_message("[Remote Config] Initializing...") + remote_config.initialize() func _on_module_init_done(success: bool, module_name: String) -> void: var module_btn: Button = null @@ -234,6 +268,8 @@ func _on_module_init_done(success: bool, module_name: String) -> void: module_btn = crashlytics_btn "Messaging": module_btn = messaging_btn + "RemoteConfig": + module_btn = remote_config_btn if success: log_message("[%s] โœ“ Initialized" % module_name) @@ -429,3 +465,121 @@ func _on_copy_log_pressed() -> void: if log_output: DisplayServer.clipboard_set(log_output.text) log_message("[System] Log copied to clipboard") + + +# ============== REMOTE CONFIG ============== + +func _on_rc_fetch_pressed() -> void: + var btn_path = _module_btn_path("Remote Config", "FetchButton") + if not remote_config: + log_message("[Remote Config] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Remote Config] Fetching and Activating...") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["RemoteConfig"] = btn_path + remote_config.fetch_and_activate() + +func _on_rc_fetch_completed(status: int) -> void: + var status_str = "UNKNOWN" + match status: + 0: status_str = "SUCCESS" + 1: status_str = "CACHED" + 2: status_str = "FAILURE" + 3: status_str = "THROTTLED" + log_message("[Remote Config] โœ“ Fetch result: " + status_str) + if status == 0 or status == 1: + _clear_pending("RemoteConfig") + else: + var path: String = _pending_call.get("RemoteConfig", "") + if path != "": + flash_status(path, TestButton.Status.FAILURE) + _pending_call["RemoteConfig"] = "" + +func _on_rc_get_string_pressed() -> void: + var path = _module_btn_path("Remote Config", "GetStringButton") + if remote_config: + var val = remote_config.get_string("welcome_message", "DEFAULT_VALUE") + log_message("[Remote Config] 'welcome_message' = " + str(val)) + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_rc_get_int_pressed() -> void: + var path = _module_btn_path("Remote Config", "GetIntButton") + if remote_config: + var val = remote_config.get_int("min_version", -1) + log_message("[Remote Config] 'min_version' = " + str(val)) + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_rc_get_float_pressed() -> void: + var path = _module_btn_path("Remote Config", "GetFloatButton") + if remote_config: + var val = remote_config.get_float("drop_rate", 0.0) + log_message("[Remote Config] 'drop_rate' = " + str(val)) + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_rc_get_bool_pressed() -> void: + var path = _module_btn_path("Remote Config", "GetBoolButton") + if remote_config: + var val = remote_config.get_bool("feature_enabled", false) + log_message("[Remote Config] 'feature_enabled' = " + str(val)) + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_rc_get_dict_pressed() -> void: + var path = _module_btn_path("Remote Config", "GetDictButton") + if remote_config: + var val = remote_config.get_dictionary("game_config") + log_message("[Remote Config] 'game_config' = " + str(val)) + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_rc_set_defaults_pressed() -> void: + var path = _module_btn_path("Remote Config", "SetDefaultsButton") + if remote_config: + var defaults = { + "welcome_message": "Hello from Defaults!", + "min_version": 10, + "drop_rate": 0.05, + "feature_enabled": true + } + remote_config.set_defaults(defaults) + log_message("[Remote Config] Local defaults set") + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_rc_set_interval_pressed() -> void: + var path = _module_btn_path("Remote Config", "SetIntervalButton") + if remote_config: + remote_config.set_minimum_fetch_interval(0.0) + log_message("[Remote Config] Fetch interval set to 0s (Dev Mode)") + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_rc_listener_toggle_pressed() -> void: + var path = _module_btn_path("Remote Config", "ListenerButton") + if remote_config: + log_message("[Remote Config] Real-time updates toggle requested (Native log only)") + flash_status(path, TestButton.Status.SUCCESS) + else: + log_message("[Remote Config] Plugin not available") + flash_status(path, TestButton.Status.FAILURE) + +func _on_config_updated(keys: Array) -> void: + log_message("[Remote Config] ๐Ÿ“ก Config updated: " + str(keys)) diff --git a/source/android/firebase_remote_config/build.gradle.kts b/source/android/firebase_remote_config/build.gradle.kts new file mode 100644 index 0000000..6d324fa --- /dev/null +++ b/source/android/firebase_remote_config/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.library") version "8.13.2" + id("org.jetbrains.kotlin.android") version "2.3.0" +} + +android { + namespace = "com.godotx.firebase.remoteconfig" + compileSdk = 35 + + defaultConfig { + minSdk = 21 + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11 + } + } +} + +dependencies { + compileOnly("org.godotengine:godot:4.6.0.stable") + + // Firebase Remote Config + implementation("com.google.firebase:firebase-config-ktx:22.0.1") +} diff --git a/source/android/firebase_remote_config/consumer-rules.pro b/source/android/firebase_remote_config/consumer-rules.pro new file mode 100644 index 0000000..f09cdf2 --- /dev/null +++ b/source/android/firebase_remote_config/consumer-rules.pro @@ -0,0 +1,39 @@ +#################################### +# Firebase +#################################### +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** + +#################################### +# Google Play Tasks +#################################### +-keep class com.google.android.gms.tasks.** { *; } +-dontwarn com.google.android.gms.tasks.** + +#################################### +# Kotlin (ktx / reflection) +#################################### +-keep class kotlin.Metadata { *; } +-keep class kotlin.** { *; } +-dontwarn kotlin.** + +#################################### +# Godot Plugin API +#################################### +-keep class org.godotengine.godot.plugin.** { *; } +-dontwarn org.godotengine.godot.plugin.** + +#################################### +# Keep Godot Annotations +#################################### +-keepattributes *Annotation* + +#################################### +# Godot Firebase Plugin +#################################### +-keep class com.godotx.firebase.** { *; } +-dontwarn com.godotx.firebase.** + +-keepclassmembers class com.godotx.firebase.** { + public (org.godotengine.godot.Godot); +} diff --git a/source/android/firebase_remote_config/gradle.properties b/source/android/firebase_remote_config/gradle.properties new file mode 100644 index 0000000..bee0af8 --- /dev/null +++ b/source/android/firebase_remote_config/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4g +android.useAndroidX=true +android.enableJetifier=true diff --git a/source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.jar b/source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.properties b/source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f407850 --- /dev/null +++ b/source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/source/android/firebase_remote_config/gradlew b/source/android/firebase_remote_config/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/source/android/firebase_remote_config/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/source/android/firebase_remote_config/gradlew.bat b/source/android/firebase_remote_config/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/source/android/firebase_remote_config/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/source/android/firebase_remote_config/settings.gradle.kts b/source/android/firebase_remote_config/settings.gradle.kts new file mode 100644 index 0000000..d778b4d --- /dev/null +++ b/source/android/firebase_remote_config/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "godotx-firebase-remote-config" diff --git a/source/android/firebase_remote_config/src/main/AndroidManifest.xml b/source/android/firebase_remote_config/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ef63953 --- /dev/null +++ b/source/android/firebase_remote_config/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt b/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt new file mode 100644 index 0000000..a7cb451 --- /dev/null +++ b/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt @@ -0,0 +1,197 @@ +package com.godotx.firebase.remoteconfig + +import android.util.Log +import com.google.firebase.FirebaseApp +import com.google.firebase.remoteconfig.ConfigUpdate +import com.google.firebase.remoteconfig.ConfigUpdateListener +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigException +import com.google.firebase.remoteconfig.FirebaseRemoteConfigFetchThrottledException +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration +import org.godotengine.godot.Dictionary +import org.godotengine.godot.Godot +import org.godotengine.godot.plugin.GodotPlugin +import org.godotengine.godot.plugin.SignalInfo +import org.godotengine.godot.plugin.UsedByGodot +import org.json.JSONArray +import org.json.JSONObject + +class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { + + private val remoteConfig: FirebaseRemoteConfig by lazy { + FirebaseRemoteConfig.getInstance() + } + + private var listenerRegistration: ConfigUpdateListenerRegistration? = null + + companion object { + private val TAG = FirebaseRemoteConfigPlugin::class.java.simpleName + private const val FETCH_SUCCESS = 0 + private const val FETCH_CACHED = 1 + private const val FETCH_FAILURE = 2 + private const val FETCH_THROTTLED = 3 + } + + init { + Log.v(TAG, "Firebase Remote Config plugin loaded") + } + + override fun getPluginName(): String { + return "GodotxFirebaseRemoteConfig" + } + + override fun getPluginSignals(): Set { + return setOf( + SignalInfo("remote_config_initialized", Boolean::class.javaObjectType), + SignalInfo("remote_config_error", String::class.java), + SignalInfo("fetch_completed", Int::class.javaObjectType), + SignalInfo("config_updated", Array::class.java) + ) + } + + @UsedByGodot + fun initialize() { + val ctx = activity + if (ctx == null) { + Log.e(TAG, "initialize: activity is null") + emitSignal("remote_config_initialized", false) + emitSignal("remote_config_error", "activity_null") + return + } + + if (FirebaseApp.getApps(ctx).isEmpty()) { + Log.e(TAG, "Firebase is not initialized โ€” call FirebaseCore.initialize() first") + emitSignal("remote_config_initialized", false) + emitSignal("remote_config_error", "firebase_not_initialized") + return + } + + setupRealtimeUpdates() + Log.d(TAG, "Firebase Remote Config initialized") + emitSignal("remote_config_initialized", true) + } + + private fun setupRealtimeUpdates() { + listenerRegistration = remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { + override fun onUpdate(configUpdate: ConfigUpdate) { + Log.d(TAG, "Config updated keys: " + configUpdate.updatedKeys) + remoteConfig.activate().addOnCompleteListener { task -> + if (task.isSuccessful) { + val updatedKeysArray = configUpdate.updatedKeys.toTypedArray() + emitSignal("config_updated", updatedKeysArray as Any) + } + } + } + + override fun onError(error: FirebaseRemoteConfigException) { + Log.e(TAG, "Config update error", error) + emitSignal("remote_config_error", error.message ?: "config_update_error") + } + }) + } + + @UsedByGodot + fun fetch_and_activate() { + remoteConfig.fetchAndActivate().addOnCompleteListener { task -> + if (task.isSuccessful) { + val status = if (task.result) FETCH_SUCCESS else FETCH_CACHED + emitSignal("fetch_completed", status) + } else { + val status = if (task.exception is FirebaseRemoteConfigFetchThrottledException) + FETCH_THROTTLED else FETCH_FAILURE + emitSignal("fetch_completed", status) + } + } + } + + @UsedByGodot + fun get_string(key: String, defaultValue: String): String { + val value = remoteConfig.getValue(key) + return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue + else value.asString() + } + + @UsedByGodot + fun get_int(key: String, defaultValue: Int): Int { + val value = remoteConfig.getValue(key) + return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue + else value.asLong().toInt() + } + + @UsedByGodot + fun get_float(key: String, defaultValue: Float): Float { + val value = remoteConfig.getValue(key) + return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue + else value.asDouble().toFloat() + } + + @UsedByGodot + fun get_bool(key: String, defaultValue: Boolean): Boolean { + val value = remoteConfig.getValue(key) + return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue + else value.asBoolean() + } + + @UsedByGodot + fun get_dictionary(key: String): Dictionary { + val value = remoteConfig.getValue(key) + val dict = Dictionary() + if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) return dict + + return try { + jsonToDictionary(JSONObject(value.asString())) + } catch (e: Exception) { + Log.e(TAG, "Error parsing JSON for key: $key", e) + dict + } + } + + @UsedByGodot + fun set_defaults(defaults: Dictionary) { + val map = mutableMapOf() + for (key in defaults.keys) { + val value = defaults[key] + if (value != null) map[key] = value + } + remoteConfig.setDefaultsAsync(map) + } + + @UsedByGodot + fun set_minimum_fetch_interval(seconds: Float) { + val settings = FirebaseRemoteConfigSettings.Builder() + .setMinimumFetchIntervalInSeconds(seconds.toLong()) + .build() + remoteConfig.setConfigSettingsAsync(settings) + } + + @UsedByGodot + fun remove_config_update_listener() { + listenerRegistration?.remove() + listenerRegistration = null + Log.d(TAG, "Config update listener removed") + } + + private fun jsonToDictionary(json: JSONObject): Dictionary { + val dict = Dictionary() + val keys = json.keys() + while (keys.hasNext()) { + val key = keys.next() + dict[key] = wrapValue(json.get(key)) + } + return dict + } + + private fun wrapValue(value: Any): Any? { + return when (value) { + is JSONObject -> jsonToDictionary(value) + is JSONArray -> { + val list = mutableListOf() + for (i in 0 until value.length()) list.add(wrapValue(value.get(i))) + list.toTypedArray() + } + JSONObject.NULL -> null + else -> value + } + } +} diff --git a/source/ios/firebase_remote_config/.gdignore b/source/ios/firebase_remote_config/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/source/ios/firebase_remote_config/Podfile b/source/ios/firebase_remote_config/Podfile new file mode 100644 index 0000000..0e08487 --- /dev/null +++ b/source/ios/firebase_remote_config/Podfile @@ -0,0 +1,15 @@ +platform :ios, '15.0' +use_frameworks! + +target 'GodotxFirebaseRemoteConfig' do + pod 'Firebase/RemoteConfig', '12.7.0' +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'YES' + end + end +end diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h new file mode 100644 index 0000000..5c55f08 --- /dev/null +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h @@ -0,0 +1,34 @@ +#ifndef GODOTX_FIREBASE_REMOTE_CONFIG_H +#define GODOTX_FIREBASE_REMOTE_CONFIG_H + +#include "core/object/class_db.h" + +class GodotxFirebaseRemoteConfig : public Object { + GDCLASS(GodotxFirebaseRemoteConfig, Object); + +private: + static GodotxFirebaseRemoteConfig *instance; + +protected: + static void _bind_methods(); + +public: + static GodotxFirebaseRemoteConfig *get_singleton(); + + void initialize(); + void fetch_and_activate(); + String get_string(const String &key, const String &default_value); + int get_int(const String &key, int default_value); + float get_float(const String &key, float default_value); + bool get_bool(const String &key, bool default_value); + Dictionary get_dictionary(const String &key); + void set_defaults(const Dictionary &defaults); + void set_minimum_fetch_interval(float seconds); + void setup_realtime_updates(); + void remove_config_update_listener(); + + GodotxFirebaseRemoteConfig(); + ~GodotxFirebaseRemoteConfig(); +}; + +#endif // GODOTX_FIREBASE_REMOTE_CONFIG_H diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm new file mode 100644 index 0000000..c49a67a --- /dev/null +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm @@ -0,0 +1,228 @@ +#import "godotx_firebase_remote_config.h" +#import + +@import Firebase; + +#include "core/object/class_db.h" + +GodotxFirebaseRemoteConfig *GodotxFirebaseRemoteConfig::instance = nullptr; + +// Stored at file scope so the C++ header stays free of ObjC types. +// ARC manages lifetime: assigning nil releases the registration and stops the listener. +static FIRConfigUpdateListenerRegistration *_listenerRegistration = nil; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static String string_from_ns(NSString *ns) { + if (!ns) return String(); + return String::utf8([ns UTF8String]); +} + +static NSString *ns_from_string(const String &s) { + return [NSString stringWithUTF8String:s.utf8().get_data()]; +} + +static Dictionary ns_dict_to_godot(NSDictionary *nsDict); + +static Variant ns_value_to_godot(id value) { + if ([value isKindOfClass:[NSString class]]) { + return string_from_ns((NSString *)value); + } else if ([value isKindOfClass:[NSNumber class]]) { + NSNumber *num = (NSNumber *)value; + if (strcmp([num objCType], @encode(BOOL)) == 0) { + return (bool)[num boolValue]; + } + return (double)[num doubleValue]; + } else if ([value isKindOfClass:[NSDictionary class]]) { + return ns_dict_to_godot((NSDictionary *)value); + } + return Variant(); +} + +static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { + Dictionary dict; + for (id key in nsDict) { + String godot_key = string_from_ns([key description]); + dict[godot_key] = ns_value_to_godot(nsDict[key]); + } + return dict; +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +GodotxFirebaseRemoteConfig::GodotxFirebaseRemoteConfig() { + ERR_FAIL_COND(instance != nullptr); + instance = this; +} + +GodotxFirebaseRemoteConfig::~GodotxFirebaseRemoteConfig() { + [_listenerRegistration remove]; + _listenerRegistration = nil; + if (instance == this) instance = nullptr; +} + +GodotxFirebaseRemoteConfig *GodotxFirebaseRemoteConfig::get_singleton() { + return instance; +} + +// --------------------------------------------------------------------------- +// _bind_methods +// --------------------------------------------------------------------------- + +void GodotxFirebaseRemoteConfig::_bind_methods() { + ClassDB::bind_method(D_METHOD("initialize"), &GodotxFirebaseRemoteConfig::initialize); + ClassDB::bind_method(D_METHOD("fetch_and_activate"), &GodotxFirebaseRemoteConfig::fetch_and_activate); + ClassDB::bind_method(D_METHOD("get_string", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_string); + ClassDB::bind_method(D_METHOD("get_int", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_int); + ClassDB::bind_method(D_METHOD("get_float", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_float); + ClassDB::bind_method(D_METHOD("get_bool", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_bool); + ClassDB::bind_method(D_METHOD("get_dictionary", "key"), &GodotxFirebaseRemoteConfig::get_dictionary); + ClassDB::bind_method(D_METHOD("set_defaults", "defaults"), &GodotxFirebaseRemoteConfig::set_defaults); + ClassDB::bind_method(D_METHOD("set_minimum_fetch_interval", "seconds"), &GodotxFirebaseRemoteConfig::set_minimum_fetch_interval); + ClassDB::bind_method(D_METHOD("setup_realtime_updates"), &GodotxFirebaseRemoteConfig::setup_realtime_updates); + ClassDB::bind_method(D_METHOD("remove_config_update_listener"), &GodotxFirebaseRemoteConfig::remove_config_update_listener); + + ADD_SIGNAL(MethodInfo("remote_config_initialized", PropertyInfo(Variant::BOOL, "success"))); + ADD_SIGNAL(MethodInfo("remote_config_error", PropertyInfo(Variant::STRING, "message"))); + ADD_SIGNAL(MethodInfo("fetch_completed", PropertyInfo(Variant::INT, "status"))); + ADD_SIGNAL(MethodInfo("config_updated", PropertyInfo(Variant::ARRAY, "updated_keys"))); +} + +// --------------------------------------------------------------------------- +// initialize +// --------------------------------------------------------------------------- + +void GodotxFirebaseRemoteConfig::initialize() { + if (![FIRApp defaultApp]) { + emit_signal("remote_config_initialized", false); + emit_signal("remote_config_error", String("firebase_not_initialized")); + return; + } + emit_signal("remote_config_initialized", true); +} + +// --------------------------------------------------------------------------- +// fetch_and_activate +// FetchStatus: 0=SUCCESS, 1=CACHED, 2=FAILURE, 3=THROTTLED +// --------------------------------------------------------------------------- + +void GodotxFirebaseRemoteConfig::fetch_and_activate() { + FIRRemoteConfig *rc = [FIRRemoteConfig remoteConfig]; + [rc fetchAndActivateWithCompletionHandler:^(FIRRemoteConfigFetchAndActivateStatus status, + NSError *error) { + int godot_status; + switch (status) { + case FIRRemoteConfigFetchAndActivateStatusSuccessFetchedFromRemote: + godot_status = 0; + break; + case FIRRemoteConfigFetchAndActivateStatusSuccessUsingPreFetchedData: + godot_status = 1; + break; + case FIRRemoteConfigFetchAndActivateStatusError: + godot_status = (error && error.code == FIRRemoteConfigErrorThrottled) ? 3 : 2; + break; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (GodotxFirebaseRemoteConfig::instance) { + GodotxFirebaseRemoteConfig::instance->emit_signal("fetch_completed", godot_status); + } + }); + }]; +} + +// --------------------------------------------------------------------------- +// Value getters โ€” return default_value when source is Static (key unknown) +// --------------------------------------------------------------------------- + +String GodotxFirebaseRemoteConfig::get_string(const String &key, const String &default_value) { + FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; + if (value.source == FIRRemoteConfigSourceStatic) return default_value; + return string_from_ns(value.stringValue); +} + +int GodotxFirebaseRemoteConfig::get_int(const String &key, int default_value) { + FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; + if (value.source == FIRRemoteConfigSourceStatic) return default_value; + return [value.numberValue intValue]; +} + +float GodotxFirebaseRemoteConfig::get_float(const String &key, float default_value) { + FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; + if (value.source == FIRRemoteConfigSourceStatic) return default_value; + return [value.numberValue floatValue]; +} + +bool GodotxFirebaseRemoteConfig::get_bool(const String &key, bool default_value) { + FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; + if (value.source == FIRRemoteConfigSourceStatic) return default_value; + return value.boolValue; +} + +Dictionary GodotxFirebaseRemoteConfig::get_dictionary(const String &key) { + FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; + id json = value.JSONValue; + if (![json isKindOfClass:[NSDictionary class]]) return Dictionary(); + return ns_dict_to_godot((NSDictionary *)json); +} + +// --------------------------------------------------------------------------- +// Defaults & settings +// --------------------------------------------------------------------------- + +void GodotxFirebaseRemoteConfig::set_defaults(const Dictionary &defaults) { + NSMutableDictionary *nsDefaults = [NSMutableDictionary dictionary]; + Array keys = defaults.keys(); + for (int i = 0; i < keys.size(); i++) { + String key = keys[i]; + Variant val = defaults[key]; + NSString *nsKey = ns_from_string(key); + if (val.get_type() == Variant::STRING) { + nsDefaults[nsKey] = ns_from_string(String(val)); + } else if (val.get_type() == Variant::INT) { + nsDefaults[nsKey] = @((int64_t)val); + } else if (val.get_type() == Variant::FLOAT) { + nsDefaults[nsKey] = @((double)val); + } else if (val.get_type() == Variant::BOOL) { + nsDefaults[nsKey] = @((bool)val); + } + } + [[FIRRemoteConfig remoteConfig] setDefaults:nsDefaults]; +} + +void GodotxFirebaseRemoteConfig::set_minimum_fetch_interval(float seconds) { + FIRRemoteConfigSettings *settings = [[FIRRemoteConfigSettings alloc] init]; + settings.minimumFetchInterval = (NSTimeInterval)seconds; + [FIRRemoteConfig remoteConfig].configSettings = settings; +} + +// --------------------------------------------------------------------------- +// Real-time listener +// --------------------------------------------------------------------------- + +void GodotxFirebaseRemoteConfig::setup_realtime_updates() { + FIRRemoteConfig *rc = [FIRRemoteConfig remoteConfig]; + _listenerRegistration = [rc addOnConfigUpdateListener:^(FIRRemoteConfigUpdate *update, + NSError *error) { + if (error) { return; } + [rc activateWithCompletion:^(BOOL changed, NSError *err) { + Array keys; + for (NSString *k in update.updatedKeys) { + keys.push_back(string_from_ns(k)); + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (GodotxFirebaseRemoteConfig::instance) { + GodotxFirebaseRemoteConfig::instance->emit_signal("config_updated", keys); + } + }); + }]; + }]; +} + +void GodotxFirebaseRemoteConfig::remove_config_update_listener() { + [_listenerRegistration remove]; + _listenerRegistration = nil; +} diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.cpp b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.cpp new file mode 100644 index 0000000..ffa9104 --- /dev/null +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.cpp @@ -0,0 +1,19 @@ +#include "godotx_firebase_remote_config_module.h" +#include "godotx_firebase_remote_config.h" + +#include "core/config/engine.h" +#include "core/object/class_db.h" + +GodotxFirebaseRemoteConfig *godotx_firebase_remote_config = nullptr; + +void initialize_godotx_firebase_remote_config_module() { + godotx_firebase_remote_config = memnew(GodotxFirebaseRemoteConfig); + Engine::get_singleton()->add_singleton(Engine::Singleton("GodotxFirebaseRemoteConfig", godotx_firebase_remote_config)); +} + +void uninitialize_godotx_firebase_remote_config_module() { + if (godotx_firebase_remote_config) { + memdelete(godotx_firebase_remote_config); + godotx_firebase_remote_config = nullptr; + } +} diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.h b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.h new file mode 100644 index 0000000..450ae93 --- /dev/null +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config_module.h @@ -0,0 +1,7 @@ +#ifndef GODOTX_FIREBASE_REMOTE_CONFIG_MODULE_H +#define GODOTX_FIREBASE_REMOTE_CONFIG_MODULE_H + +void initialize_godotx_firebase_remote_config_module(); +void uninitialize_godotx_firebase_remote_config_module(); + +#endif // GODOTX_FIREBASE_REMOTE_CONFIG_MODULE_H diff --git a/source/ios/firebase_remote_config/firebase_remote_config.gdip b/source/ios/firebase_remote_config/firebase_remote_config.gdip new file mode 100644 index 0000000..002dc72 --- /dev/null +++ b/source/ios/firebase_remote_config/firebase_remote_config.gdip @@ -0,0 +1,21 @@ +[config] + +name="GodotxFirebaseRemoteConfig" +binary="GodotxFirebaseRemoteConfig.xcframework" + +initialization="initialize_godotx_firebase_remote_config_module" +deinitialization="uninitialize_godotx_firebase_remote_config_module" + +[dependencies] + +linked=[] + +embedded=["FirebaseABTesting.xcframework", "FirebaseRemoteConfig.xcframework", "FirebaseRemoteConfigInterop.xcframework", "FirebaseSharedSwift.xcframework"] + +system=["UIKit.framework", "Foundation.framework"] + +capabilities=[] + +files=[] + +linker_flags=["-ObjC", "-L$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)"] diff --git a/source/ios/firebase_remote_config/project.yml b/source/ios/firebase_remote_config/project.yml new file mode 100644 index 0000000..3d8c336 --- /dev/null +++ b/source/ios/firebase_remote_config/project.yml @@ -0,0 +1,77 @@ +name: GodotxFirebaseRemoteConfig +options: + minimumXcodeGenVersion: 2.34.0 +targets: + GodotxFirebaseRemoteConfig: + type: library.static + platform: iOS + deploymentTarget: "15.0" + sources: + - path: Sources + headerVisibility: public + settings: + base: + PRODUCT_NAME: GodotxFirebaseRemoteConfig + PRODUCT_MODULE_NAME: GodotxFirebaseRemoteConfig + CLANG_ENABLE_MODULES: YES + CLANG_CXX_LANGUAGE_STANDARD: "gnu++17" + CLANG_CXX_LIBRARY: "libc++" + GCC_SYMBOLS_PRIVATE_EXTERN: NO + SKIP_INSTALL: NO + OTHER_CFLAGS: + - "-fcxx-modules" + - "-fmodules" + - "$(inherited)" + OTHER_CPLUSPLUSFLAGS: + - "-fcxx-modules" + - "-fmodules" + - "$(inherited)" + OTHER_LDFLAGS: + - "-ObjC" + - "$(inherited)" + HEADER_SEARCH_PATHS: + - "$(inherited)" + - "$(PODS_ROOT)/**" + - "$(SRCROOT)/../../../../godot" + - "$(SRCROOT)/../../../../godot/platform/ios" + LIBRARY_SEARCH_PATHS: + - "$(inherited)" + - "$(SDKROOT)/usr/lib/swift" + - "$(DEVELOPER_DIR)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$(PLATFORM_NAME)" + configs: + Debug: + GCC_PREPROCESSOR_DEFINITIONS: + - "$(inherited)" + - DEBUG=1 + - PTRCALL_ENABLED=1 + - TYPED_METHOD_BIND=1 + - DEBUG_ENABLED=1 + - DEBUG_MEMORY_ALLOC=1 + - DISABLE_FORCED_INLINE=1 + - DISABLE_DEPRECATED=1 + - DEBUG_METHODS_ENABLED=1 + OTHER_CFLAGS: + - "$(inherited)" + - "-fcxx-modules" + - "-fmodules" + - "-g" + OTHER_CPLUSPLUSFLAGS: + - "$(inherited)" + - "-fcxx-modules" + - "-fmodules" + - "-g" + Release: + GCC_PREPROCESSOR_DEFINITIONS: + - "$(inherited)" + - PTRCALL_ENABLED=1 + - TYPED_METHOD_BIND=1 + OTHER_CFLAGS: + - "$(inherited)" + - "-fcxx-modules" + - "-fmodules" + - "-g" + OTHER_CPLUSPLUSFLAGS: + - "$(inherited)" + - "-fcxx-modules" + - "-fmodules" + - "-g" From 7d03a40b7babeae93f84013a57ef0e243e4b855d Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Sun, 26 Apr 2026 00:28:10 +0300 Subject: [PATCH 3/9] feat(analytics-crashlytics): extend functionality and fix predefined game events --- scripts/Main.gd | 154 ++++++++++++- .../analytics/FirebaseAnalyticsPlugin.kt | 208 +++++++++++++++--- .../crashlytics/FirebaseCrashlyticsPlugin.kt | 33 +++ .../Sources/godotx_firebase_analytics.h | 14 ++ .../Sources/godotx_firebase_analytics.mm | 169 ++++++++++++++ .../Sources/godotx_firebase_crashlytics.h | 1 + .../Sources/godotx_firebase_crashlytics.mm | 25 +++ .../firebase_crashlytics.gdip | 2 +- 8 files changed, 573 insertions(+), 33 deletions(-) diff --git a/scripts/Main.gd b/scripts/Main.gd index 8142f8a..4a292fb 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -43,6 +43,10 @@ var _pending_call: Dictionary = { "RemoteConfig": "", } +var _fcm_token: String = "" +var _messaging_permission_granted: bool = false +var _apns_ready: bool = false + func _ready() -> void: get_viewport().size_changed.connect(_apply_safe_area) _apply_safe_area() @@ -189,17 +193,29 @@ func _module_btn_path(module_name: String, btn_name: String) -> String: var base_path = "VBoxContainer/ContextGroup/ModuleContainer/" + module_name.replace(" ", "") + "View/" if module_name == "Remote Config": return base_path + "List/" + btn_name + elif module_name == "Analytics": + return base_path + "ScrollContainer/List/" + btn_name return base_path + btn_name func _connect_module_buttons(module_name: String, instance: Node) -> void: if module_name == "Analytics": - _connect_btn(instance, "LogEventButton", _on_log_event_pressed) - _connect_btn(instance, "LogScreenButton", _on_log_screen_pressed) - _connect_btn(instance, "UserPropsButton", _on_set_user_property_pressed) + var list = instance.get_node("ScrollContainer/List") + _connect_btn(list, "LogEventButton", _on_log_event_pressed) + _connect_btn(list, "LogScreenButton", _on_log_screen_pressed) + _connect_btn(list, "UserPropsButton", _on_set_user_property_pressed) + _connect_btn(list, "SetUserIdButton", _on_set_user_id_pressed) + _connect_btn(list, "SetDefaultParamsButton", _on_set_default_params_pressed) + _connect_btn(list, "SetConsentButton", _on_set_consent_pressed) + _connect_btn(list, "SetCollectionEnabledButton", _on_set_collection_enabled_pressed) + _connect_btn(list, "ResetDataButton", _on_reset_data_pressed) + _connect_btn(list, "LogLevelStartButton", _on_log_level_start_pressed) elif module_name == "Messaging": + _connect_btn(instance, "GetTokenButton", _on_get_token_pressed) _connect_btn(instance, "PermissionButton", _on_request_messaging_permission_pressed) _connect_btn(instance, "SubscribeButton", _on_subscribe_topic_pressed) _connect_btn(instance, "UnsubscribeButton", _on_unsubscribe_topic_pressed) + _connect_btn(instance, "GetLastNotificationButton", _on_get_last_notification_pressed) + _update_messaging_view_state(instance) elif module_name == "Crashlytics": _connect_btn(instance, "FatalButton", _on_crash_pressed) _connect_btn(instance, "NonFatalButton", _on_non_fatal_pressed) @@ -325,8 +341,80 @@ func _on_analytics_property_set(prop_name: String) -> void: log_message("[Analytics] โœ“ Property set: " + prop_name) _clear_pending("Analytics") +func _on_set_user_id_pressed() -> void: + var btn_path = _module_btn_path("Analytics", "SetUserIdButton") + if not analytics: + log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Setting User ID: player_123") + flash_status(btn_path, TestButton.Status.SUCCESS) + analytics.set_user_id("player_123") + +func _on_set_default_params_pressed() -> void: + var btn_path = _module_btn_path("Analytics", "SetDefaultParamsButton") + if not analytics: + log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Setting default params: app_version=1.0.0") + flash_status(btn_path, TestButton.Status.SUCCESS) + analytics.set_default_event_parameters({"app_version": "1.0.0"}) + +func _on_set_consent_pressed() -> void: + var btn_path = _module_btn_path("Analytics", "SetConsentButton") + if not analytics: + log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Setting Consent: analytics_storage=false") + flash_status(btn_path, TestButton.Status.SUCCESS) + analytics.set_consent({"analytics_storage": false}) + +func _on_set_collection_enabled_pressed() -> void: + var btn_path = _module_btn_path("Analytics", "SetCollectionEnabledButton") + if not analytics: + log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Toggling Collection Enabled: false") + flash_status(btn_path, TestButton.Status.SUCCESS) + analytics.set_collection_enabled(false) + +func _on_reset_data_pressed() -> void: + var btn_path = _module_btn_path("Analytics", "ResetDataButton") + if not analytics: + log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Resetting Analytics Data") + flash_status(btn_path, TestButton.Status.SUCCESS) + analytics.reset_analytics_data() + +func _on_log_level_start_pressed() -> void: + var btn_path = _module_btn_path("Analytics", "LogLevelStartButton") + if not analytics: + log_message("[Analytics] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Analytics] Logging level_start: level_1") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Analytics"] = btn_path + analytics.log_level_start("level_1") + # ============== MESSAGING ============== +func _on_get_token_pressed() -> void: + var btn_path = _module_btn_path("Messaging", "GetTokenButton") + if not messaging: + log_message("[Messaging] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Messaging] Requesting FCM token...") + flash_status(btn_path, TestButton.Status.PENDING) + _pending_call["Messaging"] = btn_path + messaging.get_token() + func _on_request_messaging_permission_pressed() -> void: var btn_path = _module_btn_path("Messaging", "PermissionButton") if not messaging: @@ -362,7 +450,12 @@ func _on_unsubscribe_topic_pressed() -> void: func _on_messaging_permission_granted() -> void: log_message("[Messaging] โœ“ Permission granted") + _messaging_permission_granted = true _clear_pending("Messaging") + + var view = module_container.get_node_or_null("MessagingView") + if view: + _update_messaging_view_state(view) func _on_messaging_permission_denied() -> void: log_message("[Messaging] โœ— Permission denied") @@ -380,13 +473,62 @@ func _on_messaging_topic_unsubscribed(topic: String) -> void: _clear_pending("Messaging") func _on_messaging_token_received(token: String) -> void: - log_message("[Messaging] Token: " + token) + _fcm_token = token + log_message("[Messaging] Token received: " + token) + _clear_pending("Messaging") + + var view = module_container.get_node_or_null("MessagingView") + if view: + _update_messaging_view_state(view) func _on_messaging_apn_token_received(token: String) -> void: - log_message("[Messaging] APNs Token: " + token) + _apns_ready = true + log_message("[Messaging] APNs Token received (Ready for FCM)") + # If we already have permissions, we can now fetch FCM token + if _messaging_permission_granted and messaging: + messaging.get_token() + +func _update_messaging_view_state(view: Node) -> void: + var has_token = !_fcm_token.is_empty() + var permission_ok = _messaging_permission_granted + + var perm_btn = view.get_node_or_null("PermissionButton") + var token_btn = view.get_node_or_null("GetTokenButton") + var sub_btn = view.get_node_or_null("SubscribeButton") + var unsub_btn = view.get_node_or_null("UnsubscribeButton") + var last_notification_btn = view.get_node_or_null("GetLastNotificationButton") + + # Step 1: Permission button is always enabled + if perm_btn: perm_btn.disabled = false + + # Step 2: Get Token only enabled after permission is granted + if token_btn: token_btn.disabled = !permission_ok -func _on_messaging_message_received(title: String, body: String) -> void: + # Step 3: Topic operations and Get Last Notification only enabled after both permission and token are obtained + if sub_btn: sub_btn.disabled = !(permission_ok and has_token) + if unsub_btn: unsub_btn.disabled = !(permission_ok and has_token) + if last_notification_btn: last_notification_btn.disabled = !(permission_ok and has_token) + +func _on_messaging_message_received(title: String, body: String, data: Dictionary = {}) -> void: log_message("[Messaging] Message received: " + title + " โ€” " + body) + if not data.is_empty(): + log_message("[Messaging] Data payload: " + str(data)) + +func _on_get_last_notification_pressed() -> void: + var btn_path = _module_btn_path("Messaging", "GetLastNotificationButton") + if not messaging: + log_message("[Messaging] Plugin not available") + flash_status(btn_path, TestButton.Status.FAILURE) + return + log_message("\n[Messaging] Getting last notification...") + flash_status(btn_path, TestButton.Status.PENDING) + var data = messaging.get_last_notification() + if typeof(data) == TYPE_DICTIONARY and not data.is_empty(): + log_message("[Messaging] โœ“ Last notification data: " + str(data)) + flash_status(btn_path, TestButton.Status.SUCCESS) + else: + log_message("[Messaging] โœ— No previous notification data found") + flash_status(btn_path, TestButton.Status.FAILURE) # ============== CRASHLYTICS ============== diff --git a/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt b/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt index ab9965d..a5b7b0b 100644 --- a/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt +++ b/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt @@ -36,6 +36,14 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { "analytics_event_logged", String::class.java ), + SignalInfo( + "analytics_screen_logged", + String::class.java + ), + SignalInfo( + "analytics_property_set", + String::class.java + ), SignalInfo( "analytics_error", String::class.java @@ -63,6 +71,35 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { } } + private fun dictionaryToBundle(params: Dictionary): Bundle { + val bundle = Bundle() + for (key in params.keys) { + val value = params[key] + + // firebase parameter names must be strings + if (key !is String || value == null) { + continue + } + + when (value) { + is Int -> bundle.putInt(key, value) + is Long -> bundle.putLong(key, value) + is Float -> bundle.putDouble(key, value.toDouble()) + is Double -> bundle.putDouble(key, value) + is Boolean -> { + // firebase analytics does NOT support boolean + bundle.putInt(key, if (value) 1 else 0) + } + is String -> bundle.putString(key, value) + else -> { + // unsupported types are silently ignored + Log.w(TAG, "Unsupported param type for key=$key (${value::class.java})") + } + } + } + return bundle + } + @UsedByGodot fun log_event(event_name: String, params: Dictionary) { val analytics = firebaseAnalytics @@ -72,41 +109,160 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { return } + try { + val bundle = dictionaryToBundle(params) + analytics.logEvent(event_name, bundle) + Log.d(TAG, "Event logged: $event_name") + emitSignal("analytics_event_logged", event_name) + } catch (e: Exception) { + Log.e(TAG, "Failed to log event", e) + emitSignal("analytics_error", e.message ?: "event_log_error") + } + } + + @UsedByGodot + fun set_user_id(user_id: String?) { + val analytics = firebaseAnalytics ?: return + try { + analytics.setUserId(user_id) + Log.d(TAG, "User ID set") + } catch (e: Exception) { + Log.e(TAG, "Failed to set User ID", e) + } + } + + @UsedByGodot + fun set_user_property(name: String, value: String?) { + val analytics = firebaseAnalytics ?: return + try { + analytics.setUserProperty(name, value) + Log.d(TAG, "User property set: $name = $value") + } catch (e: Exception) { + Log.e(TAG, "Failed to set user property", e) + } + } + + @UsedByGodot + fun log_screen_view(screen_name: String, screen_class: String) { + val analytics = firebaseAnalytics ?: return try { val bundle = Bundle() + bundle.putString(FirebaseAnalytics.Param.SCREEN_NAME, screen_name) + bundle.putString(FirebaseAnalytics.Param.SCREEN_CLASS, screen_class) + analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, bundle) + Log.d(TAG, "Screen view logged: $screen_name ($screen_class)") + } catch (e: Exception) { + Log.e(TAG, "Failed to log screen view", e) + } + } - for (key in params.keys) { - val value = params[key] + @UsedByGodot + fun set_default_event_parameters(params: Dictionary) { + val analytics = firebaseAnalytics ?: return + try { + val bundle = dictionaryToBundle(params) + analytics.setDefaultEventParameters(bundle) + Log.d(TAG, "Default event parameters set") + } catch (e: Exception) { + Log.e(TAG, "Failed to set default event parameters", e) + } + } - // firebase parameter names must be strings - if (key !is String || value == null) { - continue - } + @UsedByGodot + fun set_collection_enabled(enabled: Boolean) { + val analytics = firebaseAnalytics ?: return + try { + analytics.setAnalyticsCollectionEnabled(enabled) + Log.d(TAG, "Analytics collection enabled: $enabled") + } catch (e: Exception) { + Log.e(TAG, "Failed to set collection enabled", e) + } + } - when (value) { - is Int -> bundle.putInt(key, value) - is Long -> bundle.putLong(key, value) - is Float -> bundle.putDouble(key, value.toDouble()) - is Double -> bundle.putDouble(key, value) - is Boolean -> { - // firebase analytics does NOT support boolean - bundle.putInt(key, if (value) 1 else 0) - } - is String -> bundle.putString(key, value) - else -> { - // unsupported types are silently ignored - Log.w(TAG, "Unsupported param type for key=$key (${value::class.java})") - } - } + @UsedByGodot + fun reset_analytics_data() { + val analytics = firebaseAnalytics ?: return + try { + analytics.resetAnalyticsData() + Log.d(TAG, "Analytics data reset") + } catch (e: Exception) { + Log.e(TAG, "Failed to reset analytics data", e) + } + } + + @UsedByGodot + fun set_consent(consent_data: Dictionary) { + val analytics = firebaseAnalytics ?: return + try { + val consentMap = java.util.EnumMap(FirebaseAnalytics.ConsentType::class.java) + + val adStorage = consent_data["ad_storage"] + if (adStorage is Boolean) { + consentMap[FirebaseAnalytics.ConsentType.AD_STORAGE] = if (adStorage) FirebaseAnalytics.ConsentStatus.GRANTED else FirebaseAnalytics.ConsentStatus.DENIED } - analytics.logEvent(event_name, bundle) - Log.d(TAG, "Event logged: $event_name") - emitSignal("analytics_event_logged", event_name) + val analyticsStorage = consent_data["analytics_storage"] + if (analyticsStorage is Boolean) { + consentMap[FirebaseAnalytics.ConsentType.ANALYTICS_STORAGE] = if (analyticsStorage) FirebaseAnalytics.ConsentStatus.GRANTED else FirebaseAnalytics.ConsentStatus.DENIED + } + + val adUserData = consent_data["ad_user_data"] + if (adUserData is Boolean) { + consentMap[FirebaseAnalytics.ConsentType.AD_USER_DATA] = if (adUserData) FirebaseAnalytics.ConsentStatus.GRANTED else FirebaseAnalytics.ConsentStatus.DENIED + } + + val adPersonalization = consent_data["ad_personalization"] + if (adPersonalization is Boolean) { + consentMap[FirebaseAnalytics.ConsentType.AD_PERSONALIZATION] = if (adPersonalization) FirebaseAnalytics.ConsentStatus.GRANTED else FirebaseAnalytics.ConsentStatus.DENIED + } + + analytics.setConsent(consentMap) + Log.d(TAG, "Analytics consent set: $consentMap") } catch (e: Exception) { - Log.e(TAG, "Failed to log event", e) - emitSignal("analytics_error", e.message ?: "event_log_error") + Log.e(TAG, "Failed to set consent", e) } } + + @UsedByGodot + fun log_level_start(level_name: String) { + val params = Dictionary() + params[FirebaseAnalytics.Param.LEVEL_NAME] = level_name + log_event(FirebaseAnalytics.Event.LEVEL_START, params) + } + + @UsedByGodot + fun log_level_end(level_name: String, success: Boolean) { + val params = Dictionary() + params[FirebaseAnalytics.Param.LEVEL_NAME] = level_name + params[FirebaseAnalytics.Param.SUCCESS] = if (success) "1" else "0" + log_event(FirebaseAnalytics.Event.LEVEL_END, params) + } + + @UsedByGodot + fun log_earn_currency(currency_name: String, value: Float) { + val params = Dictionary() + params[FirebaseAnalytics.Param.VIRTUAL_CURRENCY_NAME] = currency_name + params[FirebaseAnalytics.Param.VALUE] = value + log_event(FirebaseAnalytics.Event.EARN_VIRTUAL_CURRENCY, params) + } + + @UsedByGodot + fun log_spend_currency(currency_name: String, value: Float, item_name: String) { + val params = Dictionary() + params[FirebaseAnalytics.Param.VIRTUAL_CURRENCY_NAME] = currency_name + params[FirebaseAnalytics.Param.VALUE] = value + params[FirebaseAnalytics.Param.ITEM_NAME] = item_name + log_event(FirebaseAnalytics.Event.SPEND_VIRTUAL_CURRENCY, params) + } + + @UsedByGodot + fun log_tutorial_begin() { + log_event(FirebaseAnalytics.Event.TUTORIAL_BEGIN, Dictionary()) + } + + @UsedByGodot + fun log_tutorial_complete() { + log_event(FirebaseAnalytics.Event.TUTORIAL_COMPLETE, Dictionary()) + } } diff --git a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt index 443e207..f80060b 100644 --- a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt +++ b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt @@ -28,6 +28,15 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { SignalInfo("crashlytics_initialized", Boolean::class.javaObjectType ), + SignalInfo("crashlytics_non_fatal_logged", + String::class.java + ), + SignalInfo("crashlytics_message_logged", + String::class.java + ), + SignalInfo("crashlytics_value_set", + String::class.java + ), SignalInfo("crashlytics_error", String::class.java ) @@ -54,6 +63,25 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { crash!!.length } + @UsedByGodot + fun log_non_fatal_exception(message: String) { + val crashlyticsInstance = crashlytics + if (crashlyticsInstance == null) { + Log.e(TAG, "Firebase Crashlytics not initialized") + emitSignal("crashlytics_error", "crashlytics_not_initialized") + return + } + + try { + crashlyticsInstance.recordException(Exception(message)) + Log.d(TAG, "Recorded non-fatal exception: $message") + emitSignal("crashlytics_non_fatal_logged", message) + } catch (e: Exception) { + Log.e(TAG, "Failed to record non-fatal exception", e) + emitSignal("crashlytics_error", e.message ?: "non_fatal_log_error") + } + } + @UsedByGodot fun log_message(message: String) { val crashlyticsInstance = crashlytics @@ -66,6 +94,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { crashlyticsInstance.log(message) Log.d(TAG, "Logged message to Crashlytics: $message") + emitSignal("crashlytics_message_logged", message) } catch (e: Exception) { Log.e(TAG, "Failed to log message", e) emitSignal("crashlytics_error", e.message ?: "log_error") @@ -102,6 +131,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { crashlyticsInstance.setCustomKey(key, value) Log.d(TAG, "Set custom value: $key = $value") + emitSignal("crashlytics_value_set", key) } catch (e: Exception) { Log.e(TAG, "Failed to set custom value", e) emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") @@ -120,6 +150,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { crashlyticsInstance.setCustomKey(key, value.toLong()) Log.d(TAG, "Set custom value: $key = $value") + emitSignal("crashlytics_value_set", key) } catch (e: Exception) { Log.e(TAG, "Failed to set custom value", e) emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") @@ -138,6 +169,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { crashlyticsInstance.setCustomKey(key, value) Log.d(TAG, "Set custom value: $key = $value") + emitSignal("crashlytics_value_set", key) } catch (e: Exception) { Log.e(TAG, "Failed to set custom value", e) emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") @@ -156,6 +188,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { crashlyticsInstance.setCustomKey(key, value.toDouble()) Log.d(TAG, "Set custom value: $key = $value") + emitSignal("crashlytics_value_set", key) } catch (e: Exception) { Log.e(TAG, "Failed to set custom value", e) emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") diff --git a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h index fd7d916..d386d80 100644 --- a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h +++ b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h @@ -17,6 +17,20 @@ class GodotxFirebaseAnalytics : public Object { void initialize(); void log_event(String event_name, Dictionary params); + void log_screen_view(String screen_name, String screen_class); + void set_user_property(String name, String value); + void set_user_id(String user_id); + void set_default_event_parameters(Dictionary params); + void set_collection_enabled(bool enabled); + void reset_analytics_data(); + void set_consent(Dictionary consent_data); + + void log_level_start(String level_name); + void log_level_end(String level_name, bool success); + void log_earn_currency(String currency_name, float value); + void log_spend_currency(String currency_name, float value, String item_name); + void log_tutorial_begin(); + void log_tutorial_complete(); GodotxFirebaseAnalytics(); ~GodotxFirebaseAnalytics(); diff --git a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm index b7d5f68..5e53f2f 100644 --- a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm +++ b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm @@ -10,9 +10,25 @@ void GodotxFirebaseAnalytics::_bind_methods() { ClassDB::bind_method(D_METHOD("initialize"), &GodotxFirebaseAnalytics::initialize); ClassDB::bind_method(D_METHOD("log_event", "event_name", "params"), &GodotxFirebaseAnalytics::log_event); + ClassDB::bind_method(D_METHOD("log_screen_view", "screen_name", "screen_class"), &GodotxFirebaseAnalytics::log_screen_view); + ClassDB::bind_method(D_METHOD("set_user_property", "name", "value"), &GodotxFirebaseAnalytics::set_user_property); + ClassDB::bind_method(D_METHOD("set_user_id", "user_id"), &GodotxFirebaseAnalytics::set_user_id); + ClassDB::bind_method(D_METHOD("set_default_event_parameters", "params"), &GodotxFirebaseAnalytics::set_default_event_parameters); + ClassDB::bind_method(D_METHOD("set_collection_enabled", "enabled"), &GodotxFirebaseAnalytics::set_collection_enabled); + ClassDB::bind_method(D_METHOD("reset_analytics_data"), &GodotxFirebaseAnalytics::reset_analytics_data); + ClassDB::bind_method(D_METHOD("set_consent", "consent_data"), &GodotxFirebaseAnalytics::set_consent); + + ClassDB::bind_method(D_METHOD("log_level_start", "level_name"), &GodotxFirebaseAnalytics::log_level_start); + ClassDB::bind_method(D_METHOD("log_level_end", "level_name", "success"), &GodotxFirebaseAnalytics::log_level_end); + ClassDB::bind_method(D_METHOD("log_earn_currency", "currency_name", "value"), &GodotxFirebaseAnalytics::log_earn_currency); + ClassDB::bind_method(D_METHOD("log_spend_currency", "currency_name", "value", "item_name"), &GodotxFirebaseAnalytics::log_spend_currency); + ClassDB::bind_method(D_METHOD("log_tutorial_begin"), &GodotxFirebaseAnalytics::log_tutorial_begin); + ClassDB::bind_method(D_METHOD("log_tutorial_complete"), &GodotxFirebaseAnalytics::log_tutorial_complete); ADD_SIGNAL(MethodInfo("analytics_initialized", PropertyInfo(Variant::BOOL, "success"))); ADD_SIGNAL(MethodInfo("analytics_event_logged", PropertyInfo(Variant::STRING, "event_name"))); + ADD_SIGNAL(MethodInfo("analytics_screen_logged", PropertyInfo(Variant::STRING, "screen_name"))); + ADD_SIGNAL(MethodInfo("analytics_property_set", PropertyInfo(Variant::STRING, "name"))); ADD_SIGNAL(MethodInfo("analytics_error", PropertyInfo(Variant::STRING, "message"))); } @@ -49,6 +65,159 @@ emit_signal("analytics_initialized", true); } +void GodotxFirebaseAnalytics::log_screen_view(String screen_name, String screen_class) { + NSLog(@"[GodotxFirebaseAnalytics] log_screen_view: %s (%s)", screen_name.utf8().get_data(), screen_class.utf8().get_data()); + + @try { + NSString* nsScreenName = [NSString stringWithUTF8String:screen_name.utf8().get_data()]; + NSString* nsScreenClass = [NSString stringWithUTF8String:screen_class.utf8().get_data()]; + + [FIRAnalytics logEventWithName:kFIREventScreenView + parameters:@{ + kFIRParameterScreenName: nsScreenName, + kFIRParameterScreenClass: nsScreenClass + }]; + + emit_signal("analytics_screen_logged", screen_name); + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseAnalytics] Failed to log screen view: %@", exception.reason); + emit_signal("analytics_error", String::utf8([exception.reason UTF8String])); + } +} + +void GodotxFirebaseAnalytics::set_user_property(String name, String value) { + NSLog(@"[GodotxFirebaseAnalytics] set_user_property: %s = %s", name.utf8().get_data(), value.utf8().get_data()); + + @try { + NSString* nsName = [NSString stringWithUTF8String:name.utf8().get_data()]; + NSString* nsValue = [NSString stringWithUTF8String:value.utf8().get_data()]; + + [FIRAnalytics setUserPropertyString:nsValue forName:nsName]; + + emit_signal("analytics_property_set", name); + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseAnalytics] Failed to set user property: %@", exception.reason); + emit_signal("analytics_error", String::utf8([exception.reason UTF8String])); + } +} + +void GodotxFirebaseAnalytics::set_user_id(String user_id) { + NSLog(@"[GodotxFirebaseAnalytics] set_user_id: %s", user_id.utf8().get_data()); + @try { + NSString* nsUserId = [NSString stringWithUTF8String:user_id.utf8().get_data()]; + [FIRAnalytics setUserID:nsUserId]; + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseAnalytics] Failed to set user id: %@", exception.reason); + emit_signal("analytics_error", String::utf8([exception.reason UTF8String])); + } +} + +void GodotxFirebaseAnalytics::set_default_event_parameters(Dictionary params) { + NSLog(@"[GodotxFirebaseAnalytics] set_default_event_parameters"); + @try { + NSDictionary* nsParams = dictionary_to_nsdict(params); + [FIRAnalytics setDefaultEventParameters:nsParams]; + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseAnalytics] Failed to set default parameters: %@", exception.reason); + emit_signal("analytics_error", String::utf8([exception.reason UTF8String])); + } +} + +void GodotxFirebaseAnalytics::set_collection_enabled(bool enabled) { + NSLog(@"[GodotxFirebaseAnalytics] set_collection_enabled: %d", enabled); + @try { + [FIRAnalytics setAnalyticsCollectionEnabled:enabled]; + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseAnalytics] Failed to set collection enabled: %@", exception.reason); + emit_signal("analytics_error", String::utf8([exception.reason UTF8String])); + } +} + +void GodotxFirebaseAnalytics::reset_analytics_data() { + NSLog(@"[GodotxFirebaseAnalytics] reset_analytics_data"); + @try { + [FIRAnalytics resetAnalyticsData]; + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseAnalytics] Failed to reset analytics data: %@", exception.reason); + emit_signal("analytics_error", String::utf8([exception.reason UTF8String])); + } +} + +void GodotxFirebaseAnalytics::set_consent(Dictionary consent_data) { + NSLog(@"[GodotxFirebaseAnalytics] set_consent"); + @try { + NSMutableDictionary *consentMap = [NSMutableDictionary dictionary]; + + if (consent_data.has("ad_storage") && consent_data["ad_storage"].get_type() == Variant::BOOL) { + bool val = consent_data["ad_storage"]; + consentMap[FIRConsentTypeAdStorage] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + } + + if (consent_data.has("analytics_storage") && consent_data["analytics_storage"].get_type() == Variant::BOOL) { + bool val = consent_data["analytics_storage"]; + consentMap[FIRConsentTypeAnalyticsStorage] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + } + + if (consent_data.has("ad_user_data") && consent_data["ad_user_data"].get_type() == Variant::BOOL) { + bool val = consent_data["ad_user_data"]; + consentMap[FIRConsentTypeAdUserData] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + } + + if (consent_data.has("ad_personalization") && consent_data["ad_personalization"].get_type() == Variant::BOOL) { + bool val = consent_data["ad_personalization"]; + consentMap[FIRConsentTypeAdPersonalization] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + } + + [FIRAnalytics setConsent:consentMap]; + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseAnalytics] Failed to set consent: %@", exception.reason); + emit_signal("analytics_error", String::utf8([exception.reason UTF8String])); + } +} + +void GodotxFirebaseAnalytics::log_level_start(String level_name) { + Dictionary params; + params["level_name"] = level_name; + log_event(String(kFIREventLevelStart.UTF8String), params); +} + +void GodotxFirebaseAnalytics::log_level_end(String level_name, bool success) { + Dictionary params; + params["level_name"] = level_name; + params["success"] = success ? "1" : "0"; + log_event(String(kFIREventLevelEnd.UTF8String), params); +} + +void GodotxFirebaseAnalytics::log_earn_currency(String currency_name, float value) { + Dictionary params; + params["virtual_currency_name"] = currency_name; + params["value"] = value; + log_event(String(kFIREventEarnVirtualCurrency.UTF8String), params); +} + +void GodotxFirebaseAnalytics::log_spend_currency(String currency_name, float value, String item_name) { + Dictionary params; + params["virtual_currency_name"] = currency_name; + params["value"] = value; + params["item_name"] = item_name; + log_event(String(kFIREventSpendVirtualCurrency.UTF8String), params); +} + +void GodotxFirebaseAnalytics::log_tutorial_begin() { + log_event(String(kFIREventTutorialBegin.UTF8String), Dictionary()); +} + +void GodotxFirebaseAnalytics::log_tutorial_complete() { + log_event(String(kFIREventTutorialComplete.UTF8String), Dictionary()); +} + void GodotxFirebaseAnalytics::log_event(String event_name, Dictionary params) { NSLog(@"[GodotxFirebaseAnalytics] log_event: %s", event_name.utf8().get_data()); diff --git a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h index 30eee57..e3f521d 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h @@ -17,6 +17,7 @@ class GodotxFirebaseCrashlytics : public Object { void initialize(); void crash(); + void log_non_fatal_exception(String message); void log_message(String message); void set_user_id(String user_id); void set_custom_value_string(String key, String value); diff --git a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm index 87b4a07..630cc30 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm @@ -10,6 +10,7 @@ void GodotxFirebaseCrashlytics::_bind_methods() { ClassDB::bind_method(D_METHOD("initialize"), &GodotxFirebaseCrashlytics::initialize); ClassDB::bind_method(D_METHOD("crash"), &GodotxFirebaseCrashlytics::crash); + ClassDB::bind_method(D_METHOD("log_non_fatal_exception", "message"), &GodotxFirebaseCrashlytics::log_non_fatal_exception); ClassDB::bind_method(D_METHOD("log_message", "message"), &GodotxFirebaseCrashlytics::log_message); ClassDB::bind_method(D_METHOD("set_user_id", "user_id"), &GodotxFirebaseCrashlytics::set_user_id); ClassDB::bind_method(D_METHOD("set_custom_value_string", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value_string); @@ -18,6 +19,9 @@ ClassDB::bind_method(D_METHOD("set_custom_value_float", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value_float); ADD_SIGNAL(MethodInfo("crashlytics_initialized", PropertyInfo(Variant::BOOL, "success"))); + ADD_SIGNAL(MethodInfo("crashlytics_non_fatal_logged", PropertyInfo(Variant::STRING, "message"))); + ADD_SIGNAL(MethodInfo("crashlytics_message_logged", PropertyInfo(Variant::STRING, "message"))); + ADD_SIGNAL(MethodInfo("crashlytics_value_set", PropertyInfo(Variant::STRING, "key"))); ADD_SIGNAL(MethodInfo("crashlytics_error", PropertyInfo(Variant::STRING, "message"))); } @@ -34,11 +38,28 @@ @[][1]; } +void GodotxFirebaseCrashlytics::log_non_fatal_exception(String message) { + @try { + NSString* nsMessage = [NSString stringWithUTF8String:message.utf8().get_data()]; + NSError* error = [NSError errorWithDomain:@"GodotxFirebaseHarness" + code:0 + userInfo:@{NSLocalizedDescriptionKey: nsMessage}]; + [[FIRCrashlytics crashlytics] recordError:error]; + NSLog(@"[GodotxFirebaseCrashlytics] Recorded non-fatal exception: %@", nsMessage); + emit_signal("crashlytics_non_fatal_logged", message); + } + @catch (NSException *exception) { + NSLog(@"[GodotxFirebaseCrashlytics] Failed to record non-fatal: %@", exception.reason); + emit_signal("crashlytics_error", String::utf8([exception.reason UTF8String])); + } +} + void GodotxFirebaseCrashlytics::log_message(String message) { @try { NSString* nsMessage = [NSString stringWithUTF8String:message.utf8().get_data()]; [[FIRCrashlytics crashlytics] log:nsMessage]; NSLog(@"[GodotxFirebaseCrashlytics] Logged message: %@", nsMessage); + emit_signal("crashlytics_message_logged", message); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseCrashlytics] Failed to log message: %@", exception.reason); @@ -64,6 +85,7 @@ NSString* nsValue = [NSString stringWithUTF8String:value.utf8().get_data()]; [[FIRCrashlytics crashlytics] setCustomValue:nsValue forKey:nsKey]; NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %@", nsKey, nsValue); + emit_signal("crashlytics_value_set", key); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseCrashlytics] Failed to set custom value: %@", exception.reason); @@ -76,6 +98,7 @@ NSString* nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; [[FIRCrashlytics crashlytics] setCustomValue:@(value) forKey:nsKey]; NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %lld", nsKey, value); + emit_signal("crashlytics_value_set", key); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseCrashlytics] Failed to set custom value: %@", exception.reason); @@ -88,6 +111,7 @@ NSString* nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; [[FIRCrashlytics crashlytics] setCustomValue:@(value) forKey:nsKey]; NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %d", nsKey, value); + emit_signal("crashlytics_value_set", key); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseCrashlytics] Failed to set custom value: %@", exception.reason); @@ -100,6 +124,7 @@ NSString* nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; [[FIRCrashlytics crashlytics] setCustomValue:@(value) forKey:nsKey]; NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %f", nsKey, value); + emit_signal("crashlytics_value_set", key); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseCrashlytics] Failed to set custom value: %@", exception.reason); diff --git a/source/ios/firebase_crashlytics/firebase_crashlytics.gdip b/source/ios/firebase_crashlytics/firebase_crashlytics.gdip index ed40ae5..7d82fed 100644 --- a/source/ios/firebase_crashlytics/firebase_crashlytics.gdip +++ b/source/ios/firebase_crashlytics/firebase_crashlytics.gdip @@ -10,7 +10,7 @@ deinitialization="uninitialize_godotx_firebase_crashlytics_module" linked=[] -embedded=["FirebaseCoreExtension.xcframework", "FirebaseCrashlytics.xcframework", "FirebaseRemoteConfigInterop.xcframework", "FirebaseSessions.xcframework", "GoogleDataTransport.xcframework", "Promises.xcframework"] +embedded=["FirebaseCoreExtension.xcframework", "FirebaseCrashlytics.xcframework", "FirebaseRemoteConfigInterop.xcframework", "FirebaseSessions.xcframework", "Promises.xcframework"] system=["UserNotifications.framework", "UIKit.framework", "Foundation.framework"] From a8c3d5e2b283e6f60960011e860255846d9f0a95 Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Sun, 26 Apr 2026 00:59:35 +0300 Subject: [PATCH 4/9] feat(messaging): extend Phase 2 APIs and fix GoogleDataTransport dependency - Extend Android messaging plugin with topic subscription, token access, and notification data payload - Extend iOS messaging delegate with APN token handling and data payload forwarding - Relocate GoogleDataTransport.xcframework to Firebase Core to avoid duplicate symbol errors - Update AnalyticsView and MessagingView scenes with Phase 2 test harness UI - Update Makefile to manage GoogleDataTransport in Core plugin directory only --- .gitignore | 2 + Makefile | 10 ++- scenes/Main.tscn | 9 +++ scenes/view_stack/AnalyticsView.tscn | 58 ++++++++++++++++- scenes/view_stack/MessagingView.tscn | 18 ++++++ .../messaging/FirebaseMessagingPlugin.kt | 59 +++++++++++++----- source/ios/firebase_core/firebase_core.gdip | 2 +- .../Sources/godot_app_delegate+apns.mm | 5 -- .../Sources/godotx_apn_delegate.h | 2 + .../Sources/godotx_apn_delegate.mm | 30 ++++++++- .../Sources/godotx_firebase_messaging.h | 6 ++ .../Sources/godotx_firebase_messaging.mm | 62 ++++++++++++++++++- .../firebase_messaging.gdip | 2 +- 13 files changed, 231 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index a6b60ba..3573cac 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ Thumbs.db # Temp files temp/ /*.zip +*.uid +rc.json diff --git a/Makefile b/Makefile index 669f427..b18c6aa 100644 --- a/Makefile +++ b/Makefile @@ -242,16 +242,20 @@ build-apple: setup-apple case $$module in \ firebase_core) \ echo " - Copying frameworks from FirebaseAnalytics..." && \ - cp -a $(FIREBASE_SDK_DIR)/FirebaseAnalytics/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ + cp -a $(FIREBASE_SDK_DIR)/FirebaseAnalytics/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ && \ + echo " - Copying GoogleDataTransport from FirebaseMessaging..." && \ + cp -a $(FIREBASE_SDK_DIR)/FirebaseMessaging/GoogleDataTransport.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ firebase_analytics) \ echo " - Copying frameworks from FirebaseAnalytics..." && \ cp -a $(FIREBASE_SDK_DIR)/FirebaseAnalytics/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ firebase_crashlytics) \ echo " - Copying frameworks from FirebaseCrashlytics..." && \ - cp -a $(FIREBASE_SDK_DIR)/FirebaseCrashlytics/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ + cp -a $(FIREBASE_SDK_DIR)/FirebaseCrashlytics/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ && \ + rm -rf $(IOS_PLUGINS_DIR)/$$module/GoogleDataTransport.xcframework ;; \ firebase_messaging) \ echo " - Copying frameworks from FirebaseMessaging..." \ - && cp -a $(FIREBASE_SDK_DIR)/FirebaseMessaging/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ + && cp -a $(FIREBASE_SDK_DIR)/FirebaseMessaging/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ \ + && rm -rf $(IOS_PLUGINS_DIR)/$$module/GoogleDataTransport.xcframework ;; \ firebase_remote_config) \ echo " - Copying frameworks from FirebaseRemoteConfig..." \ && cp -a $(FIREBASE_SDK_DIR)/FirebaseRemoteConfig/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ diff --git a/scenes/Main.tscn b/scenes/Main.tscn index cd10f21..b3b0c7b 100644 --- a/scenes/Main.tscn +++ b/scenes/Main.tscn @@ -103,6 +103,14 @@ theme_override_font_sizes/font_size = 32 text = "MESSAGING" script = ExtResource("2") +[node name="RemoteConfigButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +disabled = true +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = "REMOTE CONFIG" +script = ExtResource("2") + [node name="ModuleContainer" type="Control" parent="VBoxContainer/ContextGroup"] @@ -165,6 +173,7 @@ text = "Copy Logs" [connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" to="." method="show_module" binds= ["Analytics"]] [connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" to="." method="show_module" binds= ["Crashlytics"]] [connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" to="." method="show_module" binds= ["Messaging"]] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton" to="." method="show_module" binds= ["Remote Config"]] [connection signal="pressed" from="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls/ClearLogButton" to="." method="_on_clear_log_pressed"] [connection signal="pressed" from="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls/CopyLogButton" to="." method="_on_copy_log_pressed"] diff --git a/scenes/view_stack/AnalyticsView.tscn b/scenes/view_stack/AnalyticsView.tscn index 09c3cae..0f797fc 100644 --- a/scenes/view_stack/AnalyticsView.tscn +++ b/scenes/view_stack/AnalyticsView.tscn @@ -16,23 +16,75 @@ theme_override_font_sizes/font_size = 32 text = "Analytics Module" horizontal_alignment = 1 -[node name="LogEventButton" type="Button" parent="."] +[node name="ScrollContainer" type="ScrollContainer" parent="."] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="List" type="VBoxContainer" parent="ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 20 + +[node name="LogEventButton" type="Button" parent="ScrollContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“Š Log Test Event" script = ExtResource("1") -[node name="LogScreenButton" type="Button" parent="."] +[node name="LogScreenButton" type="Button" parent="ScrollContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“ฑ Log Screen View" script = ExtResource("1") -[node name="UserPropsButton" type="Button" parent="."] +[node name="UserPropsButton" type="Button" parent="ScrollContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ‘ค Set User Property" script = ExtResource("1") + +[node name="SetUserIdButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ†” Set User ID" +script = ExtResource("1") + +[node name="SetDefaultParamsButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "โš™๏ธ Set Default Params" +script = ExtResource("1") + +[node name="SetConsentButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ›ก๏ธ Set Consent" +script = ExtResource("1") + +[node name="SetCollectionEnabledButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”„ Toggle Collection" +script = ExtResource("1") + +[node name="ResetDataButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ—‘๏ธ Reset Data" +script = ExtResource("1") + +[node name="LogLevelStartButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐ŸŽฎ Log Level Start" +script = ExtResource("1") + diff --git a/scenes/view_stack/MessagingView.tscn b/scenes/view_stack/MessagingView.tscn index 8a244c1..eb277c0 100644 --- a/scenes/view_stack/MessagingView.tscn +++ b/scenes/view_stack/MessagingView.tscn @@ -23,9 +23,18 @@ theme_override_font_sizes/font_size = 28 text = "๐Ÿ”” Request Permission" script = ExtResource("1") +[node name="GetTokenButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +disabled = true +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”‘ Get FCM Token" +script = ExtResource("1") + [node name="SubscribeButton" type="Button" parent="."] custom_minimum_size = Vector2(0, 100) layout_mode = 2 +disabled = true theme_override_font_sizes/font_size = 28 text = "โž• Subscribe to Topic" script = ExtResource("1") @@ -33,6 +42,15 @@ script = ExtResource("1") [node name="UnsubscribeButton" type="Button" parent="."] custom_minimum_size = Vector2(0, 100) layout_mode = 2 +disabled = true theme_override_font_sizes/font_size = 28 text = "โž– Unsubscribe from Topic" script = ExtResource("1") + +[node name="GetLastNotificationButton" type="Button" parent="."] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +disabled = true +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“ฑ Get Last Notification" +script = ExtResource("1") diff --git a/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt b/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt index c1fa6ff..eb1252b 100644 --- a/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt +++ b/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt @@ -9,6 +9,7 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.RemoteMessage +import org.godotengine.godot.Dictionary import org.godotengine.godot.Godot import org.godotengine.godot.plugin.GodotPlugin import org.godotengine.godot.plugin.SignalInfo @@ -32,6 +33,7 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { private var coldStartIntent: Intent? = null private var deferredToken: String? = null private var isInitialized = false + private var lastNotification: Dictionary? = null override fun getPluginName(): String { return "GodotxFirebaseMessaging" @@ -44,7 +46,19 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { fun notifyMessageReceived(remoteMessage: RemoteMessage) { val title = remoteMessage.notification?.title ?: "" val body = remoteMessage.notification?.body ?: "" - emitSignal("messaging_message_received", title, body) + + val dataDict = Dictionary() + for ((key, value) in remoteMessage.data) { + dataDict[key] = value + } + + val cached = Dictionary() + cached["title"] = title + cached["body"] = body + cached["data"] = dataDict + lastNotification = cached + + emitSignal("messaging_message_received", title, body, dataDict) } fun notifyNewToken(token: String) { @@ -88,6 +102,9 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { override fun getPluginSignals(): Set { return setOf( + SignalInfo("messaging_initialized", + Boolean::class.javaObjectType + ), SignalInfo("messaging_permission_granted"), SignalInfo("messaging_permission_denied"), SignalInfo("messaging_token_received", @@ -95,6 +112,13 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { ), SignalInfo("messaging_message_received", String::class.java, + String::class.java, + Dictionary::class.java + ), + SignalInfo("messaging_topic_subscribed", + String::class.java + ), + SignalInfo("messaging_topic_unsubscribed", String::class.java ), SignalInfo("messaging_error", @@ -125,6 +149,7 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { if (ctx == null) { Log.e(TAG, "initialize: activity is null") + emitSignal("messaging_initialized", false) emitSignal("messaging_error", "activity_null") return } @@ -134,6 +159,7 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { if (apps.isEmpty()) { Log.e(TAG, "Firebase is NOT initialized") + emitSignal("messaging_initialized", false) emitSignal("messaging_error", "firebase_not_initialized") return } @@ -141,6 +167,7 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { Log.d(TAG, "Firebase Messaging initialized (${apps.size} Firebase app(s) found)") isInitialized = true + emitSignal("messaging_initialized", true) // Emit any notification that was received before initialization (cold start or early resume) coldStartIntent?.let { handleIntentMessage(it) } @@ -149,6 +176,7 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { deferredToken = null } catch (e: Exception) { Log.e(TAG, "Firebase initialization check failed", e) + emitSignal("messaging_initialized", false) emitSignal("messaging_error", e.message ?: "firebase_check_failed") } } @@ -206,14 +234,10 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { fun subscribe_to_topic(topic: String) { try { FirebaseMessaging.getInstance().subscribeToTopic(topic) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - Log.d(TAG, "Subscribed to topic: $topic") - } else { - Log.e(TAG, "Failed to subscribe to topic", task.exception) - emitSignal("messaging_error", task.exception?.message ?: "subscribe_failed") - } - } + .addOnSuccessListener { Log.d(TAG, "Subscribed to topic (server confirmed): $topic") } + .addOnFailureListener { e -> Log.e(TAG, "Subscribe server sync failed for $topic", e) } + Log.d(TAG, "Subscribe to topic queued: $topic") + emitSignal("messaging_topic_subscribed", topic) } catch (e: Exception) { Log.e(TAG, "Error subscribing to topic", e) emitSignal("messaging_error", e.message ?: "subscribe_error") @@ -224,18 +248,19 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { fun unsubscribe_from_topic(topic: String) { try { FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - Log.d(TAG, "Unsubscribed from topic: $topic") - } else { - Log.e(TAG, "Failed to unsubscribe from topic", task.exception) - emitSignal("messaging_error", task.exception?.message ?: "unsubscribe_failed") - } - } + .addOnSuccessListener { Log.d(TAG, "Unsubscribed from topic (server confirmed): $topic") } + .addOnFailureListener { e -> Log.e(TAG, "Unsubscribe server sync failed for $topic", e) } + Log.d(TAG, "Unsubscribe from topic queued: $topic") + emitSignal("messaging_topic_unsubscribed", topic) } catch (e: Exception) { Log.e(TAG, "Error unsubscribing from topic", e) emitSignal("messaging_error", e.message ?: "unsubscribe_error") } } + + @UsedByGodot + fun get_last_notification(): Dictionary { + return lastNotification ?: Dictionary() + } } diff --git a/source/ios/firebase_core/firebase_core.gdip b/source/ios/firebase_core/firebase_core.gdip index 505149b..28a394a 100644 --- a/source/ios/firebase_core/firebase_core.gdip +++ b/source/ios/firebase_core/firebase_core.gdip @@ -10,7 +10,7 @@ deinitialization="uninitialize_godotx_firebase_core_module" linked=[] -embedded=["FBLPromises.xcframework", "FirebaseAnalytics.xcframework", "FirebaseCore.xcframework", "FirebaseCoreInternal.xcframework", "FirebaseInstallations.xcframework", "GoogleAdsOnDeviceConversion.xcframework", "GoogleAppMeasurement.xcframework", "GoogleAppMeasurementIdentitySupport.xcframework", "GoogleUtilities.xcframework", "nanopb.xcframework"] +embedded=["FBLPromises.xcframework", "FirebaseAnalytics.xcframework", "FirebaseCore.xcframework", "FirebaseCoreInternal.xcframework", "FirebaseInstallations.xcframework", "GoogleAdsOnDeviceConversion.xcframework", "GoogleAppMeasurement.xcframework", "GoogleAppMeasurementIdentitySupport.xcframework", "GoogleDataTransport.xcframework", "GoogleUtilities.xcframework", "nanopb.xcframework"] system=["UserNotifications.framework", "UIKit.framework", "Foundation.framework"] diff --git a/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm b/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm index 9d9797b..3e89901 100644 --- a/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm +++ b/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm @@ -8,11 +8,6 @@ @implementation GDTApplicationDelegate (APNS) - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { NSLog(@"[GodotxFirebaseMessaging] didRegisterForRemoteNotificationsWithDeviceToken"); - if (![FIRApp defaultApp]) { - NSLog(@"[GodotxFirebaseMessaging] Firebase not configured yet, skipping APNs token"); - return; - } - [FIRMessaging messaging].APNSToken = deviceToken; const unsigned char *data = (const unsigned char *)deviceToken.bytes; diff --git a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.h b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.h index 61d5a1d..f19edb9 100644 --- a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.h +++ b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.h @@ -6,6 +6,8 @@ @interface GodotxAPNDelegate : NSObject +@property (nonatomic, strong) NSDictionary *lastNotificationInfo; + + (instancetype)shared; - (void)activateNotificationCenterDelegate; diff --git a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm index 25745d8..48e3a0b 100644 --- a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm +++ b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm @@ -25,6 +25,25 @@ + (instancetype)shared { return sharedInstance; } +// Helper: convert userInfo to Godot Dictionary, excluding APNs/FCM internal keys +static Dictionary userInfoToGodotDictionary(NSDictionary *userInfo) { + Dictionary dict; + NSSet *reservedKeys = [NSSet setWithArray:@[ + @"aps", @"gcm.message_id", @"google.c.a.e", @"google.c.fid", + @"google.c.sender.id", @"gcm.notification.sound" + ]]; + for (NSString *key in userInfo) { + if ([reservedKeys containsObject:key]) continue; + id val = userInfo[key]; + if ([val isKindOfClass:[NSString class]]) { + dict[String::utf8([key UTF8String])] = String::utf8([(NSString*)val UTF8String]); + } else if ([val isKindOfClass:[NSNumber class]]) { + dict[String::utf8([key UTF8String])] = String::utf8([[val stringValue] UTF8String]); + } + } + return dict; +} + #pragma mark - UNUserNotificationCenterDelegate - (void)userNotificationCenter:(UNUserNotificationCenter *)center @@ -33,15 +52,18 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center NSDictionary *userInfo = notification.request.content.userInfo; NSLog(@"[GodotxAPNDelegate] Received notification in foreground: %@", userInfo); + self.lastNotificationInfo = userInfo; NSString *title = notification.request.content.title ?: @""; NSString *body = notification.request.content.body ?: @""; + Dictionary data = userInfoToGodotDictionary(userInfo); dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseMessaging::instance) { GodotxFirebaseMessaging::instance->emit_signal("messaging_message_received", String::utf8([title UTF8String]), - String::utf8([body UTF8String])); + String::utf8([body UTF8String]), + data); } }); @@ -58,15 +80,18 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center NSDictionary *userInfo = response.notification.request.content.userInfo; NSLog(@"[GodotxAPNDelegate] User tapped notification: %@", userInfo); + self.lastNotificationInfo = userInfo; NSString *title = response.notification.request.content.title ?: @""; NSString *body = response.notification.request.content.body ?: @""; + Dictionary data = userInfoToGodotDictionary(userInfo); dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseMessaging::instance) { GodotxFirebaseMessaging::instance->emit_signal("messaging_message_received", String::utf8([title UTF8String]), - String::utf8([body UTF8String])); + String::utf8([body UTF8String]), + data); } }); @@ -74,4 +99,3 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center } @end - diff --git a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h index d297142..71cdf55 100644 --- a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h +++ b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h @@ -3,6 +3,11 @@ #include "core/object/class_db.h" +#ifdef __OBJC__ +@class GodotxAPNDelegate; +#endif + + class GodotxFirebaseMessaging : public Object { GDCLASS(GodotxFirebaseMessaging, Object); @@ -20,6 +25,7 @@ class GodotxFirebaseMessaging : public Object { void attempt_get_fcm_token(); void subscribe_to_topic(String topic); void unsubscribe_from_topic(String topic); + Dictionary get_last_notification(); GodotxFirebaseMessaging(); ~GodotxFirebaseMessaging(); diff --git a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm index 1fd3940..ac4d188 100644 --- a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm +++ b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm @@ -39,12 +39,16 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin ClassDB::bind_method(D_METHOD("get_apns_token"), &GodotxFirebaseMessaging::get_apns_token); ClassDB::bind_method(D_METHOD("subscribe_to_topic", "topic"), &GodotxFirebaseMessaging::subscribe_to_topic); ClassDB::bind_method(D_METHOD("unsubscribe_from_topic", "topic"), &GodotxFirebaseMessaging::unsubscribe_from_topic); + ClassDB::bind_method(D_METHOD("get_last_notification"), &GodotxFirebaseMessaging::get_last_notification); + ADD_SIGNAL(MethodInfo("messaging_initialized", PropertyInfo(Variant::BOOL, "success"))); ADD_SIGNAL(MethodInfo("messaging_permission_granted")); ADD_SIGNAL(MethodInfo("messaging_permission_denied")); ADD_SIGNAL(MethodInfo("messaging_token_received", PropertyInfo(Variant::STRING, "token"))); ADD_SIGNAL(MethodInfo("messaging_apn_token_received", PropertyInfo(Variant::STRING, "token"))); - ADD_SIGNAL(MethodInfo("messaging_message_received", PropertyInfo(Variant::STRING, "title"), PropertyInfo(Variant::STRING, "body"))); + ADD_SIGNAL(MethodInfo("messaging_message_received", PropertyInfo(Variant::STRING, "title"), PropertyInfo(Variant::STRING, "body"), PropertyInfo(Variant::DICTIONARY, "data"))); + ADD_SIGNAL(MethodInfo("messaging_topic_subscribed", PropertyInfo(Variant::STRING, "topic"))); + ADD_SIGNAL(MethodInfo("messaging_topic_unsubscribed", PropertyInfo(Variant::STRING, "topic"))); ADD_SIGNAL(MethodInfo("messaging_error", PropertyInfo(Variant::STRING, "message"))); } @@ -69,6 +73,8 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin if (![FIRApp defaultApp]) { NSLog(@"[GodotxFirebaseMessaging] Firebase core not ready"); + emit_signal("messaging_initialized", false); + emit_signal("messaging_error", String("firebase_not_initialized")); return; } @@ -82,6 +88,7 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin [[GodotxAPNDelegate shared] activateNotificationCenterDelegate]; NSLog(@"[GodotxFirebaseMessaging] Initialized"); + emit_signal("messaging_initialized", true); } void GodotxFirebaseMessaging::request_permission() { @@ -255,6 +262,11 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin }); } else { NSLog(@"[GodotxFirebaseMessaging] Successfully subscribed to topic: %@", nsTopic); + dispatch_async(dispatch_get_main_queue(), ^{ + if (GodotxFirebaseMessaging::instance) { + GodotxFirebaseMessaging::instance->emit_signal("messaging_topic_subscribed", String::utf8([nsTopic UTF8String])); + } + }); } }]; } @@ -275,6 +287,54 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin }); } else { NSLog(@"[GodotxFirebaseMessaging] Successfully unsubscribed from topic: %@", nsTopic); + dispatch_async(dispatch_get_main_queue(), ^{ + if (GodotxFirebaseMessaging::instance) { + GodotxFirebaseMessaging::instance->emit_signal("messaging_topic_unsubscribed", String::utf8([nsTopic UTF8String])); + } + }); } }]; } + +Dictionary GodotxFirebaseMessaging::get_last_notification() { + NSDictionary *userInfo = [GodotxAPNDelegate shared].lastNotificationInfo; + if (!userInfo) { + return Dictionary(); + } + + // Extract title and body from aps.alert + NSString *title = @""; + NSString *body = @""; + NSDictionary *aps = userInfo[@"aps"]; + if ([aps isKindOfClass:[NSDictionary class]]) { + id alert = aps[@"alert"]; + if ([alert isKindOfClass:[NSDictionary class]]) { + title = alert[@"title"] ?: @""; + body = alert[@"body"] ?: @""; + } else if ([alert isKindOfClass:[NSString class]]) { + body = alert; + } + } + + // Collect custom data fields (exclude internal APNs/FCM keys) + Dictionary dataDict; + NSSet *reservedKeys = [NSSet setWithArray:@[ + @"aps", @"gcm.message_id", @"google.c.a.e", @"google.c.fid", + @"google.c.sender.id", @"gcm.notification.sound" + ]]; + for (NSString *key in userInfo) { + if ([reservedKeys containsObject:key]) continue; + id val = userInfo[key]; + if ([val isKindOfClass:[NSString class]]) { + dataDict[String::utf8([key UTF8String])] = String::utf8([(NSString*)val UTF8String]); + } else if ([val isKindOfClass:[NSNumber class]]) { + dataDict[String::utf8([key UTF8String])] = String::utf8([[val stringValue] UTF8String]); + } + } + + Dictionary result; + result["title"] = String::utf8([title UTF8String]); + result["body"] = String::utf8([body UTF8String]); + result["data"] = dataDict; + return result; +} diff --git a/source/ios/firebase_messaging/firebase_messaging.gdip b/source/ios/firebase_messaging/firebase_messaging.gdip index 2208602..2b60f6e 100644 --- a/source/ios/firebase_messaging/firebase_messaging.gdip +++ b/source/ios/firebase_messaging/firebase_messaging.gdip @@ -10,7 +10,7 @@ deinitialization="uninitialize_godotx_firebase_messaging_module" linked=[] -embedded=["FirebaseMessaging.xcframework", "GoogleDataTransport.xcframework"] +embedded=["FirebaseMessaging.xcframework"] system=["UserNotifications.framework", "UIKit.framework", "Foundation.framework"] From ed684b4d70abba0cb75b268fc67eeed88ceef2de Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Mon, 27 Apr 2026 16:35:37 +0300 Subject: [PATCH 5/9] feat: add automated Firebase test harness to Main script and update plugin infrastructure --- scenes/view_stack/AnalyticsView.tscn | 49 ++ scripts/Main.gd | 591 +++++++----------- .../analytics/FirebaseAnalyticsPlugin.kt | 53 +- .../crashlytics/FirebaseCrashlyticsPlugin.kt | 4 + .../FirebaseRemoteConfigPlugin.kt | 68 +- .../Sources/godotx_firebase_analytics.h | 2 + .../Sources/godotx_firebase_analytics.mm | 42 +- .../Sources/godotx_firebase_crashlytics.mm | 2 + .../Sources/godotx_apn_delegate.mm | 24 +- .../Sources/godotx_firebase_messaging.h | 4 + .../Sources/godotx_firebase_messaging.mm | 57 +- .../Sources/godotx_firebase_remote_config.h | 5 +- .../Sources/godotx_firebase_remote_config.mm | 49 +- 13 files changed, 515 insertions(+), 435 deletions(-) diff --git a/scenes/view_stack/AnalyticsView.tscn b/scenes/view_stack/AnalyticsView.tscn index 0f797fc..5ba2f8b 100644 --- a/scenes/view_stack/AnalyticsView.tscn +++ b/scenes/view_stack/AnalyticsView.tscn @@ -88,3 +88,52 @@ theme_override_font_sizes/font_size = 28 text = "๐ŸŽฎ Log Level Start" script = ExtResource("1") +[node name="LogLevelEndButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ Log Level End" +script = ExtResource("1") + +[node name="LogEarnButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ’ฐ Log Earn Currency" +script = ExtResource("1") + +[node name="LogSpendButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ’ธ Log Spend Currency" +script = ExtResource("1") + +[node name="LogTutorialBeginButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“– Log Tutorial Begin" +script = ExtResource("1") + +[node name="LogTutorialCompleteButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐ŸŽ“ Log Tutorial Complete" +script = ExtResource("1") + +[node name="LogPostScoreButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ† Log Post Score" +script = ExtResource("1") + +[node name="LogUnlockAchievementButton" type="Button" parent="ScrollContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”“ Log Unlock Achievement" +script = ExtResource("1") + diff --git a/scripts/Main.gd b/scripts/Main.gd index 4a292fb..12b570e 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -1,7 +1,6 @@ extends Control - # Firebase Singletons var core: Object = null var analytics: Object = null @@ -34,6 +33,100 @@ const CRASHLYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/Crashlytics const MESSAGING_PATH := "VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" const REMOTE_CONFIG_PATH := "VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton" +# Action Registry for Test Harness +var ACTIONS := { + "Analytics": { + "LogEventButton": {"method": "log_event", "args": ["test_event", {"p1": "v1", "p2": 123}], "signal": "analytics_event_logged", "desc": "Logging event: test_event"}, + "LogScreenButton": {"method": "log_screen_view", "args": ["MainScene", "GodotSampleActivity"], "signal": "analytics_screen_logged", "desc": "Logging screen: MainScene"}, + "UserPropsButton": {"method": "set_user_property", "args": ["test_prop", "test_value"], "signal": "analytics_property_set", "desc": "Setting user property: test_prop = test_value"}, + "SetUserIdButton": {"method": "set_user_id", "args": ["player_123"], "signal": "analytics_user_id_set", "desc": "Setting User ID: player_123"}, + "SetDefaultParamsButton": {"method": "set_default_event_parameters", "args": [ {"app_version": "1.0.0"}], "signal": "analytics_default_params_set", "desc": "Setting default params: app_version=1.0.0"}, + "SetConsentButton": {"method": "set_consent", "args": [ {"analytics_storage": false}], "signal": "analytics_consent_set", "desc": "Setting Consent: analytics_storage=false"}, + "SetCollectionEnabledButton": {"method": "set_collection_enabled", "args": [false], "signal": "analytics_collection_enabled_set", "desc": "Toggling Collection Enabled: false"}, + "ResetDataButton": {"method": "reset_analytics_data", "args": [], "signal": "analytics_data_reset", "desc": "Resetting Analytics Data"}, + "LogLevelStartButton": {"method": "log_level_start", "args": ["level_1"], "signal": "analytics_event_logged", "desc": "Logging level_start: level_1"}, + "LogLevelEndButton": {"method": "log_level_end", "args": ["level_1", true], "signal": "analytics_event_logged", "desc": "Logging level_end: level_1 (Success)"}, + "LogEarnButton": {"method": "log_earn_currency", "args": ["gold", 100.0], "signal": "analytics_event_logged", "desc": "Logging earn_currency: 100 gold"}, + "LogSpendButton": {"method": "log_spend_currency", "args": ["gold", 50.0, "sword"], "signal": "analytics_event_logged", "desc": "Logging spend_currency: 50 gold for sword"}, + "LogTutorialBeginButton": {"method": "log_tutorial_begin", "args": [], "signal": "analytics_event_logged", "desc": "Logging tutorial_begin"}, + "LogTutorialCompleteButton": {"method": "log_tutorial_complete", "args": [], "signal": "analytics_event_logged", "desc": "Logging tutorial_complete"}, + "LogPostScoreButton": {"method": "log_post_score", "args": [5000, "hall_of_fame", "ninja"], "signal": "analytics_event_logged", "desc": "Logging post_score: 5000"}, + "LogUnlockAchievementButton": {"method": "log_unlock_achievement", "args": ["master_of_gemini"], "signal": "analytics_event_logged", "desc": "Logging unlock_achievement: master_of_gemini"} + }, + "Crashlytics": { + "FatalButton": {"method": "crash", "args": [], "mode": "manual", "desc": "!!! FORCING FATAL CRASH !!!"}, + "NonFatalButton": {"method": "log_non_fatal_exception", "args": ["This is a test non-fatal error"], "signal": "crashlytics_non_fatal_logged", "desc": "Logging non-fatal error"}, + "CustomValueButton": {"method": "set_custom_value_string", "args": ["test_key", "test_value"], "signal": "crashlytics_value_set", "desc": "Setting custom value"} + }, + "RemoteConfig": { + "FetchButton": {"method": "fetch_and_activate", "args": [], "signal": "remote_config_fetch_completed", "desc": "Fetching and Activating..."}, + "GetStringButton": { + "method": "get_string", + "args": ["welcome_message", "DEFAULT"], + "mode": "getter", + "desc": "Getting 'welcome_message'", + "validator": func(res): return typeof(res) == TYPE_STRING, + "failure_log": "Expected String, but received invalid type" + }, + "GetIntButton": { + "method": "get_int", + "args": ["min_version", -1], + "mode": "getter", + "desc": "Getting 'min_version'", + "validator": func(res): return typeof(res) == TYPE_INT, + "failure_log": "Expected Int, but received invalid type" + }, + "GetFloatButton": { + "method": "get_float", + "args": ["drop_rate", 0.0], + "mode": "getter", + "desc": "Getting 'drop_rate'", + "validator": func(res): return typeof(res) == TYPE_FLOAT, + "failure_log": "Expected Float, but received invalid type" + }, + "GetBoolButton": { + "method": "get_bool", + "args": ["feature_enabled", false], + "mode": "getter", + "desc": "Getting 'feature_enabled'", + "validator": func(res): return typeof(res) == TYPE_INT and (res == 0 or res == 1), + "failure_log": "Expected Int (1 or 0), but received invalid type" + }, + "GetDictButton": { + "method": "get_dictionary", + "args": ["game_config"], + "mode": "getter", + "desc": "Getting 'game_config'", + "validator": func(res): return typeof(res) == TYPE_DICTIONARY, + "failure_log": "Expected Dictionary, but received invalid type" + }, + "SetDefaultsButton": {"method": "set_defaults", "args": [ {"welcome_message": "Hello from Defaults!", "min_version": 10, "drop_rate": 0.05, "feature_enabled": true}], "signal": "remote_config_defaults_set", "desc": "Setting local defaults"}, + "SetIntervalButton": {"method": "set_minimum_fetch_interval", "args": [0.0], "signal": "remote_config_settings_updated", "desc": "Setting fetch interval to 0s (Dev Mode)"}, + "ListenerButton": { + "method": "setup_realtime_updates", + "args": [], + "mode": "getter", + "desc": "Enabling Real-time updates listener", + "validator": func(res): return res == true or res == 1, + "failure_log": "Failed to setup listener (method missing or returned false)" + } + }, + "Messaging": { + "GetTokenButton": {"method": "get_token", "args": [], "signal": "messaging_token_received", "desc": "Requesting FCM token..."}, + "PermissionButton": {"method": "request_permission", "args": [], "signal": "messaging_permission_granted", "desc": "Requesting permissions..."}, + "SubscribeButton": {"method": "subscribe_to_topic", "args": ["test_topic"], "signal": "messaging_topic_subscribed", "desc": "Subscribing to: test_topic"}, + "UnsubscribeButton": {"method": "unsubscribe_from_topic", "args": ["test_topic"], "signal": "messaging_topic_unsubscribed", "desc": "Unsubscribing from: test_topic"}, + "GetLastNotificationButton": { + "method": "get_last_notification", + "args": [], + "mode": "getter", + "desc": "Getting last notification...", + "validator": func(res): return typeof(res) == TYPE_DICTIONARY and not res.is_empty(), + "failure_log": "No previous notification data found" + } + } +} + # Tracks the module-view button currently awaiting an async signal, per module. # The harness only permits one in-flight call per module at a time. var _pending_call: Dictionary = { @@ -70,9 +163,9 @@ func _apply_safe_area() -> void: if has_node("VBoxContainer"): var vbox = $VBoxContainer vbox.offset_top = top_margin - vbox.offset_bottom = -bottom_margin + vbox.offset_bottom = - bottom_margin vbox.offset_left = left_margin - vbox.offset_right = -right_margin + vbox.offset_right = - right_margin func initialize_firebase_plugins() -> void: # Core @@ -88,9 +181,35 @@ func initialize_firebase_plugins() -> void: if Engine.has_singleton("GodotxFirebaseAnalytics"): analytics = Engine.get_singleton("GodotxFirebaseAnalytics") analytics.analytics_initialized.connect(_on_module_init_done.bind("Analytics")) - analytics.analytics_event_logged.connect(_on_analytics_event_logged) - analytics.analytics_screen_logged.connect(_on_analytics_screen_logged) - analytics.analytics_property_set.connect(_on_analytics_property_set) + + # Connect all async signals to generic success handler with validation + analytics.analytics_event_logged.connect(func(event_name): + var success = not event_name.is_empty() + if success: log_message("[Analytics] โœ“ Event logged: " + event_name) + else: log_message("[Analytics] โœ— Event log returned empty name") + _clear_pending("Analytics", success)) + + analytics.analytics_screen_logged.connect(func(screen_name): + var success = not screen_name.is_empty() + if success: log_message("[Analytics] โœ“ Screen logged: " + screen_name) + else: log_message("[Analytics] โœ— Screen log returned empty name") + _clear_pending("Analytics", success)) + + analytics.analytics_property_set.connect(func(prop_name): + var success = not prop_name.is_empty() + if success: log_message("[Analytics] โœ“ Property set: " + prop_name) + else: log_message("[Analytics] โœ— Property set returned empty name") + _clear_pending("Analytics", success)) + + analytics.analytics_user_id_set.connect(func(id): + # Note: User ID could intentionally be empty if resetting + log_message("[Analytics] โœ“ User ID set: " + id); _clear_pending("Analytics", true)) + + analytics.analytics_default_params_set.connect(func(): log_message("[Analytics] โœ“ Default params set"); _clear_pending("Analytics")) + analytics.analytics_collection_enabled_set.connect(func(enabled): log_message("[Analytics] โœ“ Collection enabled: " + str(enabled)); _clear_pending("Analytics")) + analytics.analytics_data_reset.connect(func(): log_message("[Analytics] โœ“ Analytics data reset"); _clear_pending("Analytics")) + analytics.analytics_consent_set.connect(func(): log_message("[Analytics] โœ“ Consent updated"); _clear_pending("Analytics")) + analytics.analytics_error.connect(_on_module_error.bind("Analytics")) log_message("โœ“ Firebase Analytics plugin found") else: @@ -100,9 +219,12 @@ func initialize_firebase_plugins() -> void: if Engine.has_singleton("GodotxFirebaseCrashlytics"): crashlytics = Engine.get_singleton("GodotxFirebaseCrashlytics") crashlytics.crashlytics_initialized.connect(_on_module_init_done.bind("Crashlytics")) - crashlytics.crashlytics_non_fatal_logged.connect(_on_crashlytics_non_fatal_logged) - crashlytics.crashlytics_message_logged.connect(_on_crashlytics_message_logged) - crashlytics.crashlytics_value_set.connect(_on_crashlytics_value_set) + + # Connect async signals + crashlytics.crashlytics_non_fatal_logged.connect(func(msg): log_message("[Crashlytics] โœ“ Non-fatal logged: " + msg); _clear_pending("Crashlytics")) + crashlytics.crashlytics_message_logged.connect(func(msg): log_message("[Crashlytics] โœ“ Message logged: " + msg); _clear_pending("Crashlytics")) + crashlytics.crashlytics_value_set.connect(func(key): log_message("[Crashlytics] โœ“ Value set for: " + key); _clear_pending("Crashlytics")) + crashlytics.crashlytics_error.connect(_on_module_error.bind("Crashlytics")) log_message("โœ“ Firebase Crashlytics plugin found") else: @@ -129,8 +251,17 @@ func initialize_firebase_plugins() -> void: if Engine.has_singleton("GodotxFirebaseRemoteConfig"): remote_config = Engine.get_singleton("GodotxFirebaseRemoteConfig") remote_config.remote_config_initialized.connect(_on_module_init_done.bind("RemoteConfig")) - remote_config.fetch_completed.connect(_on_rc_fetch_completed) - remote_config.config_updated.connect(_on_config_updated) + + # Connect async signals (with validation where needed) + remote_config.remote_config_fetch_completed.connect(func(status): + var _status_map = {0: "SUCCESS", 1: "CACHED", 2: "FAILURE", 3: "THROTTLED"} + log_message("[Remote Config] Fetch result: " + _status_map.get(status, "UNKNOWN")) + _clear_pending("RemoteConfig", status == 0 or status == 1) + ) + remote_config.remote_config_defaults_set.connect(func(): _clear_pending("RemoteConfig")) + remote_config.remote_config_settings_updated.connect(func(): _clear_pending("RemoteConfig")) + remote_config.remote_config_updated.connect(_on_config_updated) + remote_config.remote_config_error.connect(_on_module_error.bind("RemoteConfig")) log_message("โœ“ Firebase Remote Config plugin found") else: @@ -191,7 +322,7 @@ func enable_service_buttons(enabled: bool) -> void: func _module_btn_path(module_name: String, btn_name: String) -> String: var base_path = "VBoxContainer/ContextGroup/ModuleContainer/" + module_name.replace(" ", "") + "View/" - if module_name == "Remote Config": + if module_name in ["Remote Config", "RemoteConfig"]: return base_path + "List/" + btn_name elif module_name == "Analytics": return base_path + "ScrollContainer/List/" + btn_name @@ -200,43 +331,73 @@ func _module_btn_path(module_name: String, btn_name: String) -> String: func _connect_module_buttons(module_name: String, instance: Node) -> void: if module_name == "Analytics": var list = instance.get_node("ScrollContainer/List") - _connect_btn(list, "LogEventButton", _on_log_event_pressed) - _connect_btn(list, "LogScreenButton", _on_log_screen_pressed) - _connect_btn(list, "UserPropsButton", _on_set_user_property_pressed) - _connect_btn(list, "SetUserIdButton", _on_set_user_id_pressed) - _connect_btn(list, "SetDefaultParamsButton", _on_set_default_params_pressed) - _connect_btn(list, "SetConsentButton", _on_set_consent_pressed) - _connect_btn(list, "SetCollectionEnabledButton", _on_set_collection_enabled_pressed) - _connect_btn(list, "ResetDataButton", _on_reset_data_pressed) - _connect_btn(list, "LogLevelStartButton", _on_log_level_start_pressed) + for btn_name in ACTIONS["Analytics"].keys(): + _connect_btn(list, btn_name, _run_action.bind("Analytics", btn_name)) elif module_name == "Messaging": - _connect_btn(instance, "GetTokenButton", _on_get_token_pressed) - _connect_btn(instance, "PermissionButton", _on_request_messaging_permission_pressed) - _connect_btn(instance, "SubscribeButton", _on_subscribe_topic_pressed) - _connect_btn(instance, "UnsubscribeButton", _on_unsubscribe_topic_pressed) - _connect_btn(instance, "GetLastNotificationButton", _on_get_last_notification_pressed) + for btn_name in ACTIONS["Messaging"].keys(): + _connect_btn(instance, btn_name, _run_action.bind("Messaging", btn_name)) _update_messaging_view_state(instance) elif module_name == "Crashlytics": - _connect_btn(instance, "FatalButton", _on_crash_pressed) - _connect_btn(instance, "NonFatalButton", _on_non_fatal_pressed) - _connect_btn(instance, "CustomValueButton", _on_set_custom_value_pressed) + for btn_name in ACTIONS["Crashlytics"].keys(): + _connect_btn(instance, btn_name, _run_action.bind("Crashlytics", btn_name)) elif module_name == "Remote Config": - # Note: Buttons are inside the 'List' child of the ScrollContainer var list = instance.get_node("List") - _connect_btn(list, "FetchButton", _on_rc_fetch_pressed) - _connect_btn(list, "GetStringButton", _on_rc_get_string_pressed) - _connect_btn(list, "GetIntButton", _on_rc_get_int_pressed) - _connect_btn(list, "GetFloatButton", _on_rc_get_float_pressed) - _connect_btn(list, "GetBoolButton", _on_rc_get_bool_pressed) - _connect_btn(list, "GetDictButton", _on_rc_get_dict_pressed) - _connect_btn(list, "SetDefaultsButton", _on_rc_set_defaults_pressed) - _connect_btn(list, "SetIntervalButton", _on_rc_set_interval_pressed) - _connect_btn(list, "ListenerButton", _on_rc_listener_toggle_pressed) + for btn_name in ACTIONS["RemoteConfig"].keys(): + _connect_btn(list, btn_name, _run_action.bind("RemoteConfig", btn_name)) func _connect_btn(instance: Node, btn_name: String, method: Callable) -> void: var btn = instance.get_node_or_null(btn_name) if btn: btn.pressed.connect(method) +# ============== ACTION RUNNER ============== + +func _run_action(module_name: String, action_id: String) -> void: + var log_name = "Remote Config" if module_name == "RemoteConfig" else module_name + var config: Dictionary = ACTIONS.get(module_name, {}).get(action_id, {}) + if config.is_empty(): + log_message("[System] Error: No config for %s:%s" % [module_name, action_id]) + return + + var plugin = null + match module_name: + "Analytics": plugin = analytics + "Crashlytics": plugin = crashlytics + "Messaging": plugin = messaging + "RemoteConfig": plugin = remote_config + + var btn_path = _module_btn_path(module_name, action_id) + if not plugin: + log_message("[%s] Plugin not available" % log_name) + flash_status(btn_path, TestButton.Status.FAILURE) + return + + log_message("\n[%s] %s" % [log_name, config.get("desc", "Running...")]) + flash_status(btn_path, TestButton.Status.PENDING) + + # Mark as pending for async signals + if config.get("signal", "") != "": + _pending_call[module_name] = btn_path + + # Execute the call + var method = config["method"] + var args = config.get("args", []) + var result = plugin.callv(method, args) + + # If it's a getter, log the result and validate + if config.get("mode", "") == "getter": + var _key_prefix = "'%s' = " % args[0] if args.size() > 0 and typeof(args[0]) == TYPE_STRING else "" + log_message("[%s] %s%s" % [log_name, _key_prefix, str(result)]) + var is_valid = true + if config.has("validator"): + is_valid = config["validator"].call(result) + if not is_valid and config.has("failure_log"): + log_message("[%s] โœ— %s" % [log_name, config["failure_log"]]) + flash_status(btn_path, TestButton.Status.SUCCESS if is_valid else TestButton.Status.FAILURE) + + # If it's a sync call (no signal and not manual mode), set success immediately + if config.get("signal", "") == "" and config.get("mode", "") != "manual" and config.get("mode", "") != "getter": + flash_status(btn_path, TestButton.Status.SUCCESS) + # ============== CORE ============== func _on_initialize_pressed() -> void: @@ -294,165 +455,15 @@ func _on_module_init_done(success: bool, module_name: String) -> void: log_message("[%s] โœ— Initialization failed" % module_name) if module_btn: module_btn.disabled = true -# ============== ANALYTICS ============== - -func _on_log_event_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "LogEventButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Logging event: test_event") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Analytics"] = btn_path - analytics.log_event("test_event", {"p1": "v1", "p2": 123}) - -func _on_log_screen_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "LogScreenButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Logging screen: MainScene") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Analytics"] = btn_path - analytics.log_screen_view("MainScene", "GodotSampleActivity") - -func _on_analytics_event_logged(event_name: String) -> void: - log_message("[Analytics] โœ“ Event logged: " + event_name) - _clear_pending("Analytics") +# (Analytics Handlers removed - now using _run_action) -func _on_analytics_screen_logged(screen_name: String) -> void: - log_message("[Analytics] โœ“ Screen logged: " + screen_name) - _clear_pending("Analytics") - -func _on_set_user_property_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "UserPropsButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Setting user property: test_prop = test_value") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Analytics"] = btn_path - analytics.set_user_property("test_prop", "test_value") - -func _on_analytics_property_set(prop_name: String) -> void: - log_message("[Analytics] โœ“ Property set: " + prop_name) - _clear_pending("Analytics") - -func _on_set_user_id_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "SetUserIdButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Setting User ID: player_123") - flash_status(btn_path, TestButton.Status.SUCCESS) - analytics.set_user_id("player_123") - -func _on_set_default_params_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "SetDefaultParamsButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Setting default params: app_version=1.0.0") - flash_status(btn_path, TestButton.Status.SUCCESS) - analytics.set_default_event_parameters({"app_version": "1.0.0"}) - -func _on_set_consent_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "SetConsentButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Setting Consent: analytics_storage=false") - flash_status(btn_path, TestButton.Status.SUCCESS) - analytics.set_consent({"analytics_storage": false}) - -func _on_set_collection_enabled_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "SetCollectionEnabledButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Toggling Collection Enabled: false") - flash_status(btn_path, TestButton.Status.SUCCESS) - analytics.set_collection_enabled(false) - -func _on_reset_data_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "ResetDataButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Resetting Analytics Data") - flash_status(btn_path, TestButton.Status.SUCCESS) - analytics.reset_analytics_data() - -func _on_log_level_start_pressed() -> void: - var btn_path = _module_btn_path("Analytics", "LogLevelStartButton") - if not analytics: - log_message("[Analytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Analytics] Logging level_start: level_1") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Analytics"] = btn_path - analytics.log_level_start("level_1") - -# ============== MESSAGING ============== - -func _on_get_token_pressed() -> void: - var btn_path = _module_btn_path("Messaging", "GetTokenButton") - if not messaging: - log_message("[Messaging] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Messaging] Requesting FCM token...") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Messaging"] = btn_path - messaging.get_token() - -func _on_request_messaging_permission_pressed() -> void: - var btn_path = _module_btn_path("Messaging", "PermissionButton") - if not messaging: - log_message("[Messaging] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Messaging] Requesting permissions...") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Messaging"] = btn_path - messaging.request_permission() - -func _on_subscribe_topic_pressed() -> void: - var btn_path = _module_btn_path("Messaging", "SubscribeButton") - if not messaging: - log_message("[Messaging] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Messaging] Subscribing to: test_topic") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Messaging"] = btn_path - messaging.subscribe_to_topic("test_topic") - -func _on_unsubscribe_topic_pressed() -> void: - var btn_path = _module_btn_path("Messaging", "UnsubscribeButton") - if not messaging: - log_message("[Messaging] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Messaging] Unsubscribing from: test_topic") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Messaging"] = btn_path - messaging.unsubscribe_from_topic("test_topic") +# (Messaging pressed handlers removed - now using _run_action) func _on_messaging_permission_granted() -> void: log_message("[Messaging] โœ“ Permission granted") _messaging_permission_granted = true _clear_pending("Messaging") - + var view = module_container.get_node_or_null("MessagingView") if view: _update_messaging_view_state(view) @@ -473,20 +484,22 @@ func _on_messaging_topic_unsubscribed(topic: String) -> void: _clear_pending("Messaging") func _on_messaging_token_received(token: String) -> void: + if token.is_empty(): + log_message("[Messaging] โœ— Token received but it is EMPTY") + _clear_pending("Messaging", false) + return + _fcm_token = token log_message("[Messaging] Token received: " + token) - _clear_pending("Messaging") + _clear_pending("Messaging", true) var view = module_container.get_node_or_null("MessagingView") if view: _update_messaging_view_state(view) -func _on_messaging_apn_token_received(token: String) -> void: +func _on_messaging_apn_token_received(_token: String) -> void: _apns_ready = true log_message("[Messaging] APNs Token received (Ready for FCM)") - # If we already have permissions, we can now fetch FCM token - if _messaging_permission_granted and messaging: - messaging.get_token() func _update_messaging_view_state(view: Node) -> void: var has_token = !_fcm_token.is_empty() @@ -515,69 +528,11 @@ func _on_messaging_message_received(title: String, body: String, data: Dictionar log_message("[Messaging] Data payload: " + str(data)) func _on_get_last_notification_pressed() -> void: - var btn_path = _module_btn_path("Messaging", "GetLastNotificationButton") - if not messaging: - log_message("[Messaging] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Messaging] Getting last notification...") - flash_status(btn_path, TestButton.Status.PENDING) - var data = messaging.get_last_notification() - if typeof(data) == TYPE_DICTIONARY and not data.is_empty(): - log_message("[Messaging] โœ“ Last notification data: " + str(data)) - flash_status(btn_path, TestButton.Status.SUCCESS) - else: - log_message("[Messaging] โœ— No previous notification data found") - flash_status(btn_path, TestButton.Status.FAILURE) - -# ============== CRASHLYTICS ============== - -func _on_crash_pressed() -> void: - var btn_path = _module_btn_path("Crashlytics", "FatalButton") - if not crashlytics: - log_message("[Crashlytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Crashlytics] !!! FORCING FATAL CRASH !!!") - flash_status(btn_path, TestButton.Status.PENDING) - # If the crash truly propagates, the app terminates and the yellow state is lost. - # If the exception is caught by Godot's dispatcher, the button stays yellow โ€” a - # visible hint that the crash did not actually take down the process. - crashlytics.crash() - -func _on_non_fatal_pressed() -> void: - var btn_path = _module_btn_path("Crashlytics", "NonFatalButton") - if not crashlytics: - log_message("[Crashlytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Crashlytics] Logging non-fatal error") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["Crashlytics"] = btn_path - crashlytics.log_non_fatal_exception("This is a test non-fatal error") - -func _on_set_custom_value_pressed() -> void: - var btn_path = _module_btn_path("Crashlytics", "CustomValueButton") - if not crashlytics: - log_message("[Crashlytics] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Crashlytics] Setting custom value") - flash_status(btn_path, TestButton.Status.PENDING) - crashlytics.set_custom_value_string("test_key", "test_value") - _pending_call["Crashlytics"] = btn_path + # Keep this as a separate handler if it needs complex return logic, + # but for now we've moved the basic call to _run_action. + pass -func _on_crashlytics_non_fatal_logged(message: String) -> void: - log_message("[Crashlytics] โœ“ Non-fatal logged: " + message) - _clear_pending("Crashlytics") - -func _on_crashlytics_message_logged(message: String) -> void: - log_message("[Crashlytics] โœ“ Message logged: " + message) - _clear_pending("Crashlytics") - -func _on_crashlytics_value_set(key: String) -> void: - log_message("[Crashlytics] โœ“ Value set for: " + key) - _clear_pending("Crashlytics") +# (Crashlytics Handlers removed - now using _run_action) # ============== ERRORS ============== @@ -591,10 +546,10 @@ func _on_module_error(message: String, module_name: String) -> void: flash_status(path, TestButton.Status.FAILURE) _pending_call[module_name] = "" -func _clear_pending(module_name: String) -> void: +func _clear_pending(module_name: String, success: bool = true) -> void: var path: String = _pending_call.get(module_name, "") if path != "": - flash_status(path, TestButton.Status.SUCCESS) + flash_status(path, TestButton.Status.SUCCESS if success else TestButton.Status.FAILURE) _pending_call[module_name] = "" # ============== LOG CONTROLS ============== @@ -609,119 +564,7 @@ func _on_copy_log_pressed() -> void: log_message("[System] Log copied to clipboard") -# ============== REMOTE CONFIG ============== - -func _on_rc_fetch_pressed() -> void: - var btn_path = _module_btn_path("Remote Config", "FetchButton") - if not remote_config: - log_message("[Remote Config] Plugin not available") - flash_status(btn_path, TestButton.Status.FAILURE) - return - log_message("\n[Remote Config] Fetching and Activating...") - flash_status(btn_path, TestButton.Status.PENDING) - _pending_call["RemoteConfig"] = btn_path - remote_config.fetch_and_activate() - -func _on_rc_fetch_completed(status: int) -> void: - var status_str = "UNKNOWN" - match status: - 0: status_str = "SUCCESS" - 1: status_str = "CACHED" - 2: status_str = "FAILURE" - 3: status_str = "THROTTLED" - log_message("[Remote Config] โœ“ Fetch result: " + status_str) - if status == 0 or status == 1: - _clear_pending("RemoteConfig") - else: - var path: String = _pending_call.get("RemoteConfig", "") - if path != "": - flash_status(path, TestButton.Status.FAILURE) - _pending_call["RemoteConfig"] = "" - -func _on_rc_get_string_pressed() -> void: - var path = _module_btn_path("Remote Config", "GetStringButton") - if remote_config: - var val = remote_config.get_string("welcome_message", "DEFAULT_VALUE") - log_message("[Remote Config] 'welcome_message' = " + str(val)) - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) - -func _on_rc_get_int_pressed() -> void: - var path = _module_btn_path("Remote Config", "GetIntButton") - if remote_config: - var val = remote_config.get_int("min_version", -1) - log_message("[Remote Config] 'min_version' = " + str(val)) - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) - -func _on_rc_get_float_pressed() -> void: - var path = _module_btn_path("Remote Config", "GetFloatButton") - if remote_config: - var val = remote_config.get_float("drop_rate", 0.0) - log_message("[Remote Config] 'drop_rate' = " + str(val)) - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) - -func _on_rc_get_bool_pressed() -> void: - var path = _module_btn_path("Remote Config", "GetBoolButton") - if remote_config: - var val = remote_config.get_bool("feature_enabled", false) - log_message("[Remote Config] 'feature_enabled' = " + str(val)) - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) - -func _on_rc_get_dict_pressed() -> void: - var path = _module_btn_path("Remote Config", "GetDictButton") - if remote_config: - var val = remote_config.get_dictionary("game_config") - log_message("[Remote Config] 'game_config' = " + str(val)) - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) - -func _on_rc_set_defaults_pressed() -> void: - var path = _module_btn_path("Remote Config", "SetDefaultsButton") - if remote_config: - var defaults = { - "welcome_message": "Hello from Defaults!", - "min_version": 10, - "drop_rate": 0.05, - "feature_enabled": true - } - remote_config.set_defaults(defaults) - log_message("[Remote Config] Local defaults set") - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) - -func _on_rc_set_interval_pressed() -> void: - var path = _module_btn_path("Remote Config", "SetIntervalButton") - if remote_config: - remote_config.set_minimum_fetch_interval(0.0) - log_message("[Remote Config] Fetch interval set to 0s (Dev Mode)") - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) - -func _on_rc_listener_toggle_pressed() -> void: - var path = _module_btn_path("Remote Config", "ListenerButton") - if remote_config: - log_message("[Remote Config] Real-time updates toggle requested (Native log only)") - flash_status(path, TestButton.Status.SUCCESS) - else: - log_message("[Remote Config] Plugin not available") - flash_status(path, TestButton.Status.FAILURE) +# (Remote Config Handlers removed - now using _run_action) func _on_config_updated(keys: Array) -> void: log_message("[Remote Config] ๐Ÿ“ก Config updated: " + str(keys)) diff --git a/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt b/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt index a5b7b0b..46bfb33 100644 --- a/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt +++ b/source/android/firebase_analytics/src/main/java/com/godotx/firebase/analytics/FirebaseAnalyticsPlugin.kt @@ -44,6 +44,23 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { "analytics_property_set", String::class.java ), + SignalInfo( + "analytics_user_id_set", + String::class.java + ), + SignalInfo( + "analytics_default_params_set" + ), + SignalInfo( + "analytics_collection_enabled_set", + Boolean::class.javaObjectType + ), + SignalInfo( + "analytics_data_reset" + ), + SignalInfo( + "analytics_consent_set" + ), SignalInfo( "analytics_error", String::class.java @@ -126,8 +143,10 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { analytics.setUserId(user_id) Log.d(TAG, "User ID set") + emitSignal("analytics_user_id_set", user_id ?: "") } catch (e: Exception) { Log.e(TAG, "Failed to set User ID", e) + emitSignal("analytics_error", e.message ?: "user_id_error") } } @@ -137,8 +156,10 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { analytics.setUserProperty(name, value) Log.d(TAG, "User property set: $name = $value") + emitSignal("analytics_property_set", name) } catch (e: Exception) { Log.e(TAG, "Failed to set user property", e) + emitSignal("analytics_error", e.message ?: "user_property_error") } } @@ -151,8 +172,10 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { bundle.putString(FirebaseAnalytics.Param.SCREEN_CLASS, screen_class) analytics.logEvent(FirebaseAnalytics.Event.SCREEN_VIEW, bundle) Log.d(TAG, "Screen view logged: $screen_name ($screen_class)") + emitSignal("analytics_screen_logged", screen_name) } catch (e: Exception) { Log.e(TAG, "Failed to log screen view", e) + emitSignal("analytics_error", e.message ?: "screen_view_error") } } @@ -163,8 +186,10 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { val bundle = dictionaryToBundle(params) analytics.setDefaultEventParameters(bundle) Log.d(TAG, "Default event parameters set") + emitSignal("analytics_default_params_set") } catch (e: Exception) { Log.e(TAG, "Failed to set default event parameters", e) + emitSignal("analytics_error", e.message ?: "default_params_error") } } @@ -174,8 +199,10 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { analytics.setAnalyticsCollectionEnabled(enabled) Log.d(TAG, "Analytics collection enabled: $enabled") + emitSignal("analytics_collection_enabled_set", enabled) } catch (e: Exception) { Log.e(TAG, "Failed to set collection enabled", e) + emitSignal("analytics_error", e.message ?: "collection_enabled_error") } } @@ -185,8 +212,10 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { analytics.resetAnalyticsData() Log.d(TAG, "Analytics data reset") + emitSignal("analytics_data_reset") } catch (e: Exception) { Log.e(TAG, "Failed to reset analytics data", e) + emitSignal("analytics_error", e.message ?: "reset_data_error") } } @@ -218,8 +247,10 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { analytics.setConsent(consentMap) Log.d(TAG, "Analytics consent set: $consentMap") + emitSignal("analytics_consent_set") } catch (e: Exception) { Log.e(TAG, "Failed to set consent", e) + emitSignal("analytics_error", e.message ?: "consent_error") } } @@ -234,7 +265,7 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { fun log_level_end(level_name: String, success: Boolean) { val params = Dictionary() params[FirebaseAnalytics.Param.LEVEL_NAME] = level_name - params[FirebaseAnalytics.Param.SUCCESS] = if (success) "1" else "0" + params[FirebaseAnalytics.Param.SUCCESS] = if (success) 1 else 0 log_event(FirebaseAnalytics.Event.LEVEL_END, params) } @@ -264,5 +295,25 @@ class FirebaseAnalyticsPlugin(godot: Godot) : GodotPlugin(godot) { fun log_tutorial_complete() { log_event(FirebaseAnalytics.Event.TUTORIAL_COMPLETE, Dictionary()) } + + @UsedByGodot + fun log_post_score(score: Long, board: String, character: String) { + val params = Dictionary() + params[FirebaseAnalytics.Param.SCORE] = score + if (board.isNotEmpty()) { + params[FirebaseAnalytics.Param.LEVEL_NAME] = board + } + if (character.isNotEmpty()) { + params[FirebaseAnalytics.Param.CHARACTER] = character + } + log_event(FirebaseAnalytics.Event.POST_SCORE, params) + } + + @UsedByGodot + fun log_unlock_achievement(id: String) { + val params = Dictionary() + params[FirebaseAnalytics.Param.ACHIEVEMENT_ID] = id + log_event(FirebaseAnalytics.Event.UNLOCK_ACHIEVEMENT, params) + } } diff --git a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt index f80060b..37b30a1 100644 --- a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt +++ b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt @@ -37,6 +37,9 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { SignalInfo("crashlytics_value_set", String::class.java ), + SignalInfo("crashlytics_user_id_set", + String::class.java + ), SignalInfo("crashlytics_error", String::class.java ) @@ -113,6 +116,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { try { crashlyticsInstance.setUserId(user_id) Log.d(TAG, "Set user ID: $user_id") + emitSignal("crashlytics_user_id_set", user_id) } catch (e: Exception) { Log.e(TAG, "Failed to set user ID", e) emitSignal("crashlytics_error", e.message ?: "set_user_error") diff --git a/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt b/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt index a7cb451..da47507 100644 --- a/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt +++ b/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt @@ -37,6 +37,11 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { Log.v(TAG, "Firebase Remote Config plugin loaded") } + private fun isInitialized(): Boolean { + val ctx = activity ?: return false + return FirebaseApp.getApps(ctx).isNotEmpty() + } + override fun getPluginName(): String { return "GodotxFirebaseRemoteConfig" } @@ -45,8 +50,10 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { return setOf( SignalInfo("remote_config_initialized", Boolean::class.javaObjectType), SignalInfo("remote_config_error", String::class.java), - SignalInfo("fetch_completed", Int::class.javaObjectType), - SignalInfo("config_updated", Array::class.java) + SignalInfo("remote_config_fetch_completed", Int::class.javaObjectType), + SignalInfo("remote_config_updated", Array::class.java), + SignalInfo("remote_config_defaults_set"), + SignalInfo("remote_config_settings_updated") ) } @@ -67,19 +74,22 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { return } - setupRealtimeUpdates() + setup_realtime_updates() Log.d(TAG, "Firebase Remote Config initialized") emitSignal("remote_config_initialized", true) } - private fun setupRealtimeUpdates() { + @UsedByGodot + fun setup_realtime_updates(): Boolean { + if (!isInitialized()) return false + if (listenerRegistration != null) return true listenerRegistration = remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { override fun onUpdate(configUpdate: ConfigUpdate) { Log.d(TAG, "Config updated keys: " + configUpdate.updatedKeys) remoteConfig.activate().addOnCompleteListener { task -> if (task.isSuccessful) { val updatedKeysArray = configUpdate.updatedKeys.toTypedArray() - emitSignal("config_updated", updatedKeysArray as Any) + emitSignal("remote_config_updated", updatedKeysArray as Any) } } } @@ -89,24 +99,27 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { emitSignal("remote_config_error", error.message ?: "config_update_error") } }) + return true } @UsedByGodot fun fetch_and_activate() { + if (!isInitialized()) return remoteConfig.fetchAndActivate().addOnCompleteListener { task -> if (task.isSuccessful) { val status = if (task.result) FETCH_SUCCESS else FETCH_CACHED - emitSignal("fetch_completed", status) + emitSignal("remote_config_fetch_completed", status) } else { val status = if (task.exception is FirebaseRemoteConfigFetchThrottledException) FETCH_THROTTLED else FETCH_FAILURE - emitSignal("fetch_completed", status) + emitSignal("remote_config_fetch_completed", status) } } } @UsedByGodot fun get_string(key: String, defaultValue: String): String { + if (!isInitialized()) return defaultValue val value = remoteConfig.getValue(key) return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue else value.asString() @@ -114,6 +127,7 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { @UsedByGodot fun get_int(key: String, defaultValue: Int): Int { + if (!isInitialized()) return defaultValue val value = remoteConfig.getValue(key) return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue else value.asLong().toInt() @@ -121,22 +135,34 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { @UsedByGodot fun get_float(key: String, defaultValue: Float): Float { + if (!isInitialized()) return defaultValue val value = remoteConfig.getValue(key) return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue else value.asDouble().toFloat() } @UsedByGodot - fun get_bool(key: String, defaultValue: Boolean): Boolean { + fun get_double(key: String, defaultValue: Double): Double { + if (!isInitialized()) return defaultValue val value = remoteConfig.getValue(key) return if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue - else value.asBoolean() + else value.asDouble() } @UsedByGodot - fun get_dictionary(key: String): Dictionary { + fun get_bool(key: String, defaultValue: Boolean): Int { + if (!isInitialized()) return if (defaultValue) 1 else 0 val value = remoteConfig.getValue(key) + val boolVal = if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) defaultValue + else value.asBoolean() + return if (boolVal) 1 else 0 + } + + @UsedByGodot + fun get_dictionary(key: String): Dictionary { val dict = Dictionary() + if (!isInitialized()) return dict + val value = remoteConfig.getValue(key) if (value.source == FirebaseRemoteConfig.VALUE_SOURCE_STATIC) return dict return try { @@ -149,20 +175,38 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { @UsedByGodot fun set_defaults(defaults: Dictionary) { + if (!isInitialized()) return val map = mutableMapOf() for (key in defaults.keys) { val value = defaults[key] if (value != null) map[key] = value } - remoteConfig.setDefaultsAsync(map) + remoteConfig.setDefaultsAsync(map).addOnCompleteListener { task -> + if (task.isSuccessful) { + Log.d(TAG, "Default config parameters set") + emitSignal("remote_config_defaults_set") + } else { + Log.e(TAG, "Failed to set default config parameters", task.exception) + emitSignal("remote_config_error", task.exception?.message ?: "set_defaults_error") + } + } } @UsedByGodot fun set_minimum_fetch_interval(seconds: Float) { + if (!isInitialized()) return val settings = FirebaseRemoteConfigSettings.Builder() .setMinimumFetchIntervalInSeconds(seconds.toLong()) .build() - remoteConfig.setConfigSettingsAsync(settings) + remoteConfig.setConfigSettingsAsync(settings).addOnCompleteListener { task -> + if (task.isSuccessful) { + Log.d(TAG, "Remote Config settings updated") + emitSignal("remote_config_settings_updated") + } else { + Log.e(TAG, "Failed to update Remote Config settings", task.exception) + emitSignal("remote_config_error", task.exception?.message ?: "update_settings_error") + } + } } @UsedByGodot diff --git a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h index d386d80..45f5d7d 100644 --- a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h +++ b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h @@ -31,6 +31,8 @@ class GodotxFirebaseAnalytics : public Object { void log_spend_currency(String currency_name, float value, String item_name); void log_tutorial_begin(); void log_tutorial_complete(); + void log_post_score(int64_t score, String board, String character); + void log_unlock_achievement(String achievement_id); GodotxFirebaseAnalytics(); ~GodotxFirebaseAnalytics(); diff --git a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm index 5e53f2f..fc662c0 100644 --- a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm +++ b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm @@ -24,11 +24,18 @@ ClassDB::bind_method(D_METHOD("log_spend_currency", "currency_name", "value", "item_name"), &GodotxFirebaseAnalytics::log_spend_currency); ClassDB::bind_method(D_METHOD("log_tutorial_begin"), &GodotxFirebaseAnalytics::log_tutorial_begin); ClassDB::bind_method(D_METHOD("log_tutorial_complete"), &GodotxFirebaseAnalytics::log_tutorial_complete); + ClassDB::bind_method(D_METHOD("log_post_score", "score", "board", "character"), &GodotxFirebaseAnalytics::log_post_score, DEFVAL(""), DEFVAL("")); + ClassDB::bind_method(D_METHOD("log_unlock_achievement", "id"), &GodotxFirebaseAnalytics::log_unlock_achievement); ADD_SIGNAL(MethodInfo("analytics_initialized", PropertyInfo(Variant::BOOL, "success"))); ADD_SIGNAL(MethodInfo("analytics_event_logged", PropertyInfo(Variant::STRING, "event_name"))); ADD_SIGNAL(MethodInfo("analytics_screen_logged", PropertyInfo(Variant::STRING, "screen_name"))); ADD_SIGNAL(MethodInfo("analytics_property_set", PropertyInfo(Variant::STRING, "name"))); + ADD_SIGNAL(MethodInfo("analytics_user_id_set", PropertyInfo(Variant::STRING, "user_id"))); + ADD_SIGNAL(MethodInfo("analytics_default_params_set")); + ADD_SIGNAL(MethodInfo("analytics_collection_enabled_set", PropertyInfo(Variant::BOOL, "enabled"))); + ADD_SIGNAL(MethodInfo("analytics_data_reset")); + ADD_SIGNAL(MethodInfo("analytics_consent_set")); ADD_SIGNAL(MethodInfo("analytics_error", PropertyInfo(Variant::STRING, "message"))); } @@ -108,6 +115,7 @@ @try { NSString* nsUserId = [NSString stringWithUTF8String:user_id.utf8().get_data()]; [FIRAnalytics setUserID:nsUserId]; + emit_signal("analytics_user_id_set", user_id); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseAnalytics] Failed to set user id: %@", exception.reason); @@ -120,6 +128,7 @@ @try { NSDictionary* nsParams = dictionary_to_nsdict(params); [FIRAnalytics setDefaultEventParameters:nsParams]; + emit_signal("analytics_default_params_set"); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseAnalytics] Failed to set default parameters: %@", exception.reason); @@ -131,6 +140,7 @@ NSLog(@"[GodotxFirebaseAnalytics] set_collection_enabled: %d", enabled); @try { [FIRAnalytics setAnalyticsCollectionEnabled:enabled]; + emit_signal("analytics_collection_enabled_set", enabled); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseAnalytics] Failed to set collection enabled: %@", exception.reason); @@ -142,6 +152,7 @@ NSLog(@"[GodotxFirebaseAnalytics] reset_analytics_data"); @try { [FIRAnalytics resetAnalyticsData]; + emit_signal("analytics_data_reset"); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseAnalytics] Failed to reset analytics data: %@", exception.reason); @@ -152,29 +163,30 @@ void GodotxFirebaseAnalytics::set_consent(Dictionary consent_data) { NSLog(@"[GodotxFirebaseAnalytics] set_consent"); @try { - NSMutableDictionary *consentMap = [NSMutableDictionary dictionary]; + NSMutableDictionary *consentMap = [NSMutableDictionary dictionary]; if (consent_data.has("ad_storage") && consent_data["ad_storage"].get_type() == Variant::BOOL) { bool val = consent_data["ad_storage"]; - consentMap[FIRConsentTypeAdStorage] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + consentMap[FIRConsentTypeAdStorage] = val ? FIRConsentStatusGranted : FIRConsentStatusDenied; } if (consent_data.has("analytics_storage") && consent_data["analytics_storage"].get_type() == Variant::BOOL) { bool val = consent_data["analytics_storage"]; - consentMap[FIRConsentTypeAnalyticsStorage] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + consentMap[FIRConsentTypeAnalyticsStorage] = val ? FIRConsentStatusGranted : FIRConsentStatusDenied; } if (consent_data.has("ad_user_data") && consent_data["ad_user_data"].get_type() == Variant::BOOL) { bool val = consent_data["ad_user_data"]; - consentMap[FIRConsentTypeAdUserData] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + consentMap[FIRConsentTypeAdUserData] = val ? FIRConsentStatusGranted : FIRConsentStatusDenied; } if (consent_data.has("ad_personalization") && consent_data["ad_personalization"].get_type() == Variant::BOOL) { bool val = consent_data["ad_personalization"]; - consentMap[FIRConsentTypeAdPersonalization] = @(val ? FIRConsentStatusGranted : FIRConsentStatusDenied); + consentMap[FIRConsentTypeAdPersonalization] = val ? FIRConsentStatusGranted : FIRConsentStatusDenied; } [FIRAnalytics setConsent:consentMap]; + emit_signal("analytics_consent_set"); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseAnalytics] Failed to set consent: %@", exception.reason); @@ -191,7 +203,7 @@ void GodotxFirebaseAnalytics::log_level_end(String level_name, bool success) { Dictionary params; params["level_name"] = level_name; - params["success"] = success ? "1" : "0"; + params["success"] = success ? 1 : 0; log_event(String(kFIREventLevelEnd.UTF8String), params); } @@ -218,6 +230,24 @@ log_event(String(kFIREventTutorialComplete.UTF8String), Dictionary()); } +void GodotxFirebaseAnalytics::log_post_score(int64_t score, String board, String character) { + Dictionary params; + params[String(kFIRParameterScore.UTF8String)] = score; + if (!board.is_empty()) { + params[String(kFIRParameterLevelName.UTF8String)] = board; + } + if (!character.is_empty()) { + params[String(kFIRParameterCharacter.UTF8String)] = character; + } + log_event(String(kFIREventPostScore.UTF8String), params); +} + +void GodotxFirebaseAnalytics::log_unlock_achievement(String achievement_id) { + Dictionary params; + params[String(kFIRParameterAchievementID.UTF8String)] = achievement_id; + log_event(String(kFIREventUnlockAchievement.UTF8String), params); +} + void GodotxFirebaseAnalytics::log_event(String event_name, Dictionary params) { NSLog(@"[GodotxFirebaseAnalytics] log_event: %s", event_name.utf8().get_data()); diff --git a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm index 630cc30..e27038b 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm @@ -22,6 +22,7 @@ ADD_SIGNAL(MethodInfo("crashlytics_non_fatal_logged", PropertyInfo(Variant::STRING, "message"))); ADD_SIGNAL(MethodInfo("crashlytics_message_logged", PropertyInfo(Variant::STRING, "message"))); ADD_SIGNAL(MethodInfo("crashlytics_value_set", PropertyInfo(Variant::STRING, "key"))); + ADD_SIGNAL(MethodInfo("crashlytics_user_id_set", PropertyInfo(Variant::STRING, "user_id"))); ADD_SIGNAL(MethodInfo("crashlytics_error", PropertyInfo(Variant::STRING, "message"))); } @@ -72,6 +73,7 @@ NSString* nsUserId = [NSString stringWithUTF8String:user_id.utf8().get_data()]; [[FIRCrashlytics crashlytics] setUserID:nsUserId]; NSLog(@"[GodotxFirebaseCrashlytics] Set user ID: %@", nsUserId); + emit_signal("crashlytics_user_id_set", user_id); } @catch (NSException *exception) { NSLog(@"[GodotxFirebaseCrashlytics] Failed to set user ID: %@", exception.reason); diff --git a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm index 48e3a0b..bcc7c8d 100644 --- a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm +++ b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm @@ -25,24 +25,8 @@ + (instancetype)shared { return sharedInstance; } -// Helper: convert userInfo to Godot Dictionary, excluding APNs/FCM internal keys -static Dictionary userInfoToGodotDictionary(NSDictionary *userInfo) { - Dictionary dict; - NSSet *reservedKeys = [NSSet setWithArray:@[ - @"aps", @"gcm.message_id", @"google.c.a.e", @"google.c.fid", - @"google.c.sender.id", @"gcm.notification.sound" - ]]; - for (NSString *key in userInfo) { - if ([reservedKeys containsObject:key]) continue; - id val = userInfo[key]; - if ([val isKindOfClass:[NSString class]]) { - dict[String::utf8([key UTF8String])] = String::utf8([(NSString*)val UTF8String]); - } else if ([val isKindOfClass:[NSNumber class]]) { - dict[String::utf8([key UTF8String])] = String::utf8([[val stringValue] UTF8String]); - } - } - return dict; -} +// Note: reserved keys filtering and recursive parsing is now handled centrally +// in GodotxFirebaseMessaging::user_info_to_dictionary #pragma mark - UNUserNotificationCenterDelegate @@ -56,7 +40,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center NSString *title = notification.request.content.title ?: @""; NSString *body = notification.request.content.body ?: @""; - Dictionary data = userInfoToGodotDictionary(userInfo); + Dictionary data = GodotxFirebaseMessaging::user_info_to_dictionary(userInfo); dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseMessaging::instance) { @@ -84,7 +68,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center NSString *title = response.notification.request.content.title ?: @""; NSString *body = response.notification.request.content.body ?: @""; - Dictionary data = userInfoToGodotDictionary(userInfo); + Dictionary data = GodotxFirebaseMessaging::user_info_to_dictionary(userInfo); dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseMessaging::instance) { diff --git a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h index 71cdf55..64910d2 100644 --- a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h +++ b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h @@ -26,6 +26,10 @@ class GodotxFirebaseMessaging : public Object { void subscribe_to_topic(String topic); void unsubscribe_from_topic(String topic); Dictionary get_last_notification(); + + static Variant ns_object_to_variant(id val); + static Dictionary user_info_to_dictionary(NSDictionary *userInfo); + GodotxFirebaseMessaging(); ~GodotxFirebaseMessaging(); diff --git a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm index ac4d188..53aeb45 100644 --- a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm +++ b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm @@ -316,25 +316,56 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin } } - // Collect custom data fields (exclude internal APNs/FCM keys) + Dictionary result; + result["title"] = String::utf8([title UTF8String]); + result["body"] = String::utf8([body UTF8String]); + result["data"] = user_info_to_dictionary(userInfo); + return result; +} + +Variant GodotxFirebaseMessaging::ns_object_to_variant(id val) { + if ([val isKindOfClass:[NSString class]]) { + return String::utf8([(NSString *)val UTF8String]); + } else if ([val isKindOfClass:[NSNumber class]]) { + NSNumber *n = (NSNumber *)val; + if (CFNumberIsFloatType((CFNumberRef)n)) { + return [n doubleValue]; + } else { + return (int64_t)[n longLongValue]; + } + } else if ([val isKindOfClass:[NSDictionary class]]) { + Dictionary d; + NSDictionary *dict = (NSDictionary *)val; + for (id key in dict) { + d[ns_object_to_variant(key)] = ns_object_to_variant(dict[key]); + } + return d; + } else if ([val isKindOfClass:[NSArray class]]) { + Array a; + NSArray *arr = (NSArray *)val; + for (id item in arr) { + a.push_back(ns_object_to_variant(item)); + } + return a; + } + return Variant(); +} + +Dictionary GodotxFirebaseMessaging::user_info_to_dictionary(NSDictionary *userInfo) { Dictionary dataDict; + if (!userInfo) return dataDict; + NSSet *reservedKeys = [NSSet setWithArray:@[ @"aps", @"gcm.message_id", @"google.c.a.e", @"google.c.fid", @"google.c.sender.id", @"gcm.notification.sound" ]]; + for (NSString *key in userInfo) { - if ([reservedKeys containsObject:key]) continue; - id val = userInfo[key]; - if ([val isKindOfClass:[NSString class]]) { - dataDict[String::utf8([key UTF8String])] = String::utf8([(NSString*)val UTF8String]); - } else if ([val isKindOfClass:[NSNumber class]]) { - dataDict[String::utf8([key UTF8String])] = String::utf8([[val stringValue] UTF8String]); + // Skip reserved keys and any key starting with "gcm." or "google." + if ([reservedKeys containsObject:key] || [key hasPrefix:@"gcm."] || [key hasPrefix:@"google."]) { + continue; } + dataDict[String::utf8([key UTF8String])] = ns_object_to_variant(userInfo[key]); } - - Dictionary result; - result["title"] = String::utf8([title UTF8String]); - result["body"] = String::utf8([body UTF8String]); - result["data"] = dataDict; - return result; + return dataDict; } diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h index 5c55f08..2445fcb 100644 --- a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h @@ -20,11 +20,12 @@ class GodotxFirebaseRemoteConfig : public Object { String get_string(const String &key, const String &default_value); int get_int(const String &key, int default_value); float get_float(const String &key, float default_value); - bool get_bool(const String &key, bool default_value); + double get_double(const String &key, double default_value); + int get_bool(const String &key, bool default_value); Dictionary get_dictionary(const String &key); void set_defaults(const Dictionary &defaults); void set_minimum_fetch_interval(float seconds); - void setup_realtime_updates(); + bool setup_realtime_updates(); void remove_config_update_listener(); GodotxFirebaseRemoteConfig(); diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm index c49a67a..d3c4faf 100644 --- a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm @@ -50,6 +50,18 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { return dict; } +#define FIREBASE_CHECK_INITIALIZED_V(ret) \ + if (![FIRApp defaultApp]) { \ + ERR_PRINT("Firebase not initialized. Call initialize() first."); \ + return ret; \ + } + +#define FIREBASE_CHECK_INITIALIZED() \ + if (![FIRApp defaultApp]) { \ + ERR_PRINT("Firebase not initialized. Call initialize() first."); \ + return; \ + } + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -79,6 +91,7 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { ClassDB::bind_method(D_METHOD("get_string", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_string); ClassDB::bind_method(D_METHOD("get_int", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_int); ClassDB::bind_method(D_METHOD("get_float", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_float); + ClassDB::bind_method(D_METHOD("get_double", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_double); ClassDB::bind_method(D_METHOD("get_bool", "key", "default_value"), &GodotxFirebaseRemoteConfig::get_bool); ClassDB::bind_method(D_METHOD("get_dictionary", "key"), &GodotxFirebaseRemoteConfig::get_dictionary); ClassDB::bind_method(D_METHOD("set_defaults", "defaults"), &GodotxFirebaseRemoteConfig::set_defaults); @@ -88,8 +101,10 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { ADD_SIGNAL(MethodInfo("remote_config_initialized", PropertyInfo(Variant::BOOL, "success"))); ADD_SIGNAL(MethodInfo("remote_config_error", PropertyInfo(Variant::STRING, "message"))); - ADD_SIGNAL(MethodInfo("fetch_completed", PropertyInfo(Variant::INT, "status"))); - ADD_SIGNAL(MethodInfo("config_updated", PropertyInfo(Variant::ARRAY, "updated_keys"))); + ADD_SIGNAL(MethodInfo("remote_config_fetch_completed", PropertyInfo(Variant::INT, "status"))); + ADD_SIGNAL(MethodInfo("remote_config_updated", PropertyInfo(Variant::ARRAY, "updated_keys"))); + ADD_SIGNAL(MethodInfo("remote_config_defaults_set")); + ADD_SIGNAL(MethodInfo("remote_config_settings_updated")); } // --------------------------------------------------------------------------- @@ -111,6 +126,7 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { // --------------------------------------------------------------------------- void GodotxFirebaseRemoteConfig::fetch_and_activate() { + FIREBASE_CHECK_INITIALIZED(); FIRRemoteConfig *rc = [FIRRemoteConfig remoteConfig]; [rc fetchAndActivateWithCompletionHandler:^(FIRRemoteConfigFetchAndActivateStatus status, NSError *error) { @@ -128,7 +144,7 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { } dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseRemoteConfig::instance) { - GodotxFirebaseRemoteConfig::instance->emit_signal("fetch_completed", godot_status); + GodotxFirebaseRemoteConfig::instance->emit_signal("remote_config_fetch_completed", godot_status); } }); }]; @@ -139,30 +155,42 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { // --------------------------------------------------------------------------- String GodotxFirebaseRemoteConfig::get_string(const String &key, const String &default_value) { + FIREBASE_CHECK_INITIALIZED_V(default_value); FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; if (value.source == FIRRemoteConfigSourceStatic) return default_value; return string_from_ns(value.stringValue); } int GodotxFirebaseRemoteConfig::get_int(const String &key, int default_value) { + FIREBASE_CHECK_INITIALIZED_V(default_value); FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; if (value.source == FIRRemoteConfigSourceStatic) return default_value; return [value.numberValue intValue]; } float GodotxFirebaseRemoteConfig::get_float(const String &key, float default_value) { + FIREBASE_CHECK_INITIALIZED_V(default_value); FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; if (value.source == FIRRemoteConfigSourceStatic) return default_value; return [value.numberValue floatValue]; } -bool GodotxFirebaseRemoteConfig::get_bool(const String &key, bool default_value) { +double GodotxFirebaseRemoteConfig::get_double(const String &key, double default_value) { + FIREBASE_CHECK_INITIALIZED_V(default_value); FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; if (value.source == FIRRemoteConfigSourceStatic) return default_value; - return value.boolValue; + return [value.numberValue doubleValue]; +} + +int GodotxFirebaseRemoteConfig::get_bool(const String &key, bool default_value) { + FIREBASE_CHECK_INITIALIZED_V(default_value ? 1 : 0); + FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; + bool boolVal = (value.source == FIRRemoteConfigSourceStatic) ? default_value : value.boolValue; + return boolVal ? 1 : 0; } Dictionary GodotxFirebaseRemoteConfig::get_dictionary(const String &key) { + FIREBASE_CHECK_INITIALIZED_V(Dictionary()); FIRRemoteConfigValue *value = [FIRRemoteConfig remoteConfig][ns_from_string(key)]; id json = value.JSONValue; if (![json isKindOfClass:[NSDictionary class]]) return Dictionary(); @@ -174,6 +202,7 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { // --------------------------------------------------------------------------- void GodotxFirebaseRemoteConfig::set_defaults(const Dictionary &defaults) { + FIREBASE_CHECK_INITIALIZED(); NSMutableDictionary *nsDefaults = [NSMutableDictionary dictionary]; Array keys = defaults.keys(); for (int i = 0; i < keys.size(); i++) { @@ -191,19 +220,24 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { } } [[FIRRemoteConfig remoteConfig] setDefaults:nsDefaults]; + emit_signal("remote_config_defaults_set"); } void GodotxFirebaseRemoteConfig::set_minimum_fetch_interval(float seconds) { + FIREBASE_CHECK_INITIALIZED(); FIRRemoteConfigSettings *settings = [[FIRRemoteConfigSettings alloc] init]; settings.minimumFetchInterval = (NSTimeInterval)seconds; [FIRRemoteConfig remoteConfig].configSettings = settings; + emit_signal("remote_config_settings_updated"); } // --------------------------------------------------------------------------- // Real-time listener // --------------------------------------------------------------------------- -void GodotxFirebaseRemoteConfig::setup_realtime_updates() { +bool GodotxFirebaseRemoteConfig::setup_realtime_updates() { + FIREBASE_CHECK_INITIALIZED_V(false); + if (_listenerRegistration) return true; FIRRemoteConfig *rc = [FIRRemoteConfig remoteConfig]; _listenerRegistration = [rc addOnConfigUpdateListener:^(FIRRemoteConfigUpdate *update, NSError *error) { @@ -215,11 +249,12 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { } dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseRemoteConfig::instance) { - GodotxFirebaseRemoteConfig::instance->emit_signal("config_updated", keys); + GodotxFirebaseRemoteConfig::instance->emit_signal("remote_config_updated", keys); } }); }]; }]; + return true; } void GodotxFirebaseRemoteConfig::remove_config_update_listener() { From 2945d7f9a8d3acff62c358df1d0479c91ef454a6 Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Mon, 27 Apr 2026 16:36:19 +0300 Subject: [PATCH 6/9] refactor: move action registry logic to separate script and clean up main script structure --- scripts/ActionRegistry.gd | 159 ++++++++++ scripts/Main.gd | 282 ++++++------------ .../crashlytics/FirebaseCrashlyticsPlugin.kt | 2 +- .../Sources/godotx_firebase_crashlytics.h | 2 +- .../Sources/godotx_firebase_crashlytics.mm | 4 +- 5 files changed, 258 insertions(+), 191 deletions(-) create mode 100644 scripts/ActionRegistry.gd diff --git a/scripts/ActionRegistry.gd b/scripts/ActionRegistry.gd new file mode 100644 index 0000000..eadd662 --- /dev/null +++ b/scripts/ActionRegistry.gd @@ -0,0 +1,159 @@ +static func get_actions() -> Dictionary: + return { + "Analytics": { + "LogEventButton": { + "method": "log_event", "args": ["test_event", {"p1": "v1", "p2": 123}], + "signal": "analytics_event_logged", "desc": "Logging event: test_event" + }, + "LogScreenButton": { + "method": "log_screen_view", "args": ["MainScene", "GodotSampleActivity"], + "signal": "analytics_screen_logged", "desc": "Logging screen: MainScene" + }, + "UserPropsButton": { + "method": "set_user_property", "args": ["test_prop", "test_value"], + "signal": "analytics_property_set", "desc": "Setting user property: test_prop = test_value" + }, + "SetUserIdButton": { + "method": "set_user_id", "args": ["player_123"], + "signal": "analytics_user_id_set", "desc": "Setting User ID: player_123" + }, + "SetDefaultParamsButton": { + "method": "set_default_event_parameters", "args": [ {"app_version": "1.0.0"}], + "signal": "analytics_default_params_set", "desc": "Setting default params: app_version=1.0.0" + }, + "SetConsentButton": { + "method": "set_consent", "args": [ {"analytics_storage": false}], + "signal": "analytics_consent_set", "desc": "Setting Consent: analytics_storage=false" + }, + "SetCollectionEnabledButton": { + "method": "set_collection_enabled", "args": [false], + "signal": "analytics_collection_enabled_set", "desc": "Toggling Collection Enabled: false" + }, + "ResetDataButton": { + "method": "reset_analytics_data", "args": [], + "signal": "analytics_data_reset", "desc": "Resetting Analytics Data" + }, + "LogLevelStartButton": { + "method": "log_level_start", "args": ["level_1"], + "signal": "analytics_event_logged", "desc": "Logging level_start: level_1" + }, + "LogLevelEndButton": { + "method": "log_level_end", "args": ["level_1", true], + "signal": "analytics_event_logged", "desc": "Logging level_end: level_1 (Success)" + }, + "LogEarnButton": { + "method": "log_earn_currency", "args": ["gold", 100.0], + "signal": "analytics_event_logged", "desc": "Logging earn_currency: 100 gold" + }, + "LogSpendButton": { + "method": "log_spend_currency", "args": ["gold", 50.0, "sword"], + "signal": "analytics_event_logged", "desc": "Logging spend_currency: 50 gold for sword" + }, + "LogTutorialBeginButton": { + "method": "log_tutorial_begin", "args": [], + "signal": "analytics_event_logged", "desc": "Logging tutorial_begin" + }, + "LogTutorialCompleteButton": { + "method": "log_tutorial_complete", "args": [], + "signal": "analytics_event_logged", "desc": "Logging tutorial_complete" + }, + "LogPostScoreButton": { + "method": "log_post_score", "args": [5000, "hall_of_fame", "ninja"], + "signal": "analytics_event_logged", "desc": "Logging post_score: 5000" + }, + "LogUnlockAchievementButton": { + "method": "log_unlock_achievement", "args": ["master_of_gemini"], + "signal": "analytics_event_logged", "desc": "Logging unlock_achievement: master_of_gemini" + } + }, + "Crashlytics": { + "FatalButton": {"method": "crash", "args": [], "mode": "manual", "desc": "!!! FORCING FATAL CRASH !!!"}, + "NonFatalButton": { + "method": "log_non_fatal", "args": ["This is a test non-fatal error"], + "signal": "crashlytics_non_fatal_logged", "desc": "Logging non-fatal error" + }, + "LogMsgButton": { + "method": "log_message", "args": ["This is a custom log message"], + "signal": "crashlytics_message_logged", "desc": "Logging custom message" + }, + "SetUserIdButton": { + "method": "set_user_id", "args": ["player_crash_123"], + "signal": "crashlytics_user_id_set", "desc": "Setting User ID for crashes" + }, + "CustomValueButton": { + "method": "set_custom_value_string", "args": ["test_key", "test_value"], + "signal": "crashlytics_value_set", "desc": "Setting custom value" + } + }, + "RemoteConfig": { + "FetchButton": {"method": "fetch_and_activate", "args": [], "signal": "remote_config_fetch_completed", "desc": "Fetching and Activating..."}, + "GetStringButton": { + "method": "get_string", + "args": ["welcome_message", "DEFAULT"], + "mode": "getter", + "desc": "Getting 'welcome_message'", + "validator": func(res: Variant): return typeof(res) == TYPE_STRING, + "failure_log": "Expected String, but received invalid type" + }, + "GetIntButton": { + "method": "get_int", + "args": ["min_version", -1], + "mode": "getter", + "desc": "Getting 'min_version'", + "validator": func(res: Variant): return typeof(res) == TYPE_INT, + "failure_log": "Expected Int, but received invalid type" + }, + "GetFloatButton": { + "method": "get_float", + "args": ["drop_rate", 0.0], + "mode": "getter", + "desc": "Getting 'drop_rate'", + "validator": func(res: Variant): return typeof(res) == TYPE_FLOAT, + "failure_log": "Expected Float, but received invalid type" + }, + "GetBoolButton": { + "method": "get_bool", + "args": ["feature_enabled", false], + "mode": "getter", + "desc": "Getting 'feature_enabled'", + "validator": func(res: Variant): return typeof(res) == TYPE_INT and (res == 0 or res == 1), + "failure_log": "Expected Int (1 or 0), but received invalid type" + }, + "GetDictButton": { + "method": "get_dictionary", + "args": ["game_config"], + "mode": "getter", + "desc": "Getting 'game_config'", + "validator": func(res: Variant): return typeof(res) == TYPE_DICTIONARY, + "failure_log": "Expected Dictionary, but received invalid type" + }, + "SetDefaultsButton": { + "method": "set_defaults", + "args": [ {"welcome_message": "Hello from Defaults!", "min_version": 10, "drop_rate": 0.05, "feature_enabled": true}], + "signal": "remote_config_defaults_set", "desc": "Setting local defaults" + }, + "SetIntervalButton": {"method": "set_minimum_fetch_interval", "args": [0.0], "signal": "remote_config_settings_updated", "desc": "Setting fetch interval to 0s (Dev Mode)"}, + "ListenerButton": { + "method": "setup_realtime_updates", + "args": [], + "mode": "getter", + "desc": "Enabling Real-time updates listener", + "validator": func(res: Variant): return res == true or res == 1, + "failure_log": "Failed to setup listener (method missing or returned false)" + } + }, + "Messaging": { + "GetTokenButton": {"method": "get_token", "args": [], "signal": "messaging_token_received", "desc": "Requesting FCM token..."}, + "PermissionButton": {"method": "request_permission", "args": [], "signal": "messaging_permission_granted", "desc": "Requesting permissions..."}, + "SubscribeButton": {"method": "subscribe_to_topic", "args": ["test_topic"], "signal": "messaging_topic_subscribed", "desc": "Subscribing to: test_topic"}, + "UnsubscribeButton": {"method": "unsubscribe_from_topic", "args": ["test_topic"], "signal": "messaging_topic_unsubscribed", "desc": "Unsubscribing from: test_topic"}, + "GetLastNotificationButton": { + "method": "get_last_notification", + "args": [], + "mode": "getter", + "desc": "Getting last notification...", + "validator": func(res: Variant): return typeof(res) == TYPE_DICTIONARY and not res.is_empty(), + "failure_log": "No previous notification data found" + } + } + } diff --git a/scripts/Main.gd b/scripts/Main.gd index 12b570e..abafb1e 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -1,13 +1,32 @@ extends Control -# Firebase Singletons +# Dashboard button paths (used by flash_status / update_btn_status) +const ActionRegistry = preload("res://scripts/ActionRegistry.gd") +const INIT_PATH := "VBoxContainer/ContextGroup/Dashboard/List/InitializeButton" +const ANALYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" +const CRASHLYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" +const MESSAGING_PATH := "VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" +const REMOTE_CONFIG_PATH := "VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton" + +# Firebase Singletons (Public) var core: Object = null var analytics: Object = null var crashlytics: Object = null var messaging: Object = null var remote_config: Object = null +# Internal State (Private) +var _pending_call: Dictionary = { + "Analytics": "", + "Crashlytics": "", + "Messaging": "", + "RemoteConfig": "", +} +var _fcm_token: String = "" +var _messaging_permission_granted: bool = false +var _apns_ready: bool = false + # Navigation Elements @onready var back_button: Button = $VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer/BackButton @onready var view_title: Label = $VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer/ViewTitle @@ -26,119 +45,8 @@ var remote_config: Object = null # Log Elements @onready var log_output: TextEdit = $VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogOutput -# Dashboard button paths (used by flash_status / update_btn_status) -const INIT_PATH := "VBoxContainer/ContextGroup/Dashboard/List/InitializeButton" -const ANALYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" -const CRASHLYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" -const MESSAGING_PATH := "VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" -const REMOTE_CONFIG_PATH := "VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton" - -# Action Registry for Test Harness -var ACTIONS := { - "Analytics": { - "LogEventButton": {"method": "log_event", "args": ["test_event", {"p1": "v1", "p2": 123}], "signal": "analytics_event_logged", "desc": "Logging event: test_event"}, - "LogScreenButton": {"method": "log_screen_view", "args": ["MainScene", "GodotSampleActivity"], "signal": "analytics_screen_logged", "desc": "Logging screen: MainScene"}, - "UserPropsButton": {"method": "set_user_property", "args": ["test_prop", "test_value"], "signal": "analytics_property_set", "desc": "Setting user property: test_prop = test_value"}, - "SetUserIdButton": {"method": "set_user_id", "args": ["player_123"], "signal": "analytics_user_id_set", "desc": "Setting User ID: player_123"}, - "SetDefaultParamsButton": {"method": "set_default_event_parameters", "args": [ {"app_version": "1.0.0"}], "signal": "analytics_default_params_set", "desc": "Setting default params: app_version=1.0.0"}, - "SetConsentButton": {"method": "set_consent", "args": [ {"analytics_storage": false}], "signal": "analytics_consent_set", "desc": "Setting Consent: analytics_storage=false"}, - "SetCollectionEnabledButton": {"method": "set_collection_enabled", "args": [false], "signal": "analytics_collection_enabled_set", "desc": "Toggling Collection Enabled: false"}, - "ResetDataButton": {"method": "reset_analytics_data", "args": [], "signal": "analytics_data_reset", "desc": "Resetting Analytics Data"}, - "LogLevelStartButton": {"method": "log_level_start", "args": ["level_1"], "signal": "analytics_event_logged", "desc": "Logging level_start: level_1"}, - "LogLevelEndButton": {"method": "log_level_end", "args": ["level_1", true], "signal": "analytics_event_logged", "desc": "Logging level_end: level_1 (Success)"}, - "LogEarnButton": {"method": "log_earn_currency", "args": ["gold", 100.0], "signal": "analytics_event_logged", "desc": "Logging earn_currency: 100 gold"}, - "LogSpendButton": {"method": "log_spend_currency", "args": ["gold", 50.0, "sword"], "signal": "analytics_event_logged", "desc": "Logging spend_currency: 50 gold for sword"}, - "LogTutorialBeginButton": {"method": "log_tutorial_begin", "args": [], "signal": "analytics_event_logged", "desc": "Logging tutorial_begin"}, - "LogTutorialCompleteButton": {"method": "log_tutorial_complete", "args": [], "signal": "analytics_event_logged", "desc": "Logging tutorial_complete"}, - "LogPostScoreButton": {"method": "log_post_score", "args": [5000, "hall_of_fame", "ninja"], "signal": "analytics_event_logged", "desc": "Logging post_score: 5000"}, - "LogUnlockAchievementButton": {"method": "log_unlock_achievement", "args": ["master_of_gemini"], "signal": "analytics_event_logged", "desc": "Logging unlock_achievement: master_of_gemini"} - }, - "Crashlytics": { - "FatalButton": {"method": "crash", "args": [], "mode": "manual", "desc": "!!! FORCING FATAL CRASH !!!"}, - "NonFatalButton": {"method": "log_non_fatal_exception", "args": ["This is a test non-fatal error"], "signal": "crashlytics_non_fatal_logged", "desc": "Logging non-fatal error"}, - "CustomValueButton": {"method": "set_custom_value_string", "args": ["test_key", "test_value"], "signal": "crashlytics_value_set", "desc": "Setting custom value"} - }, - "RemoteConfig": { - "FetchButton": {"method": "fetch_and_activate", "args": [], "signal": "remote_config_fetch_completed", "desc": "Fetching and Activating..."}, - "GetStringButton": { - "method": "get_string", - "args": ["welcome_message", "DEFAULT"], - "mode": "getter", - "desc": "Getting 'welcome_message'", - "validator": func(res): return typeof(res) == TYPE_STRING, - "failure_log": "Expected String, but received invalid type" - }, - "GetIntButton": { - "method": "get_int", - "args": ["min_version", -1], - "mode": "getter", - "desc": "Getting 'min_version'", - "validator": func(res): return typeof(res) == TYPE_INT, - "failure_log": "Expected Int, but received invalid type" - }, - "GetFloatButton": { - "method": "get_float", - "args": ["drop_rate", 0.0], - "mode": "getter", - "desc": "Getting 'drop_rate'", - "validator": func(res): return typeof(res) == TYPE_FLOAT, - "failure_log": "Expected Float, but received invalid type" - }, - "GetBoolButton": { - "method": "get_bool", - "args": ["feature_enabled", false], - "mode": "getter", - "desc": "Getting 'feature_enabled'", - "validator": func(res): return typeof(res) == TYPE_INT and (res == 0 or res == 1), - "failure_log": "Expected Int (1 or 0), but received invalid type" - }, - "GetDictButton": { - "method": "get_dictionary", - "args": ["game_config"], - "mode": "getter", - "desc": "Getting 'game_config'", - "validator": func(res): return typeof(res) == TYPE_DICTIONARY, - "failure_log": "Expected Dictionary, but received invalid type" - }, - "SetDefaultsButton": {"method": "set_defaults", "args": [ {"welcome_message": "Hello from Defaults!", "min_version": 10, "drop_rate": 0.05, "feature_enabled": true}], "signal": "remote_config_defaults_set", "desc": "Setting local defaults"}, - "SetIntervalButton": {"method": "set_minimum_fetch_interval", "args": [0.0], "signal": "remote_config_settings_updated", "desc": "Setting fetch interval to 0s (Dev Mode)"}, - "ListenerButton": { - "method": "setup_realtime_updates", - "args": [], - "mode": "getter", - "desc": "Enabling Real-time updates listener", - "validator": func(res): return res == true or res == 1, - "failure_log": "Failed to setup listener (method missing or returned false)" - } - }, - "Messaging": { - "GetTokenButton": {"method": "get_token", "args": [], "signal": "messaging_token_received", "desc": "Requesting FCM token..."}, - "PermissionButton": {"method": "request_permission", "args": [], "signal": "messaging_permission_granted", "desc": "Requesting permissions..."}, - "SubscribeButton": {"method": "subscribe_to_topic", "args": ["test_topic"], "signal": "messaging_topic_subscribed", "desc": "Subscribing to: test_topic"}, - "UnsubscribeButton": {"method": "unsubscribe_from_topic", "args": ["test_topic"], "signal": "messaging_topic_unsubscribed", "desc": "Unsubscribing from: test_topic"}, - "GetLastNotificationButton": { - "method": "get_last_notification", - "args": [], - "mode": "getter", - "desc": "Getting last notification...", - "validator": func(res): return typeof(res) == TYPE_DICTIONARY and not res.is_empty(), - "failure_log": "No previous notification data found" - } - } -} - -# Tracks the module-view button currently awaiting an async signal, per module. -# The harness only permits one in-flight call per module at a time. -var _pending_call: Dictionary = { - "Analytics": "", - "Crashlytics": "", - "Messaging": "", - "RemoteConfig": "", -} +@onready var _actions: Dictionary = ActionRegistry.get_actions() -var _fcm_token: String = "" -var _messaging_permission_granted: bool = false -var _apns_ready: bool = false func _ready() -> void: get_viewport().size_changed.connect(_apply_safe_area) @@ -148,25 +56,6 @@ func _ready() -> void: enable_service_buttons(false) initialize_firebase_plugins() -func _apply_safe_area() -> void: - var os_name = OS.get_name() - if os_name != "iOS" and os_name != "Android": - return - var safe_area = DisplayServer.get_display_safe_area() - var window_size = DisplayServer.window_get_size() - if safe_area.size != Vector2i.ZERO and safe_area.size != window_size: - var top_margin = safe_area.position.y - var bottom_margin = window_size.y - (safe_area.position.y + safe_area.size.y) - var left_margin = safe_area.position.x - var right_margin = window_size.x - (safe_area.position.x + safe_area.size.x) - - if has_node("VBoxContainer"): - var vbox = $VBoxContainer - vbox.offset_top = top_margin - vbox.offset_bottom = - bottom_margin - vbox.offset_left = left_margin - vbox.offset_right = - right_margin - func initialize_firebase_plugins() -> void: # Core if Engine.has_singleton("GodotxFirebaseCore"): @@ -181,35 +70,35 @@ func initialize_firebase_plugins() -> void: if Engine.has_singleton("GodotxFirebaseAnalytics"): analytics = Engine.get_singleton("GodotxFirebaseAnalytics") analytics.analytics_initialized.connect(_on_module_init_done.bind("Analytics")) - + # Connect all async signals to generic success handler with validation - analytics.analytics_event_logged.connect(func(event_name): - var success = not event_name.is_empty() + analytics.analytics_event_logged.connect(func(event_name: String): + var success: bool = not event_name.is_empty() if success: log_message("[Analytics] โœ“ Event logged: " + event_name) else: log_message("[Analytics] โœ— Event log returned empty name") _clear_pending("Analytics", success)) - analytics.analytics_screen_logged.connect(func(screen_name): - var success = not screen_name.is_empty() + analytics.analytics_screen_logged.connect(func(screen_name: String): + var success: bool = not screen_name.is_empty() if success: log_message("[Analytics] โœ“ Screen logged: " + screen_name) else: log_message("[Analytics] โœ— Screen log returned empty name") _clear_pending("Analytics", success)) - analytics.analytics_property_set.connect(func(prop_name): - var success = not prop_name.is_empty() + analytics.analytics_property_set.connect(func(prop_name: String): + var success: bool = not prop_name.is_empty() if success: log_message("[Analytics] โœ“ Property set: " + prop_name) else: log_message("[Analytics] โœ— Property set returned empty name") _clear_pending("Analytics", success)) - analytics.analytics_user_id_set.connect(func(id): + analytics.analytics_user_id_set.connect(func(id: String): # Note: User ID could intentionally be empty if resetting log_message("[Analytics] โœ“ User ID set: " + id); _clear_pending("Analytics", true)) analytics.analytics_default_params_set.connect(func(): log_message("[Analytics] โœ“ Default params set"); _clear_pending("Analytics")) - analytics.analytics_collection_enabled_set.connect(func(enabled): log_message("[Analytics] โœ“ Collection enabled: " + str(enabled)); _clear_pending("Analytics")) + analytics.analytics_collection_enabled_set.connect(func(enabled: bool): log_message("[Analytics] โœ“ Collection enabled: " + str(enabled)); _clear_pending("Analytics")) analytics.analytics_data_reset.connect(func(): log_message("[Analytics] โœ“ Analytics data reset"); _clear_pending("Analytics")) analytics.analytics_consent_set.connect(func(): log_message("[Analytics] โœ“ Consent updated"); _clear_pending("Analytics")) - + analytics.analytics_error.connect(_on_module_error.bind("Analytics")) log_message("โœ“ Firebase Analytics plugin found") else: @@ -219,12 +108,13 @@ func initialize_firebase_plugins() -> void: if Engine.has_singleton("GodotxFirebaseCrashlytics"): crashlytics = Engine.get_singleton("GodotxFirebaseCrashlytics") crashlytics.crashlytics_initialized.connect(_on_module_init_done.bind("Crashlytics")) - + # Connect async signals - crashlytics.crashlytics_non_fatal_logged.connect(func(msg): log_message("[Crashlytics] โœ“ Non-fatal logged: " + msg); _clear_pending("Crashlytics")) - crashlytics.crashlytics_message_logged.connect(func(msg): log_message("[Crashlytics] โœ“ Message logged: " + msg); _clear_pending("Crashlytics")) - crashlytics.crashlytics_value_set.connect(func(key): log_message("[Crashlytics] โœ“ Value set for: " + key); _clear_pending("Crashlytics")) - + crashlytics.crashlytics_non_fatal_logged.connect(func(msg: String): log_message("[Crashlytics] โœ“ Non-fatal logged: " + msg); _clear_pending("Crashlytics")) + crashlytics.crashlytics_message_logged.connect(func(msg: String): log_message("[Crashlytics] โœ“ Message logged: " + msg); _clear_pending("Crashlytics")) + crashlytics.crashlytics_value_set.connect(func(key: String): log_message("[Crashlytics] โœ“ Value set for: " + key); _clear_pending("Crashlytics")) + crashlytics.crashlytics_user_id_set.connect(func(uid: String): log_message("[Crashlytics] โœ“ User ID set: " + uid); _clear_pending("Crashlytics")) + crashlytics.crashlytics_error.connect(_on_module_error.bind("Crashlytics")) log_message("โœ“ Firebase Crashlytics plugin found") else: @@ -246,22 +136,22 @@ func initialize_firebase_plugins() -> void: log_message("โœ“ Firebase Messaging plugin found") else: log_message("โœ— Firebase Messaging plugin not found") - + # Remote Config if Engine.has_singleton("GodotxFirebaseRemoteConfig"): remote_config = Engine.get_singleton("GodotxFirebaseRemoteConfig") remote_config.remote_config_initialized.connect(_on_module_init_done.bind("RemoteConfig")) - + # Connect async signals (with validation where needed) - remote_config.remote_config_fetch_completed.connect(func(status): - var _status_map = {0: "SUCCESS", 1: "CACHED", 2: "FAILURE", 3: "THROTTLED"} - log_message("[Remote Config] Fetch result: " + _status_map.get(status, "UNKNOWN")) + remote_config.remote_config_fetch_completed.connect(func(status: int): + var status_map: Dictionary = {0: "SUCCESS", 1: "CACHED", 2: "FAILURE", 3: "THROTTLED"} + log_message("[Remote Config] Fetch result: " + status_map.get(status, "UNKNOWN")) _clear_pending("RemoteConfig", status == 0 or status == 1) ) remote_config.remote_config_defaults_set.connect(func(): _clear_pending("RemoteConfig")) remote_config.remote_config_settings_updated.connect(func(): _clear_pending("RemoteConfig")) remote_config.remote_config_updated.connect(_on_config_updated) - + remote_config.remote_config_error.connect(_on_module_error.bind("RemoteConfig")) log_message("โœ“ Firebase Remote Config plugin found") else: @@ -283,15 +173,15 @@ func show_module(module_name: String) -> void: back_button.visible = true view_title.text = "Firebase " + module_name - for child in module_container.get_children(): + for child: Node in module_container.get_children(): child.queue_free() - var node_name = module_name.replace(" ", "") + "View" - var scene_path = "res://scenes/view_stack/" + node_name + ".tscn" + var node_name: String = module_name.replace(" ", "") + "View" + var scene_path: String = "res://scenes/view_stack/" + node_name + ".tscn" if ResourceLoader.exists(scene_path): - var scene = load(scene_path) - var instance = scene.instantiate() + var scene: PackedScene = load(scene_path) + var instance: Node = scene.instantiate() module_container.add_child(instance) instance.name = node_name _connect_module_buttons(module_name, instance) @@ -307,7 +197,7 @@ func log_message(message: String) -> void: log_output.scroll_vertical = log_output.get_line_count() func update_btn_status(path: String, status: int) -> void: - var btn = get_node_or_null(path) + var btn: Node = get_node_or_null(path) if btn and btn.has_method("update_status"): btn.update_status(status) @@ -321,51 +211,53 @@ func enable_service_buttons(enabled: bool) -> void: remote_config_btn.disabled = !enabled func _module_btn_path(module_name: String, btn_name: String) -> String: - var base_path = "VBoxContainer/ContextGroup/ModuleContainer/" + module_name.replace(" ", "") + "View/" + var base_path: String = "VBoxContainer/ContextGroup/ModuleContainer/" + module_name.replace(" ", "") + "View/" if module_name in ["Remote Config", "RemoteConfig"]: return base_path + "List/" + btn_name - elif module_name == "Analytics": + + if module_name == "Analytics": return base_path + "ScrollContainer/List/" + btn_name + return base_path + btn_name func _connect_module_buttons(module_name: String, instance: Node) -> void: if module_name == "Analytics": - var list = instance.get_node("ScrollContainer/List") - for btn_name in ACTIONS["Analytics"].keys(): + var list: Node = instance.get_node("ScrollContainer/List") + for btn_name in _actions["Analytics"].keys(): _connect_btn(list, btn_name, _run_action.bind("Analytics", btn_name)) elif module_name == "Messaging": - for btn_name in ACTIONS["Messaging"].keys(): + for btn_name in _actions["Messaging"].keys(): _connect_btn(instance, btn_name, _run_action.bind("Messaging", btn_name)) _update_messaging_view_state(instance) elif module_name == "Crashlytics": - for btn_name in ACTIONS["Crashlytics"].keys(): + for btn_name in _actions["Crashlytics"].keys(): _connect_btn(instance, btn_name, _run_action.bind("Crashlytics", btn_name)) elif module_name == "Remote Config": - var list = instance.get_node("List") - for btn_name in ACTIONS["RemoteConfig"].keys(): + var list: Node = instance.get_node("List") + for btn_name in _actions["RemoteConfig"].keys(): _connect_btn(list, btn_name, _run_action.bind("RemoteConfig", btn_name)) func _connect_btn(instance: Node, btn_name: String, method: Callable) -> void: - var btn = instance.get_node_or_null(btn_name) + var btn: Button = instance.get_node_or_null(btn_name) as Button if btn: btn.pressed.connect(method) # ============== ACTION RUNNER ============== func _run_action(module_name: String, action_id: String) -> void: - var log_name = "Remote Config" if module_name == "RemoteConfig" else module_name - var config: Dictionary = ACTIONS.get(module_name, {}).get(action_id, {}) + var log_name: String = "Remote Config" if module_name == "RemoteConfig" else module_name + var config: Dictionary = _actions.get(module_name, {}).get(action_id, {}) if config.is_empty(): log_message("[System] Error: No config for %s:%s" % [module_name, action_id]) return - var plugin = null + var plugin: Object = null match module_name: "Analytics": plugin = analytics "Crashlytics": plugin = crashlytics "Messaging": plugin = messaging "RemoteConfig": plugin = remote_config - var btn_path = _module_btn_path(module_name, action_id) + var btn_path: String = _module_btn_path(module_name, action_id) if not plugin: log_message("[%s] Plugin not available" % log_name) flash_status(btn_path, TestButton.Status.FAILURE) @@ -379,15 +271,15 @@ func _run_action(module_name: String, action_id: String) -> void: _pending_call[module_name] = btn_path # Execute the call - var method = config["method"] - var args = config.get("args", []) - var result = plugin.callv(method, args) + var method: String = config["method"] + var args: Array = config.get("args", []) + var result: Variant = plugin.callv(method, args) # If it's a getter, log the result and validate if config.get("mode", "") == "getter": - var _key_prefix = "'%s' = " % args[0] if args.size() > 0 and typeof(args[0]) == TYPE_STRING else "" - log_message("[%s] %s%s" % [log_name, _key_prefix, str(result)]) - var is_valid = true + var key_prefix: String = "'%s' = " % args[0] if args.size() > 0 and typeof(args[0]) == TYPE_STRING else "" + log_message("[%s] %s%s" % [log_name, key_prefix, str(result)]) + var is_valid: bool = true if config.has("validator"): is_valid = config["validator"].call(result) if not is_valid and config.has("failure_log"): @@ -488,11 +380,11 @@ func _on_messaging_token_received(token: String) -> void: log_message("[Messaging] โœ— Token received but it is EMPTY") _clear_pending("Messaging", false) return - + _fcm_token = token log_message("[Messaging] Token received: " + token) _clear_pending("Messaging", true) - + var view = module_container.get_node_or_null("MessagingView") if view: _update_messaging_view_state(view) @@ -504,13 +396,13 @@ func _on_messaging_apn_token_received(_token: String) -> void: func _update_messaging_view_state(view: Node) -> void: var has_token = !_fcm_token.is_empty() var permission_ok = _messaging_permission_granted - + var perm_btn = view.get_node_or_null("PermissionButton") var token_btn = view.get_node_or_null("GetTokenButton") var sub_btn = view.get_node_or_null("SubscribeButton") var unsub_btn = view.get_node_or_null("UnsubscribeButton") var last_notification_btn = view.get_node_or_null("GetLastNotificationButton") - + # Step 1: Permission button is always enabled if perm_btn: perm_btn.disabled = false @@ -527,13 +419,29 @@ func _on_messaging_message_received(title: String, body: String, data: Dictionar if not data.is_empty(): log_message("[Messaging] Data payload: " + str(data)) -func _on_get_last_notification_pressed() -> void: - # Keep this as a separate handler if it needs complex return logic, - # but for now we've moved the basic call to _run_action. - pass - # (Crashlytics Handlers removed - now using _run_action) +# ============== INTERNAL / PRIVATE ============== + +func _apply_safe_area() -> void: + var os_name: String = OS.get_name() + if os_name != "iOS" and os_name != "Android": + return + var safe_area: Rect2i = DisplayServer.get_display_safe_area() + var window_size: Vector2i = DisplayServer.window_get_size() + if safe_area.size != Vector2i.ZERO and safe_area.size != window_size: + var top_margin: int = safe_area.position.y + var bottom_margin: int = window_size.y - (safe_area.position.y + safe_area.size.y) + var left_margin: int = safe_area.position.x + var right_margin: int = window_size.x - (safe_area.position.x + safe_area.size.x) + + if has_node("VBoxContainer"): + var vbox: Control = $VBoxContainer as Control + vbox.offset_top = top_margin + vbox.offset_bottom = - bottom_margin + vbox.offset_left = left_margin + vbox.offset_right = - right_margin + # ============== ERRORS ============== func _on_error(message: String, module: String) -> void: diff --git a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt index 37b30a1..7e8b2f2 100644 --- a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt +++ b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt @@ -67,7 +67,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { } @UsedByGodot - fun log_non_fatal_exception(message: String) { + fun log_non_fatal(message: String) { val crashlyticsInstance = crashlytics if (crashlyticsInstance == null) { Log.e(TAG, "Firebase Crashlytics not initialized") diff --git a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h index e3f521d..2c89043 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h @@ -17,7 +17,7 @@ class GodotxFirebaseCrashlytics : public Object { void initialize(); void crash(); - void log_non_fatal_exception(String message); + void log_non_fatal(String message); void log_message(String message); void set_user_id(String user_id); void set_custom_value_string(String key, String value); diff --git a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm index e27038b..9e86967 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm @@ -10,7 +10,7 @@ void GodotxFirebaseCrashlytics::_bind_methods() { ClassDB::bind_method(D_METHOD("initialize"), &GodotxFirebaseCrashlytics::initialize); ClassDB::bind_method(D_METHOD("crash"), &GodotxFirebaseCrashlytics::crash); - ClassDB::bind_method(D_METHOD("log_non_fatal_exception", "message"), &GodotxFirebaseCrashlytics::log_non_fatal_exception); + ClassDB::bind_method(D_METHOD("log_non_fatal", "message"), &GodotxFirebaseCrashlytics::log_non_fatal); ClassDB::bind_method(D_METHOD("log_message", "message"), &GodotxFirebaseCrashlytics::log_message); ClassDB::bind_method(D_METHOD("set_user_id", "user_id"), &GodotxFirebaseCrashlytics::set_user_id); ClassDB::bind_method(D_METHOD("set_custom_value_string", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value_string); @@ -39,7 +39,7 @@ @[][1]; } -void GodotxFirebaseCrashlytics::log_non_fatal_exception(String message) { +void GodotxFirebaseCrashlytics::log_non_fatal(String message) { @try { NSString* nsMessage = [NSString stringWithUTF8String:message.utf8().get_data()]; NSError* error = [NSError errorWithDomain:@"GodotxFirebaseHarness" From 55a50d3cb06dd7581386d68b444d31f7cd235d7f Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Mon, 27 Apr 2026 20:49:54 +0300 Subject: [PATCH 7/9] refactor: move RemoteConfig listener to signal-based architecture, update iOS notification handling, and integrate Godot MCP tools. --- Makefile | 2 +- scripts/ActionRegistry.gd | 6 +- scripts/Main.gd | 1 + .../FirebaseRemoteConfigPlugin.kt | 25 ++++---- .../Sources/godotx_apn_delegate.mm | 11 ++-- .../Sources/godotx_firebase_messaging.h | 4 +- .../Sources/godotx_firebase_messaging.mm | 61 ++++++++++--------- .../godotx_firebase_messaging_internal.h | 13 ++++ .../Sources/godotx_firebase_remote_config.h | 2 +- .../Sources/godotx_firebase_remote_config.mm | 12 ++-- 10 files changed, 78 insertions(+), 59 deletions(-) create mode 100644 source/ios/firebase_messaging/Sources/godotx_firebase_messaging_internal.h diff --git a/Makefile b/Makefile index b18c6aa..9467867 100644 --- a/Makefile +++ b/Makefile @@ -259,7 +259,7 @@ build-apple: setup-apple firebase_remote_config) \ echo " - Copying frameworks from FirebaseRemoteConfig..." \ && cp -a $(FIREBASE_SDK_DIR)/FirebaseRemoteConfig/*.xcframework $(IOS_PLUGINS_DIR)/$$module/ ;; \ - esac); \ + esac) || exit 1; \ echo " โœ“ $$module build complete (Debug + Release)"; \ echo ""; \ done diff --git a/scripts/ActionRegistry.gd b/scripts/ActionRegistry.gd index eadd662..b3a5c8c 100644 --- a/scripts/ActionRegistry.gd +++ b/scripts/ActionRegistry.gd @@ -136,10 +136,8 @@ static func get_actions() -> Dictionary: "ListenerButton": { "method": "setup_realtime_updates", "args": [], - "mode": "getter", - "desc": "Enabling Real-time updates listener", - "validator": func(res: Variant): return res == true or res == 1, - "failure_log": "Failed to setup listener (method missing or returned false)" + "signal": "remote_config_listener_registered", + "desc": "Enabling Real-time updates listener" } }, "Messaging": { diff --git a/scripts/Main.gd b/scripts/Main.gd index abafb1e..3f9eea8 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -150,6 +150,7 @@ func initialize_firebase_plugins() -> void: ) remote_config.remote_config_defaults_set.connect(func(): _clear_pending("RemoteConfig")) remote_config.remote_config_settings_updated.connect(func(): _clear_pending("RemoteConfig")) + remote_config.remote_config_listener_registered.connect(func(): _clear_pending("RemoteConfig")) remote_config.remote_config_updated.connect(_on_config_updated) remote_config.remote_config_error.connect(_on_module_error.bind("RemoteConfig")) diff --git a/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt b/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt index da47507..e650aab 100644 --- a/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt +++ b/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt @@ -48,12 +48,13 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { override fun getPluginSignals(): Set { return setOf( - SignalInfo("remote_config_initialized", Boolean::class.javaObjectType), + SignalInfo("remote_config_initialized", Int::class.javaObjectType), + SignalInfo("remote_config_updated", Array::class.java), SignalInfo("remote_config_error", String::class.java), SignalInfo("remote_config_fetch_completed", Int::class.javaObjectType), - SignalInfo("remote_config_updated", Array::class.java), SignalInfo("remote_config_defaults_set"), - SignalInfo("remote_config_settings_updated") + SignalInfo("remote_config_settings_updated"), + SignalInfo("remote_config_listener_registered") ) } @@ -62,27 +63,29 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { val ctx = activity if (ctx == null) { Log.e(TAG, "initialize: activity is null") - emitSignal("remote_config_initialized", false) + emitSignal("remote_config_initialized", 0) emitSignal("remote_config_error", "activity_null") return } if (FirebaseApp.getApps(ctx).isEmpty()) { Log.e(TAG, "Firebase is not initialized โ€” call FirebaseCore.initialize() first") - emitSignal("remote_config_initialized", false) + emitSignal("remote_config_initialized", 0) emitSignal("remote_config_error", "firebase_not_initialized") return } - setup_realtime_updates() Log.d(TAG, "Firebase Remote Config initialized") - emitSignal("remote_config_initialized", true) + emitSignal("remote_config_initialized", 1) } @UsedByGodot - fun setup_realtime_updates(): Boolean { - if (!isInitialized()) return false - if (listenerRegistration != null) return true + fun setup_realtime_updates() { + if (!isInitialized()) return + if (listenerRegistration != null) { + emitSignal("remote_config_listener_registered") + return + } listenerRegistration = remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { override fun onUpdate(configUpdate: ConfigUpdate) { Log.d(TAG, "Config updated keys: " + configUpdate.updatedKeys) @@ -99,7 +102,7 @@ class FirebaseRemoteConfigPlugin(godot: Godot) : GodotPlugin(godot) { emitSignal("remote_config_error", error.message ?: "config_update_error") } }) - return true + emitSignal("remote_config_listener_registered") } @UsedByGodot diff --git a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm index bcc7c8d..9c5a362 100644 --- a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm +++ b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm @@ -1,4 +1,5 @@ #import "godotx_apn_delegate.h" +#import "godotx_firebase_messaging_internal.h" #include "godotx_firebase_messaging.h" @implementation GodotxAPNDelegate @@ -40,7 +41,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center NSString *title = notification.request.content.title ?: @""; NSString *body = notification.request.content.body ?: @""; - Dictionary data = GodotxFirebaseMessaging::user_info_to_dictionary(userInfo); + Dictionary data = user_info_to_dictionary(userInfo); dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseMessaging::instance) { @@ -51,11 +52,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center } }); - if (@available(iOS 14.0, *)) { - completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge); - } else { - completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge); - } + completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge); } - (void)userNotificationCenter:(UNUserNotificationCenter *)center @@ -68,7 +65,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center NSString *title = response.notification.request.content.title ?: @""; NSString *body = response.notification.request.content.body ?: @""; - Dictionary data = GodotxFirebaseMessaging::user_info_to_dictionary(userInfo); + Dictionary data = user_info_to_dictionary(userInfo); dispatch_async(dispatch_get_main_queue(), ^{ if (GodotxFirebaseMessaging::instance) { diff --git a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h index 64910d2..2c63e78 100644 --- a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h +++ b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.h @@ -27,8 +27,8 @@ class GodotxFirebaseMessaging : public Object { void unsubscribe_from_topic(String topic); Dictionary get_last_notification(); - static Variant ns_object_to_variant(id val); - static Dictionary user_info_to_dictionary(NSDictionary *userInfo); + + GodotxFirebaseMessaging(); diff --git a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm index 53aeb45..883959d 100644 --- a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm +++ b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging.mm @@ -1,4 +1,5 @@ #import "godotx_firebase_messaging.h" +#import "godotx_firebase_messaging_internal.h" #import "godotx_apn_delegate.h" #include "core/object/class_db.h" @@ -296,34 +297,7 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin }]; } -Dictionary GodotxFirebaseMessaging::get_last_notification() { - NSDictionary *userInfo = [GodotxAPNDelegate shared].lastNotificationInfo; - if (!userInfo) { - return Dictionary(); - } - - // Extract title and body from aps.alert - NSString *title = @""; - NSString *body = @""; - NSDictionary *aps = userInfo[@"aps"]; - if ([aps isKindOfClass:[NSDictionary class]]) { - id alert = aps[@"alert"]; - if ([alert isKindOfClass:[NSDictionary class]]) { - title = alert[@"title"] ?: @""; - body = alert[@"body"] ?: @""; - } else if ([alert isKindOfClass:[NSString class]]) { - body = alert; - } - } - - Dictionary result; - result["title"] = String::utf8([title UTF8String]); - result["body"] = String::utf8([body UTF8String]); - result["data"] = user_info_to_dictionary(userInfo); - return result; -} - -Variant GodotxFirebaseMessaging::ns_object_to_variant(id val) { +Variant ns_object_to_variant(id val) { if ([val isKindOfClass:[NSString class]]) { return String::utf8([(NSString *)val UTF8String]); } else if ([val isKindOfClass:[NSNumber class]]) { @@ -351,7 +325,7 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin return Variant(); } -Dictionary GodotxFirebaseMessaging::user_info_to_dictionary(NSDictionary *userInfo) { +Dictionary user_info_to_dictionary(NSDictionary *userInfo) { Dictionary dataDict; if (!userInfo) return dataDict; @@ -369,3 +343,32 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin } return dataDict; } + +Dictionary GodotxFirebaseMessaging::get_last_notification() { + NSDictionary *userInfo = [GodotxAPNDelegate shared].lastNotificationInfo; + if (!userInfo) { + return Dictionary(); + } + + // Extract title and body from aps.alert + NSString *title = @""; + NSString *body = @""; + NSDictionary *aps = userInfo[@"aps"]; + if ([aps isKindOfClass:[NSDictionary class]]) { + id alert = aps[@"alert"]; + if ([alert isKindOfClass:[NSDictionary class]]) { + title = alert[@"title"] ?: @""; + body = alert[@"body"] ?: @""; + } else if ([alert isKindOfClass:[NSString class]]) { + body = alert; + } + } + + Dictionary result; + result["title"] = String::utf8([title UTF8String]); + result["body"] = String::utf8([body UTF8String]); + result["data"] = user_info_to_dictionary(userInfo); + return result; +} + + diff --git a/source/ios/firebase_messaging/Sources/godotx_firebase_messaging_internal.h b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging_internal.h new file mode 100644 index 0000000..d75c983 --- /dev/null +++ b/source/ios/firebase_messaging/Sources/godotx_firebase_messaging_internal.h @@ -0,0 +1,13 @@ +#ifndef GODOTX_FIREBASE_MESSAGING_INTERNAL_H +#define GODOTX_FIREBASE_MESSAGING_INTERNAL_H + +#import +#include "core/variant/variant.h" +#include "core/variant/dictionary.h" + +// Internal helper functions for converting Objective-C types to Godot types. +// These are only for use within .mm (Objective-C++) files. +Variant ns_object_to_variant(id val); +Dictionary user_info_to_dictionary(NSDictionary *userInfo); + +#endif // GODOTX_FIREBASE_MESSAGING_INTERNAL_H diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h index 2445fcb..03ddbb5 100644 --- a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h @@ -25,7 +25,7 @@ class GodotxFirebaseRemoteConfig : public Object { Dictionary get_dictionary(const String &key); void set_defaults(const Dictionary &defaults); void set_minimum_fetch_interval(float seconds); - bool setup_realtime_updates(); + void setup_realtime_updates(); void remove_config_update_listener(); GodotxFirebaseRemoteConfig(); diff --git a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm index d3c4faf..294e787 100644 --- a/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm @@ -105,6 +105,7 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { ADD_SIGNAL(MethodInfo("remote_config_updated", PropertyInfo(Variant::ARRAY, "updated_keys"))); ADD_SIGNAL(MethodInfo("remote_config_defaults_set")); ADD_SIGNAL(MethodInfo("remote_config_settings_updated")); + ADD_SIGNAL(MethodInfo("remote_config_listener_registered")); } // --------------------------------------------------------------------------- @@ -235,9 +236,12 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { // Real-time listener // --------------------------------------------------------------------------- -bool GodotxFirebaseRemoteConfig::setup_realtime_updates() { - FIREBASE_CHECK_INITIALIZED_V(false); - if (_listenerRegistration) return true; +void GodotxFirebaseRemoteConfig::setup_realtime_updates() { + FIREBASE_CHECK_INITIALIZED(); + if (_listenerRegistration) { + emit_signal("remote_config_listener_registered"); + return; + } FIRRemoteConfig *rc = [FIRRemoteConfig remoteConfig]; _listenerRegistration = [rc addOnConfigUpdateListener:^(FIRRemoteConfigUpdate *update, NSError *error) { @@ -254,7 +258,7 @@ static Dictionary ns_dict_to_godot(NSDictionary *nsDict) { }); }]; }]; - return true; + emit_signal("remote_config_listener_registered"); } void GodotxFirebaseRemoteConfig::remove_config_update_listener() { From dc8fcb16f3f7c0ce5a70ee24ff93f505dc25703a Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Tue, 28 Apr 2026 00:37:36 +0300 Subject: [PATCH 8/9] feat: Scrolling fixed, update build configurations, and refactor Crashlytics plugin for improved key-value handling --- scenes/Main.tscn | 32 +++++---- scenes/view_stack/AnalyticsView.tscn | 40 +++++++----- scenes/view_stack/CrashlyticsView.tscn | 35 +++++++++- scenes/view_stack/MessagingView.tscn | 25 +++++-- scenes/view_stack/RemoteConfigView.tscn | 33 ++++++---- scripts/ActionRegistry.gd | 4 +- scripts/Main.gd | 39 +++++------ scripts/TestButton.gd | 5 +- .../crashlytics/FirebaseCrashlyticsPlugin.kt | 65 ++----------------- .../Sources/godotx_firebase_crashlytics.h | 5 +- .../Sources/godotx_firebase_crashlytics.mm | 50 ++------------ 11 files changed, 150 insertions(+), 183 deletions(-) diff --git a/scenes/Main.tscn b/scenes/Main.tscn index b3b0c7b..7da9a4a 100644 --- a/scenes/Main.tscn +++ b/scenes/Main.tscn @@ -62,24 +62,31 @@ theme_override_constants/margin_bottom = 20 [node name="Dashboard" type="ScrollContainer" parent="VBoxContainer/ContextGroup"] layout_mode = 2 +size_flags_vertical = 3 -[node name="List" type="VBoxContainer" parent="VBoxContainer/ContextGroup/Dashboard"] +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/ContextGroup/Dashboard"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 + +[node name="List" type="VBoxContainer" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer"] layout_mode = 2 size_flags_horizontal = 3 theme_override_constants/separation = 20 -[node name="InitializeButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +[node name="InitializeButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] custom_minimum_size = Vector2(0, 120) layout_mode = 2 theme_override_font_sizes/font_size = 36 text = "๐Ÿ”ฅ INITIALIZE FIREBASE" script = ExtResource("2") -[node name="HSeparator" type="HSeparator" parent="VBoxContainer/ContextGroup/Dashboard/List"] +[node name="HSeparator" type="HSeparator" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] layout_mode = 2 theme_override_constants/separation = 20 -[node name="AnalyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +[node name="AnalyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] disabled = true custom_minimum_size = Vector2(0, 100) layout_mode = 2 @@ -87,7 +94,7 @@ theme_override_font_sizes/font_size = 32 text = "ANALYTICS" script = ExtResource("2") -[node name="CrashlyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +[node name="CrashlyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] disabled = true custom_minimum_size = Vector2(0, 100) layout_mode = 2 @@ -95,7 +102,7 @@ theme_override_font_sizes/font_size = 32 text = "CRASHLYTICS" script = ExtResource("2") -[node name="MessagingButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +[node name="MessagingButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] disabled = true custom_minimum_size = Vector2(0, 100) layout_mode = 2 @@ -103,7 +110,7 @@ theme_override_font_sizes/font_size = 32 text = "MESSAGING" script = ExtResource("2") -[node name="RemoteConfigButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/List"] +[node name="RemoteConfigButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] disabled = true custom_minimum_size = Vector2(0, 100) layout_mode = 2 @@ -116,6 +123,7 @@ script = ExtResource("2") [node name="ModuleContainer" type="Control" parent="VBoxContainer/ContextGroup"] visible = false layout_mode = 2 +size_flags_vertical = 3 [node name="LogGroup" type="VBoxContainer" parent="VBoxContainer"] custom_minimum_size = Vector2(0, 400) @@ -169,11 +177,11 @@ theme_override_font_sizes/font_size = 24 text = "Copy Logs" [connection signal="pressed" from="VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer/BackButton" to="." method="show_dashboard"] -[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/InitializeButton" to="." method="_on_initialize_pressed"] -[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" to="." method="show_module" binds= ["Analytics"]] -[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" to="." method="show_module" binds= ["Crashlytics"]] -[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" to="." method="show_module" binds= ["Messaging"]] -[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton" to="." method="show_module" binds= ["Remote Config"]] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/InitializeButton" to="." method="_on_initialize_pressed"] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/AnalyticsButton" to="." method="show_module" binds= ["Analytics"]] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/CrashlyticsButton" to="." method="show_module" binds= ["Crashlytics"]] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/MessagingButton" to="." method="show_module" binds= ["Messaging"]] +[connection signal="pressed" from="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/RemoteConfigButton" to="." method="show_module" binds= ["Remote Config"]] [connection signal="pressed" from="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls/ClearLogButton" to="." method="_on_clear_log_pressed"] [connection signal="pressed" from="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls/CopyLogButton" to="." method="_on_copy_log_pressed"] diff --git a/scenes/view_stack/AnalyticsView.tscn b/scenes/view_stack/AnalyticsView.tscn index 5ba2f8b..2b3dad7 100644 --- a/scenes/view_stack/AnalyticsView.tscn +++ b/scenes/view_stack/AnalyticsView.tscn @@ -20,117 +20,123 @@ horizontal_alignment = 1 layout_mode = 2 size_flags_vertical = 3 -[node name="List" type="VBoxContainer" parent="ScrollContainer"] +[node name="MarginContainer" type="MarginContainer" parent="ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 + +[node name="List" type="VBoxContainer" parent="ScrollContainer/MarginContainer"] layout_mode = 2 size_flags_horizontal = 3 theme_override_constants/separation = 20 -[node name="LogEventButton" type="Button" parent="ScrollContainer/List"] +[node name="LogEventButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“Š Log Test Event" script = ExtResource("1") -[node name="LogScreenButton" type="Button" parent="ScrollContainer/List"] +[node name="LogScreenButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“ฑ Log Screen View" script = ExtResource("1") -[node name="UserPropsButton" type="Button" parent="ScrollContainer/List"] +[node name="UserPropsButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ‘ค Set User Property" script = ExtResource("1") -[node name="SetUserIdButton" type="Button" parent="ScrollContainer/List"] +[node name="SetUserIdButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ†” Set User ID" script = ExtResource("1") -[node name="SetDefaultParamsButton" type="Button" parent="ScrollContainer/List"] +[node name="SetDefaultParamsButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "โš™๏ธ Set Default Params" script = ExtResource("1") -[node name="SetConsentButton" type="Button" parent="ScrollContainer/List"] +[node name="SetConsentButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ›ก๏ธ Set Consent" script = ExtResource("1") -[node name="SetCollectionEnabledButton" type="Button" parent="ScrollContainer/List"] +[node name="SetCollectionEnabledButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ”„ Toggle Collection" script = ExtResource("1") -[node name="ResetDataButton" type="Button" parent="ScrollContainer/List"] +[node name="ResetDataButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ—‘๏ธ Reset Data" script = ExtResource("1") -[node name="LogLevelStartButton" type="Button" parent="ScrollContainer/List"] +[node name="LogLevelStartButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐ŸŽฎ Log Level Start" script = ExtResource("1") -[node name="LogLevelEndButton" type="Button" parent="ScrollContainer/List"] +[node name="LogLevelEndButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ Log Level End" script = ExtResource("1") -[node name="LogEarnButton" type="Button" parent="ScrollContainer/List"] +[node name="LogEarnButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ’ฐ Log Earn Currency" script = ExtResource("1") -[node name="LogSpendButton" type="Button" parent="ScrollContainer/List"] +[node name="LogSpendButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ’ธ Log Spend Currency" script = ExtResource("1") -[node name="LogTutorialBeginButton" type="Button" parent="ScrollContainer/List"] +[node name="LogTutorialBeginButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“– Log Tutorial Begin" script = ExtResource("1") -[node name="LogTutorialCompleteButton" type="Button" parent="ScrollContainer/List"] +[node name="LogTutorialCompleteButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐ŸŽ“ Log Tutorial Complete" script = ExtResource("1") -[node name="LogPostScoreButton" type="Button" parent="ScrollContainer/List"] +[node name="LogPostScoreButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ† Log Post Score" script = ExtResource("1") -[node name="LogUnlockAchievementButton" type="Button" parent="ScrollContainer/List"] +[node name="LogUnlockAchievementButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 diff --git a/scenes/view_stack/CrashlyticsView.tscn b/scenes/view_stack/CrashlyticsView.tscn index 111458d..fdbfa17 100644 --- a/scenes/view_stack/CrashlyticsView.tscn +++ b/scenes/view_stack/CrashlyticsView.tscn @@ -16,7 +16,22 @@ theme_override_font_sizes/font_size = 32 text = "Crashlytics Module" horizontal_alignment = 1 -[node name="FatalButton" type="Button" parent="."] +[node name="ScrollContainer" type="ScrollContainer" parent="."] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="MarginContainer" type="MarginContainer" parent="ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 + +[node name="List" type="VBoxContainer" parent="ScrollContainer/MarginContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 20 + +[node name="FatalButton" type="Button" parent="ScrollContainer/MarginContainer/List"] modulate = Color(1, 0.4, 0.4, 1) custom_minimum_size = Vector2(0, 100) layout_mode = 2 @@ -24,14 +39,28 @@ theme_override_font_sizes/font_size = 28 text = "๐Ÿ’€ Force Fatal Crash" script = ExtResource("1") -[node name="NonFatalButton" type="Button" parent="."] +[node name="NonFatalButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "โš ๏ธ Log Non-Fatal Error" script = ExtResource("1") -[node name="CustomValueButton" type="Button" parent="."] +[node name="LogMsgButton" type="Button" parent="ScrollContainer/MarginContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ“ Log Message" +script = ExtResource("1") + +[node name="SetUserIdButton" type="Button" parent="ScrollContainer/MarginContainer/List"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ‘ค Set User ID" +script = ExtResource("1") + +[node name="CustomValueButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 diff --git a/scenes/view_stack/MessagingView.tscn b/scenes/view_stack/MessagingView.tscn index eb277c0..bf98326 100644 --- a/scenes/view_stack/MessagingView.tscn +++ b/scenes/view_stack/MessagingView.tscn @@ -16,14 +16,29 @@ theme_override_font_sizes/font_size = 32 text = "Cloud Messaging" horizontal_alignment = 1 -[node name="PermissionButton" type="Button" parent="."] +[node name="ScrollContainer" type="ScrollContainer" parent="."] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="MarginContainer" type="MarginContainer" parent="ScrollContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 + +[node name="List" type="VBoxContainer" parent="ScrollContainer/MarginContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 20 + +[node name="PermissionButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ”” Request Permission" script = ExtResource("1") -[node name="GetTokenButton" type="Button" parent="."] +[node name="GetTokenButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 disabled = true @@ -31,7 +46,7 @@ theme_override_font_sizes/font_size = 28 text = "๐Ÿ”‘ Get FCM Token" script = ExtResource("1") -[node name="SubscribeButton" type="Button" parent="."] +[node name="SubscribeButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 disabled = true @@ -39,7 +54,7 @@ theme_override_font_sizes/font_size = 28 text = "โž• Subscribe to Topic" script = ExtResource("1") -[node name="UnsubscribeButton" type="Button" parent="."] +[node name="UnsubscribeButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 disabled = true @@ -47,7 +62,7 @@ theme_override_font_sizes/font_size = 28 text = "โž– Unsubscribe from Topic" script = ExtResource("1") -[node name="GetLastNotificationButton" type="Button" parent="."] +[node name="GetLastNotificationButton" type="Button" parent="ScrollContainer/MarginContainer/List"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 disabled = true diff --git a/scenes/view_stack/RemoteConfigView.tscn b/scenes/view_stack/RemoteConfigView.tscn index 84dec61..a3c72cf 100644 --- a/scenes/view_stack/RemoteConfigView.tscn +++ b/scenes/view_stack/RemoteConfigView.tscn @@ -12,7 +12,6 @@ grow_vertical = 2 [node name="List" type="VBoxContainer" parent="."] layout_mode = 2 size_flags_horizontal = 3 -theme_override_constants/separation = 20 [node name="Title" type="Label" parent="List"] layout_mode = 2 @@ -20,69 +19,79 @@ theme_override_font_sizes/font_size = 32 text = "Remote Config Module" horizontal_alignment = 1 -[node name="FetchButton" type="Button" parent="List"] +[node name="MarginContainer" type="MarginContainer" parent="List"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 + +[node name="ButtonList" type="VBoxContainer" parent="List/MarginContainer"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="FetchButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ”„ Fetch & Activate" script = ExtResource("1") -[node name="HSeparator" type="HSeparator" parent="List"] +[node name="HSeparator" type="HSeparator" parent="List/MarginContainer/ButtonList"] layout_mode = 2 -[node name="GetStringButton" type="Button" parent="List"] +[node name="GetStringButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“„ Get String (welcome_message)" script = ExtResource("1") -[node name="GetIntButton" type="Button" parent="List"] +[node name="GetIntButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ”ข Get Int (min_version)" script = ExtResource("1") -[node name="GetFloatButton" type="Button" parent="List"] +[node name="GetFloatButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿงช Get Float (drop_rate)" script = ExtResource("1") -[node name="GetBoolButton" type="Button" parent="List"] +[node name="GetBoolButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ”˜ Get Bool (feature_enabled)" script = ExtResource("1") -[node name="GetDictButton" type="Button" parent="List"] +[node name="GetDictButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“ Get Dictionary (game_config)" script = ExtResource("1") -[node name="HSeparator2" type="HSeparator" parent="List"] +[node name="HSeparator2" type="HSeparator" parent="List/MarginContainer/ButtonList"] layout_mode = 2 -[node name="SetDefaultsButton" type="Button" parent="List"] +[node name="SetDefaultsButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ› ๏ธ Set Local Defaults" script = ExtResource("1") -[node name="SetIntervalButton" type="Button" parent="List"] +[node name="SetIntervalButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "โšก Dev Mode (0s Interval)" script = ExtResource("1") -[node name="ListenerButton" type="Button" parent="List"] +[node name="ListenerButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 theme_override_font_sizes/font_size = 28 diff --git a/scripts/ActionRegistry.gd b/scripts/ActionRegistry.gd index b3a5c8c..1f3c5b7 100644 --- a/scripts/ActionRegistry.gd +++ b/scripts/ActionRegistry.gd @@ -81,8 +81,8 @@ static func get_actions() -> Dictionary: "signal": "crashlytics_user_id_set", "desc": "Setting User ID for crashes" }, "CustomValueButton": { - "method": "set_custom_value_string", "args": ["test_key", "test_value"], - "signal": "crashlytics_value_set", "desc": "Setting custom value" + "method": "set_custom_value", "args": ["test_key", "test_value"], + "signal": "crashlytics_value_set", "desc": "Setting custom value: test_key = test_value (String)" } }, "RemoteConfig": { diff --git a/scripts/Main.gd b/scripts/Main.gd index 3f9eea8..c4acc92 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -3,11 +3,11 @@ extends Control # Dashboard button paths (used by flash_status / update_btn_status) const ActionRegistry = preload("res://scripts/ActionRegistry.gd") -const INIT_PATH := "VBoxContainer/ContextGroup/Dashboard/List/InitializeButton" -const ANALYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton" -const CRASHLYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton" -const MESSAGING_PATH := "VBoxContainer/ContextGroup/Dashboard/List/MessagingButton" -const REMOTE_CONFIG_PATH := "VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton" +const INIT_PATH := "VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/InitializeButton" +const ANALYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/AnalyticsButton" +const CRASHLYTICS_PATH := "VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/CrashlyticsButton" +const MESSAGING_PATH := "VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/MessagingButton" +const REMOTE_CONFIG_PATH := "VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/RemoteConfigButton" # Firebase Singletons (Public) var core: Object = null @@ -36,11 +36,11 @@ var _apns_ready: bool = false @onready var module_container: Control = $VBoxContainer/ContextGroup/ModuleContainer # Dashboard Buttons -@onready var init_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/InitializeButton -@onready var analytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/AnalyticsButton -@onready var crashlytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/CrashlyticsButton -@onready var messaging_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/MessagingButton -@onready var remote_config_btn: Button = $VBoxContainer/ContextGroup/Dashboard/List/RemoteConfigButton +@onready var init_btn: Button = $VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/InitializeButton +@onready var analytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/AnalyticsButton +@onready var crashlytics_btn: Button = $VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/CrashlyticsButton +@onready var messaging_btn: Button = $VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/MessagingButton +@onready var remote_config_btn: Button = $VBoxContainer/ContextGroup/Dashboard/MarginContainer/List/RemoteConfigButton # Log Elements @onready var log_output: TextEdit = $VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogOutput @@ -213,28 +213,29 @@ func enable_service_buttons(enabled: bool) -> void: func _module_btn_path(module_name: String, btn_name: String) -> String: var base_path: String = "VBoxContainer/ContextGroup/ModuleContainer/" + module_name.replace(" ", "") + "View/" - if module_name in ["Remote Config", "RemoteConfig"]: - return base_path + "List/" + btn_name - if module_name == "Analytics": - return base_path + "ScrollContainer/List/" + btn_name + if module_name in ["Remote Config", "RemoteConfig"]: + return base_path + "List/MarginContainer/ButtonList/" + btn_name - return base_path + btn_name + # Analytics, Messaging ve Crashlytics artฤฑk aynฤฑ Scroll/Margin yapฤฑsฤฑnฤฑ kullanฤฑyor + return base_path + "ScrollContainer/MarginContainer/List/" + btn_name func _connect_module_buttons(module_name: String, instance: Node) -> void: if module_name == "Analytics": - var list: Node = instance.get_node("ScrollContainer/List") + var list: Node = instance.get_node("ScrollContainer/MarginContainer/List") for btn_name in _actions["Analytics"].keys(): _connect_btn(list, btn_name, _run_action.bind("Analytics", btn_name)) elif module_name == "Messaging": + var list: Node = instance.get_node("ScrollContainer/MarginContainer/List") for btn_name in _actions["Messaging"].keys(): - _connect_btn(instance, btn_name, _run_action.bind("Messaging", btn_name)) + _connect_btn(list, btn_name, _run_action.bind("Messaging", btn_name)) _update_messaging_view_state(instance) elif module_name == "Crashlytics": + var list: Node = instance.get_node("ScrollContainer/MarginContainer/List") for btn_name in _actions["Crashlytics"].keys(): - _connect_btn(instance, btn_name, _run_action.bind("Crashlytics", btn_name)) + _connect_btn(list, btn_name, _run_action.bind("Crashlytics", btn_name)) elif module_name == "Remote Config": - var list: Node = instance.get_node("List") + var list: Node = instance.get_node("List/MarginContainer/ButtonList") for btn_name in _actions["RemoteConfig"].keys(): _connect_btn(list, btn_name, _run_action.bind("RemoteConfig", btn_name)) diff --git a/scripts/TestButton.gd b/scripts/TestButton.gd index 1149a50..ebff52b 100644 --- a/scripts/TestButton.gd +++ b/scripts/TestButton.gd @@ -1,5 +1,5 @@ -extends Button class_name TestButton +extends Button enum Status { IDLE, @@ -12,6 +12,7 @@ enum Status { var _timer: SceneTreeTimer = null func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_PASS update_status(Status.IDLE) func update_status(status: int) -> void: @@ -29,6 +30,6 @@ func update_status(status: int) -> void: func _start_reset_timer() -> void: if _timer: _timer = null # Cancel previous timer by letting it die - + _timer = get_tree().create_timer(reset_time) _timer.timeout.connect(func(): update_status(Status.IDLE)) diff --git a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt index 7e8b2f2..e63fc5a 100644 --- a/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt +++ b/source/android/firebase_crashlytics/src/main/java/com/godotx/firebase/crashlytics/FirebaseCrashlyticsPlugin.kt @@ -124,16 +124,15 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { } @UsedByGodot - fun set_custom_value_string(key: String, value: String) { - val crashlyticsInstance = crashlytics - if (crashlyticsInstance == null) { + fun set_custom_value(key: String, value: String) { + val c = crashlytics + if (c == null) { Log.e(TAG, "Firebase Crashlytics not initialized") emitSignal("crashlytics_error", "crashlytics_not_initialized") return } - try { - crashlyticsInstance.setCustomKey(key, value) + c.setCustomKey(key, value) Log.d(TAG, "Set custom value: $key = $value") emitSignal("crashlytics_value_set", key) } catch (e: Exception) { @@ -142,61 +141,5 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { } } - @UsedByGodot - fun set_custom_value_int(key: String, value: Int) { - val crashlyticsInstance = crashlytics - if (crashlyticsInstance == null) { - Log.e(TAG, "Firebase Crashlytics not initialized") - emitSignal("crashlytics_error", "crashlytics_not_initialized") - return - } - - try { - crashlyticsInstance.setCustomKey(key, value.toLong()) - Log.d(TAG, "Set custom value: $key = $value") - emitSignal("crashlytics_value_set", key) - } catch (e: Exception) { - Log.e(TAG, "Failed to set custom value", e) - emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") - } - } - - @UsedByGodot - fun set_custom_value_bool(key: String, value: Boolean) { - val crashlyticsInstance = crashlytics - if (crashlyticsInstance == null) { - Log.e(TAG, "Firebase Crashlytics not initialized") - emitSignal("crashlytics_error", "crashlytics_not_initialized") - return - } - - try { - crashlyticsInstance.setCustomKey(key, value) - Log.d(TAG, "Set custom value: $key = $value") - emitSignal("crashlytics_value_set", key) - } catch (e: Exception) { - Log.e(TAG, "Failed to set custom value", e) - emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") - } - } - - @UsedByGodot - fun set_custom_value_float(key: String, value: Float) { - val crashlyticsInstance = crashlytics - if (crashlyticsInstance == null) { - Log.e(TAG, "Firebase Crashlytics not initialized") - emitSignal("crashlytics_error", "crashlytics_not_initialized") - return - } - - try { - crashlyticsInstance.setCustomKey(key, value.toDouble()) - Log.d(TAG, "Set custom value: $key = $value") - emitSignal("crashlytics_value_set", key) - } catch (e: Exception) { - Log.e(TAG, "Failed to set custom value", e) - emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") - } - } } diff --git a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h index 2c89043..cc0a727 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h @@ -20,10 +20,7 @@ class GodotxFirebaseCrashlytics : public Object { void log_non_fatal(String message); void log_message(String message); void set_user_id(String user_id); - void set_custom_value_string(String key, String value); - void set_custom_value_int(String key, int64_t value); - void set_custom_value_bool(String key, bool value); - void set_custom_value_float(String key, double value); + void set_custom_value(String key, String value); GodotxFirebaseCrashlytics(); ~GodotxFirebaseCrashlytics(); diff --git a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm index 9e86967..2b3a065 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm @@ -13,10 +13,7 @@ ClassDB::bind_method(D_METHOD("log_non_fatal", "message"), &GodotxFirebaseCrashlytics::log_non_fatal); ClassDB::bind_method(D_METHOD("log_message", "message"), &GodotxFirebaseCrashlytics::log_message); ClassDB::bind_method(D_METHOD("set_user_id", "user_id"), &GodotxFirebaseCrashlytics::set_user_id); - ClassDB::bind_method(D_METHOD("set_custom_value_string", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value_string); - ClassDB::bind_method(D_METHOD("set_custom_value_int", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value_int); - ClassDB::bind_method(D_METHOD("set_custom_value_bool", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value_bool); - ClassDB::bind_method(D_METHOD("set_custom_value_float", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value_float); + ClassDB::bind_method(D_METHOD("set_custom_value", "key", "value"), &GodotxFirebaseCrashlytics::set_custom_value); ADD_SIGNAL(MethodInfo("crashlytics_initialized", PropertyInfo(Variant::BOOL, "success"))); ADD_SIGNAL(MethodInfo("crashlytics_non_fatal_logged", PropertyInfo(Variant::STRING, "message"))); @@ -81,10 +78,10 @@ } } -void GodotxFirebaseCrashlytics::set_custom_value_string(String key, String value) { +void GodotxFirebaseCrashlytics::set_custom_value(String key, String value) { @try { - NSString* nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; - NSString* nsValue = [NSString stringWithUTF8String:value.utf8().get_data()]; + NSString *nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; + NSString *nsValue = [NSString stringWithUTF8String:value.utf8().get_data()]; [[FIRCrashlytics crashlytics] setCustomValue:nsValue forKey:nsKey]; NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %@", nsKey, nsValue); emit_signal("crashlytics_value_set", key); @@ -95,45 +92,6 @@ } } -void GodotxFirebaseCrashlytics::set_custom_value_int(String key, int64_t value) { - @try { - NSString* nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; - [[FIRCrashlytics crashlytics] setCustomValue:@(value) forKey:nsKey]; - NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %lld", nsKey, value); - emit_signal("crashlytics_value_set", key); - } - @catch (NSException *exception) { - NSLog(@"[GodotxFirebaseCrashlytics] Failed to set custom value: %@", exception.reason); - emit_signal("crashlytics_error", String::utf8([exception.reason UTF8String])); - } -} - -void GodotxFirebaseCrashlytics::set_custom_value_bool(String key, bool value) { - @try { - NSString* nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; - [[FIRCrashlytics crashlytics] setCustomValue:@(value) forKey:nsKey]; - NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %d", nsKey, value); - emit_signal("crashlytics_value_set", key); - } - @catch (NSException *exception) { - NSLog(@"[GodotxFirebaseCrashlytics] Failed to set custom value: %@", exception.reason); - emit_signal("crashlytics_error", String::utf8([exception.reason UTF8String])); - } -} - -void GodotxFirebaseCrashlytics::set_custom_value_float(String key, double value) { - @try { - NSString* nsKey = [NSString stringWithUTF8String:key.utf8().get_data()]; - [[FIRCrashlytics crashlytics] setCustomValue:@(value) forKey:nsKey]; - NSLog(@"[GodotxFirebaseCrashlytics] Set custom value: %@ = %f", nsKey, value); - emit_signal("crashlytics_value_set", key); - } - @catch (NSException *exception) { - NSLog(@"[GodotxFirebaseCrashlytics] Failed to set custom value: %@", exception.reason); - emit_signal("crashlytics_error", String::utf8([exception.reason UTF8String])); - } -} - GodotxFirebaseCrashlytics::GodotxFirebaseCrashlytics() { ERR_FAIL_COND(instance != NULL); instance = this; From b359e27671c3044851db10c22da8a993403e8a3c Mon Sep 17 00:00:00 2001 From: Erdem Giray Date: Tue, 28 Apr 2026 14:11:08 +0300 Subject: [PATCH 9/9] feat: implement iOS remote notification background handling, update export presets --- scenes/view_stack/RemoteConfigView.tscn | 14 ++++++++ scripts/ActionRegistry.gd | 14 ++++++++ scripts/Main.gd | 11 +++--- .../messaging/FirebaseMessagingPlugin.kt | 9 +++++ .../Sources/godot_app_delegate+apns.mm | 36 +++++++++++++++++++ .../Sources/godotx_apn_delegate.mm | 4 +++ 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/scenes/view_stack/RemoteConfigView.tscn b/scenes/view_stack/RemoteConfigView.tscn index a3c72cf..65b5d84 100644 --- a/scenes/view_stack/RemoteConfigView.tscn +++ b/scenes/view_stack/RemoteConfigView.tscn @@ -60,6 +60,13 @@ theme_override_font_sizes/font_size = 28 text = "๐Ÿงช Get Float (drop_rate)" script = ExtResource("1") +[node name="GetDoubleButton" type="Button" parent="List/MarginContainer/ButtonList"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”ฌ Get Double (drop_rate_v2)" +script = ExtResource("1") + [node name="GetBoolButton" type="Button" parent="List/MarginContainer/ButtonList"] custom_minimum_size = Vector2(0, 100) layout_mode = 2 @@ -97,3 +104,10 @@ layout_mode = 2 theme_override_font_sizes/font_size = 28 text = "๐Ÿ“ก Real-time Updates: OFF" script = ExtResource("1") + +[node name="RemoveListenerButton" type="Button" parent="List/MarginContainer/ButtonList"] +custom_minimum_size = Vector2(0, 100) +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "๐Ÿ”• Remove Listener" +script = ExtResource("1") diff --git a/scripts/ActionRegistry.gd b/scripts/ActionRegistry.gd index 1f3c5b7..345e320 100644 --- a/scripts/ActionRegistry.gd +++ b/scripts/ActionRegistry.gd @@ -111,6 +111,14 @@ static func get_actions() -> Dictionary: "validator": func(res: Variant): return typeof(res) == TYPE_FLOAT, "failure_log": "Expected Float, but received invalid type" }, + "GetDoubleButton": { + "method": "get_double", + "args": ["drop_rate_v2", 0.0], + "mode": "getter", + "desc": "Getting 'drop_rate_v2' (double)", + "validator": func(res: Variant): return typeof(res) == TYPE_FLOAT, + "failure_log": "Expected Float/Double, but received invalid type" + }, "GetBoolButton": { "method": "get_bool", "args": ["feature_enabled", false], @@ -138,10 +146,16 @@ static func get_actions() -> Dictionary: "args": [], "signal": "remote_config_listener_registered", "desc": "Enabling Real-time updates listener" + }, + "RemoveListenerButton": { + "method": "remove_config_update_listener", + "args": [], + "desc": "Removing Real-time updates listener" } }, "Messaging": { "GetTokenButton": {"method": "get_token", "args": [], "signal": "messaging_token_received", "desc": "Requesting FCM token..."}, + "GetAPNSTokenButton": {"method": "get_apns_token", "args": [], "signal": "messaging_apn_token_received", "desc": "Requesting APNs token (iOS)..."}, "PermissionButton": {"method": "request_permission", "args": [], "signal": "messaging_permission_granted", "desc": "Requesting permissions..."}, "SubscribeButton": {"method": "subscribe_to_topic", "args": ["test_topic"], "signal": "messaging_topic_subscribed", "desc": "Subscribing to: test_topic"}, "UnsubscribeButton": {"method": "unsubscribe_from_topic", "args": ["test_topic"], "signal": "messaging_topic_unsubscribed", "desc": "Unsubscribing from: test_topic"}, diff --git a/scripts/Main.gd b/scripts/Main.gd index c4acc92..757682c 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -399,11 +399,12 @@ func _update_messaging_view_state(view: Node) -> void: var has_token = !_fcm_token.is_empty() var permission_ok = _messaging_permission_granted - var perm_btn = view.get_node_or_null("PermissionButton") - var token_btn = view.get_node_or_null("GetTokenButton") - var sub_btn = view.get_node_or_null("SubscribeButton") - var unsub_btn = view.get_node_or_null("UnsubscribeButton") - var last_notification_btn = view.get_node_or_null("GetLastNotificationButton") + var list_path := "ScrollContainer/MarginContainer/List/" + var perm_btn = view.get_node_or_null(list_path + "PermissionButton") + var token_btn = view.get_node_or_null(list_path + "GetTokenButton") + var sub_btn = view.get_node_or_null(list_path + "SubscribeButton") + var unsub_btn = view.get_node_or_null(list_path + "UnsubscribeButton") + var last_notification_btn = view.get_node_or_null(list_path + "GetLastNotificationButton") # Step 1: Permission button is always enabled if perm_btn: perm_btn.disabled = false diff --git a/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt b/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt index eb1252b..189450e 100644 --- a/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt +++ b/source/android/firebase_messaging/src/main/java/com/godotx/firebase/messaging/FirebaseMessagingPlugin.kt @@ -123,6 +123,9 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { ), SignalInfo("messaging_error", String::class.java + ), + SignalInfo("messaging_apn_token_received", + String::class.java ) ) } @@ -230,6 +233,12 @@ class FirebaseMessagingPlugin(godot: Godot) : GodotPlugin(godot) { } } + @UsedByGodot + fun get_apns_token() { + Log.d(TAG, "get_apns_token called on Android - ignoring (iOS only)") + emitSignal("messaging_apn_token_received", "") + } + @UsedByGodot fun subscribe_to_topic(topic: String) { try { diff --git a/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm b/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm index 3e89901..1020bc3 100644 --- a/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm +++ b/source/ios/firebase_messaging/Sources/godot_app_delegate+apns.mm @@ -1,5 +1,6 @@ #import "drivers/apple_embedded/godot_app_delegate.h" #include "godotx_firebase_messaging.h" +#import "godotx_firebase_messaging_internal.h" @import Firebase; @@ -41,4 +42,39 @@ - (void)application:(UIApplication *)application didFailToRegisterForRemoteNotif }); } +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler { + [[FIRMessaging messaging] appDidReceiveMessage:userInfo]; + NSLog(@"[GodotxFirebaseMessaging] didReceiveRemoteNotification (Silent/Background): %@", userInfo); + + // If it's a silent push (content-available: 1), it won't trigger UNUserNotificationCenterDelegate. + // We emit the signal here directly. + + // Extract title/body if present (usually empty in silent push) + NSString *title = @""; + NSString *body = @""; + NSDictionary *aps = userInfo[@"aps"]; + if ([aps isKindOfClass:[NSDictionary class]]) { + id alert = aps[@"alert"]; + if ([alert isKindOfClass:[NSDictionary class]]) { + title = alert[@"title"] ?: @""; + body = alert[@"body"] ?: @""; + } else if ([alert isKindOfClass:[NSString class]]) { + body = alert; + } + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (GodotxFirebaseMessaging::instance) { + GodotxFirebaseMessaging::instance->emit_signal( + "messaging_message_received", + String::utf8([title UTF8String]), + String::utf8([body UTF8String]), + user_info_to_dictionary(userInfo) + ); + } + }); + + completionHandler(UIBackgroundFetchResultNewData); +} + @end diff --git a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm index 9c5a362..ae7eac6 100644 --- a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm +++ b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm @@ -2,6 +2,8 @@ #import "godotx_firebase_messaging_internal.h" #include "godotx_firebase_messaging.h" +@import Firebase; + @implementation GodotxAPNDelegate - (instancetype)init { @@ -36,6 +38,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler { NSDictionary *userInfo = notification.request.content.userInfo; + [[FIRMessaging messaging] appDidReceiveMessage:userInfo]; NSLog(@"[GodotxAPNDelegate] Received notification in foreground: %@", userInfo); self.lastNotificationInfo = userInfo; @@ -60,6 +63,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center withCompletionHandler:(void (^)(void))completionHandler { NSDictionary *userInfo = response.notification.request.content.userInfo; + [[FIRMessaging messaging] appDidReceiveMessage:userInfo]; NSLog(@"[GodotxAPNDelegate] User tapped notification: %@", userInfo); self.lastNotificationInfo = userInfo;