com.amanotes.gdk 0.2.81-alpha.12 → 0.2.81-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/Extra/AmaGDKInstaller.unitypackage +0 -0
  2. package/Extra/AutoEventQC.unitypackage +0 -0
  3. package/Extra/CheckDiskSpace.unitypackage +0 -0
  4. package/Extra/Consent.unitypackage +0 -0
  5. package/Extra/CrashlyticHook.unitypackage +0 -0
  6. package/Extra/ForceUpdate.unitypackage +0 -0
  7. package/Extra/LegacyGDKUpdateHelper.unitypackage +0 -0
  8. package/Extra/PostProcessor.unitypackage +0 -0
  9. package/Packages/AmaGDKConfig.unitypackage +0 -0
  10. package/Packages/AmaGDKExample.unitypackage +0 -0
  11. package/Packages/AmaGDKTest.unitypackage +0 -0
  12. package/Packages/AppsFlyerAdapter.PurchaseConnector.unitypackage +0 -0
  13. package/Packages/AppsFlyerAdapter.unitypackage +0 -0
  14. package/Packages/FirebaseAnalyticsAdapter.unitypackage +0 -0
  15. package/Packages/FirebaseRemoteConfigAdapter.unitypackage +0 -0
  16. package/Packages/IronSourceAdapter.AdQuality.unitypackage +0 -0
  17. package/Packages/IronSourceAdapter.unitypackage +0 -0
  18. package/Packages/MaxAdNetworkAdapter.unitypackage +0 -0
  19. package/Packages/RevenueCatAdapter.unitypackage +0 -0
  20. package/Packages/SqliteAnalyticsAdapter.unitypackage +0 -0
  21. package/Runtime/Ad/AdLogic.cs +44 -41
  22. package/Runtime/Ad/AdModuleConfig.cs +3 -0
  23. package/Runtime/Ad/AmaGDK.Ads.cs +1 -1
  24. package/Runtime/AmaGDK.Analytics.cs +14 -7
  25. package/Runtime/AmaGDK.IAP.cs +35 -60
  26. package/Runtime/AmaGDK.RemoteConfig.cs +1 -1
  27. package/Runtime/AmaGDK.UserProfile.cs +2 -2
  28. package/Runtime/AmaGDK.cs +1 -1
  29. package/Runtime/Consent/AmaGDK.Consent.cs +2 -2
  30. package/Runtime/Core/GDKDebug.cs +15 -1
  31. package/Runtime/Internal/AmaGDK.Utils.cs +1 -1
  32. package/Runtime/Internal/ForceQuitMonitor.cs +1 -1
  33. package/Runtime/Utils/GDKFileUtils.cs +240 -104
  34. package/Runtime/Utils/GDKUtils.cs +33 -0
  35. package/package.json +1 -1
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -8,7 +8,6 @@ namespace Amanotes.Core.Internal
8
8
  {
9
9
  public abstract class AdLogic
10
10
  {
11
- private const int MAX_FRAMES_WAIT_FOR_CALLBACK = 60;
12
11
  protected AdModuleConfig adConfig => Config.ad;
13
12
  protected AdShowContext context => Ads.context;
14
13
 
@@ -256,20 +255,18 @@ namespace Amanotes.Core.Internal
256
255
  }
257
256
  };
258
257
  unityCallbacks.OnApplicationFocus += onApplicationFocusCallback;
259
- int counter = 0;
260
258
  while (context != null && !context.potentiallyCompleted)
261
259
  {
262
260
  if (hasFocusAgain && hasOutOfFocus)
263
261
  {
264
- // Start counting frames only after closed the ad
265
- counter++;
266
- }
267
- if (counter > MAX_FRAMES_WAIT_FOR_CALLBACK)
268
- {
269
- // Timeout reached, force close the ad
270
- LogError("Force close the ad");
271
- StopShowAd();
272
- break;
262
+ // Start counting only after closed the ad
263
+ yield return GDKUtils.WaitForSecondsRealtime(Config.ad.adCallBackTimeOut);
264
+ if (context != null && !context.potentiallyCompleted)
265
+ {
266
+ LogError("Force close the ad");
267
+ StopShowAd();
268
+ break;
269
+ }
273
270
  }
274
271
  yield return null;
275
272
  }
@@ -351,24 +348,27 @@ namespace Amanotes.Core.Internal
351
348
 
352
349
  private void TriggerBeforeAdShow()
353
350
  {
354
- bool willSaveVolume = Config.ad.autoMute
355
- && AudioListener.volume > 0
356
- && Ads._savedVolume < 0;
351
+ if (Application.platform == RuntimePlatform.IPhonePlayer)
352
+ {
353
+ bool willSaveVolume = Config.ad.autoMute
354
+ && AudioListener.volume > 0
355
+ && Ads._savedVolume < 0;
357
356
 
358
- bool willSaveTimeScale = Config.ad.autoTimeScale
359
- && Time.timeScale > 0
360
- && Ads._savedTimeScale < 0;
357
+ bool willSaveTimeScale = Config.ad.autoTimeScale
358
+ && Time.timeScale > 0
359
+ && Ads._savedTimeScale < 0;
361
360
 
362
- if (willSaveVolume)
363
- {
364
- Ads._savedVolume = AudioListener.volume;
365
- AudioListener.volume = 0;
366
- }
361
+ if (willSaveVolume)
362
+ {
363
+ Ads._savedVolume = AudioListener.volume;
364
+ AudioListener.volume = 0;
365
+ }
367
366
 
368
- if (willSaveTimeScale)
369
- {
370
- Ads._savedTimeScale = Time.timeScale;
371
- Time.timeScale = 0;
367
+ if (willSaveTimeScale)
368
+ {
369
+ Ads._savedTimeScale = Time.timeScale;
370
+ Time.timeScale = 0;
371
+ }
372
372
  }
373
373
 
374
374
  dispatcher.Dispatch(_isInterstitial ? AmaGDK.Event.INTER_SHOW_BEGIN : AmaGDK.Event.REWARD_VIDEO_SHOW_BEGIN);
@@ -377,24 +377,27 @@ namespace Amanotes.Core.Internal
377
377
 
378
378
  private void TriggerOnAfterAdShow()
379
379
  {
380
- bool willRestoreVolume = Config.ad.autoMute
381
- && AudioListener.volume == 0
382
- && Ads._savedVolume > 0;
380
+ if (Application.platform == RuntimePlatform.IPhonePlayer)
381
+ {
382
+ bool willRestoreVolume = Config.ad.autoMute
383
+ && Mathf.Approximately(AudioListener.volume, 0)
384
+ && Ads._savedVolume > 0;
383
385
 
384
- bool willRestoreTimeScale = Config.ad.autoTimeScale
385
- && Time.timeScale == 0
386
- && Ads._savedTimeScale > 0;
386
+ bool willRestoreTimeScale = Config.ad.autoTimeScale
387
+ && Mathf.Approximately(Time.timeScale, 0)
388
+ && Ads._savedTimeScale > 0;
387
389
 
388
- if (willRestoreVolume)
389
- {
390
- AudioListener.volume = Ads._savedVolume;
391
- Ads._savedVolume = -1;
392
- }
390
+ if (willRestoreVolume)
391
+ {
392
+ AudioListener.volume = Ads._savedVolume;
393
+ Ads._savedVolume = -1;
394
+ }
393
395
 
394
- if (willRestoreTimeScale)
395
- {
396
- Time.timeScale = Ads._savedTimeScale;
397
- Ads._savedTimeScale = -1;
396
+ if (willRestoreTimeScale)
397
+ {
398
+ Time.timeScale = Ads._savedTimeScale;
399
+ Ads._savedTimeScale = -1;
400
+ }
398
401
  }
399
402
 
400
403
  bool isSuccess = _showState == ShowAdsState.ShowSuccess;
@@ -45,6 +45,9 @@ namespace Amanotes.Core.Internal
45
45
  [Header("EDITOR")]
46
46
  [Tooltip("Only applicable in Editor\n0 = 0% success\n1 = 100% success")]
47
47
  [Range(0f, 1f)] public float rewardAdShowSuccessRate = 1;
48
+
49
+ [Tooltip(("The maximum time (in seconds) to wait for the callback response after the ad activity has finished and the app returns to the Unity activity (default = 5s)"))]
50
+ public float adCallBackTimeOut = 5f;
48
51
  }
49
52
 
50
53
  public partial class ConfigAsset
@@ -93,7 +93,7 @@ namespace Amanotes.Core
93
93
 
94
94
  internal void Save()
95
95
  {
96
- this.SaveJsonToFile();
96
+ this.SaveJsonToFile(false);
97
97
  }
98
98
  }
99
99
 
@@ -130,12 +130,11 @@ namespace Amanotes.Core
130
130
  if (Config.analytics.autoFlushEventDetail)
131
131
  {
132
132
  LogWarning("[Analytics] This function FlushEventDetails() is not supposed to be called manually!");
133
- return;
134
133
  }
135
134
 
136
135
  if (!localData._dirty) return;
137
136
  localData._dirty = false;
138
- localData.SaveJsonToFile();
137
+ localData.SaveJsonToFile(false);
139
138
  }
140
139
 
141
140
  public static IEventParamsBuilder LogFunnelEvent(string eventName, string signature = "")
@@ -648,7 +647,12 @@ namespace Amanotes.Core
648
647
  internal static void PostPrepareEventProcess(EventParams eventData)
649
648
  {
650
649
  if (!eventData.parameters.ContainsKey("ama_device_id"))
651
- eventData.AddParam("ama_device_id", User.AmaDeviceId);
650
+ {
651
+ if (!string.IsNullOrWhiteSpace(User.AmaDeviceId))
652
+ {
653
+ eventData.AddParam("ama_device_id", User.AmaDeviceId);
654
+ }
655
+ }
652
656
  }
653
657
 
654
658
  internal static bool ShouldSendEvent(EventParams eventData)
@@ -715,10 +719,13 @@ namespace Amanotes.Core
715
719
  {
716
720
  sb.Append(eventNameToSend);
717
721
  sb.Append("\t");
718
- for (var i = 0; i < adapters.Count; i++)
722
+ if (adapters != null && adapters.Count > 0)
719
723
  {
720
- sb.Append(adapters[i]);
721
- if (i < adapters.Count - 1) sb.Append(",");
724
+ for (var i = 0; i < adapters.Count; i++)
725
+ {
726
+ sb.Append(adapters[i]);
727
+ if (i < adapters.Count - 1) sb.Append(",");
728
+ }
722
729
  }
723
730
  sb.Append("\t");
724
731
  sb.AppendLine(eventParamsInJSON);
@@ -843,7 +850,7 @@ namespace Amanotes.Core
843
850
  if (!Config.analytics.autoFlushEventDetail) return;
844
851
  if (!_dirty) return;
845
852
  _dirty = false;
846
- this.SaveJsonToFile();
853
+ this.SaveJsonToFile(false);
847
854
  }
848
855
 
849
856
  internal void UpdateMigrationLog()
@@ -21,7 +21,10 @@ namespace Amanotes.Core
21
21
  public static DateTime? SubscriptionExpiredDate => localData.SubscriptionExpiredDate;
22
22
  public static DateTime? UnsubscribedDate => localData.UnsubscribedDate;
23
23
  public static readonly Dictionary<string, ProductInfo> AllProducts = new Dictionary<string, ProductInfo>();
24
+
24
25
  private const string DATE_FORMAT = "yyyy/MM/yy HH:mm:ss";
26
+ private static readonly GDKTaskTracker<bool> _syncTaskTracker = new GDKTaskTracker<bool>();
27
+ private static readonly GDKTaskTracker _getProductsTaskTracker = new GDKTaskTracker();
25
28
 
26
29
  public static void SetCallback_OnModuleReady(Action onReady)
27
30
  {
@@ -37,42 +40,32 @@ namespace Amanotes.Core
37
40
  _onModuleReady += onReady;
38
41
  }
39
42
 
40
- public static void SetCallback_OnGetProductsComplete(Action onComplete)
41
- {
42
- if (onComplete == null) return;
43
-
44
- if (isGetProductComplete)
45
- {
46
- onComplete();
47
- return;
48
- }
49
-
50
- _onGetProductsComplete -= onComplete;
51
- _onGetProductsComplete += onComplete;
52
- }
53
-
54
- public static void SetCallback_OnGetCustomerInfoComplete(Action onComplete)
55
- {
56
- if (onComplete == null) return;
57
-
58
- if (isGetCustomerInfoComplete)
59
- {
60
- onComplete();
61
- return;
62
- }
63
-
64
- _onGetCustomerInfoComplete -= onComplete;
65
- _onGetCustomerInfoComplete += onComplete;
66
- }
67
-
68
43
  public static void SyncCustomerInfo(Action<bool> onComplete = null)
69
44
  {
70
- if (_adapter == null)
45
+ if (Adapter == null)
71
46
  {
72
47
  onComplete?.Invoke(false);
73
48
  return;
74
49
  }
75
- _adapter.SyncCustomerInfo(onComplete);
50
+
51
+ _syncTaskTracker.RegisterOnCompleteCallback(onComplete);
52
+ if (_syncTaskTracker.HasStarted) return;
53
+
54
+ _syncTaskTracker.MarkStarted();
55
+ Adapter.SyncCustomerInfo(getSuccess =>
56
+ {
57
+ if (getSuccess)
58
+ {
59
+ localData.Sync();
60
+ _syncTaskTracker.MarkCompleted(true);
61
+ }
62
+ else
63
+ {
64
+ _syncTaskTracker.MarkCompleted(false);
65
+ _syncTaskTracker.Reset();
66
+ LogWarning("[IAP] Fail to sync customer info. Please try again later.");
67
+ }
68
+ });
76
69
  }
77
70
 
78
71
  public static ISetupBuilder GetProducts(bool appendExistingList = false)
@@ -181,13 +174,8 @@ namespace Amanotes.Core
181
174
  }
182
175
  }
183
176
 
184
- private static bool isGetProductComplete;
185
- private static bool isGetCustomerInfoComplete;
186
- private static bool IsModuleReady => isGetProductComplete && isGetCustomerInfoComplete;
187
- private static Action _onGetProductsComplete;
188
- private static Action _onGetCustomerInfoComplete;
177
+ private static bool IsModuleReady => _syncTaskTracker.HasCompleted && _getProductsTaskTracker.HasCompleted;
189
178
  private static Action _onModuleReady;
190
-
191
179
  internal static bool InitModule()
192
180
  {
193
181
  _adapter = Adapter2.GetAdapter<IAPAdapter>();
@@ -199,14 +187,8 @@ namespace Amanotes.Core
199
187
  internal static void StartModule()
200
188
  {
201
189
  if (_adapter == null) return;
202
- _adapter.SyncCustomerInfo(
203
- success =>
204
- {
205
- isGetCustomerInfoComplete = true;
206
- GDKUtils.SafeInvokeAndClear(ref _onGetCustomerInfoComplete);
207
- if (IsModuleReady) GDKUtils.SafeInvokeAndClear(ref _onModuleReady);
208
- });
209
- localData.Sync();
190
+ SyncCustomerInfo(_ => CheckModuleReady());
191
+ _getProductsTaskTracker.MarkStarted();
210
192
  if (Config.iap.autoGetProducts)
211
193
  {
212
194
  GetProducts()
@@ -218,11 +200,15 @@ namespace Amanotes.Core
218
200
  OnGetProductsComplete();
219
201
  }
220
202
  }
221
-
203
+
222
204
  private static void OnGetProductsComplete()
223
205
  {
224
- isGetProductComplete = true;
225
- GDKUtils.SafeInvokeAndClear(ref _onGetProductsComplete);
206
+ _getProductsTaskTracker.MarkCompleted();
207
+ CheckModuleReady();
208
+ }
209
+
210
+ private static void CheckModuleReady()
211
+ {
226
212
  if (IsModuleReady) GDKUtils.SafeInvokeAndClear(ref _onModuleReady);
227
213
  }
228
214
  }
@@ -284,19 +270,8 @@ namespace Amanotes.Core
284
270
  }
285
271
  }
286
272
 
287
- public void Sync()
273
+ internal void Sync()
288
274
  {
289
- GDKUtils.StartCoroutine(SyncRoutine());
290
- }
291
-
292
- IEnumerator SyncRoutine()
293
- {
294
- if (Adapter == null)
295
- {
296
- LogWarning("[IAP] Adapter is not available");
297
- yield break;
298
- }
299
- yield return new WaitUntil(() => Adapter.IsReady);
300
275
  activeSubscriptionId = Adapter.GetActiveSubscriptionId();
301
276
  subscriptionPeriodType = Adapter.GetSubscriptionPeriodType();
302
277
  purchasedProducts = Adapter.GetPurchasedProducts();
@@ -306,7 +281,7 @@ namespace Amanotes.Core
306
281
  subscriptionExpiredDate = _subscriptionExpiredDate?.ToString(DATE_FORMAT);
307
282
  _unsubscribedDate = Adapter.GetUnsubscribedDate();
308
283
  unsubscribedDate = _unsubscribedDate?.ToString(DATE_FORMAT);
309
- this.SaveJsonToFile();
284
+ this.SaveJsonToFile(false);
310
285
  }
311
286
  }
312
287
  }
@@ -235,7 +235,7 @@ namespace Amanotes.Core
235
235
  }
236
236
 
237
237
  string filePath = GDKFileUtils.GetPath(fileName);
238
- GDKFileUtils.Save(filePath, sb.ToString());
238
+ GDKFileUtils.Save(filePath, sb.ToString(), false);
239
239
  GDKPool.Return(sb);
240
240
 
241
241
  Log($"[RemoteConfig] Saved remote config to {filePath}");
@@ -33,14 +33,14 @@ namespace Amanotes.Core
33
33
  private bool _dirty = false;
34
34
  private void Save()
35
35
  {
36
- this.SaveJsonToFile();
36
+ this.SaveJsonToFile(false);
37
37
  }
38
38
 
39
39
  private void SaveIfDirty()
40
40
  {
41
41
  if (!_dirty) return;
42
42
  _dirty = false;
43
- this.SaveJsonToFile();
43
+ this.SaveJsonToFile(false);
44
44
  }
45
45
  }
46
46
 
package/Runtime/AmaGDK.cs CHANGED
@@ -27,7 +27,7 @@ namespace Amanotes.Core
27
27
  {
28
28
  public partial class AmaGDK : MonoBehaviour
29
29
  {
30
- public const string VERSION = "v0.2.81-alpha.12";
30
+ public const string VERSION = "v0.2.81-alpha.14";
31
31
 
32
32
  internal static Status _status = Status.None;
33
33
  internal static ConfigAsset _config = null;
@@ -76,7 +76,7 @@ namespace Amanotes.Core
76
76
  ConsentHook.ATTRequest(isAllowed =>
77
77
  {
78
78
  data.att = isAllowed ? Status.Allowed : Status.NotAllowed;
79
- data.SaveJsonToFile();
79
+ data.SaveJsonToFile(false);
80
80
  onComplete?.Invoke(allowedATT);
81
81
  });
82
82
  }
@@ -119,7 +119,7 @@ namespace Amanotes.Core
119
119
  {
120
120
  data.canShowAds = canShowAd;
121
121
  data.consentAlreadyShown = true;
122
- data.SaveJsonToFile();
122
+ data.SaveJsonToFile(false);
123
123
  onComplete?.Invoke(canShowAd);
124
124
  }, onError);
125
125
  }
@@ -11,7 +11,8 @@ namespace Amanotes.Core
11
11
  Warning,
12
12
  Verbose
13
13
  }
14
-
14
+
15
+ #if GDK_REMOVE_STACKTRACE
15
16
  internal readonly struct NoStackTrace : IDisposable
16
17
  {
17
18
  internal readonly LogType logType;
@@ -41,10 +42,13 @@ namespace Amanotes.Core
41
42
 
42
43
  }
43
44
  }
45
+ #endif
44
46
 
45
47
  public class GDKDebug
46
48
  {
49
+ #if GDK_REMOVE_STACKTRACE
47
50
  internal static readonly IDisposable NoStackTraceNull = new NoStackTraceNull();
51
+ #endif
48
52
 
49
53
  public static Action<string> logNonfatalErrorHook = null;
50
54
  private static readonly HashSet<string> nonFatalDict = new HashSet<string>();
@@ -59,7 +63,9 @@ namespace Amanotes.Core
59
63
  if (!logWarningDict.Add(key)) return;
60
64
  }
61
65
 
66
+ #if GDK_REMOVE_STACKTRACE
62
67
  using (GDKUtils.isOnMainThread ? NoStackTrace.Warning: NoStackTraceNull)
68
+ #endif
63
69
  {
64
70
  Debug.LogWarning($"AmaGDK {message}");
65
71
  }
@@ -67,7 +73,9 @@ namespace Amanotes.Core
67
73
  public static void LogWarning(string message)
68
74
  {
69
75
  if (AmaGDK.logLevel < LogLevel.Warning) return;
76
+ #if GDK_REMOVE_STACKTRACE
70
77
  using (GDKUtils.isOnMainThread ? NoStackTrace.Warning: NoStackTraceNull)
78
+ #endif
71
79
  {
72
80
  Debug.LogWarning($"AmaGDK {message}");
73
81
  }
@@ -75,7 +83,9 @@ namespace Amanotes.Core
75
83
  public static void LogError(string message)
76
84
  {
77
85
  if (AmaGDK.logLevel < LogLevel.Error) return;
86
+ #if GDK_REMOVE_STACKTRACE
78
87
  using (GDKUtils.isOnMainThread ? NoStackTrace.Error: NoStackTraceNull)
88
+ #endif
79
89
  {
80
90
  Debug.LogError($"AmaGDK {message}");
81
91
  }
@@ -100,7 +110,9 @@ namespace Amanotes.Core
100
110
  public static void Log(string message)
101
111
  {
102
112
  if (AmaGDK.logLevel < LogLevel.Verbose) return;
113
+ #if GDK_REMOVE_STACKTRACE
103
114
  using (GDKUtils.isOnMainThread ? NoStackTrace.Log: NoStackTraceNull)
115
+ #endif
104
116
  {
105
117
  Debug.Log($"AmaGDK {message}");
106
118
  }
@@ -108,7 +120,9 @@ namespace Amanotes.Core
108
120
 
109
121
  public static void ForceLog(string message)
110
122
  {
123
+ #if GDK_REMOVE_STACKTRACE
111
124
  using (GDKUtils.isOnMainThread ? NoStackTrace.Log: NoStackTraceNull)
125
+ #endif
112
126
  {
113
127
  Debug.Log($"AmaGDK {message}");
114
128
  }
@@ -200,7 +200,7 @@ namespace Amanotes.Core.Internal
200
200
  return GDKFileUtils.LoadJsonFromFile(instance);
201
201
  }
202
202
 
203
- internal static void SaveJsonToFile<T>(this T instance, bool immediately = true) where T: IJsonFile
203
+ internal static void SaveJsonToFile<T>(this T instance, bool immediately) where T: IJsonFile
204
204
  {
205
205
  GDKFileUtils.SaveJsonToFile(instance, immediately);
206
206
  }
@@ -34,7 +34,7 @@ namespace Amanotes.Core.Internal
34
34
  internal void SaveIfDirty(bool forceSave = false)
35
35
  {
36
36
  if (!isDirty && !forceSave) return;
37
- this.SaveJsonToFile();
37
+ this.SaveJsonToFile(false);
38
38
  isDirty = false;
39
39
  }
40
40
  }
@@ -1,6 +1,11 @@
1
+ // #define GDK_DEBUG_FILE_IO
2
+
1
3
  using System;
4
+ using System.Collections.Concurrent;
2
5
  using System.Collections.Generic;
6
+ using System.Diagnostics;
3
7
  using System.IO;
8
+ using System.Threading;
4
9
  using System.Threading.Tasks;
5
10
  using UnityEngine;
6
11
  using UnityEngine.Networking;
@@ -11,64 +16,145 @@ namespace Amanotes.Core.Internal
11
16
  public static class GDKFileUtils
12
17
  {
13
18
  private static string _basePath;
19
+ private static readonly ConcurrentDictionary<string, string> _pendingWrites =
20
+ new ConcurrentDictionary<string, string>();
21
+ private static readonly List<KeyValuePair<string, string>> _itemsToWrite =
22
+ new List<KeyValuePair<string, string>>(8);
23
+
24
+ private static readonly object _fileLock = new object();
25
+ private static int _isProcessing = 0;
26
+
14
27
  public static string basePath
15
28
  {
16
29
  get
17
30
  {
18
31
  if (!string.IsNullOrEmpty(_basePath)) return _basePath;
32
+
19
33
  _basePath = Application.isEditor
20
34
  ? Path.Combine(Directory.GetCurrentDirectory(), "Library", "AmaGDK")
21
35
  : Path.Combine(Application.persistentDataPath, "AmaGDK");
22
36
 
23
- if (!Directory.Exists(_basePath)) Directory.CreateDirectory(_basePath);
37
+ try
38
+ {
39
+ if (!Directory.Exists(_basePath))
40
+ {
41
+ Directory.CreateDirectory(_basePath);
42
+ }
43
+ } catch (Exception ex)
44
+ {
45
+ LogError($"[GDKFileUtils] Failed to create directory: {ex.Message}");
46
+ _basePath = Application.persistentDataPath;
47
+ }
48
+
24
49
  return _basePath;
25
50
  }
26
51
  }
27
-
52
+
28
53
  internal static string GetPath(string fileName)
29
54
  {
30
- return Path.Combine(basePath, fileName);
55
+ if (string.IsNullOrEmpty(fileName)) return null;
56
+ return Path.Combine(basePath, Path.GetFileName(fileName));
31
57
  }
32
58
 
33
59
  internal static bool Exist(string fileName)
34
60
  {
35
- return File.Exists(GetPath(fileName));
61
+ if (string.IsNullOrEmpty(fileName)) return false;
62
+
63
+ string path = GetPath(fileName);
64
+ if (string.IsNullOrEmpty(path)) return false;
65
+
66
+ lock (_fileLock)
67
+ {
68
+ return File.Exists(path);
69
+ }
70
+ }
71
+
72
+ internal static int writePendingCount => _pendingWrites.Count;
73
+ internal static bool HasAnyWritePending => _pendingWrites.Count > 0;
74
+ internal static bool IsWritePending(string fileName)
75
+ {
76
+ return !string.IsNullOrEmpty(fileName) && _pendingWrites.ContainsKey(fileName);
36
77
  }
37
-
38
- public static void Save(string fileName, string content, bool immediately = true)
78
+
79
+ public static void Save(string fileName, string content, bool immediately)
39
80
  {
40
- if (string.IsNullOrWhiteSpace(fileName))
81
+ if (string.IsNullOrEmpty(fileName)) return;
82
+
83
+ if (immediately)
41
84
  {
42
- LogWarningOnce("File name must be not null or empty");
85
+ _pendingWrites.TryRemove(fileName, out _);
86
+ WriteFileDirectly(fileName, content);
43
87
  return;
44
88
  }
45
89
 
46
- if (string.IsNullOrWhiteSpace(content))
90
+ // Just replace any existing content
91
+ _pendingWrites[fileName] = content;
92
+
93
+ // Use atomic CompareExchange to ensure only one Task runs at a time
94
+ CheckSpawnThread();
95
+ }
96
+
97
+ private static void CheckSpawnThread()
98
+ {
99
+ if (Interlocked.CompareExchange(ref _isProcessing, 1, 0) == 0)
47
100
  {
48
- LogWarningOnce($"Content for {fileName} is empty");
101
+ Task.Factory.StartNew(ProcessWriteQueue, TaskCreationOptions.None);
49
102
  }
50
-
51
- ThreadWrite(fileName, content, immediately);
52
103
  }
53
104
 
54
- private static void ThreadWrite(string fileName, string content, bool immediately)
105
+ private static void ProcessWriteQueue()
55
106
  {
56
- // Only write on main thread if immediately is true
57
- if (!immediately && GDKUtils.isOnMainThread)
107
+ try
58
108
  {
59
- Task.Run(()=>ThreadWrite(fileName, content, true))
60
- .ConfigureAwait(false);
61
- return;
109
+ // var w = Stopwatch.StartNew();
110
+ while (!_pendingWrites.IsEmpty)
111
+ {
112
+ // Blocking delay so batching actually occurs
113
+ // In this case, it's actually better than await Task.Delay(500), why?
114
+ Thread.Sleep(500);
115
+
116
+ _itemsToWrite.Clear();
117
+ _itemsToWrite.AddRange(_pendingWrites);
118
+ _pendingWrites.Clear();
119
+
120
+ lock (_fileLock)
121
+ {
122
+ foreach (var kvp in _itemsToWrite)
123
+ {
124
+ WriteFileDirectly(kvp.Key, kvp.Value);
125
+ }
126
+ }
127
+ }
128
+
129
+ // w.Stop();
130
+ // LogWarning($"Process time: {w.ElapsedTicks} ticks | {w.ElapsedMilliseconds} milliseconds");
131
+ }
132
+ catch (Exception e)
133
+ {
134
+ LogError($"[GDKFileUtils] Write queue error: {e.Message}");
135
+ } finally
136
+ {
137
+ Interlocked.Exchange(ref _isProcessing, 0);
138
+ if (!_pendingWrites.IsEmpty) CheckSpawnThread(); // until next time!
62
139
  }
140
+ }
141
+
142
+ // Write directly to file without temp files
143
+ private static void WriteFileDirectly(string fileName, string content)
144
+ {
145
+ if (string.IsNullOrEmpty(fileName)) return;
63
146
 
64
147
  string filePath = GetPath(fileName);
65
- try
148
+ if (string.IsNullOrEmpty(filePath)) return;
149
+
150
+ // Lock only for the duration of actual I/O
151
+ lock (_fileLock)
66
152
  {
67
153
  File.WriteAllText(filePath, content);
68
- }
69
- catch (Exception ex)
70
- {
71
- LogWarningOnce($"Exception in ThreadWrite(): {filePath}\n{ex}");
154
+
155
+ #if GDK_DEBUG_FILE_IO
156
+ Log($"WriteFileDirectly threadId={Thread.CurrentThread.ManagedThreadId}: {fileName} -> {content}");
157
+ #endif
72
158
  }
73
159
  }
74
160
 
@@ -76,23 +162,28 @@ namespace Amanotes.Core.Internal
76
162
  {
77
163
  if (string.IsNullOrWhiteSpace(fileName))
78
164
  {
79
- LogWarningOnce("File name must be not null or empty");
165
+ LogWarningOnce("File name must not be null or empty");
80
166
  return;
81
167
  }
82
-
168
+
169
+ // Check if `contents` is null or empty
83
170
  if (contents == null || !contents.GetEnumerator().MoveNext())
84
171
  {
85
172
  LogWarningOnce($"Content for {fileName} is empty");
86
173
  }
87
174
 
88
175
  string path = GetPath(fileName);
89
- try
90
- {
91
- File.AppendAllLines(path, contents);
92
- }
93
- catch (Exception ex)
176
+
177
+ // Concurrency-safe write
178
+ lock (_fileLock)
94
179
  {
95
- LogWarningOnce(path + "\n" + ex);
180
+ try
181
+ {
182
+ File.AppendAllLines(path, contents);
183
+ } catch (Exception ex)
184
+ {
185
+ LogWarningOnce(path + "\n" + ex);
186
+ }
96
187
  }
97
188
  }
98
189
 
@@ -100,147 +191,192 @@ namespace Amanotes.Core.Internal
100
191
  {
101
192
  if (string.IsNullOrWhiteSpace(fileName))
102
193
  {
103
- LogWarningOnce("File name must be not null or empty");
194
+ LogWarningOnce("File name must not be null or empty");
104
195
  return;
105
196
  }
106
197
 
198
+ // Check if `content` is null or empty
107
199
  if (string.IsNullOrWhiteSpace(content))
108
200
  {
109
201
  LogWarningOnce($"Content for {fileName} is empty");
110
202
  }
111
203
 
112
204
  string path = GetPath(fileName);
113
- try
114
- {
115
- File.AppendAllText(path, content);
116
- }
117
- catch (Exception ex)
205
+
206
+ // Concurrency-safe write
207
+ lock (_fileLock)
118
208
  {
119
- LogWarningOnce(path + "\n" + ex);
209
+ try
210
+ {
211
+ File.AppendAllText(path, content);
212
+ } catch (Exception ex)
213
+ {
214
+ LogWarningOnce(path + "\n" + ex);
215
+ }
120
216
  }
121
217
  }
122
218
 
123
219
  public static string Load(string fileName)
124
220
  {
125
- var content = "";
221
+ if (string.IsNullOrEmpty(fileName)) return string.Empty;
126
222
 
127
- if (string.IsNullOrWhiteSpace(fileName))
223
+ // If there's a pending in-memory write, return that content first
224
+ if (_pendingWrites.TryGetValue(fileName, out var inMemoryContent))
128
225
  {
129
- LogWarningOnce("File name must be not null or empty");
130
- return content;
226
+ return inMemoryContent;
131
227
  }
132
228
 
133
229
  string path = GetPath(fileName);
134
- if (!File.Exists(path)) return content;
230
+ if (string.IsNullOrEmpty(path)) return string.Empty;
135
231
 
136
- try
137
- {
138
- content = File.ReadAllText(path);
139
- }
140
- catch (Exception ex)
232
+ lock (_fileLock)
141
233
  {
142
- LogWarningOnce(path + "\n" + ex);
234
+ try
235
+ {
236
+ return !File.Exists(path) ? string.Empty : File.ReadAllText(path);
237
+ } catch (Exception ex)
238
+ {
239
+ LogError($"[GDKFileUtils] Failed to read {fileName}: {ex.Message}");
240
+ return string.Empty;
241
+ }
143
242
  }
144
-
145
- return content;
146
243
  }
147
244
 
148
245
  public static async Task<string> LoadFromStreamingAssets(string fileName)
149
246
  {
247
+ if (string.IsNullOrEmpty(fileName)) return null;
150
248
  string filePath = Path.Combine(Application.streamingAssetsPath, fileName);
151
249
 
152
- // Android
153
250
  if (Application.platform == RuntimePlatform.Android)
154
251
  {
155
- using (UnityWebRequest request = UnityWebRequest.Get(filePath))
252
+ using (var request = UnityWebRequest.Get(filePath))
156
253
  {
157
- var operation = request.SendWebRequest();
158
- while (!operation.isDone)
254
+ try
159
255
  {
160
- await Task.Yield();
161
- }
162
- if (request.result != UnityWebRequest.Result.Success)
256
+ var operation = request.SendWebRequest();
257
+ while (!operation.isDone)
258
+ {
259
+ await Task.Yield();
260
+ }
261
+
262
+ return request.result != UnityWebRequest.Result.Success
263
+ ? null
264
+ : request.downloadHandler.text;
265
+ } catch (Exception ex)
163
266
  {
164
- LogWarning($"Error reading file: {request.error}");
267
+ LogError($"[GDKFileUtils] StreamingAssets load error: {ex.Message}");
165
268
  return null;
166
269
  }
167
- return request.downloadHandler.text;
168
270
  }
169
271
  }
170
272
 
171
- // Other
172
- try
173
- {
174
- return File.ReadAllText(filePath);
175
- }
176
- catch (Exception e)
273
+ lock (_fileLock)
177
274
  {
178
- LogWarning($"Error reading file: {e.Message}");
275
+ try
276
+ {
277
+ return File.ReadAllText(filePath);
278
+ } catch (Exception ex)
279
+ {
280
+ LogError($"[GDKFileUtils] StreamingAssets load error: {ex.Message}");
281
+ return null;
282
+ }
179
283
  }
180
- return null;
181
284
  }
182
285
 
183
-
184
286
  internal static void Delete(string fileName)
185
287
  {
288
+ if (string.IsNullOrEmpty(fileName)) return;
289
+
290
+ _pendingWrites.TryRemove(fileName, out _);
291
+
186
292
  string path = GetPath(fileName);
187
- if (!File.Exists(path)) return;
293
+ if (string.IsNullOrEmpty(path)) return;
188
294
 
189
- try
295
+ lock (_fileLock)
190
296
  {
191
- File.Delete(path);
192
- }
193
- catch (Exception ex)
194
- {
195
- LogWarningOnce(path + "\n" + ex);
297
+ try
298
+ {
299
+ if (File.Exists(path))
300
+ {
301
+ File.Delete(path);
302
+ }
303
+ } catch (Exception ex)
304
+ {
305
+ LogError($"[GDKFileUtils] Delete error: {ex.Message}");
306
+ }
196
307
  }
197
308
  }
198
-
309
+
199
310
  public static void ClearCacheData()
200
311
  {
201
- var sb = GDKPool.GetStringBuilder();
202
- sb.AppendLine("[AmaGDK] All cached data & stats cleared");
312
+ _pendingWrites.Clear();
313
+
314
+ if (!Directory.Exists(basePath)) return;
203
315
 
204
- if (!Directory.Exists(basePath))
316
+ lock (_fileLock)
205
317
  {
206
- sb.AppendLine("Cache path not exists: " + basePath);
207
- return;
318
+ foreach (string file in Directory.GetFiles(basePath))
319
+ {
320
+ try
321
+ {
322
+ File.Delete(file);
323
+ } catch (Exception ex)
324
+ {
325
+ LogWarning($"[GDKFileUtils] Failed to delete {file}: {ex.Message}");
326
+ }
327
+ }
208
328
  }
209
-
210
- string[] filePaths = Directory.GetFiles(basePath);
211
- foreach (string path in filePaths)
329
+ }
330
+
331
+ // Synchronously flush everything
332
+ public static void Shutdown()
333
+ {
334
+ // Write out all pending items directly
335
+ foreach (var kvp in _pendingWrites)
212
336
  {
213
- Delete(path);
214
- sb.AppendLine($"[deleted] - {path}");
337
+ try
338
+ {
339
+ WriteFileDirectly(kvp.Key, kvp.Value);
340
+ } catch (Exception ex)
341
+ {
342
+ LogError($"[GDKFileUtils] Shutdown write error: {ex.Message}");
343
+ }
215
344
  }
216
- Debug.Log(sb);
345
+
346
+ _pendingWrites.Clear();
217
347
  }
218
-
219
- public static T LoadJsonFromFile<T>(T instance) where T: new()
348
+
349
+ public static T LoadJsonFromFile<T>(T instance) where T : new()
220
350
  {
221
351
  instance ??= new T();
222
-
223
- string fileName = instance.GetType().Name;
224
- string json = Load(fileName);
225
- if (!string.IsNullOrEmpty(json))
352
+
353
+ try
226
354
  {
227
- try
355
+ string json = Load(instance.GetType().Name);
356
+ if (!string.IsNullOrEmpty(json))
228
357
  {
229
358
  JsonUtility.FromJsonOverwrite(json, instance);
230
- } catch (Exception e)
231
- {
232
- LogWarning("LoadJsonFromFile error: " + fileName + "\n" + e);
233
359
  }
360
+ } catch (Exception ex)
361
+ {
362
+ LogError($"[GDKFileUtils] JSON load error: {ex.Message}");
234
363
  }
235
-
364
+
236
365
  return instance;
237
366
  }
238
-
239
- public static void SaveJsonToFile<T>(T instance, bool immediately = true)
367
+
368
+ public static void SaveJsonToFile<T>(T instance, bool immediately)
240
369
  {
241
- string fileName = instance.GetType().Name;
242
- string json = JsonUtility.ToJson(instance);
243
- ThreadWrite(fileName, json, immediately);
370
+ if (instance == null) return;
371
+
372
+ try
373
+ {
374
+ string json = JsonUtility.ToJson(instance);
375
+ Save(instance.GetType().Name, json, immediately);
376
+ } catch (Exception ex)
377
+ {
378
+ LogError($"[GDKFileUtils] JSON save error: {ex.Message}");
379
+ }
244
380
  }
245
381
  }
246
382
  }
@@ -1,4 +1,5 @@
1
1
  using System;
2
+ using System.Collections;
2
3
  using UnityEngine;
3
4
  using static Amanotes.Core.GDKDebug;
4
5
 
@@ -96,5 +97,37 @@ namespace Amanotes.Core.Internal
96
97
 
97
98
  LogWarning(message);
98
99
  }
100
+
101
+ public static IEnumerator WaitForSecondsRealtime(float duration, bool focusOnly = true)
102
+ {
103
+ if (!focusOnly)
104
+ {
105
+ yield return new WaitForSecondsRealtime(duration);
106
+ yield break;
107
+ }
108
+ float elapsedTime = 0f;
109
+ float lastTimeStamp = Time.realtimeSinceStartup;
110
+ bool focusChangedSinceLastFrame = false;
111
+ Action<bool> onApplicationFocusCallback = _ => focusChangedSinceLastFrame = true;
112
+ Application.focusChanged += onApplicationFocusCallback;
113
+
114
+ while (elapsedTime < duration)
115
+ {
116
+ if (Application.isFocused)
117
+ {
118
+ if (focusChangedSinceLastFrame)
119
+ {
120
+ lastTimeStamp = Time.realtimeSinceStartup;
121
+ focusChangedSinceLastFrame = false;
122
+ }
123
+
124
+ elapsedTime += Time.realtimeSinceStartup - lastTimeStamp;
125
+ lastTimeStamp = Time.realtimeSinceStartup;
126
+ }
127
+
128
+ yield return null;
129
+ }
130
+ Application.focusChanged -= onApplicationFocusCallback;
131
+ }
99
132
  }
100
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "com.amanotes.gdk",
3
- "version": "0.2.81-alpha.12",
3
+ "version": "0.2.81-alpha.14",
4
4
  "displayName": "AmaGDK",
5
5
  "description": "Amanotes Game Development Kit",
6
6
  "unity": "2019.4",