AndroidSavedGameClient.cs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. #if UNITY_ANDROID
  2. #pragma warning disable 0642 // Possible mistaken empty statement
  3. namespace GooglePlayGames.Android
  4. {
  5. using System;
  6. using System.Collections.Generic;
  7. using System.Text.RegularExpressions;
  8. using GooglePlayGames.BasicApi;
  9. using GooglePlayGames.BasicApi.SavedGame;
  10. using GooglePlayGames.OurUtils;
  11. using UnityEngine;
  12. internal class AndroidSavedGameClient : ISavedGameClient
  13. {
  14. // Regex for a valid filename. Valid file names are between 1 and 100 characters (inclusive)
  15. // and only include URL-safe characters: a-z, A-Z, 0-9, or the symbols "-", ".", "_", or "~".
  16. // This regex is guarded by \A and \Z which guarantee that the entire string matches this
  17. // regex. If these were omitted, then illegal strings containing legal subsequences would be
  18. // allowed (since the regex would match those subsequences).
  19. private static readonly Regex ValidFilenameRegex = new Regex(@"\A[a-zA-Z0-9-._~]{1,100}\Z");
  20. private volatile AndroidJavaObject mSnapshotsClient;
  21. private volatile AndroidClient mAndroidClient;
  22. public AndroidSavedGameClient(AndroidClient androidClient, AndroidJavaObject account)
  23. {
  24. mAndroidClient = androidClient;
  25. using (var gamesClass = new AndroidJavaClass("com.google.android.gms.games.Games"))
  26. {
  27. mSnapshotsClient = gamesClass.CallStatic<AndroidJavaObject>("getSnapshotsClient",
  28. AndroidHelperFragment.GetActivity(), account);
  29. }
  30. }
  31. public void OpenWithAutomaticConflictResolution(string filename, DataSource source,
  32. ConflictResolutionStrategy resolutionStrategy,
  33. Action<SavedGameRequestStatus, ISavedGameMetadata> completedCallback)
  34. {
  35. Misc.CheckNotNull(filename);
  36. Misc.CheckNotNull(completedCallback);
  37. bool prefetchDataOnConflict = false;
  38. ConflictCallback conflictCallback = null;
  39. completedCallback = ToOnGameThread(completedCallback);
  40. if (conflictCallback == null)
  41. {
  42. conflictCallback = (resolver, original, originalData, unmerged, unmergedData) =>
  43. {
  44. switch (resolutionStrategy)
  45. {
  46. case ConflictResolutionStrategy.UseOriginal:
  47. resolver.ChooseMetadata(original);
  48. return;
  49. case ConflictResolutionStrategy.UseUnmerged:
  50. resolver.ChooseMetadata(unmerged);
  51. return;
  52. case ConflictResolutionStrategy.UseLongestPlaytime:
  53. if (original.TotalTimePlayed >= unmerged.TotalTimePlayed)
  54. {
  55. resolver.ChooseMetadata(original);
  56. }
  57. else
  58. {
  59. resolver.ChooseMetadata(unmerged);
  60. }
  61. return;
  62. default:
  63. OurUtils.Logger.e("Unhandled strategy " + resolutionStrategy);
  64. completedCallback(SavedGameRequestStatus.InternalError, null);
  65. return;
  66. }
  67. };
  68. }
  69. conflictCallback = ToOnGameThread(conflictCallback);
  70. if (!IsValidFilename(filename))
  71. {
  72. OurUtils.Logger.e("Received invalid filename: " + filename);
  73. completedCallback(SavedGameRequestStatus.BadInputError, null);
  74. return;
  75. }
  76. InternalOpen(filename, source, resolutionStrategy, prefetchDataOnConflict, conflictCallback,
  77. completedCallback);
  78. }
  79. public void OpenWithManualConflictResolution(string filename, DataSource source, bool prefetchDataOnConflict,
  80. ConflictCallback conflictCallback, Action<SavedGameRequestStatus, ISavedGameMetadata> completedCallback)
  81. {
  82. Misc.CheckNotNull(filename);
  83. Misc.CheckNotNull(conflictCallback);
  84. Misc.CheckNotNull(completedCallback);
  85. conflictCallback = ToOnGameThread(conflictCallback);
  86. completedCallback = ToOnGameThread(completedCallback);
  87. if (!IsValidFilename(filename))
  88. {
  89. OurUtils.Logger.e("Received invalid filename: " + filename);
  90. completedCallback(SavedGameRequestStatus.BadInputError, null);
  91. return;
  92. }
  93. InternalOpen(filename, source, ConflictResolutionStrategy.UseManual, prefetchDataOnConflict,
  94. conflictCallback, completedCallback);
  95. }
  96. private void InternalOpen(string filename, DataSource source, ConflictResolutionStrategy resolutionStrategy,
  97. bool prefetchDataOnConflict, ConflictCallback conflictCallback,
  98. Action<SavedGameRequestStatus, ISavedGameMetadata> completedCallback)
  99. {
  100. int conflictPolicy; // SnapshotsClient.java#RetentionPolicy
  101. switch (resolutionStrategy)
  102. {
  103. case ConflictResolutionStrategy.UseLastKnownGood:
  104. conflictPolicy = 2 /* RESOLUTION_POLICY_LAST_KNOWN_GOOD */;
  105. break;
  106. case ConflictResolutionStrategy.UseMostRecentlySaved:
  107. conflictPolicy = 3 /* RESOLUTION_POLICY_MOST_RECENTLY_MODIFIED */;
  108. break;
  109. case ConflictResolutionStrategy.UseLongestPlaytime:
  110. conflictPolicy = 1 /* RESOLUTION_POLICY_LONGEST_PLAYTIME*/;
  111. break;
  112. case ConflictResolutionStrategy.UseManual:
  113. conflictPolicy = -1 /* RESOLUTION_POLICY_MANUAL */;
  114. break;
  115. default:
  116. conflictPolicy = 3 /* RESOLUTION_POLICY_MOST_RECENTLY_MODIFIED */;
  117. break;
  118. }
  119. using (var task =
  120. mSnapshotsClient.Call<AndroidJavaObject>("open", filename, /* createIfNotFound= */ true,
  121. conflictPolicy))
  122. {
  123. AndroidTaskUtils.AddOnSuccessListener<AndroidJavaObject>(
  124. task,
  125. dataOrConflict =>
  126. {
  127. if (dataOrConflict.Call<bool>("isConflict"))
  128. {
  129. var conflict = dataOrConflict.Call<AndroidJavaObject>("getConflict");
  130. AndroidSnapshotMetadata original =
  131. new AndroidSnapshotMetadata(conflict.Call<AndroidJavaObject>("getSnapshot"));
  132. AndroidSnapshotMetadata unmerged =
  133. new AndroidSnapshotMetadata(
  134. conflict.Call<AndroidJavaObject>("getConflictingSnapshot"));
  135. // Instantiate the conflict resolver. Note that the retry callback closes over
  136. // all the parameters we need to retry the open attempt. Once the conflict is
  137. // resolved by invoking the appropriate resolution method on
  138. // AndroidConflictResolver, the resolver will invoke this callback, which will
  139. // result in this method being re-executed. This recursion will continue until
  140. // all conflicts are resolved or an error occurs.
  141. AndroidConflictResolver resolver = new AndroidConflictResolver(
  142. this,
  143. mSnapshotsClient,
  144. conflict,
  145. original,
  146. unmerged,
  147. completedCallback,
  148. () => InternalOpen(filename, source, resolutionStrategy,
  149. prefetchDataOnConflict,
  150. conflictCallback, completedCallback));
  151. var originalBytes = original.JavaContents.Call<byte[]>("readFully");
  152. var unmergedBytes = unmerged.JavaContents.Call<byte[]>("readFully");
  153. conflictCallback(resolver, original, originalBytes, unmerged, unmergedBytes);
  154. }
  155. else
  156. {
  157. using (var snapshot = dataOrConflict.Call<AndroidJavaObject>("getData"))
  158. {
  159. AndroidJavaObject metadata = snapshot.Call<AndroidJavaObject>("freeze");
  160. completedCallback(SavedGameRequestStatus.Success,
  161. new AndroidSnapshotMetadata(metadata));
  162. }
  163. }
  164. });
  165. AddOnFailureListenerWithSignOut(
  166. task,
  167. exception => {
  168. OurUtils.Logger.d("InternalOpen has failed: " + exception.Call<string>("toString"));
  169. var status = mAndroidClient.IsAuthenticated() ?
  170. SavedGameRequestStatus.InternalError :
  171. SavedGameRequestStatus.AuthenticationError;
  172. completedCallback(status, null);
  173. }
  174. );
  175. }
  176. }
  177. public void ReadBinaryData(ISavedGameMetadata metadata,
  178. Action<SavedGameRequestStatus, byte[]> completedCallback)
  179. {
  180. Misc.CheckNotNull(metadata);
  181. Misc.CheckNotNull(completedCallback);
  182. completedCallback = ToOnGameThread(completedCallback);
  183. AndroidSnapshotMetadata convertedMetadata = metadata as AndroidSnapshotMetadata;
  184. if (convertedMetadata == null)
  185. {
  186. OurUtils.Logger.e("Encountered metadata that was not generated by this ISavedGameClient");
  187. completedCallback(SavedGameRequestStatus.BadInputError, null);
  188. return;
  189. }
  190. if (!convertedMetadata.IsOpen)
  191. {
  192. OurUtils.Logger.e("This method requires an open ISavedGameMetadata.");
  193. completedCallback(SavedGameRequestStatus.BadInputError, null);
  194. return;
  195. }
  196. byte[] data = convertedMetadata.JavaContents.Call<byte[]>("readFully");
  197. if (data == null)
  198. {
  199. completedCallback(SavedGameRequestStatus.BadInputError, null);
  200. }
  201. else
  202. {
  203. completedCallback(SavedGameRequestStatus.Success, data);
  204. }
  205. }
  206. public void ShowSelectSavedGameUI(string uiTitle, uint maxDisplayedSavedGames, bool showCreateSaveUI,
  207. bool showDeleteSaveUI, Action<SelectUIStatus, ISavedGameMetadata> callback)
  208. {
  209. Misc.CheckNotNull(uiTitle);
  210. Misc.CheckNotNull(callback);
  211. callback = ToOnGameThread(callback);
  212. if (!(maxDisplayedSavedGames > 0))
  213. {
  214. OurUtils.Logger.e("maxDisplayedSavedGames must be greater than 0");
  215. callback(SelectUIStatus.BadInputError, null);
  216. return;
  217. }
  218. AndroidHelperFragment.ShowSelectSnapshotUI(
  219. showCreateSaveUI, showDeleteSaveUI, (int) maxDisplayedSavedGames, uiTitle, callback);
  220. }
  221. public void CommitUpdate(ISavedGameMetadata metadata, SavedGameMetadataUpdate updateForMetadata,
  222. byte[] updatedBinaryData, Action<SavedGameRequestStatus, ISavedGameMetadata> callback)
  223. {
  224. Misc.CheckNotNull(metadata);
  225. Misc.CheckNotNull(updatedBinaryData);
  226. Misc.CheckNotNull(callback);
  227. callback = ToOnGameThread(callback);
  228. AndroidSnapshotMetadata convertedMetadata = metadata as AndroidSnapshotMetadata;
  229. if (convertedMetadata == null)
  230. {
  231. OurUtils.Logger.e("Encountered metadata that was not generated by this ISavedGameClient");
  232. callback(SavedGameRequestStatus.BadInputError, null);
  233. return;
  234. }
  235. if (!convertedMetadata.IsOpen)
  236. {
  237. OurUtils.Logger.e("This method requires an open ISavedGameMetadata.");
  238. callback(SavedGameRequestStatus.BadInputError, null);
  239. return;
  240. }
  241. if (!convertedMetadata.JavaContents.Call<bool>("writeBytes", updatedBinaryData))
  242. {
  243. OurUtils.Logger.e("This method requires an open ISavedGameMetadata.");
  244. callback(SavedGameRequestStatus.BadInputError, null);
  245. }
  246. using (var convertedMetadataChange = AsMetadataChange(updateForMetadata))
  247. using (var task = mSnapshotsClient.Call<AndroidJavaObject>("commitAndClose", convertedMetadata.JavaSnapshot,
  248. convertedMetadataChange))
  249. {
  250. AndroidTaskUtils.AddOnSuccessListener<AndroidJavaObject>(
  251. task,
  252. /* disposeResult= */ false,
  253. snapshotMetadata =>
  254. {
  255. Debug.Log("commitAndClose.succeed");
  256. callback(SavedGameRequestStatus.Success,
  257. new AndroidSnapshotMetadata(snapshotMetadata, /* contents= */null));
  258. });
  259. AddOnFailureListenerWithSignOut(
  260. task,
  261. exception =>
  262. {
  263. Debug.Log("commitAndClose.failed: " + exception.Call<string>("toString"));
  264. var status = mAndroidClient.IsAuthenticated() ?
  265. SavedGameRequestStatus.InternalError :
  266. SavedGameRequestStatus.AuthenticationError;
  267. callback(status, null);
  268. });
  269. }
  270. }
  271. public void FetchAllSavedGames(DataSource source,
  272. Action<SavedGameRequestStatus, List<ISavedGameMetadata>> callback)
  273. {
  274. Misc.CheckNotNull(callback);
  275. callback = ToOnGameThread(callback);
  276. using (var task =
  277. mSnapshotsClient.Call<AndroidJavaObject>("load", /* forecReload= */
  278. source == DataSource.ReadNetworkOnly))
  279. {
  280. AndroidTaskUtils.AddOnSuccessListener<AndroidJavaObject>(
  281. task,
  282. annotatedData =>
  283. {
  284. using (var buffer = annotatedData.Call<AndroidJavaObject>("get"))
  285. {
  286. int count = buffer.Call<int>("getCount");
  287. List<ISavedGameMetadata> result = new List<ISavedGameMetadata>();
  288. for (int i = 0; i < count; ++i)
  289. {
  290. using (var metadata = buffer.Call<AndroidJavaObject>("get", i))
  291. {
  292. result.Add(new AndroidSnapshotMetadata(
  293. metadata.Call<AndroidJavaObject>("freeze"), /* contents= */null));
  294. }
  295. }
  296. buffer.Call("release");
  297. callback(SavedGameRequestStatus.Success, result);
  298. }
  299. });
  300. AddOnFailureListenerWithSignOut(
  301. task,
  302. exception => {
  303. OurUtils.Logger.d("FetchAllSavedGames failed: " + exception.Call<string>("toString"));
  304. var status = mAndroidClient.IsAuthenticated() ?
  305. SavedGameRequestStatus.InternalError :
  306. SavedGameRequestStatus.AuthenticationError;
  307. callback(status, new List<ISavedGameMetadata>());
  308. }
  309. );
  310. }
  311. }
  312. public void Delete(ISavedGameMetadata metadata)
  313. {
  314. AndroidSnapshotMetadata androidMetadata = metadata as AndroidSnapshotMetadata;
  315. Misc.CheckNotNull(androidMetadata);
  316. using (mSnapshotsClient.Call<AndroidJavaObject>("delete", androidMetadata.JavaMetadata)) ;
  317. }
  318. private void AddOnFailureListenerWithSignOut(AndroidJavaObject task, Action<AndroidJavaObject> callback)
  319. {
  320. AndroidTaskUtils.AddOnFailureListener(
  321. task,
  322. exception =>
  323. {
  324. if (Misc.IsApiException(exception))
  325. {
  326. var statusCode = exception.Call<int>("getStatusCode");
  327. if (statusCode == /* CommonStatusCodes.SignInRequired */ 4 ||
  328. statusCode == /* GamesClientStatusCodes.CLIENT_RECONNECT_REQUIRED */ 26502)
  329. {
  330. mAndroidClient.SignOut();
  331. }
  332. }
  333. callback(exception);
  334. });
  335. }
  336. private ConflictCallback ToOnGameThread(ConflictCallback conflictCallback)
  337. {
  338. return (resolver, original, originalData, unmerged, unmergedData) =>
  339. {
  340. OurUtils.Logger.d("Invoking conflict callback");
  341. PlayGamesHelperObject.RunOnGameThread(() =>
  342. conflictCallback(resolver, original, originalData, unmerged, unmergedData));
  343. };
  344. }
  345. /// <summary>
  346. /// A helper class that encapsulates the state around resolving a file conflict. It holds all
  347. /// the state that is necessary to invoke <see cref="SnapshotManager.Resolve"/> as well as a
  348. /// callback that will re-attempt to open the file after the resolution concludes.
  349. /// </summary>
  350. private class AndroidConflictResolver : IConflictResolver
  351. {
  352. private readonly AndroidJavaObject mSnapshotsClient;
  353. private readonly AndroidJavaObject mConflict;
  354. private readonly AndroidSnapshotMetadata mOriginal;
  355. private readonly AndroidSnapshotMetadata mUnmerged;
  356. private readonly Action<SavedGameRequestStatus, ISavedGameMetadata> mCompleteCallback;
  357. private readonly Action mRetryFileOpen;
  358. private readonly AndroidSavedGameClient mAndroidSavedGameClient;
  359. internal AndroidConflictResolver(AndroidSavedGameClient androidSavedGameClient, AndroidJavaObject snapshotClient, AndroidJavaObject conflict,
  360. AndroidSnapshotMetadata original, AndroidSnapshotMetadata unmerged,
  361. Action<SavedGameRequestStatus, ISavedGameMetadata> completeCallback, Action retryOpen)
  362. {
  363. this.mAndroidSavedGameClient = androidSavedGameClient;
  364. this.mSnapshotsClient = Misc.CheckNotNull(snapshotClient);
  365. this.mConflict = Misc.CheckNotNull(conflict);
  366. this.mOriginal = Misc.CheckNotNull(original);
  367. this.mUnmerged = Misc.CheckNotNull(unmerged);
  368. this.mCompleteCallback = Misc.CheckNotNull(completeCallback);
  369. this.mRetryFileOpen = Misc.CheckNotNull(retryOpen);
  370. }
  371. public void ResolveConflict(ISavedGameMetadata chosenMetadata, SavedGameMetadataUpdate metadataUpdate,
  372. byte[] updatedData)
  373. {
  374. AndroidSnapshotMetadata convertedMetadata = chosenMetadata as AndroidSnapshotMetadata;
  375. if (convertedMetadata != mOriginal && convertedMetadata != mUnmerged)
  376. {
  377. OurUtils.Logger.e("Caller attempted to choose a version of the metadata that was not part " +
  378. "of the conflict");
  379. mCompleteCallback(SavedGameRequestStatus.BadInputError, null);
  380. return;
  381. }
  382. using (var contentUpdate = mConflict.Call<AndroidJavaObject>("getResolutionSnapshotContents"))
  383. {
  384. if (!contentUpdate.Call<bool>("writeBytes", updatedData))
  385. {
  386. OurUtils.Logger.e("Can't update snapshot contents during conflict resolution.");
  387. mCompleteCallback(SavedGameRequestStatus.BadInputError, null);
  388. }
  389. using (var convertedMetadataChange = AsMetadataChange(metadataUpdate))
  390. using (var task = mSnapshotsClient.Call<AndroidJavaObject>(
  391. "resolveConflict",
  392. mConflict.Call<string>("getConflictId"),
  393. convertedMetadata.JavaMetadata.Call<string>("getSnapshotId"),
  394. convertedMetadataChange,
  395. contentUpdate))
  396. {
  397. AndroidTaskUtils.AddOnSuccessListener<AndroidJavaObject>(
  398. task,
  399. dataOrConflict => mRetryFileOpen());
  400. mAndroidSavedGameClient.AddOnFailureListenerWithSignOut(
  401. task,
  402. exception => {
  403. OurUtils.Logger.d("ResolveConflict failed: " + exception.Call<string>("toString"));
  404. var status = mAndroidSavedGameClient.mAndroidClient.IsAuthenticated() ?
  405. SavedGameRequestStatus.InternalError :
  406. SavedGameRequestStatus.AuthenticationError;
  407. mCompleteCallback(status, null);
  408. }
  409. );
  410. }
  411. }
  412. }
  413. public void ChooseMetadata(ISavedGameMetadata chosenMetadata)
  414. {
  415. AndroidSnapshotMetadata convertedMetadata = chosenMetadata as AndroidSnapshotMetadata;
  416. if (convertedMetadata != mOriginal && convertedMetadata != mUnmerged)
  417. {
  418. OurUtils.Logger.e("Caller attempted to choose a version of the metadata that was not part " +
  419. "of the conflict");
  420. mCompleteCallback(SavedGameRequestStatus.BadInputError, null);
  421. return;
  422. }
  423. using (var task = mSnapshotsClient.Call<AndroidJavaObject>(
  424. "resolveConflict", mConflict.Call<string>("getConflictId"), convertedMetadata.JavaSnapshot))
  425. {
  426. AndroidTaskUtils.AddOnSuccessListener<AndroidJavaObject>(
  427. task,
  428. dataOrConflict => mRetryFileOpen());
  429. mAndroidSavedGameClient.AddOnFailureListenerWithSignOut(
  430. task,
  431. exception => {
  432. OurUtils.Logger.d("ChooseMetadata failed: " + exception.Call<string>("toString"));
  433. var status = mAndroidSavedGameClient.mAndroidClient.IsAuthenticated() ?
  434. SavedGameRequestStatus.InternalError :
  435. SavedGameRequestStatus.AuthenticationError;
  436. mCompleteCallback(status, null);
  437. }
  438. );
  439. }
  440. }
  441. }
  442. internal static bool IsValidFilename(string filename)
  443. {
  444. if (filename == null)
  445. {
  446. return false;
  447. }
  448. return ValidFilenameRegex.IsMatch(filename);
  449. }
  450. private static AndroidJavaObject AsMetadataChange(SavedGameMetadataUpdate update)
  451. {
  452. using (var builder =
  453. new AndroidJavaObject("com.google.android.gms.games.snapshot.SnapshotMetadataChange$Builder"))
  454. {
  455. if (update.IsCoverImageUpdated)
  456. {
  457. using (var bitmapFactory = new AndroidJavaClass("android.graphics.BitmapFactory"))
  458. using (var bitmap = bitmapFactory.CallStatic<AndroidJavaObject>(
  459. "decodeByteArray", update.UpdatedPngCoverImage, /* offset= */0,
  460. update.UpdatedPngCoverImage.Length))
  461. using (builder.Call<AndroidJavaObject>("setCoverImage", bitmap))
  462. ;
  463. }
  464. if (update.IsDescriptionUpdated)
  465. {
  466. using (builder.Call<AndroidJavaObject>("setDescription", update.UpdatedDescription)) ;
  467. }
  468. if (update.IsPlayedTimeUpdated)
  469. {
  470. using (builder.Call<AndroidJavaObject>("setPlayedTimeMillis",
  471. Convert.ToInt64(update.UpdatedPlayedTime.Value.TotalMilliseconds))) ;
  472. }
  473. return builder.Call<AndroidJavaObject>("build");
  474. }
  475. }
  476. private static Action<T1, T2> ToOnGameThread<T1, T2>(Action<T1, T2> toConvert)
  477. {
  478. return (val1, val2) => PlayGamesHelperObject.RunOnGameThread(() => toConvert(val1, val2));
  479. }
  480. }
  481. }
  482. #endif