Ad fixes and appsflyer
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ddc06781290bc42b480134ac73d6a269
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Darkmatter.Core.Contracts.Services.LogRocket
|
||||
{
|
||||
public interface ILogRocketService
|
||||
{
|
||||
bool IsReady { get; }
|
||||
|
||||
void Identify(string userId);
|
||||
void Identify(string userId, IReadOnlyDictionary<string, object> traits);
|
||||
|
||||
void Track(string eventName);
|
||||
void Track(string eventName, string propName, string propValue);
|
||||
void Track(string eventName, IReadOnlyDictionary<string, object> properties);
|
||||
|
||||
void Log(string message, LogRocketSeverity severity = LogRocketSeverity.Info);
|
||||
void LogException(string message, string stackTrace);
|
||||
|
||||
void StartSessionReplay();
|
||||
void StopSessionReplay();
|
||||
|
||||
string SessionUrl { get; }
|
||||
}
|
||||
|
||||
public enum LogRocketSeverity
|
||||
{
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a44c903b7fed4e889c9fc91430e4b71
|
||||
@@ -103,6 +103,10 @@ public class ColorbookFlowController : IAsyncStartable, IDisposable
|
||||
|
||||
await ShowRewardedAdAsync(ct);
|
||||
|
||||
// Ad SDKs can resume the continuation on a background thread; the scene load below
|
||||
// must run on the Unity main thread. No-op when already there.
|
||||
await UniTask.SwitchToMainThread(ct);
|
||||
|
||||
var progress = new Progress<float>(p => _loadingScreen.SetProgress(p * 0.5f));
|
||||
var mappedProgress = new Progress<float>(p => _loadingScreen.SetProgress(0.5f + p * 0.25f));
|
||||
await _progression.SetLastOpenedAsync(templateId);
|
||||
|
||||
@@ -76,6 +76,13 @@ namespace Darkmatter.Services.Ads
|
||||
#if GOOGLE_MOBILE_ADS
|
||||
ApplyRequestConfiguration();
|
||||
|
||||
// AdMob raises ad callbacks (OnAdFullScreenContentClosed, etc.) on a background
|
||||
// thread by default. UniTask continuations then resume off the main thread, so the
|
||||
// scene load that runs after a rewarded ad closes calls SceneManager/Addressables
|
||||
// from a background thread and throws — leaving the loading screen stuck at 0%.
|
||||
// Force all ad events onto the Unity main thread.
|
||||
MobileAds.RaiseAdEventsOnUnityMainThread = true;
|
||||
|
||||
var tcs = new UniTaskCompletionSource<bool>();
|
||||
MobileAds.Initialize(_ => tcs.TrySetResult(true));
|
||||
|
||||
|
||||
8
Assets/Darkmatter/Code/Services/LogRocket.meta
Normal file
8
Assets/Darkmatter/Code/Services/LogRocket.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c6df39d33cdd84ce1ab71ee4e5026682
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8eef8a073e91748a09d25cf48e413e05
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Configuration
|
||||
{
|
||||
[CreateAssetMenu(menuName = "Darkmatter/Services/LogRocket Config", fileName = "LogRocketConfig")]
|
||||
public sealed class LogRocketConfig : ScriptableObject
|
||||
{
|
||||
[Header("Project")]
|
||||
[Tooltip("LogRocket application ID, e.g. \"yourorg/yourapp\".")]
|
||||
public string AppId;
|
||||
|
||||
[Header("Logs")]
|
||||
[Tooltip("Forward Unity Debug.Log / exceptions to LogRocket.")]
|
||||
public bool ForwardUnityLogs = true;
|
||||
|
||||
[Tooltip("Minimum Unity log type forwarded.")]
|
||||
public LogType MinimumLogType = LogType.Warning;
|
||||
|
||||
[Header("Session Replay")]
|
||||
[Tooltip("Periodically capture a screenshot and upload it as a replay frame.")]
|
||||
public bool EnableSessionReplay = true;
|
||||
|
||||
[Tooltip("Seconds between captured frames. Lower interval = larger payload.")]
|
||||
[Range(0.25f, 10f)] public float CaptureIntervalSeconds = 2f;
|
||||
|
||||
[Tooltip("Max width in pixels for captured frames. Frames downscale to fit.")]
|
||||
[Range(128, 2048)] public int MaxFrameWidth = 720;
|
||||
|
||||
[Tooltip("JPEG quality (1-100) used when encoding replay frames.")]
|
||||
[Range(10, 100)] public int JpegQuality = 60;
|
||||
|
||||
[Header("Behaviour")]
|
||||
[Tooltip("Disable LogRocket entirely in the editor.")]
|
||||
public bool DisableInEditor = true;
|
||||
|
||||
[Tooltip("Auto-start session on service init.")]
|
||||
public bool AutoStart = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e20e49adf823a4918b4b7823d203a28b
|
||||
8
Assets/Darkmatter/Code/Services/LogRocket/Editor.meta
Normal file
8
Assets/Darkmatter/Code/Services/LogRocket/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7656001fcf7748289669ff7f1bba786
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
External Dependency Manager (EDM4U) dependency spec for the LogRocket SDK.
|
||||
EDM injects these into the generated iOS Podfile / Android Gradle on build.
|
||||
|
||||
Verify the versions against the current LogRocket release before shipping:
|
||||
iOS pod: https://cocoapods.org/pods/LogRocket (ObjC class: LROSDK)
|
||||
Android Maven: confirm group/artifact + the bridge's reflected class name
|
||||
in LogRocketUnityBridge.java (com.logrocket.core.LogRocket is
|
||||
an UNVERIFIED guess).
|
||||
-->
|
||||
<dependencies>
|
||||
<iosPods>
|
||||
<iosPod name="LogRocket" version="~> 1.47" addToAllTargets="false">
|
||||
<sources>
|
||||
<source>https://github.com/CocoaPods/Specs</source>
|
||||
</sources>
|
||||
</iosPod>
|
||||
</iosPods>
|
||||
<androidPackages>
|
||||
<!-- TODO: confirm the real Maven coordinate. Placeholder commented out so it
|
||||
does not break Android resolution until verified.
|
||||
<androidPackage spec="com.logrocket:logrocket:+" />
|
||||
-->
|
||||
</androidPackages>
|
||||
</dependencies>
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2806d88a0ebc4aaeb06d2fd098c7555
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d3c637029058429a8ea92a186cba06f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,28 @@
|
||||
using Darkmatter.Core.Contracts.Services.LogRocket;
|
||||
using Darkmatter.Libs.Installers;
|
||||
using Darkmatter.Services.LogRocket.Configuration;
|
||||
using Darkmatter.Services.LogRocket.Systems;
|
||||
using UnityEngine;
|
||||
using VContainer;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Installers
|
||||
{
|
||||
public sealed class LogRocketModule : MonoBehaviour, IModule
|
||||
{
|
||||
[SerializeField] private LogRocketConfig _config;
|
||||
|
||||
public void Register(IContainerBuilder builder)
|
||||
{
|
||||
var config = _config;
|
||||
if (config == null)
|
||||
{
|
||||
Debug.LogWarning("[LogRocket] LogRocketModule has no config assigned. Service will be disabled.");
|
||||
config = ScriptableObject.CreateInstance<LogRocketConfig>();
|
||||
}
|
||||
|
||||
builder.RegisterInstance(config);
|
||||
builder.RegisterEntryPoint<LogRocketSystem>().As<ILogRocketService>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f78e90a5f08704f62b3624ff47ebf66c
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Services.LogRocket",
|
||||
"rootNamespace": "Darkmatter.Services.LogRocket",
|
||||
"references": [
|
||||
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
|
||||
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
|
||||
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
|
||||
"GUID:f51ebe6a0ceec4240a699833d6309b23"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5a1957cf2cabd413bad46969b408df0e
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/Darkmatter/Code/Services/LogRocket/Systems.meta
Normal file
8
Assets/Darkmatter/Code/Services/LogRocket/Systems.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 491835b65b47144db979541bb7e37499
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Systems
|
||||
{
|
||||
internal interface ILogRocketNative
|
||||
{
|
||||
bool IsAvailable { get; }
|
||||
string SessionUrl { get; }
|
||||
void Init(string appId);
|
||||
void Identify(string userId, IReadOnlyDictionary<string, object> traits);
|
||||
void Track(string eventName, IReadOnlyDictionary<string, object> properties);
|
||||
void Log(string severity, string message);
|
||||
void LogException(string message, string stackTrace);
|
||||
void CaptureFrame(byte[] jpegBytes, int width, int height);
|
||||
void StartReplay();
|
||||
void StopReplay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14484983d15d0451ebe505bb8f93f447
|
||||
@@ -0,0 +1,142 @@
|
||||
#if UNITY_ANDROID && !UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Systems
|
||||
{
|
||||
internal sealed class LogRocketNativeAndroid : ILogRocketNative
|
||||
{
|
||||
private const string BridgeClass = "com.darkmatter.logrocket.LogRocketUnityBridge";
|
||||
|
||||
private AndroidJavaObject _bridge;
|
||||
public bool IsAvailable => _bridge != null;
|
||||
public string SessionUrl => SafeCall<string>("getSessionUrl");
|
||||
|
||||
public void Init(string appId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bridgeClass = new AndroidJavaClass(BridgeClass);
|
||||
using var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
|
||||
using var activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
|
||||
_bridge = bridgeClass.CallStatic<AndroidJavaObject>("getInstance");
|
||||
_bridge.Call("init", activity, appId ?? string.Empty);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[LogRocket] Android init failed: {e}");
|
||||
_bridge = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Identify(string userId, IReadOnlyDictionary<string, object> traits)
|
||||
{
|
||||
if (_bridge == null) return;
|
||||
try { _bridge.Call("identify", userId ?? string.Empty, MapToJson(traits)); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] identify failed: {e}"); }
|
||||
}
|
||||
|
||||
public void Track(string eventName, IReadOnlyDictionary<string, object> properties)
|
||||
{
|
||||
if (_bridge == null) return;
|
||||
try { _bridge.Call("track", eventName ?? string.Empty, MapToJson(properties)); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] track failed: {e}"); }
|
||||
}
|
||||
|
||||
public void Log(string severity, string message)
|
||||
{
|
||||
if (_bridge == null) return;
|
||||
try { _bridge.Call("log", severity ?? "info", message ?? string.Empty); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] log failed: {e}"); }
|
||||
}
|
||||
|
||||
public void LogException(string message, string stackTrace)
|
||||
{
|
||||
if (_bridge == null) return;
|
||||
try { _bridge.Call("logException", message ?? string.Empty, stackTrace ?? string.Empty); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] logException failed: {e}"); }
|
||||
}
|
||||
|
||||
public void CaptureFrame(byte[] jpeg, int width, int height)
|
||||
{
|
||||
if (_bridge == null || jpeg == null || jpeg.Length == 0) return;
|
||||
try { _bridge.Call("captureFrame", jpeg, width, height); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] captureFrame failed: {e}"); }
|
||||
}
|
||||
|
||||
public void StartReplay()
|
||||
{
|
||||
if (_bridge == null) return;
|
||||
try { _bridge.Call("startReplay"); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] startReplay failed: {e}"); }
|
||||
}
|
||||
|
||||
public void StopReplay()
|
||||
{
|
||||
if (_bridge == null) return;
|
||||
try { _bridge.Call("stopReplay"); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] stopReplay failed: {e}"); }
|
||||
}
|
||||
|
||||
private T SafeCall<T>(string method)
|
||||
{
|
||||
if (_bridge == null) return default;
|
||||
try { return _bridge.Call<T>(method); }
|
||||
catch { return default; }
|
||||
}
|
||||
|
||||
private static string MapToJson(IReadOnlyDictionary<string, object> map)
|
||||
{
|
||||
if (map == null || map.Count == 0) return "{}";
|
||||
var sb = new System.Text.StringBuilder(64);
|
||||
sb.Append('{');
|
||||
bool first = true;
|
||||
foreach (var kv in map)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
AppendJsonString(sb, kv.Key);
|
||||
sb.Append(':');
|
||||
AppendJsonValue(sb, kv.Value);
|
||||
}
|
||||
sb.Append('}');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendJsonValue(System.Text.StringBuilder sb, object v)
|
||||
{
|
||||
switch (v)
|
||||
{
|
||||
case null: sb.Append("null"); break;
|
||||
case bool b: sb.Append(b ? "true" : "false"); break;
|
||||
case int or long or float or double or decimal:
|
||||
sb.Append(Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture));
|
||||
break;
|
||||
default: AppendJsonString(sb, v.ToString()); break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendJsonString(System.Text.StringBuilder sb, string s)
|
||||
{
|
||||
sb.Append('"');
|
||||
foreach (var c in s ?? string.Empty)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) sb.AppendFormat("\\u{0:X4}", (int)c);
|
||||
else sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e384b24344c8e4899be0036dd9edc333
|
||||
@@ -0,0 +1,145 @@
|
||||
#if UNITY_IOS && !UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Systems
|
||||
{
|
||||
internal sealed class LogRocketNativeIOS : ILogRocketNative
|
||||
{
|
||||
[DllImport("__Internal")] private static extern void _lr_init(string appId);
|
||||
[DllImport("__Internal")] private static extern void _lr_identify(string userId, string traitsJson);
|
||||
[DllImport("__Internal")] private static extern void _lr_track(string eventName, string propsJson);
|
||||
[DllImport("__Internal")] private static extern void _lr_log(string severity, string message);
|
||||
[DllImport("__Internal")] private static extern void _lr_logException(string message, string stack);
|
||||
[DllImport("__Internal")] private static extern void _lr_captureFrame(IntPtr bytes, int length, int width, int height);
|
||||
[DllImport("__Internal")] private static extern void _lr_startReplay();
|
||||
[DllImport("__Internal")] private static extern void _lr_stopReplay();
|
||||
[DllImport("__Internal")] private static extern IntPtr _lr_sessionUrl();
|
||||
|
||||
private bool _initialised;
|
||||
public bool IsAvailable => _initialised;
|
||||
|
||||
public string SessionUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var ptr = _lr_sessionUrl();
|
||||
return ptr == IntPtr.Zero ? null : Marshal.PtrToStringAuto(ptr);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public void Init(string appId)
|
||||
{
|
||||
try { _lr_init(appId ?? string.Empty); _initialised = true; }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] iOS init failed: {e}"); _initialised = false; }
|
||||
}
|
||||
|
||||
public void Identify(string userId, IReadOnlyDictionary<string, object> traits)
|
||||
{
|
||||
if (!_initialised) return;
|
||||
try { _lr_identify(userId ?? string.Empty, MapToJson(traits)); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] identify failed: {e}"); }
|
||||
}
|
||||
|
||||
public void Track(string eventName, IReadOnlyDictionary<string, object> properties)
|
||||
{
|
||||
if (!_initialised) return;
|
||||
try { _lr_track(eventName ?? string.Empty, MapToJson(properties)); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] track failed: {e}"); }
|
||||
}
|
||||
|
||||
public void Log(string severity, string message)
|
||||
{
|
||||
if (!_initialised) return;
|
||||
try { _lr_log(severity ?? "info", message ?? string.Empty); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] log failed: {e}"); }
|
||||
}
|
||||
|
||||
public void LogException(string message, string stack)
|
||||
{
|
||||
if (!_initialised) return;
|
||||
try { _lr_logException(message ?? string.Empty, stack ?? string.Empty); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] logException failed: {e}"); }
|
||||
}
|
||||
|
||||
public void CaptureFrame(byte[] jpeg, int width, int height)
|
||||
{
|
||||
if (!_initialised || jpeg == null || jpeg.Length == 0) return;
|
||||
var handle = GCHandle.Alloc(jpeg, GCHandleType.Pinned);
|
||||
try { _lr_captureFrame(handle.AddrOfPinnedObject(), jpeg.Length, width, height); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] captureFrame failed: {e}"); }
|
||||
finally { handle.Free(); }
|
||||
}
|
||||
|
||||
public void StartReplay()
|
||||
{
|
||||
if (!_initialised) return;
|
||||
try { _lr_startReplay(); } catch (Exception e) { Debug.LogError($"[LogRocket] startReplay failed: {e}"); }
|
||||
}
|
||||
|
||||
public void StopReplay()
|
||||
{
|
||||
if (!_initialised) return;
|
||||
try { _lr_stopReplay(); } catch (Exception e) { Debug.LogError($"[LogRocket] stopReplay failed: {e}"); }
|
||||
}
|
||||
|
||||
private static string MapToJson(IReadOnlyDictionary<string, object> map)
|
||||
{
|
||||
if (map == null || map.Count == 0) return "{}";
|
||||
var sb = new System.Text.StringBuilder(64);
|
||||
sb.Append('{');
|
||||
bool first = true;
|
||||
foreach (var kv in map)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
AppendJsonString(sb, kv.Key);
|
||||
sb.Append(':');
|
||||
AppendJsonValue(sb, kv.Value);
|
||||
}
|
||||
sb.Append('}');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendJsonValue(System.Text.StringBuilder sb, object v)
|
||||
{
|
||||
switch (v)
|
||||
{
|
||||
case null: sb.Append("null"); break;
|
||||
case bool b: sb.Append(b ? "true" : "false"); break;
|
||||
case int or long or float or double or decimal:
|
||||
sb.Append(Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture));
|
||||
break;
|
||||
default: AppendJsonString(sb, v.ToString()); break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendJsonString(System.Text.StringBuilder sb, string s)
|
||||
{
|
||||
sb.Append('"');
|
||||
foreach (var c in s ?? string.Empty)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '"': sb.Append("\\\""); break;
|
||||
case '\\': sb.Append("\\\\"); break;
|
||||
case '\n': sb.Append("\\n"); break;
|
||||
case '\r': sb.Append("\\r"); break;
|
||||
case '\t': sb.Append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) sb.AppendFormat("\\u{0:X4}", (int)c);
|
||||
else sb.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
sb.Append('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bd81dfea0269d4dec8e97e8756b80801
|
||||
@@ -0,0 +1,34 @@
|
||||
#if UNITY_EDITOR || (!UNITY_IOS && !UNITY_ANDROID)
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Systems
|
||||
{
|
||||
internal sealed class LogRocketNativeNoop : ILogRocketNative
|
||||
{
|
||||
public bool IsAvailable => true;
|
||||
public string SessionUrl => null;
|
||||
|
||||
public void Init(string appId) =>
|
||||
Debug.Log($"[LogRocket] Editor/standalone stub init (appId='{appId}'). No native upload.");
|
||||
|
||||
public void Identify(string userId, IReadOnlyDictionary<string, object> traits) =>
|
||||
Debug.Log($"[LogRocket] Identify '{userId}' traits={Count(traits)}");
|
||||
|
||||
public void Track(string eventName, IReadOnlyDictionary<string, object> properties) =>
|
||||
Debug.Log($"[LogRocket] Track '{eventName}' props={Count(properties)}");
|
||||
|
||||
public void Log(string severity, string message) =>
|
||||
Debug.Log($"[LogRocket:{severity}] {message}");
|
||||
|
||||
public void LogException(string message, string stackTrace) =>
|
||||
Debug.Log($"[LogRocket:exception] {message}\n{stackTrace}");
|
||||
|
||||
public void CaptureFrame(byte[] jpeg, int w, int h) { }
|
||||
public void StartReplay() => Debug.Log("[LogRocket] StartReplay (noop)");
|
||||
public void StopReplay() => Debug.Log("[LogRocket] StopReplay (noop)");
|
||||
|
||||
private static int Count(IReadOnlyDictionary<string, object> map) => map?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 545348daa325e4c809265a9f76584f19
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Core.Contracts.Services.LogRocket;
|
||||
using Darkmatter.Services.LogRocket.Configuration;
|
||||
using UnityEngine;
|
||||
using VContainer.Unity;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Systems
|
||||
{
|
||||
public sealed class LogRocketSystem : ILogRocketService, IAsyncStartable, IDisposable
|
||||
{
|
||||
private readonly LogRocketConfig _config;
|
||||
private readonly ILogRocketNative _native;
|
||||
private readonly Queue<Action> _pending = new();
|
||||
|
||||
private UnityLogForwarder _logForwarder;
|
||||
private SessionReplayCapture _replay;
|
||||
private bool _ready;
|
||||
private bool _disabled;
|
||||
|
||||
public LogRocketSystem(LogRocketConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_native = CreateNative();
|
||||
}
|
||||
|
||||
public bool IsReady => _ready;
|
||||
public string SessionUrl => _native?.SessionUrl;
|
||||
|
||||
public async UniTask StartAsync(CancellationToken cancellation = default)
|
||||
{
|
||||
if (_config == null)
|
||||
{
|
||||
Debug.LogWarning("[LogRocket] No config assigned. Service disabled.");
|
||||
_disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
if (_config.DisableInEditor) { _disabled = true; return; }
|
||||
#endif
|
||||
if (string.IsNullOrWhiteSpace(_config.AppId))
|
||||
{
|
||||
Debug.LogWarning("[LogRocket] AppId empty. Service disabled.");
|
||||
_disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await UniTask.SwitchToMainThread(cancellation);
|
||||
_native.Init(_config.AppId);
|
||||
|
||||
if (_config.ForwardUnityLogs)
|
||||
{
|
||||
_logForwarder = new UnityLogForwarder(_native, _config.MinimumLogType);
|
||||
_logForwarder.Enable();
|
||||
}
|
||||
|
||||
if (_config.EnableSessionReplay && _config.AutoStart)
|
||||
StartSessionReplay();
|
||||
|
||||
_ready = true;
|
||||
FlushPending();
|
||||
}
|
||||
|
||||
public void Identify(string userId) => Identify(userId, null);
|
||||
|
||||
public void Identify(string userId, IReadOnlyDictionary<string, object> traits)
|
||||
{
|
||||
Run(() => _native.Identify(userId, traits));
|
||||
}
|
||||
|
||||
public void Track(string eventName)
|
||||
{
|
||||
Run(() => _native.Track(eventName, null));
|
||||
}
|
||||
|
||||
public void Track(string eventName, string propName, string propValue)
|
||||
{
|
||||
var dict = new Dictionary<string, object>(1) { [propName] = propValue };
|
||||
Run(() => _native.Track(eventName, dict));
|
||||
}
|
||||
|
||||
public void Track(string eventName, IReadOnlyDictionary<string, object> properties)
|
||||
{
|
||||
Run(() => _native.Track(eventName, properties));
|
||||
}
|
||||
|
||||
public void Log(string message, LogRocketSeverity severity = LogRocketSeverity.Info)
|
||||
{
|
||||
Run(() => _native.Log(SeverityString(severity), message));
|
||||
}
|
||||
|
||||
public void LogException(string message, string stackTrace)
|
||||
{
|
||||
Run(() => _native.LogException(message, stackTrace));
|
||||
}
|
||||
|
||||
public void StartSessionReplay()
|
||||
{
|
||||
Run(() =>
|
||||
{
|
||||
_native.StartReplay();
|
||||
if (_replay == null)
|
||||
_replay = new SessionReplayCapture(_native, _config);
|
||||
_replay.Start();
|
||||
});
|
||||
}
|
||||
|
||||
public void StopSessionReplay()
|
||||
{
|
||||
Run(() =>
|
||||
{
|
||||
_replay?.Stop();
|
||||
_native.StopReplay();
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_logForwarder?.Disable();
|
||||
_logForwarder = null;
|
||||
_replay?.Dispose();
|
||||
_replay = null;
|
||||
_pending.Clear();
|
||||
}
|
||||
|
||||
private void Run(Action action)
|
||||
{
|
||||
if (_disabled) return;
|
||||
if (_ready)
|
||||
{
|
||||
try { action(); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] op failed: {e}"); }
|
||||
}
|
||||
else _pending.Enqueue(action);
|
||||
}
|
||||
|
||||
private void FlushPending()
|
||||
{
|
||||
while (_pending.Count > 0)
|
||||
{
|
||||
try { _pending.Dequeue().Invoke(); }
|
||||
catch (Exception e) { Debug.LogError($"[LogRocket] queued op failed: {e}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private static string SeverityString(LogRocketSeverity s) => s switch
|
||||
{
|
||||
LogRocketSeverity.Debug => "debug",
|
||||
LogRocketSeverity.Warn => "warn",
|
||||
LogRocketSeverity.Error => "error",
|
||||
_ => "info",
|
||||
};
|
||||
|
||||
private static ILogRocketNative CreateNative()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
return new LogRocketNativeNoop();
|
||||
#elif UNITY_ANDROID
|
||||
return new LogRocketNativeAndroid();
|
||||
#elif UNITY_IOS
|
||||
return new LogRocketNativeIOS();
|
||||
#else
|
||||
return new LogRocketNativeNoop();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5c8a2c56a83847a3b480e4a13fba63d
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Cysharp.Threading.Tasks;
|
||||
using Darkmatter.Services.LogRocket.Configuration;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Systems
|
||||
{
|
||||
internal sealed class SessionReplayCapture : IDisposable
|
||||
{
|
||||
private readonly ILogRocketNative _native;
|
||||
private readonly LogRocketConfig _config;
|
||||
private CancellationTokenSource _cts;
|
||||
private bool _running;
|
||||
|
||||
public SessionReplayCapture(ILogRocketNative native, LogRocketConfig config)
|
||||
{
|
||||
_native = native;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_cts = new CancellationTokenSource();
|
||||
Loop(_cts.Token).Forget();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_running) return;
|
||||
_running = false;
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private async UniTaskVoid Loop(CancellationToken ct)
|
||||
{
|
||||
float interval = Mathf.Max(0.25f, _config.CaptureIntervalSeconds);
|
||||
while (!ct.IsCancellationRequested && _running)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Must be true end-of-frame: ScreenCapture reads the backbuffer after
|
||||
// rendering completes. LastPostLateUpdate fires before render finish and
|
||||
// makes CaptureScreenshotAsTexture return an invalid texture.
|
||||
await UniTask.WaitForEndOfFrame(ct);
|
||||
if (ct.IsCancellationRequested) break;
|
||||
CaptureOnce();
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[LogRocket] replay capture failed: {e}");
|
||||
}
|
||||
|
||||
try { await UniTask.Delay(TimeSpan.FromSeconds(interval), cancellationToken: ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
private void CaptureOnce()
|
||||
{
|
||||
var src = ScreenCapture.CaptureScreenshotAsTexture();
|
||||
if (src == null) return;
|
||||
|
||||
Texture2D scaled = null;
|
||||
try
|
||||
{
|
||||
int targetW = Mathf.Min(_config.MaxFrameWidth, src.width);
|
||||
int targetH = Mathf.RoundToInt(src.height * (targetW / (float)src.width));
|
||||
scaled = targetW == src.width && targetH == src.height ? src : Resize(src, targetW, targetH);
|
||||
|
||||
var jpeg = scaled.EncodeToJPG(Mathf.Clamp(_config.JpegQuality, 10, 100));
|
||||
_native.CaptureFrame(jpeg, scaled.width, scaled.height);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (scaled != null && scaled != src) UnityEngine.Object.Destroy(scaled);
|
||||
UnityEngine.Object.Destroy(src);
|
||||
}
|
||||
}
|
||||
|
||||
private static Texture2D Resize(Texture2D src, int w, int h)
|
||||
{
|
||||
var rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32);
|
||||
var prev = RenderTexture.active;
|
||||
try
|
||||
{
|
||||
RenderTexture.active = rt;
|
||||
GL.Clear(false, true, Color.clear);
|
||||
Graphics.Blit(src, rt);
|
||||
var dst = new Texture2D(w, h, TextureFormat.RGBA32, false);
|
||||
dst.ReadPixels(new Rect(0, 0, w, h), 0, 0);
|
||||
dst.Apply();
|
||||
return dst;
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prev;
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1aae6350f04254f3dabfb7a552a50cb4
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Services.LogRocket.Systems
|
||||
{
|
||||
internal sealed class UnityLogForwarder
|
||||
{
|
||||
// Prefix every LogRocket diagnostic carries. Used to drop our own chatter so it
|
||||
// never round-trips back into the session as user telemetry.
|
||||
private const string SelfPrefix = "[LogRocket";
|
||||
|
||||
private readonly ILogRocketNative _native;
|
||||
private readonly LogType _min;
|
||||
private bool _enabled;
|
||||
|
||||
// Guards against a native Log/LogException call that itself emits a Unity log on the
|
||||
// same thread, which would otherwise recurse through OnLog.
|
||||
[ThreadStatic] private static bool _dispatching;
|
||||
|
||||
public UnityLogForwarder(ILogRocketNative native, LogType min)
|
||||
{
|
||||
_native = native;
|
||||
_min = min;
|
||||
}
|
||||
|
||||
public void Enable()
|
||||
{
|
||||
if (_enabled) return;
|
||||
Application.logMessageReceivedThreaded += OnLog;
|
||||
_enabled = true;
|
||||
}
|
||||
|
||||
public void Disable()
|
||||
{
|
||||
if (!_enabled) return;
|
||||
Application.logMessageReceivedThreaded -= OnLog;
|
||||
_enabled = false;
|
||||
}
|
||||
|
||||
private void OnLog(string condition, string stack, LogType type)
|
||||
{
|
||||
if (_dispatching) return;
|
||||
if (!ShouldForward(type)) return;
|
||||
if (condition != null && condition.StartsWith(SelfPrefix, StringComparison.Ordinal)) return;
|
||||
|
||||
_dispatching = true;
|
||||
try
|
||||
{
|
||||
if (type == LogType.Exception)
|
||||
_native.LogException(condition, stack);
|
||||
else
|
||||
_native.Log(Severity(type), condition);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dispatching = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldForward(LogType actual) => Rank(actual) >= Rank(_min);
|
||||
|
||||
private static int Rank(LogType t) => t switch
|
||||
{
|
||||
LogType.Log => 0,
|
||||
LogType.Warning => 1,
|
||||
LogType.Error => 2,
|
||||
LogType.Assert => 2,
|
||||
LogType.Exception => 3,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static string Severity(LogType t) => t switch
|
||||
{
|
||||
LogType.Warning => "warn",
|
||||
LogType.Error => "error",
|
||||
LogType.Assert => "error",
|
||||
LogType.Exception => "error",
|
||||
_ => "info",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc803059ceea74ae19a6e02ba5292d3e
|
||||
Reference in New Issue
Block a user