Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/leaderboard_and_achievements.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ final result = await Leaderboards.loadLeaderboardScores(
maxResults: 10);
```

Loading scores throws a `PlatformException` when the native service cannot
complete the request. Compare `error.code` with
`LeaderboardScoresErrorCode.notAuthenticated`,
`LeaderboardScoresErrorCode.friendsListAccessDenied` (Android),
`LeaderboardScoresErrorCode.operationCanceled` (GameKit), or
`LeaderboardScoresErrorCode.failedToLoad` instead of matching localized error
messages.

## Load previous occurrence (iOS only)

Load the previous occurrence of the player's score from a leaderboard. This returns the score data that precedes the player's current best score, which is useful for tracking score progression over time.
Expand Down
4 changes: 4 additions & 0 deletions games_services/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

- Return stable, actionable error codes when loading leaderboard scores.

## 5.1.0

- Add optional `coverImage`, `description`, and `playedTime` parameters to `SaveGame.saveGame`. On Android these are set as the snapshot metadata (cover image required to pass Google's Play Games Services quality checklist). Ignored on iOS/macOS. (#228)
Expand Down
1 change: 1 addition & 0 deletions games_services/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ kotlin {
dependencies {
implementation 'com.google.code.gson:gson:2.14.0'
implementation "com.google.android.gms:play-services-games-v2:21.0.0"
testImplementation 'junit:junit:4.13.2'
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,28 @@ package com.abedalkareem.games_services

import android.app.Activity
import android.content.Intent
import android.util.Log
import com.abedalkareem.games_services.models.LeaderboardScoreData
import com.abedalkareem.games_services.models.PlayerData
import com.abedalkareem.games_services.util.AppImageLoader
import com.abedalkareem.games_services.util.Messages
import com.abedalkareem.games_services.util.PluginError
import com.abedalkareem.games_services.util.errorCode
import com.abedalkareem.games_services.util.errorMessage
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.games.FriendsResolutionRequiredException
import com.google.android.gms.games.LeaderboardsClient
import com.google.android.gms.games.PlayGames
import com.google.android.gms.games.leaderboard.LeaderboardVariant
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import android.util.Log
import com.abedalkareem.games_services.util.Messages
import com.google.android.gms.games.FriendsResolutionRequiredException
import io.flutter.plugin.common.PluginRegistry

class Leaderboards(private var activityPluginBinding: ActivityPluginBinding) :
PluginRegistry.ActivityResultListener {
Expand All @@ -41,7 +43,6 @@ class Leaderboards(private var activityPluginBinding: ActivityPluginBinding) :
private var maxResults: Int? = null
private var forceRefresh: Boolean? = null
private var result: MethodChannel.Result? = null
private var errorMessage: String? = null
//endregion

//region Public Methods
Expand Down Expand Up @@ -145,7 +146,6 @@ class Leaderboards(private var activityPluginBinding: ActivityPluginBinding) :
this.maxResults = maxResults
this.forceRefresh = forceRefresh
this.result = result
this.errorMessage = it.localizedMessage
val pendingIntent = it.resolution
activityPluginBinding.addActivityResultListener(this)
activity.startIntentSenderForResult(
Expand All @@ -158,11 +158,7 @@ class Leaderboards(private var activityPluginBinding: ActivityPluginBinding) :
)
Log.i("GamesServices", "Friends list access requested")
} else {
result.error(
PluginError.FailedToLoadLeaderboardScores.errorCode(),
it.localizedMessage,
null
)
result.errorFromLeaderboardException(it)
}
}
}
Expand Down Expand Up @@ -262,10 +258,10 @@ class Leaderboards(private var activityPluginBinding: ActivityPluginBinding) :
//region onActivityResult for showLeaderboards Method
// handle result from friends list permission request
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?): Boolean {
activityPluginBinding.removeActivityResultListener(this)
return if (requestCode == 26703) {
activityPluginBinding.removeActivityResultListener(this)
// retry loadLeaderboard if permission granted, otherwise throw the original error
if (resultCode == -1) {
if (resultCode == Activity.RESULT_OK) {
val id = leaderboardID
val centered = playerCentered
val timeSpan = span
Expand All @@ -288,8 +284,8 @@ class Leaderboards(private var activityPluginBinding: ActivityPluginBinding) :
}
} else {
result?.error(
PluginError.FailedToLoadLeaderboardScores.errorCode(),
errorMessage,
PluginError.FriendsListAccessDenied.errorCode(),
PluginError.FriendsListAccessDenied.errorMessage(),
null,
)
}
Expand All @@ -300,12 +296,29 @@ class Leaderboards(private var activityPluginBinding: ActivityPluginBinding) :
maxResults = null
forceRefresh = null
result = null
errorMessage = null
true
} else {
false
}
}
//endregion
//endregion

private fun MethodChannel.Result.errorFromLeaderboardException(exception: Exception) {
val pluginError = leaderboardScoresPluginError(
(exception as? ApiException)?.statusCode
)
error(
pluginError.errorCode(),
exception.localizedMessage ?: pluginError.errorMessage(),
null
)
}
}

internal fun leaderboardScoresPluginError(statusCode: Int?): PluginError =
if (statusCode == CommonStatusCodes.SIGN_IN_REQUIRED) {
PluginError.NotAuthenticated
} else {
PluginError.FailedToLoadLeaderboardScores
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ enum class PluginError {
FailedToShowAchievements, FailedToIncrementAchievements, FailedToLoadAchievements,
FailedToAuthenticate, FailedToGetAuthCode, NotAuthenticated, NotSupportedForThisOSVersion,
FailedToSaveGame, FailedToLoadGame, FailedToShowSavedGames, FailedToGetSavedGames, LeaderboardNotFound,
FailedToDeleteSavedGame, FailedToLoadLeaderboardScores, OperationCanceled
FailedToDeleteSavedGame, FailedToLoadLeaderboardScores, FriendsListAccessDenied, OperationCanceled
}

fun PluginError.errorCode(): String {
Expand Down Expand Up @@ -86,6 +86,10 @@ fun PluginError.errorCode(): String {
return "failed_to_load_leaderboard_scores"
}

PluginError.FriendsListAccessDenied -> {
return "friends_list_access_denied"
}

PluginError.OperationCanceled -> {
return "operation_canceled"
}
Expand Down Expand Up @@ -166,6 +170,10 @@ fun PluginError.errorMessage(): String {
return "Failed to load leaderboard scores"
}

PluginError.FriendsListAccessDenied -> {
return "Player declined friends list access"
}

PluginError.OperationCanceled -> {
return "The operation was canceled"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.abedalkareem.games_services

import com.abedalkareem.games_services.util.PluginError
import com.google.android.gms.common.api.CommonStatusCodes
import org.junit.Assert.assertEquals
import org.junit.Test

class LeaderboardScoresErrorMapperTest {
@Test
fun signInRequiredMapsToNotAuthenticated() {
assertEquals(
PluginError.NotAuthenticated,
leaderboardScoresPluginError(CommonStatusCodes.SIGN_IN_REQUIRED)
)
}

@Test
fun otherApiStatusMapsToGenericFailure() {
assertEquals(
PluginError.FailedToLoadLeaderboardScores,
leaderboardScoresPluginError(CommonStatusCodes.DEVELOPER_ERROR)
)
}

@Test
fun nonApiExceptionMapsToGenericFailure() {
assertEquals(
PluginError.FailedToLoadLeaderboardScores,
leaderboardScoresPluginError(null)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ class Leaderboards: BaseGamesServices {
}

} catch {
result(error.flutterError(code: .failedToLoadLeaderboardScores))
result(error.leaderboardScoresFlutterError())
}
}
} else {
Expand Down Expand Up @@ -223,4 +223,3 @@ class Leaderboards: BaseGamesServices {
}

}

Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@

import GameKit
#if os(iOS) || os(tvOS)
import Flutter
#else
Expand All @@ -11,6 +12,20 @@ extension Error {
message: self.localizedDescription,
details: self.localizedDescription)
}

func leaderboardScoresFlutterError() -> FlutterError {
guard let gameKitError = self as? GKError else {
return flutterError(code: .failedToLoadLeaderboardScores)
}
switch gameKitError.code {
case .notAuthenticated:
return flutterError(code: .notAuthenticated)
case .cancelled:
return flutterError(code: .operationCanceled)
default:
return flutterError(code: .failedToLoadLeaderboardScores)
}
}
}

enum PluginError: String {
Expand Down Expand Up @@ -45,6 +60,10 @@ enum PluginError: String {
return "Failed to reset achievements"
case .failedToLoadLeaderboardScores:
return "Failed to load leaderboard scores"
case .notAuthenticated:
return "Player not authenticated, please call signIn() first"
case .operationCanceled:
return "The operation was canceled"
case .failedToLoadPreviousOccurrence:
return "Failed to load previous occurrence"
case .failedToFetchIdentityVerification:
Expand All @@ -66,6 +85,8 @@ enum PluginError: String {
case failedToLoadAchievements = "failed_to_load_achievements"
case failedToResetAchievements = "failed_to_reset_achievements"
case failedToLoadLeaderboardScores = "failed_to_load_leaderboard_scores"
case notAuthenticated = "not_authenticated"
case operationCanceled = "operation_canceled"
case failedToLoadPreviousOccurrence = "failed_to_load_previous_occurrence"
case failedToFetchIdentityVerification = "failed_to_fetch_identity_verification"

Expand Down
1 change: 1 addition & 0 deletions games_services/lib/games_services.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'package:games_services_platform_interface/errors.dart';
export 'package:games_services_platform_interface/models.dart';

export 'src/achievements.dart';
Expand Down
3 changes: 3 additions & 0 deletions games_services/lib/src/games_services.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ class GamesServices {
///
/// The `forceRefresh` argument will invalidate the cache on Android, fetching
/// the latest results. It has no affect on iOS.
///
/// A failed request throws a `PlatformException`. Its code is one of the
/// constants in [LeaderboardScoresErrorCode].
static Future<List<LeaderboardScoreData>?> loadLeaderboardScores({
String iOSLeaderboardID = "",
String androidLeaderboardID = "",
Expand Down
4 changes: 4 additions & 0 deletions games_services/lib/src/leaderboards.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:convert';

import 'package:games_services/src/models/leaderboard_score_data.dart';
import 'package:games_services_platform_interface/errors.dart';
import 'package:games_services_platform_interface/game_services_platform_interface.dart';
import 'package:games_services_platform_interface/models.dart';

Expand All @@ -27,6 +28,9 @@ abstract class Leaderboards {
///
/// The `forceRefresh` argument will invalidate the cache on Android, fetching
/// the latest results. It has no affect on iOS.
///
/// A failed request throws a `PlatformException`. Its code is one of the
/// constants in [LeaderboardScoresErrorCode].
static Future<List<LeaderboardScoreData>?> loadLeaderboardScores({
String iOSLeaderboardID = "",
String androidLeaderboardID = "",
Expand Down
4 changes: 4 additions & 0 deletions games_services_platform_interface/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

- Expose stable error-code constants for loading leaderboard scores.

## 5.1.0

- Add optional `coverImage`, `description`, and `playedTime` parameters to `saveGame`. (#228)
Expand Down
1 change: 1 addition & 0 deletions games_services_platform_interface/lib/errors.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export 'src/errors/leaderboard_scores_error_code.dart';
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:games_services_platform_interface/errors.dart';
import 'package:games_services_platform_interface/models.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';

Expand Down Expand Up @@ -90,6 +91,9 @@ abstract class GamesServicesPlatform extends PlatformInterface {

/// Get leaderboard scores as json data.
/// To show the device's default leaderboards screen use [showLeaderboards].
///
/// A failed request throws a `PlatformException`. Its code is one of the
/// constants in [LeaderboardScoresErrorCode].
Future<String?> loadLeaderboardScores({
String iOSLeaderboardID = "",
String androidLeaderboardID = "",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// Stable `PlatformException.code` values returned while loading leaderboard
/// scores.
abstract final class LeaderboardScoresErrorCode {
/// The player is not authenticated.
static const notAuthenticated = 'not_authenticated';

/// The player declined Google Play Games friends-list access.
///
/// This code is only returned on Android.
static const friendsListAccessDenied = 'friends_list_access_denied';

/// The native operation was canceled.
///
/// This code is only returned by GameKit.
static const operationCanceled = 'operation_canceled';

/// The scores could not be loaded for any other reason.
static const failedToLoad = 'failed_to_load_leaderboard_scores';
}