@byvik/capacitor-play-games 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,432 @@
1
+ package com.byvik.capacitor.playgames;
2
+
3
+ import android.content.Intent;
4
+ import android.net.Uri;
5
+ import android.util.Log;
6
+ import com.getcapacitor.JSObject;
7
+ import com.getcapacitor.Plugin;
8
+ import com.getcapacitor.PluginCall;
9
+ import com.getcapacitor.PluginMethod;
10
+ import com.getcapacitor.annotation.CapacitorPlugin;
11
+ import com.google.android.gms.common.api.ApiException;
12
+ import com.google.android.gms.games.AuthenticationResult;
13
+ import com.google.android.gms.games.GamesClientStatusCodes;
14
+ import com.google.android.gms.games.LeaderboardsClient;
15
+ import com.google.android.gms.games.PlayGames;
16
+ import com.google.android.gms.games.PlayGamesSdk;
17
+ import com.google.android.gms.games.SnapshotsClient;
18
+ import com.google.android.gms.games.leaderboard.Leaderboard;
19
+ import com.google.android.gms.games.leaderboard.LeaderboardVariant;
20
+ import com.google.android.gms.games.snapshot.Snapshot;
21
+ import com.google.android.gms.games.snapshot.SnapshotMetadataChange;
22
+ import com.google.android.gms.tasks.Task;
23
+ import java.io.IOException;
24
+ import java.nio.charset.StandardCharsets;
25
+ import org.json.JSONObject;
26
+
27
+ /**
28
+ * Google Play Games Services v2: sign-in, leaderboards, achievements and saved games.
29
+ */
30
+ @CapacitorPlugin(name = "PlayGames")
31
+ public class PlayGamesPlugin extends Plugin {
32
+
33
+ private static final String TAG = "CapacitorPlayGames";
34
+
35
+ // The Play Games app is where a Google account is added and a player profile is created.
36
+ private static final String PLAY_GAMES_PACKAGE = "com.google.android.play.games";
37
+
38
+ private static final int RC_LEADERBOARD_UI = 9004;
39
+ private static final int RC_ACHIEVEMENTS_UI = 9005;
40
+
41
+ @Override
42
+ public void load() {
43
+ // Initializing the v2 SDK also starts its silent sign-in attempt. It only succeeds when the
44
+ // player already has a Play Games profile and has not declined before, which is why
45
+ // isAuthenticated() and signIn() exist.
46
+ try {
47
+ PlayGamesSdk.initialize(getContext());
48
+ } catch (Exception e) {
49
+ // A missing or wrong setup must not take the whole app down at launch.
50
+ Log.e(TAG, "PlayGamesSdk.initialize failed. Is com.google.android.gms.games.APP_ID in the manifest?", e);
51
+ }
52
+ }
53
+
54
+ // ---------- Session ----------
55
+
56
+ @PluginMethod
57
+ public void isAuthenticated(PluginCall call) {
58
+ PlayGames.getGamesSignInClient(getActivity())
59
+ .isAuthenticated()
60
+ .addOnCompleteListener((task) -> call.resolve(authResult("isAuthenticated", task)));
61
+ }
62
+
63
+ // The only SDK call that shows Google UI. This is where a player with a Google account but no
64
+ // Play Games profile gets to create one.
65
+ @PluginMethod
66
+ public void signIn(PluginCall call) {
67
+ PlayGames.getGamesSignInClient(getActivity())
68
+ .signIn()
69
+ .addOnCompleteListener((task) -> call.resolve(authResult("signIn", task)));
70
+ }
71
+
72
+ // Neither session call rejects: "it failed" and "the player said no" both mean the game carries
73
+ // on signed out. The status code is what tells a declined prompt (16, 12501) apart from a
74
+ // misconfigured app (10: this build's SHA-1 or package name is missing from the OAuth
75
+ // credentials, so it will never work for anyone until the Play Console is fixed).
76
+ private JSObject authResult(String method, Task<AuthenticationResult> task) {
77
+ boolean authenticated = task.isSuccessful() && task.getResult() != null && task.getResult().isAuthenticated();
78
+ JSObject ret = new JSObject();
79
+ ret.put("isAuthenticated", authenticated);
80
+ Exception e = task.getException();
81
+ if (!authenticated && e != null) {
82
+ String message = describe(e);
83
+ if (e instanceof ApiException) {
84
+ ret.put("errorCode", ((ApiException) e).getStatusCode());
85
+ }
86
+ ret.put("errorMessage", message);
87
+ Log.w(TAG, method + " failed: " + message, e);
88
+ }
89
+ return ret;
90
+ }
91
+
92
+ @PluginMethod
93
+ public void getCurrentPlayer(PluginCall call) {
94
+ PlayGames.getPlayersClient(getActivity())
95
+ .getCurrentPlayer()
96
+ .addOnSuccessListener((player) -> {
97
+ JSObject ret = new JSObject();
98
+ ret.put("playerId", player.getPlayerId());
99
+ ret.put("displayName", player.getDisplayName());
100
+ ret.put("title", player.getTitle());
101
+ call.resolve(ret);
102
+ })
103
+ .addOnFailureListener((e) -> reject(call, e));
104
+ }
105
+
106
+ // For when signIn() cannot help: the device has no Google account at all. The Play Games app
107
+ // can add one and create the profile; back in the game, the silent sign-in then succeeds.
108
+ // Falls back to the store listing when the app is not installed (or not visible to us).
109
+ @PluginMethod
110
+ public void openPlayGamesApp(PluginCall call) {
111
+ Intent app = getContext().getPackageManager().getLaunchIntentForPackage(PLAY_GAMES_PACKAGE);
112
+ if (app != null) {
113
+ app.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
114
+ try {
115
+ getContext().startActivity(app);
116
+ call.resolve();
117
+ return;
118
+ } catch (Exception e) {
119
+ // Fall through to the store listing.
120
+ }
121
+ }
122
+ if (
123
+ openUri("market://details?id=" + PLAY_GAMES_PACKAGE) ||
124
+ openUri("https://play.google.com/store/apps/details?id=" + PLAY_GAMES_PACKAGE)
125
+ ) {
126
+ call.resolve();
127
+ return;
128
+ }
129
+ call.reject("Cannot open Play Games");
130
+ }
131
+
132
+ private boolean openUri(String uri) {
133
+ try {
134
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
135
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
136
+ getContext().startActivity(intent);
137
+ return true;
138
+ } catch (Exception e) {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ // ---------- Leaderboards ----------
144
+
145
+ @PluginMethod
146
+ public void submitScore(PluginCall call) {
147
+ String leaderboardId = call.getString("leaderboardId");
148
+ Long score = longArg(call, "score");
149
+ if (leaderboardId == null || score == null) {
150
+ call.reject("leaderboardId and score are required");
151
+ return;
152
+ }
153
+ if (Boolean.TRUE.equals(call.getBoolean("immediate", false))) {
154
+ // Waits for the server, so a standing read right after this resolves sees the new score.
155
+ PlayGames.getLeaderboardsClient(getActivity())
156
+ .submitScoreImmediate(leaderboardId, score)
157
+ .addOnSuccessListener((result) -> call.resolve())
158
+ .addOnFailureListener((e) -> reject(call, e));
159
+ } else {
160
+ PlayGames.getLeaderboardsClient(getActivity()).submitScore(leaderboardId, score);
161
+ call.resolve();
162
+ }
163
+ }
164
+
165
+ // Rank, score and number of entries come from the same leaderboard variant, so they are read
166
+ // in one call: fetched separately, a rank could be paired with a count from another moment.
167
+ //
168
+ // A variant only carries those values once this client has loaded scores for its time span and
169
+ // collection; plain leaderboard metadata reports every other variant as unknown. Loading the
170
+ // player-centered page of the requested variant is what fills it in.
171
+ @PluginMethod
172
+ public void getLeaderboardStanding(PluginCall call) {
173
+ String leaderboardId = call.getString("leaderboardId");
174
+ if (leaderboardId == null) {
175
+ call.reject("leaderboardId is required");
176
+ return;
177
+ }
178
+ int timeSpan = timeSpan(call);
179
+ int collection = collection(call);
180
+ boolean forceReload = Boolean.TRUE.equals(call.getBoolean("forceReload", false));
181
+ PlayGames.getLeaderboardsClient(getActivity())
182
+ .loadPlayerCenteredScores(leaderboardId, timeSpan, collection, 1, forceReload)
183
+ .addOnSuccessListener((data) -> {
184
+ // Play reports "unknown" as -1; it is surfaced as null instead.
185
+ JSObject ret = new JSObject();
186
+ ret.put("rank", JSONObject.NULL);
187
+ ret.put("score", JSONObject.NULL);
188
+ ret.put("numScores", JSONObject.NULL);
189
+ LeaderboardsClient.LeaderboardScores scores = data == null ? null : data.get();
190
+ if (scores != null) {
191
+ try {
192
+ readStanding(scores.getLeaderboard(), timeSpan, collection, ret);
193
+ } finally {
194
+ scores.release();
195
+ }
196
+ }
197
+ call.resolve(ret);
198
+ })
199
+ .addOnFailureListener((e) -> reject(call, e));
200
+ }
201
+
202
+ private static void readStanding(Leaderboard leaderboard, int timeSpan, int collection, JSObject ret) {
203
+ if (leaderboard == null || leaderboard.getVariants() == null) {
204
+ return;
205
+ }
206
+ for (LeaderboardVariant variant : leaderboard.getVariants()) {
207
+ if (variant.getTimeSpan() != timeSpan || variant.getCollection() != collection) {
208
+ continue;
209
+ }
210
+ if (variant.getNumScores() != LeaderboardVariant.NUM_SCORES_UNKNOWN) {
211
+ ret.put("numScores", variant.getNumScores());
212
+ }
213
+ if (variant.hasPlayerInfo()) {
214
+ if (variant.getPlayerRank() != LeaderboardVariant.PLAYER_RANK_UNKNOWN) {
215
+ ret.put("rank", variant.getPlayerRank());
216
+ }
217
+ if (variant.getRawPlayerScore() != LeaderboardVariant.PLAYER_SCORE_UNKNOWN) {
218
+ ret.put("score", variant.getRawPlayerScore());
219
+ }
220
+ }
221
+ return;
222
+ }
223
+ }
224
+
225
+ @PluginMethod
226
+ public void showLeaderboard(PluginCall call) {
227
+ String leaderboardId = call.getString("leaderboardId");
228
+ if (leaderboardId == null) {
229
+ call.reject("leaderboardId is required");
230
+ return;
231
+ }
232
+ PlayGames.getLeaderboardsClient(getActivity())
233
+ .getLeaderboardIntent(leaderboardId, timeSpan(call), collection(call))
234
+ .addOnSuccessListener((intent) -> {
235
+ getActivity().startActivityForResult(intent, RC_LEADERBOARD_UI);
236
+ call.resolve();
237
+ })
238
+ .addOnFailureListener((e) -> reject(call, e));
239
+ }
240
+
241
+ @PluginMethod
242
+ public void showAllLeaderboards(PluginCall call) {
243
+ PlayGames.getLeaderboardsClient(getActivity())
244
+ .getAllLeaderboardsIntent()
245
+ .addOnSuccessListener((intent) -> {
246
+ getActivity().startActivityForResult(intent, RC_LEADERBOARD_UI);
247
+ call.resolve();
248
+ })
249
+ .addOnFailureListener((e) -> reject(call, e));
250
+ }
251
+
252
+ private static int timeSpan(PluginCall call) {
253
+ String value = call.getString("timeSpan", "allTime");
254
+ if ("daily".equals(value)) {
255
+ return LeaderboardVariant.TIME_SPAN_DAILY;
256
+ }
257
+ if ("weekly".equals(value)) {
258
+ return LeaderboardVariant.TIME_SPAN_WEEKLY;
259
+ }
260
+ return LeaderboardVariant.TIME_SPAN_ALL_TIME;
261
+ }
262
+
263
+ private static int collection(PluginCall call) {
264
+ return "friends".equals(call.getString("collection", "public"))
265
+ ? LeaderboardVariant.COLLECTION_FRIENDS
266
+ : LeaderboardVariant.COLLECTION_PUBLIC;
267
+ }
268
+
269
+ // ---------- Achievements ----------
270
+ // The fire-and-forget variants are used on purpose: the SDK queues them while offline and
271
+ // syncs later, so these calls resolve as soon as the request is handed over.
272
+
273
+ @PluginMethod
274
+ public void unlockAchievement(PluginCall call) {
275
+ String achievementId = call.getString("achievementId");
276
+ if (achievementId == null) {
277
+ call.reject("achievementId is required");
278
+ return;
279
+ }
280
+ PlayGames.getAchievementsClient(getActivity()).unlock(achievementId);
281
+ call.resolve();
282
+ }
283
+
284
+ @PluginMethod
285
+ public void revealAchievement(PluginCall call) {
286
+ String achievementId = call.getString("achievementId");
287
+ if (achievementId == null) {
288
+ call.reject("achievementId is required");
289
+ return;
290
+ }
291
+ PlayGames.getAchievementsClient(getActivity()).reveal(achievementId);
292
+ call.resolve();
293
+ }
294
+
295
+ @PluginMethod
296
+ public void incrementAchievement(PluginCall call) {
297
+ String achievementId = call.getString("achievementId");
298
+ Integer steps = call.getInt("steps");
299
+ if (achievementId == null || steps == null) {
300
+ call.reject("achievementId and steps are required");
301
+ return;
302
+ }
303
+ PlayGames.getAchievementsClient(getActivity()).increment(achievementId, steps);
304
+ call.resolve();
305
+ }
306
+
307
+ // Absolute progress rather than increments, for games whose local save is the source of truth.
308
+ // Play ignores a value lower than the one it already has, so a reinstall never lowers a counter.
309
+ @PluginMethod
310
+ public void setAchievementSteps(PluginCall call) {
311
+ String achievementId = call.getString("achievementId");
312
+ Integer steps = call.getInt("steps");
313
+ if (achievementId == null || steps == null) {
314
+ call.reject("achievementId and steps are required");
315
+ return;
316
+ }
317
+ PlayGames.getAchievementsClient(getActivity()).setSteps(achievementId, steps);
318
+ call.resolve();
319
+ }
320
+
321
+ @PluginMethod
322
+ public void showAchievements(PluginCall call) {
323
+ PlayGames.getAchievementsClient(getActivity())
324
+ .getAchievementsIntent()
325
+ .addOnSuccessListener((intent) -> {
326
+ getActivity().startActivityForResult(intent, RC_ACHIEVEMENTS_UI);
327
+ call.resolve();
328
+ })
329
+ .addOnFailureListener((e) -> reject(call, e));
330
+ }
331
+
332
+ // ---------- Saved games ----------
333
+ // Conflicts are resolved with "most recently modified": the plugin treats the cloud copy as a
334
+ // mirror of the local save, not as a second timeline to merge.
335
+
336
+ @PluginMethod
337
+ public void saveGame(PluginCall call) {
338
+ String name = call.getString("name");
339
+ String data = call.getString("data");
340
+ if (name == null || data == null) {
341
+ call.reject("name and data are required");
342
+ return;
343
+ }
344
+ String description = call.getString("description");
345
+ SnapshotsClient client = PlayGames.getSnapshotsClient(getActivity());
346
+ client
347
+ .open(name, true, SnapshotsClient.RESOLUTION_POLICY_MOST_RECENTLY_MODIFIED)
348
+ .addOnSuccessListener((result) -> {
349
+ Snapshot snapshot = result.getData();
350
+ if (snapshot == null) {
351
+ call.reject("Unresolved snapshot conflict");
352
+ return;
353
+ }
354
+ snapshot.getSnapshotContents().writeBytes(data.getBytes(StandardCharsets.UTF_8));
355
+ SnapshotMetadataChange.Builder change = new SnapshotMetadataChange.Builder();
356
+ if (description != null) {
357
+ change.setDescription(description);
358
+ }
359
+ client
360
+ .commitAndClose(snapshot, change.build())
361
+ .addOnSuccessListener((metadata) -> call.resolve())
362
+ .addOnFailureListener((e) -> reject(call, e));
363
+ })
364
+ .addOnFailureListener((e) -> reject(call, e));
365
+ }
366
+
367
+ // Resolves with data = null when the saved game does not exist yet (a player's first launch),
368
+ // so "nothing saved" is not confused with "could not read".
369
+ @PluginMethod
370
+ public void loadGame(PluginCall call) {
371
+ String name = call.getString("name");
372
+ if (name == null) {
373
+ call.reject("name is required");
374
+ return;
375
+ }
376
+ SnapshotsClient client = PlayGames.getSnapshotsClient(getActivity());
377
+ client
378
+ .open(name, false, SnapshotsClient.RESOLUTION_POLICY_MOST_RECENTLY_MODIFIED)
379
+ .addOnSuccessListener((result) -> {
380
+ Snapshot snapshot = result.getData();
381
+ if (snapshot == null) {
382
+ call.reject("Unresolved snapshot conflict");
383
+ return;
384
+ }
385
+ try {
386
+ String data = new String(snapshot.getSnapshotContents().readFully(), StandardCharsets.UTF_8);
387
+ JSObject ret = new JSObject();
388
+ ret.put("data", data);
389
+ call.resolve(ret);
390
+ } catch (IOException e) {
391
+ reject(call, e);
392
+ } finally {
393
+ client.discardAndClose(snapshot);
394
+ }
395
+ })
396
+ .addOnFailureListener((e) -> {
397
+ if (e instanceof ApiException && ((ApiException) e).getStatusCode() == GamesClientStatusCodes.SNAPSHOT_NOT_FOUND) {
398
+ JSObject ret = new JSObject();
399
+ ret.put("data", JSONObject.NULL);
400
+ call.resolve(ret);
401
+ } else {
402
+ reject(call, e);
403
+ }
404
+ });
405
+ }
406
+
407
+ // ---------- Helpers ----------
408
+
409
+ // PluginCall.getLong() only accepts values already parsed as Long, and JSON integers that fit in
410
+ // an int are not.
411
+ private static Long longArg(PluginCall call, String key) {
412
+ Object value = call.getData().opt(key);
413
+ return value instanceof Number ? ((Number) value).longValue() : null;
414
+ }
415
+
416
+ // The Google API status code travels as the error code, so callers can branch on it.
417
+ private void reject(PluginCall call, Exception e) {
418
+ String code = e instanceof ApiException ? String.valueOf(((ApiException) e).getStatusCode()) : null;
419
+ call.reject(describe(e), code, e);
420
+ }
421
+
422
+ // An ApiException's own message is just "<code>: ". The status name says what happened.
423
+ private static String describe(Exception e) {
424
+ if (e instanceof ApiException) {
425
+ ApiException api = (ApiException) e;
426
+ String name = GamesClientStatusCodes.getStatusCodeString(api.getStatusCode());
427
+ String detail = api.getStatus().getStatusMessage();
428
+ return detail == null || detail.isEmpty() || detail.equals(name) ? name : name + ": " + detail;
429
+ }
430
+ return String.valueOf(e.getMessage());
431
+ }
432
+ }
File without changes