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

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