@@ -35,6 +35,7 @@ namespace PlayEveryWare.EpicOnlineServices.Samples
3535 using Epic . OnlineServices ;
3636 using Epic . OnlineServices . Achievements ;
3737 using System . Threading . Tasks ;
38+ using System . Threading ;
3839 using Debug = UnityEngine . Debug ;
3940
4041 /// <summary>
@@ -59,6 +60,12 @@ public class AchievementsService : EOSService
5960 /// </summary>
6061 private ConcurrentDictionary < ProductUserId , List < PlayerAchievement > > _playerAchievements = new ( ) ;
6162
63+ /// <summary>
64+ /// Serializes recovery attempts so multiple achievement requests do not
65+ /// try to reconnect Connect/Auth at the same time after expiration.
66+ /// </summary>
67+ private readonly SemaphoreSlim _connectRecoverySemaphore = new ( 1 , 1 ) ;
68+
6269 #region Singleton Implementation
6370
6471 /// <summary>
@@ -130,11 +137,12 @@ protected override void Reset()
130137 /// </summary>
131138 protected async override Task InternalRefreshAsync ( )
132139 {
133- if ( ! TryGetProductUserId ( out ProductUserId productUser ) )
140+ ProductUserId productUserId = await EnsureValidProductUserIdAsync ( ) ;
141+ if ( productUserId == null || ! productUserId . IsValid ( ) )
134142 {
135143 return ;
136144 }
137- ProductUserId productUserId = EOSManager . Instance . GetProductUserId ( ) ;
145+
138146 _achievements = await QueryAchievementsAsync ( productUserId ) ;
139147
140148 // If the user is not in the list, then add it.
@@ -287,6 +295,17 @@ private List<DefinitionV2> GetCachedAchievements()
287295 /// </returns>
288296 private Task < List < PlayerAchievement > > QueryPlayerAchievementsAsync ( ProductUserId productUserId )
289297 {
298+ return QueryPlayerAchievementsWithReconnectAsync ( productUserId , false ) ;
299+ }
300+
301+ private async Task < List < PlayerAchievement > > QueryPlayerAchievementsWithReconnectAsync ( ProductUserId productUserId , bool retryingAfterReconnect )
302+ {
303+ productUserId = await EnsureValidProductUserIdAsync ( ) ;
304+ if ( productUserId == null || ! productUserId . IsValid ( ) )
305+ {
306+ return new List < PlayerAchievement > ( ) ;
307+ }
308+
290309 Log ( $ "Begin query player achievements for { ProductUserIdToString ( productUserId ) } ") ;
291310
292311 QueryPlayerAchievementsOptions options = new ( )
@@ -295,22 +314,29 @@ private Task<List<PlayerAchievement>> QueryPlayerAchievementsAsync(ProductUserId
295314 TargetUserId = productUserId
296315 } ;
297316
298- TaskCompletionSource < List < PlayerAchievement > > tcs = new ( ) ;
317+ TaskCompletionSource < Result > tcs = new ( ) ;
299318
300319 GetEOSAchievementInterface ( ) . QueryPlayerAchievements ( ref options , null , ( ref OnQueryPlayerAchievementsCompleteCallbackInfo data ) =>
301320 {
302- if ( data . ResultCode != Result . Success )
303- {
304- Log ( $ "Error querying player achievements. Result code: { data . ResultCode } ") ;
305- tcs . SetResult ( new List < PlayerAchievement > ( ) ) ;
306- }
307- else
321+ tcs . TrySetResult ( data . ResultCode ) ;
322+ } ) ;
323+
324+ Result result = await tcs . Task ;
325+ if ( result == Result . Success )
326+ {
327+ return GetCachedPlayerAchievements ( productUserId ) ;
328+ }
329+
330+ if ( ! retryingAfterReconnect && ShouldRetryAfterReconnect ( result ) )
331+ {
332+ ProductUserId recoveredProductUserId = await EnsureValidProductUserIdAsync ( true ) ;
333+ if ( recoveredProductUserId != null && recoveredProductUserId . IsValid ( ) )
308334 {
309- tcs . SetResult ( GetCachedPlayerAchievements ( productUserId ) ) ;
335+ return await QueryPlayerAchievementsWithReconnectAsync ( recoveredProductUserId , true ) ;
310336 }
311- } ) ;
337+ }
312338
313- return tcs . Task ;
339+ return new List < PlayerAchievement > ( ) ;
314340 }
315341
316342 /// <summary>
@@ -361,6 +387,12 @@ public async Task<Texture2D> GetAchievementLockedIconTexture(string achievementI
361387 /// </returns>
362388 private async Task < Texture2D > GetAchievementIconTexture ( string achievementId , Func < DefinitionV2 , string > uriSelector )
363389 {
390+ if ( string . IsNullOrWhiteSpace ( achievementId ) )
391+ {
392+ Debug . LogWarning ( $ "{ nameof ( AchievementsService ) } { nameof ( GetAchievementIconTexture ) } : Cannot fetch achievement icon because the achievement id is null or empty.") ;
393+ return null ;
394+ }
395+
364396 Texture2D textureFromBytes = null ;
365397
366398 foreach ( var achievementDef in _achievements )
@@ -369,10 +401,16 @@ private async Task<Texture2D> GetAchievementIconTexture(string achievementId, Fu
369401 continue ;
370402
371403 var uri = uriSelector ( achievementDef ) ;
404+ if ( string . IsNullOrWhiteSpace ( uri ) )
405+ {
406+ Debug . LogWarning ( $ "{ nameof ( AchievementsService ) } { nameof ( GetAchievementIconTexture ) } : Achievement '{ achievementId } ' does not define an icon URL for the requested state.") ;
407+ break ;
408+ }
409+
372410 byte [ ] iconBytes = null ;
373411
374412 // Download the data
375- if ( ! _downloadCache . ContainsKey ( uri ) )
413+ if ( ! _downloadCache . TryGetValue ( uri , out iconBytes ) )
376414 {
377415 TaskCompletionSource < byte [ ] > downloadTcs = new ( ) ;
378416
@@ -395,11 +433,6 @@ private async Task<Texture2D> GetAchievementIconTexture(string achievementId, Fu
395433 _downloadCache [ uri ] = iconBytes ;
396434 }
397435 }
398- else
399- {
400- _downloadCache . TryGetValue ( uri , out iconBytes ) ;
401- }
402-
403436
404437 if ( null != iconBytes )
405438 {
@@ -502,6 +535,25 @@ public static uint GetAchievementsCount()
502535 /// </param>
503536 public Task < PlayerAchievement > UnlockAchievementAsync ( string achievementId )
504537 {
538+ return UnlockAchievementInternalAsync ( achievementId , false ) ;
539+ }
540+
541+ private async Task < PlayerAchievement > UnlockAchievementInternalAsync ( string achievementId , bool retryingAfterReconnect )
542+ {
543+ if ( string . IsNullOrWhiteSpace ( achievementId ) )
544+ {
545+ throw new ArgumentException ( "Achievement id must not be null or empty." , nameof ( achievementId ) ) ;
546+ }
547+
548+ if ( _achievements . Count == 0 )
549+ {
550+ ProductUserId refreshedProductUserId = await EnsureValidProductUserIdAsync ( ) ;
551+ if ( refreshedProductUserId != null && refreshedProductUserId . IsValid ( ) )
552+ {
553+ _achievements = await QueryAchievementsAsync ( refreshedProductUserId ) ;
554+ }
555+ }
556+
505557 DefinitionV2 ? definition = null ;
506558 for ( int i = 0 ; i < _achievements . Count ; i ++ )
507559 {
@@ -515,61 +567,152 @@ public Task<PlayerAchievement> UnlockAchievementAsync(string achievementId)
515567
516568 if ( ! definition . HasValue )
517569 {
518- return Task . FromException < PlayerAchievement > ( new Exception ( $ "Achievement definition not found for ID: { achievementId } ") ) ;
570+ ProductUserId refreshedProductUserId = await EnsureValidProductUserIdAsync ( ) ;
571+ if ( refreshedProductUserId != null && refreshedProductUserId . IsValid ( ) )
572+ {
573+ _achievements = await QueryAchievementsAsync ( refreshedProductUserId ) ;
574+
575+ for ( int i = 0 ; i < _achievements . Count ; i ++ )
576+ {
577+ DefinitionV2 otherDefinition = _achievements [ i ] ;
578+ if ( otherDefinition . AchievementId == achievementId )
579+ {
580+ definition = otherDefinition ;
581+ break ;
582+ }
583+ }
584+ }
585+ }
586+
587+ if ( ! definition . HasValue )
588+ {
589+ throw new Exception ( $ "Achievement definition not found for ID: { achievementId } ") ;
590+ }
591+
592+ var localUserId = await EnsureValidProductUserIdAsync ( ) ;
593+ if ( localUserId == null || ! localUserId . IsValid ( ) )
594+ {
595+ throw new InvalidOperationException ( "Cannot unlock achievements because the local ProductUserId is null or invalid and automatic Connect recovery did not succeed." ) ;
519596 }
520597
521- var localUserId = EOSManager . Instance . GetProductUserId ( ) ;
522598 var eosAchievementOption = new UnlockAchievementsOptions
523599 {
524600 UserId = localUserId ,
525601 AchievementIds = new Utf8String [ ] { definition . Value . AchievementId }
526602 } ;
527603
528- var tcs = new TaskCompletionSource < PlayerAchievement > ( ) ;
604+ var tcs = new TaskCompletionSource < Result > ( ) ;
529605
530606 GetEOSAchievementInterface ( ) . UnlockAchievements ( ref eosAchievementOption , null ,
531607 ( ref OnUnlockAchievementsCompleteCallbackInfo data ) =>
532608 {
533- if ( data . ResultCode != Result . Success )
609+ tcs . TrySetResult ( data . ResultCode ) ;
610+ } ) ;
611+
612+ Result unlockResult = await tcs . Task ;
613+ if ( unlockResult != Result . Success )
614+ {
615+ if ( ! retryingAfterReconnect && ShouldRetryAfterReconnect ( unlockResult ) )
616+ {
617+ await EnsureValidProductUserIdAsync ( true ) ;
618+ return await UnlockAchievementInternalAsync ( achievementId , true ) ;
619+ }
620+
621+ throw new Exception ( $ "Could not unlock achievement. Error code: { Enum . GetName ( typeof ( Result ) , unlockResult ) } ") ;
622+ }
623+
624+ var achievement = new PlayerAchievement ( )
625+ {
626+ AchievementId = definition . Value . AchievementId ,
627+ DisplayName = definition . Value . UnlockedDisplayName ,
628+ Description = definition . Value . UnlockedDescription ,
629+ Progress = 1.0 ,
630+ UnlockTime = DateTime . UtcNow ,
631+ StatInfo = null
632+ } ;
633+
634+ _playerAchievements . AddOrUpdate ( localUserId ,
635+ new List < PlayerAchievement > { achievement } ,
636+ ( id , list ) =>
637+ {
638+ int index = list . FindIndex ( a => a . AchievementId == achievement . AchievementId ) ;
639+ if ( index >= 0 )
534640 {
535- tcs . SetException ( new Exception ( $ "Could not unlock achievement. Error code: { Enum . GetName ( typeof ( Result ) , data . ResultCode ) } " ) ) ;
641+ list [ index ] = achievement ;
536642 }
537643 else
538644 {
539- var achievement = new PlayerAchievement ( )
540- {
541- AchievementId = definition . Value . AchievementId ,
542- DisplayName = definition . Value . UnlockedDisplayName ,
543- Description = definition . Value . UnlockedDescription ,
544- Progress = 1.0 ,
545- UnlockTime = DateTime . UtcNow ,
546- StatInfo = null
547- } ;
548-
549- _playerAchievements . AddOrUpdate ( localUserId ,
550- new List < PlayerAchievement > { achievement } ,
551- ( id , list ) =>
552- {
553- int index = list . FindIndex ( a => a . AchievementId == achievement . AchievementId ) ;
554- if ( index >= 0 )
555- {
556- list [ index ] = achievement ;
557- }
558- else
559- {
560- list . Add ( achievement ) ;
561- }
562- return list ;
563- } ) ;
564-
565- NotifyUpdated ( ) ;
566-
567- tcs . SetResult ( achievement ) ;
568-
645+ list . Add ( achievement ) ;
569646 }
647+ return list ;
570648 } ) ;
571649
572- return tcs . Task ;
650+ NotifyUpdated ( ) ;
651+
652+ return achievement ;
653+ }
654+
655+ private static bool ShouldRetryAfterReconnect ( Result result )
656+ {
657+ return result == Result . Canceled ||
658+ result == Result . InvalidAuth ||
659+ result == Result . AuthExpired ||
660+ result == Result . AuthExternalAuthExpired ||
661+ result == Result . ConnectAuthExpired ;
662+ }
663+
664+ private async Task < ProductUserId > EnsureValidProductUserIdAsync ( bool forceReconnect = false )
665+ {
666+ ProductUserId currentProductUserId = EOSManager . Instance . GetProductUserId ( ) ;
667+ if ( ! forceReconnect && currentProductUserId != null && currentProductUserId . IsValid ( ) )
668+ {
669+ return currentProductUserId ;
670+ }
671+
672+ EpicAccountId localUserId = EOSManager . Instance . GetLocalUserId ( ) ;
673+ if ( localUserId == null || ! localUserId . IsValid ( ) )
674+ {
675+ return null ;
676+ }
677+
678+ await _connectRecoverySemaphore . WaitAsync ( ) ;
679+ try
680+ {
681+ currentProductUserId = EOSManager . Instance . GetProductUserId ( ) ;
682+ if ( ! forceReconnect && currentProductUserId != null && currentProductUserId . IsValid ( ) )
683+ {
684+ return currentProductUserId ;
685+ }
686+
687+ TaskCompletionSource < Result > reconnectTcs = new ( ) ;
688+ EOSManager . Instance . StartConnectLoginWithEpicAccount ( localUserId , callback =>
689+ {
690+ reconnectTcs . TrySetResult ( callback . ResultCode ) ;
691+ } ) ;
692+
693+ Result reconnectResult = await reconnectTcs . Task ;
694+ if ( reconnectResult != Result . Success )
695+ {
696+ return null ;
697+ }
698+
699+ ProductUserId refreshedProductUserId = EOSManager . Instance . GetProductUserId ( ) ;
700+ if ( refreshedProductUserId == null || ! refreshedProductUserId . IsValid ( ) )
701+ {
702+ return null ;
703+ }
704+
705+ if ( _achievements . Count == 0 )
706+ {
707+ _achievements = await QueryAchievementsAsync ( refreshedProductUserId ) ;
708+ }
709+
710+ return refreshedProductUserId ;
711+ }
712+ finally
713+ {
714+ _connectRecoverySemaphore . Release ( ) ;
715+ }
573716 }
574717
575718 /// <summary>
@@ -636,8 +779,30 @@ protected struct DownloadDataCallback
636779 /// </param>
637780 private void GetAndCacheData ( string uri , Action < DownloadDataCallback > callback )
638781 {
782+ if ( callback == null )
783+ {
784+ Debug . LogWarning ( $ "{ nameof ( AchievementsService ) } { nameof ( GetAndCacheData ) } : Callback is null; aborting download.") ;
785+ return ;
786+ }
787+
788+ if ( string . IsNullOrWhiteSpace ( uri ) )
789+ {
790+ Debug . LogWarning ( $ "{ nameof ( AchievementsService ) } { nameof ( GetAndCacheData ) } : URI is null or empty; skipping download.") ;
791+ callback ( new DownloadDataCallback
792+ {
793+ data = null ,
794+ result = UnityWebRequest . Result . ProtocolError
795+ } ) ;
796+ return ;
797+ }
798+
639799 if ( _downloadCache . ContainsKey ( uri ) )
640800 {
801+ callback ( new DownloadDataCallback
802+ {
803+ data = _downloadCache [ uri ] ,
804+ result = UnityWebRequest . Result . Success
805+ } ) ;
641806 return ;
642807 }
643808
@@ -668,4 +833,4 @@ private void GetAndCacheData(string uri, Action<DownloadDataCallback> callback)
668833 } ;
669834 }
670835 }
671- }
836+ }
0 commit comments