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 95674b8..9467867 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 @@ -242,17 +242,24 @@ 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/ ;; \ - esac); \ + echo " - Copying frameworks from FirebaseMessaging..." \ + && 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/ ;; \ + esac) || exit 1; \ echo " ✓ $$module build complete (Debug + Release)"; \ echo ""; \ done 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/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..7da9a4a 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,171 @@ 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="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/HeaderGroup/MarginContainer"] layout_mode = 2 -[node name="StatusLabel" type="Label" parent="VBoxContainer"] -custom_minimum_size = Vector2(0, 60) +[node name="BackButton" type="Button" parent="VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer"] +visible = false +custom_minimum_size = Vector2(80, 60) layout_mode = 2 +size_flags_vertical = 4 theme_override_font_sizes/font_size = 32 -text = "Ready" +text = "<" + +[node name="ViewTitle" type="Label" parent="VBoxContainer/HeaderGroup/MarginContainer/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 42 +text = "Firebase Harness" horizontal_alignment = 1 -autowrap_mode = 3 vertical_alignment = 1 -[node name="HSeparator2" type="HSeparator" parent="VBoxContainer"] +[node name="HSeparator" type="HSeparator" parent="VBoxContainer"] layout_mode = 2 -[node name="ScrollContainer" type="ScrollContainer" parent="VBoxContainer"] +[node name="ContextGroup" type="MarginContainer" parent="VBoxContainer"] layout_mode = 2 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="ContentContainer" type="VBoxContainer" parent="VBoxContainer/ScrollContainer"] +[node name="Dashboard" type="ScrollContainer" parent="VBoxContainer/ContextGroup"] layout_mode = 2 -size_flags_horizontal = 3 size_flags_vertical = 3 -theme_override_constants/separation = 15 -[node name="CoreLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/ContextGroup/Dashboard"] layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "🔥 Firebase Core" +size_flags_horizontal = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 -[node name="InitializeButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="List" type="VBoxContainer" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Initialize Firebase" +size_flags_horizontal = 3 +theme_override_constants/separation = 20 -[node name="HSeparator" type="HSeparator" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[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="AnalyticsLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="HSeparator" type="HSeparator" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "📊 Firebase Analytics" +theme_override_constants/separation = 20 -[node name="LogEventButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="AnalyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] +disabled = true +custom_minimum_size = Vector2(0, 100) layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Log Test Event" +theme_override_font_sizes/font_size = 32 +text = "ANALYTICS" +script = ExtResource("2") -[node name="LogScreenButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="CrashlyticsButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] +disabled = true +custom_minimum_size = Vector2(0, 100) layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Log Screen View" +theme_override_font_sizes/font_size = 32 +text = "CRASHLYTICS" +script = ExtResource("2") -[node name="HSeparator2" type="HSeparator" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="MessagingButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/List"] +disabled = true +custom_minimum_size = Vector2(0, 100) layout_mode = 2 +theme_override_font_sizes/font_size = 32 +text = "MESSAGING" +script = ExtResource("2") -[node name="CrashlyticsLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="RemoteConfigButton" type="Button" parent="VBoxContainer/ContextGroup/Dashboard/MarginContainer/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 = "REMOTE CONFIG" +script = ExtResource("2") -[node name="LogCrashlyticsButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) -layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Log Message" -[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) +[node name="ModuleContainer" type="Control" parent="VBoxContainer/ContextGroup"] +visible = false layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Set Custom Value" +size_flags_vertical = 3 -[node name="ForceCrashButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="LogGroup" type="VBoxContainer" parent="VBoxContainer"] +custom_minimum_size = Vector2(0, 400) 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)" +theme_override_constants/separation = 10 -[node name="HSeparator3" type="HSeparator" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="HSeparator" type="HSeparator" parent="VBoxContainer/LogGroup"] layout_mode = 2 -[node name="MessagingLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="MarginContainer" type="MarginContainer" parent="VBoxContainer/LogGroup"] layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "💬 Firebase Messaging" +size_flags_vertical = 3 +theme_override_constants/margin_left = 20 +theme_override_constants/margin_right = 20 +theme_override_constants/margin_bottom = 10 -[node name="RequestPermissionButton" 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 = "Request Notification Permission" +theme_override_constants/separation = 10 -[node name="GetTokenButton" 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 = "Get FCM Token" +theme_override_font_sizes/font_size = 28 +text = "📝 Output Log" -[node name="SubscribeTopicButton" 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 = "Subscribe to 'test_topic'" +size_flags_vertical = 3 +theme_override_font_sizes/font_size = 22 +editable = false +wrap_mode = 1 -[node name="UnsubscribeTopicButton" type="Button" parent="VBoxContainer/ScrollContainer/ContentContainer"] -custom_minimum_size = Vector2(0, 70) +[node name="LogControls" type="HBoxContainer" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer"] layout_mode = 2 -theme_override_font_sizes/font_size = 26 -text = "Unsubscribe from 'test_topic'" +theme_override_constants/separation = 40 -[node name="HSeparator4" type="HSeparator" 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 = 24 +text = "Clear Logs" -[node name="LogLabel" type="Label" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="Control" type="Control" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls"] layout_mode = 2 -theme_override_font_sizes/font_size = 34 -text = "📝 Output Log" +size_flags_horizontal = 3 -[node name="LogOutput" type="TextEdit" parent="VBoxContainer/ScrollContainer/ContentContainer"] +[node name="CopyLogButton" type="Button" parent="VBoxContainer/LogGroup/MarginContainer/VBoxContainer/LogControls"] +custom_minimum_size = Vector2(200, 70) layout_mode = 2 -custom_minimum_size = Vector2(0, 450) -size_flags_horizontal = 3 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/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"]] -[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..2b3dad7 --- /dev/null +++ b/scenes/view_stack/AnalyticsView.tscn @@ -0,0 +1,145 @@ +[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="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="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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/MarginContainer/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/scenes/view_stack/CrashlyticsView.tscn b/scenes/view_stack/CrashlyticsView.tscn new file mode 100644 index 0000000..fdbfa17 --- /dev/null +++ b/scenes/view_stack/CrashlyticsView.tscn @@ -0,0 +1,68 @@ +[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="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 +theme_override_font_sizes/font_size = 28 +text = "💀 Force Fatal Crash" +script = ExtResource("1") + +[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="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 +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..bf98326 --- /dev/null +++ b/scenes/view_stack/MessagingView.tscn @@ -0,0 +1,71 @@ +[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="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="ScrollContainer/MarginContainer/List"] +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="ScrollContainer/MarginContainer/List"] +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") + +[node name="UnsubscribeButton" type="Button" parent="ScrollContainer/MarginContainer/List"] +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="ScrollContainer/MarginContainer/List"] +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/scenes/view_stack/RemoteConfigView.tscn b/scenes/view_stack/RemoteConfigView.tscn new file mode 100644 index 0000000..65b5d84 --- /dev/null +++ b/scenes/view_stack/RemoteConfigView.tscn @@ -0,0 +1,113 @@ +[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 + +[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="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/MarginContainer/ButtonList"] +layout_mode = 2 + +[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/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/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="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 +theme_override_font_sizes/font_size = 28 +text = "🔘 Get Bool (feature_enabled)" +script = ExtResource("1") + +[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/MarginContainer/ButtonList"] +layout_mode = 2 + +[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/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/MarginContainer/ButtonList"] +custom_minimum_size = Vector2(0, 100) +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 new file mode 100644 index 0000000..345e320 --- /dev/null +++ b/scripts/ActionRegistry.gd @@ -0,0 +1,171 @@ +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", "args": ["test_key", "test_value"], + "signal": "crashlytics_value_set", "desc": "Setting custom value: test_key = test_value (String)" + } + }, + "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" + }, + "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], + "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": [], + "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"}, + "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 339dd43..757682c 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -1,21 +1,63 @@ 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/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 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 + +# 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/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 + +@onready var _actions: Dictionary = ActionRegistry.get_actions() -# UI Elements -@onready var status_label: Label = $VBoxContainer/StatusLabel -@onready var log_output: TextEdit = $VBoxContainer/ScrollContainer/ContentContainer/LogOutput 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 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 +66,416 @@ 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")) + + # Connect all async signals to generic success handler with validation + 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: 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: 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: 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: 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: 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")) + + # Connect async signals + 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: 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") + # 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: 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_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")) + log_message("✓ Firebase Remote Config plugin found") + else: + log_message("✗ Firebase Remote Config 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: Node in module_container.get_children(): + child.queue_free() + + 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: PackedScene = load(scene_path) + var instance: Node = 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: Node = 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 + remote_config_btn.disabled = !enabled + +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/MarginContainer/ButtonList/" + 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/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(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(list, btn_name, _run_action.bind("Crashlytics", btn_name)) + elif module_name == "Remote Config": + 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)) + +func _connect_btn(instance: Node, btn_name: String, method: Callable) -> void: + 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: 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: Object = null + match module_name: + "Analytics": plugin = analytics + "Crashlytics": plugin = crashlytics + "Messaging": plugin = messaging + "RemoteConfig": plugin = remote_config + + 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) + 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: 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: 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"): + 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: - 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) - -func _on_crashlytics_initialized(success: bool) -> void: - if success: - log_message("[Crashlytics] ✓ Initialized") - else: - log_message("[Crashlytics] ✗ Initialization failed") - -func _on_analytics_initialized(success: bool) -> void: - if success: - log_message("[Analytics] ✓ Initialized") - update_status("Firebase Ready", Color.GREEN) - else: - log_message("[Analytics] ✗ Initialization failed") + flash_status(INIT_PATH, TestButton.Status.FAILURE) + enable_service_buttons(false) + return -# ============== 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: - log_message("[Analytics] Plugin not available") + log_message("[Core] ✓ Firebase initialized successfully!") + flash_status(INIT_PATH, TestButton.Status.SUCCESS) + _start_module_init_cascade() -func _on_log_screen_pressed() -> void: +func _start_module_init_cascade() -> 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: - log_message("[Analytics] Plugin not available") - -func _on_event_logged(event_name: String) -> void: - log_message("[Analytics] ✓ Event logged: " + event_name) - -# ============== 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: + log_message("[Analytics] Initializing...") + analytics.initialize() 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) - - log_message("[Crashlytics] ✓ Custom values set") - else: - log_message("[Crashlytics] Plugin not available") - -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") - -# ============== 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: - log_message("[Messaging] Plugin not available") - -func _on_subscribe_topic_pressed() -> void: + log_message("[Crashlytics] Initializing...") + crashlytics.initialize() 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: - log_message("[Messaging] Plugin not available") + 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 + match module_name: + "Analytics": + module_btn = analytics_btn + "Crashlytics": + module_btn = crashlytics_btn + "Messaging": + module_btn = messaging_btn + "RemoteConfig": + module_btn = remote_config_btn -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") + if success: + log_message("[%s] ✓ Initialized" % module_name) + if module_btn: module_btn.disabled = false else: - log_message("[Messaging] Plugin not available") - -func _on_permission_granted() -> void: - log_message("[Messaging] ✓ Notification permission granted") - update_status("Permission Granted", Color.GREEN) - -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) + log_message("[%s] ✗ Initialization failed" % module_name) + if module_btn: module_btn.disabled = true + +# (Analytics Handlers removed - now using _run_action) + +# (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) + +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_messaging_topic_subscribed(topic: String) -> void: + log_message("[Messaging] ✓ Subscribed to: " + topic) + _clear_pending("Messaging") + +func _on_messaging_topic_unsubscribed(topic: String) -> void: + log_message("[Messaging] ✓ Unsubscribed from: " + topic) + _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", 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: + _apns_ready = true + log_message("[Messaging] APNs Token received (Ready for FCM)") + +func _update_messaging_view_state(view: Node) -> void: + var has_token = !_fcm_token.is_empty() + var permission_ok = _messaging_permission_granted + + 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 + + # Step 2: Get Token only enabled after permission is granted + if token_btn: token_btn.disabled = !permission_ok + + # 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)) + +# (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_token_received(token: String) -> void: - log_message("[Messaging] ✓ FCM Token received:") - log_message(" " + token) - update_status("Token Received", Color.GREEN) +func _on_error(message: String, module: String) -> void: + log_message("[" + module + "] ✗ Error: " + message) -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_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 _on_message_received(title: String, body: String) -> void: - log_message("[Messaging] ✓ Message received:") - log_message(" Title: " + title) - log_message(" Body: " + body) +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 if success else TestButton.Status.FAILURE) + _pending_call[module_name] = "" -# ============== GENERAL ============== -func _on_error(message: String, module: String) -> void: - log_message("[" + module + "] ✗ Error: " + message) - update_status("Error: " + message, Color.RED) +# ============== 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") + + +# (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/scripts/TestButton.gd b/scripts/TestButton.gd new file mode 100644 index 0000000..ebff52b --- /dev/null +++ b/scripts/TestButton.gd @@ -0,0 +1,35 @@ +class_name TestButton +extends Button + +enum Status { + IDLE, + PENDING, + SUCCESS, + FAILURE +} + +@export var reset_time: float = 3.0 +var _timer: SceneTreeTimer = null + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_PASS + 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)) 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..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 @@ -36,6 +36,31 @@ 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_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 @@ -63,6 +88,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 +126,194 @@ 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") + 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") + } + } + + @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") + 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") + } + } + + @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)") + 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") + } + } - 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") + 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") + } + } - // 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") + 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") + } + } - 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") + emitSignal("analytics_data_reset") + } catch (e: Exception) { + Log.e(TAG, "Failed to reset analytics data", e) + emitSignal("analytics_error", e.message ?: "reset_data_error") + } + } + + @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") + emitSignal("analytics_consent_set") } 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) + emitSignal("analytics_error", e.message ?: "consent_error") } } + + @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()) + } + + @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 443e207..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 @@ -28,6 +28,18 @@ 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_user_id_set", + String::class.java + ), SignalInfo("crashlytics_error", String::class.java ) @@ -55,7 +67,7 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { } @UsedByGodot - fun log_message(message: String) { + fun log_non_fatal(message: String) { val crashlyticsInstance = crashlytics if (crashlyticsInstance == null) { Log.e(TAG, "Firebase Crashlytics not initialized") @@ -64,34 +76,17 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { } try { - crashlyticsInstance.log(message) - Log.d(TAG, "Logged message to Crashlytics: $message") + 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 log message", e) - emitSignal("crashlytics_error", e.message ?: "log_error") + Log.e(TAG, "Failed to record non-fatal exception", e) + emitSignal("crashlytics_error", e.message ?: "non_fatal_log_error") } } @UsedByGodot - fun set_user_id(user_id: String) { - val crashlyticsInstance = crashlytics - if (crashlyticsInstance == null) { - Log.e(TAG, "Firebase Crashlytics not initialized") - emitSignal("crashlytics_error", "crashlytics_not_initialized") - return - } - - try { - crashlyticsInstance.setUserId(user_id) - Log.d(TAG, "Set user ID: $user_id") - } catch (e: Exception) { - Log.e(TAG, "Failed to set user ID", e) - emitSignal("crashlytics_error", e.message ?: "set_user_error") - } - } - - @UsedByGodot - fun set_custom_value_string(key: String, value: String) { + fun log_message(message: String) { val crashlyticsInstance = crashlytics if (crashlyticsInstance == null) { Log.e(TAG, "Firebase Crashlytics not initialized") @@ -100,16 +95,17 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { } try { - crashlyticsInstance.setCustomKey(key, value) - Log.d(TAG, "Set custom value: $key = $value") + crashlyticsInstance.log(message) + Log.d(TAG, "Logged message to Crashlytics: $message") + emitSignal("crashlytics_message_logged", message) } catch (e: Exception) { - Log.e(TAG, "Failed to set custom value", e) - emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") + Log.e(TAG, "Failed to log message", e) + emitSignal("crashlytics_error", e.message ?: "log_error") } } @UsedByGodot - fun set_custom_value_int(key: String, value: Int) { + fun set_user_id(user_id: String) { val crashlyticsInstance = crashlytics if (crashlyticsInstance == null) { Log.e(TAG, "Firebase Crashlytics not initialized") @@ -118,48 +114,32 @@ class FirebaseCrashlyticsPlugin(godot: Godot) : GodotPlugin(godot) { } try { - crashlyticsInstance.setCustomKey(key, value.toLong()) - Log.d(TAG, "Set custom value: $key = $value") + 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 custom value", e) - emitSignal("crashlytics_error", e.message ?: "set_custom_value_error") + Log.e(TAG, "Failed to set user ID", e) + emitSignal("crashlytics_error", e.message ?: "set_user_error") } } @UsedByGodot - fun set_custom_value_bool(key: String, value: Boolean) { - 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) { 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") - } 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/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..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 @@ -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,10 +112,20 @@ 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", String::class.java + ), + SignalInfo("messaging_apn_token_received", + String::class.java ) ) } @@ -125,6 +152,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 +162,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 +170,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 +179,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") } } @@ -202,18 +233,20 @@ 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 { 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 +257,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/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 0000000..e708b1c Binary files /dev/null and b/source/android/firebase_remote_config/gradle/wrapper/gradle-wrapper.jar differ 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..e650aab --- /dev/null +++ b/source/android/firebase_remote_config/src/main/java/com/godotx/firebase/remoteconfig/FirebaseRemoteConfigPlugin.kt @@ -0,0 +1,244 @@ +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") + } + + private fun isInitialized(): Boolean { + val ctx = activity ?: return false + return FirebaseApp.getApps(ctx).isNotEmpty() + } + + override fun getPluginName(): String { + return "GodotxFirebaseRemoteConfig" + } + + override fun getPluginSignals(): Set { + return setOf( + 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_defaults_set"), + SignalInfo("remote_config_settings_updated"), + SignalInfo("remote_config_listener_registered") + ) + } + + @UsedByGodot + fun initialize() { + val ctx = activity + if (ctx == null) { + Log.e(TAG, "initialize: activity is null") + 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", 0) + emitSignal("remote_config_error", "firebase_not_initialized") + return + } + + Log.d(TAG, "Firebase Remote Config initialized") + emitSignal("remote_config_initialized", 1) + } + + @UsedByGodot + 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) + remoteConfig.activate().addOnCompleteListener { task -> + if (task.isSuccessful) { + val updatedKeysArray = configUpdate.updatedKeys.toTypedArray() + emitSignal("remote_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") + } + }) + emitSignal("remote_config_listener_registered") + } + + @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("remote_config_fetch_completed", status) + } else { + val status = if (task.exception is FirebaseRemoteConfigFetchThrottledException) + FETCH_THROTTLED else FETCH_FAILURE + 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() + } + + @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() + } + + @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_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.asDouble() + } + + @UsedByGodot + 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 { + jsonToDictionary(JSONObject(value.asString())) + } catch (e: Exception) { + Log.e(TAG, "Error parsing JSON for key: $key", e) + dict + } + } + + @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).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).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 + 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_analytics/Sources/godotx_firebase_analytics.h b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h index fd7d916..45f5d7d 100644 --- a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h +++ b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.h @@ -17,6 +17,22 @@ 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(); + 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 b7d5f68..fc662c0 100644 --- a/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm +++ b/source/ios/firebase_analytics/Sources/godotx_firebase_analytics.mm @@ -10,9 +10,32 @@ 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); + 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"))); } @@ -49,6 +72,182 @@ 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]; + emit_signal("analytics_user_id_set", user_id); + } + @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]; + emit_signal("analytics_default_params_set"); + } + @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]; + emit_signal("analytics_collection_enabled_set", 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]; + emit_signal("analytics_data_reset"); + } + @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]; + emit_signal("analytics_consent_set"); + } + @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_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_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_crashlytics/Sources/godotx_firebase_crashlytics.h b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h index 30eee57..cc0a727 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.h @@ -17,12 +17,10 @@ class GodotxFirebaseCrashlytics : public Object { void initialize(); void crash(); + 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 87b4a07..2b3a065 100644 --- a/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm +++ b/source/ios/firebase_crashlytics/Sources/godotx_firebase_crashlytics.mm @@ -10,14 +10,16 @@ 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", "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"))); + 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"))); } @@ -34,11 +36,28 @@ @[][1]; } +void GodotxFirebaseCrashlytics::log_non_fatal(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); @@ -51,6 +70,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); @@ -58,48 +78,13 @@ } } -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); - } - @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_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); - } - @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); - } - @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); 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"] 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..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; @@ -8,11 +9,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; @@ -46,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.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..ae7eac6 100644 --- a/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm +++ b/source/ios/firebase_messaging/Sources/godotx_apn_delegate.mm @@ -1,6 +1,9 @@ #import "godotx_apn_delegate.h" +#import "godotx_firebase_messaging_internal.h" #include "godotx_firebase_messaging.h" +@import Firebase; + @implementation GodotxAPNDelegate - (instancetype)init { @@ -25,6 +28,9 @@ + (instancetype)shared { return sharedInstance; } +// Note: reserved keys filtering and recursive parsing is now handled centrally +// in GodotxFirebaseMessaging::user_info_to_dictionary + #pragma mark - UNUserNotificationCenterDelegate - (void)userNotificationCenter:(UNUserNotificationCenter *)center @@ -32,24 +38,24 @@ - (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; NSString *title = notification.request.content.title ?: @""; NSString *body = notification.request.content.body ?: @""; + Dictionary data = user_info_to_dictionary(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); } }); - if (@available(iOS 14.0, *)) { - completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge); - } else { - completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge); - } + completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge); } - (void)userNotificationCenter:(UNUserNotificationCenter *)center @@ -57,16 +63,20 @@ - (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; NSString *title = response.notification.request.content.title ?: @""; NSString *body = response.notification.request.content.body ?: @""; + Dictionary data = user_info_to_dictionary(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 +84,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..2c63e78 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,11 @@ 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..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" @@ -39,12 +40,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 +74,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 +89,7 @@ - (void)messaging:(FIRMessaging *)messaging didReceiveRegistrationToken:(NSStrin [[GodotxAPNDelegate shared] activateNotificationCenterDelegate]; NSLog(@"[GodotxFirebaseMessaging] Initialized"); + emit_signal("messaging_initialized", true); } void GodotxFirebaseMessaging::request_permission() { @@ -255,6 +263,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 +288,87 @@ - (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])); + } + }); } }]; } + +Variant 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 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) { + // 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]); + } + 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_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"] 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..03ddbb5 --- /dev/null +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.h @@ -0,0 +1,35 @@ +#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); + 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(); + 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..294e787 --- /dev/null +++ b/source/ios/firebase_remote_config/Sources/godotx_firebase_remote_config.mm @@ -0,0 +1,267 @@ +#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; +} + +#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 +// --------------------------------------------------------------------------- + +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_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); + 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("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")); + ADD_SIGNAL(MethodInfo("remote_config_listener_registered")); +} + +// --------------------------------------------------------------------------- +// 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() { + FIREBASE_CHECK_INITIALIZED(); + 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("remote_config_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) { + 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]; +} + +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.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(); + return ns_dict_to_godot((NSDictionary *)json); +} + +// --------------------------------------------------------------------------- +// Defaults & settings +// --------------------------------------------------------------------------- + +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++) { + 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]; + 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() { + FIREBASE_CHECK_INITIALIZED(); + if (_listenerRegistration) { + emit_signal("remote_config_listener_registered"); + return; + } + 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("remote_config_updated", keys); + } + }); + }]; + }]; + emit_signal("remote_config_listener_registered"); +} + +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"