diff --git a/play-services-auth-base/build.gradle b/play-services-auth-base/build.gradle index 92ed11c76a..32c0565e0e 100644 --- a/play-services-auth-base/build.gradle +++ b/play-services-auth-base/build.gradle @@ -39,4 +39,6 @@ dependencies { api project(':play-services-basement') api project(':play-services-base') api project(':play-services-tasks') + + annotationProcessor project(':safe-parcel-processor') } diff --git a/play-services-auth-base/src/main/aidl/com/google/android/gms/auth/TokenData.aidl b/play-services-auth-base/src/main/aidl/com/google/android/gms/auth/TokenData.aidl new file mode 100644 index 0000000000..9b382fd475 --- /dev/null +++ b/play-services-auth-base/src/main/aidl/com/google/android/gms/auth/TokenData.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +parcelable TokenData; diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccessAccountDataBinder.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccessAccountDataBinder.java new file mode 100644 index 0000000000..c8bca6531c --- /dev/null +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccessAccountDataBinder.java @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +import android.os.Bundle; +import android.os.IBinder; +import android.os.IInterface; + +import com.google.android.auth.IAuthManagerService; + +import java.util.Objects; + +public class AccessAccountDataBinder implements DataBinder { + + private final String clientPackageName; + + public AccessAccountDataBinder(String clientPackageName) { + this.clientPackageName = clientPackageName; + } + + @Override + public Boolean getBinderData(IBinder iBinder) throws Exception { + IAuthManagerService service; + if (iBinder == null) { + service = null; + } else { + IInterface queryLocalInterface = iBinder.queryLocalInterface("com.google.android.auth.IAuthManagerService"); + if (queryLocalInterface instanceof IAuthManagerService) { + service = (IAuthManagerService) queryLocalInterface; + } else { + service = IAuthManagerService.Stub.asInterface(iBinder); + } + } + if (service == null) { + return null; + } + Bundle bundle = service.requestGoogleAccountsAccess(clientPackageName); + if (bundle == null) { + return false; + } + return Objects.equals(bundle.getString("Error"), "OK"); + } +} diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsRequest.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsRequest.java index f746a64396..d8342173d7 100644 --- a/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsRequest.java +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsRequest.java @@ -40,6 +40,14 @@ public Account getAccount() { return null; } + @Constructor + public AccountChangeEventsRequest(@Param(value = 1) int versionCode, @Param(value = 2) int since, @Param(value = 3) String accountName, @Param(value = 4) Account account) { + this.versionCode = versionCode; + this.since = since; + this.accountName = accountName; + this.account = account; + } + public static Creator CREATOR = new AutoCreator(AccountChangeEventsRequest.class); } diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsResponse.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsResponse.java index 0bff57a8f7..15fc433668 100644 --- a/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsResponse.java +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/AccountChangeEventsResponse.java @@ -28,6 +28,10 @@ public class AccountChangeEventsResponse extends AutoSafeParcelable { @SafeParceled(value = 2, subClass = AccountChangeEvent.class) private List events; + public List getEvents() { + return events; + } + public AccountChangeEventsResponse() { events = new ArrayList(); } diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/ChangeEventDataBinder.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/ChangeEventDataBinder.java new file mode 100644 index 0000000000..9f35c1686f --- /dev/null +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/ChangeEventDataBinder.java @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +import android.accounts.Account; +import android.os.IBinder; +import android.os.IInterface; + +import com.google.android.auth.IAuthManagerService; + +import java.util.List; + +public class ChangeEventDataBinder implements DataBinder> { + + private final String accountName; + private final int since; + + public ChangeEventDataBinder(String accountName, int since) { + this.accountName = accountName; + this.since = since; + } + + @Override + public List getBinderData(IBinder iBinder) throws Exception { + IAuthManagerService service; + if (iBinder == null) { + service = null; + } else { + IInterface queryLocalInterface = iBinder.queryLocalInterface("com.google.android.auth.IAuthManagerService"); + if (queryLocalInterface instanceof IAuthManagerService) { + service = (IAuthManagerService) queryLocalInterface; + } else { + service = IAuthManagerService.Stub.asInterface(iBinder); + } + } + if (service == null) { + return null; + } + AccountChangeEventsRequest request = new AccountChangeEventsRequest(1, since, accountName, new Account(accountName, GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE)); + AccountChangeEventsResponse changeEvents = service.getChangeEvents(request); + return changeEvents.getEvents(); + } +} diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/ClearTokenDataBinder.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/ClearTokenDataBinder.java new file mode 100644 index 0000000000..50d416f19e --- /dev/null +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/ClearTokenDataBinder.java @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +import android.os.Bundle; +import android.os.IBinder; +import android.os.IInterface; + +import com.google.android.auth.IAuthManagerService; + +public class ClearTokenDataBinder implements DataBinder { + + private final String token; + private final Bundle extras; + + public ClearTokenDataBinder(String token, Bundle extras) { + this.token = token; + this.extras = extras; + } + + @Override + public Void getBinderData(IBinder iBinder) throws Exception { + IAuthManagerService service; + if (iBinder == null) { + service = null; + } else { + IInterface queryLocalInterface = iBinder.queryLocalInterface("com.google.android.auth.IAuthManagerService"); + if (queryLocalInterface instanceof IAuthManagerService) { + service = (IAuthManagerService) queryLocalInterface; + } else { + service = IAuthManagerService.Stub.asInterface(iBinder); + } + } + if (service == null) { + return null; + } + service.clearToken(token, extras); + return Void.TYPE.newInstance(); + } +} diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/DataBinder.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/DataBinder.java new file mode 100644 index 0000000000..eec6fd63de --- /dev/null +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/DataBinder.java @@ -0,0 +1,12 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +import android.os.IBinder; + +public interface DataBinder { + T getBinderData(IBinder iBinder) throws Exception; +} diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/GoogleAuthUtil.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/GoogleAuthUtil.java new file mode 100644 index 0000000000..4080f31e4f --- /dev/null +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/GoogleAuthUtil.java @@ -0,0 +1,245 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; +import android.os.RemoteException; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.RequiresPermission; + +import com.google.android.gms.common.BlockingServiceConnection; +import com.google.android.gms.common.internal.GmsClientSupervisor; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.List; + +public class GoogleAuthUtil { + private static final String TAG = "GoogleAuthUtil"; + public static final String GOOGLE_ACCOUNT_TYPE = "com.google"; + private static final String[] ACCEPTABLE_ACCOUNT_TYPES = new String[]{"com.google", "com.google.work", "cn.google"}; + @SuppressLint({"InlinedApi"}) + private static final String KEY_ANDROID_PACKAGE_NAME = "androidPackageName"; + private static final String KEY_CLIENT_PACKAGE_NAME = "clientPackageName"; + private static final ComponentName GET_TOKEN_COMPONENT = new ComponentName("com.google.android.gms", "com.google.android.gms.auth.GetToken"); + + /** @deprecated */ + @Deprecated + public static String getTokenWithNotification(Context context, String accountName, String scope, Bundle extras) throws Exception { + Account account = new Account(accountName, GOOGLE_ACCOUNT_TYPE); + return getTokenWithNotification(context, account, scope, extras); + } + + /** @deprecated */ + @Deprecated + public static String getTokenWithNotification(Context context, String accountName, String scope, Bundle extras, Intent callbackIntent) throws Exception { + Account account = new Account(accountName, GOOGLE_ACCOUNT_TYPE); + return getTokenWithNotification(context, account, scope, extras, callbackIntent); + } + + /** @deprecated */ + @Deprecated + public static String getTokenWithNotification(Context context, String accountName, String scope, Bundle extras, String authority, Bundle syncExtras) throws Exception { + Account account = new Account(accountName, GOOGLE_ACCOUNT_TYPE); + return getTokenWithNotification(context, account, scope, extras, authority, syncExtras); + } + + public static String getTokenWithNotification(Context context, Account account, String scope, Bundle extras) throws Exception { + Bundle result = extras; + if (extras == null) { + result = new Bundle(); + } + + result.putBoolean("handle_notification", true); + return getAuthTokenData(context, account, scope, result).getToken(); + } + + public static String getTokenWithNotification(Context context, Account account, String scope, Bundle extras, Intent callbackIntent) throws Exception { + if (callbackIntent == null) { + throw new IllegalArgumentException("Callback cannot be null."); + } else { + String intentUri = callbackIntent.toUri(Intent.URI_INTENT_SCHEME); + + try { + Intent.parseUri(intentUri, Intent.URI_INTENT_SCHEME); + } catch (URISyntaxException var7) { + throw new IllegalArgumentException("Parameter callback contains invalid data. It must be serializable using toUri() and parseUri()."); + } + + Bundle result = extras == null ? new Bundle() : extras; + extras = result; + result.putParcelable("callback_intent", callbackIntent); + extras.putBoolean("handle_notification", true); + return getAuthTokenData(context, account, scope, extras).getToken(); + } + } + + public static String getTokenWithNotification(Context context, Account account, String scope, Bundle extras, String authority, Bundle syncExtras) throws Exception { + extras = extras == null ? new Bundle() : extras; + Bundle result = syncExtras == null ? new Bundle() : syncExtras; + syncExtras = result; + ContentResolver.validateSyncExtrasBundle(result); + extras.putString("authority", authority); + extras.putBundle("sync_extras", syncExtras); + extras.putBoolean("handle_notification", true); + return getAuthTokenData(context, account, scope, extras).getToken(); + } + + private static TokenData getAuthTokenData(Context context, Account account, String scope, Bundle extras) throws Exception { + if (extras == null) { + extras = new Bundle(); + } + return getTokenDataFromService(context, account, scope, extras); + } + + /** @deprecated */ + @Deprecated + public static String getToken(Context context, String accountName, String scope) throws Exception { + Account account = new Account(accountName, "com.google"); + return getToken(context, account, scope); + } + + /** @deprecated */ + @Deprecated + public static String getToken(Context context, String accountName, String scope, Bundle extras) throws Exception { + Account account = new Account(accountName, "com.google"); + return getToken(context, account, scope, extras); + } + + public static String getToken(Context context, Account account, String scope) throws Exception { + return getToken(context, account, scope, new Bundle()); + } + + public static String getToken(Context context, Account account, String scope, String clientPackageName) throws Exception { + Bundle bundle = new Bundle(); + bundle.putString(KEY_CLIENT_PACKAGE_NAME, clientPackageName); + bundle.putString(KEY_ANDROID_PACKAGE_NAME, clientPackageName); + return getToken(context, account, scope, bundle); + } + + public static String getToken(Context context, Account account, String scope, Bundle extra) throws Exception { + checkAccountsAvailable(account); + TokenData tokenDataFromService = getTokenDataFromService(context, account, scope, extra); + if (tokenDataFromService != null) { + return tokenDataFromService.getToken(); + } + return null; + } + + public static TokenData getTokenDataFromService(Context context, Account account, String scope, Bundle extra) throws Exception { + checkAccountsAvailable(account); + Bundle result = extra == null ? new Bundle() : new Bundle(extra); + String clientPackageName; + if (TextUtils.isEmpty(result.getString(KEY_CLIENT_PACKAGE_NAME))) { + clientPackageName = context.getApplicationInfo().packageName; + result.putString(KEY_CLIENT_PACKAGE_NAME, clientPackageName); + }else{ + clientPackageName = result.getString(KEY_CLIENT_PACKAGE_NAME); + } + if (TextUtils.isEmpty(result.getString(KEY_ANDROID_PACKAGE_NAME))) { + result.putString(KEY_ANDROID_PACKAGE_NAME, clientPackageName); + } + result.putLong("service_connection_start_time_millis", SystemClock.elapsedRealtime()); + TokenDataBinder dataBinder = new TokenDataBinder(account, scope, result); + Log.d(TAG, "getTokenDataFromService: clientPackageName: " + clientPackageName); + return getTokenService(context, GET_TOKEN_COMPONENT, dataBinder); + } + + /** @deprecated */ + @Deprecated + @RequiresPermission("android.permission.MANAGE_ACCOUNTS") + public static void invalidateToken(Context context, String authKey) { + AccountManager.get(context).invalidateAuthToken(GOOGLE_ACCOUNT_TYPE, authKey); + } + + public static void clearToken(Context context, String token) throws Exception { + Bundle result = new Bundle(); + String clientPackageName; + if (TextUtils.isEmpty(result.getString(KEY_CLIENT_PACKAGE_NAME))) { + clientPackageName = context.getApplicationInfo().packageName; + result.putString(KEY_CLIENT_PACKAGE_NAME, clientPackageName); + }else{ + clientPackageName = result.getString(KEY_CLIENT_PACKAGE_NAME); + } + if (TextUtils.isEmpty(result.getString(KEY_ANDROID_PACKAGE_NAME))) { + result.putString(KEY_ANDROID_PACKAGE_NAME, clientPackageName); + } + ClearTokenDataBinder dataBinder = new ClearTokenDataBinder(token, result); + getTokenService(context, GET_TOKEN_COMPONENT, dataBinder); + } + + public static List getAccountChangeEvents(Context context, int eventId, String accountName) throws Exception { + ChangeEventDataBinder dataBinder = new ChangeEventDataBinder(accountName, eventId); + return getTokenService(context, GET_TOKEN_COMPONENT, dataBinder); + } + + public static String getAccountId(Context context, String accountName) throws Exception { + return getToken(context, accountName, "^^_account_id_^^", new Bundle()); + } + + @TargetApi(23) + public static Bundle removeAccount(Context context, Account account) throws Exception { + checkAccountsAvailable(account); + RemoveAccountDataBinder dataBinder = new RemoveAccountDataBinder(account); + return getTokenService(context, GET_TOKEN_COMPONENT, dataBinder); + } + + @TargetApi(26) + public static Boolean requestGoogleAccountsAccess(Context context) throws Exception { + String clientPackageName = context.getApplicationInfo().packageName; + AccessAccountDataBinder dataBinder = new AccessAccountDataBinder(clientPackageName); + return getTokenService(context, GET_TOKEN_COMPONENT, dataBinder); + } + + private static void checkAccountsAvailable(Account account) { + if (account == null) { + throw new IllegalArgumentException("Account cannot be null"); + } else if (TextUtils.isEmpty(account.name)) { + throw new IllegalArgumentException("Account name cannot be empty!"); + } else { + String[] accountTypes; + int length = (accountTypes = ACCEPTABLE_ACCOUNT_TYPES).length; + for(int i = 0; i < length; ++i) { + if (accountTypes[i].equals(account.type)) { + return; + } + } + throw new IllegalArgumentException("Account type not supported"); + } + } + + private static T getTokenService(Context context, ComponentName componentName, DataBinder binder) throws Exception { + BlockingServiceConnection blockingServiceConnection = new BlockingServiceConnection(); + GmsClientSupervisor gmsClientSupervisor = GmsClientSupervisor.getInstance(context); + boolean bindServiceStatus = gmsClientSupervisor.bindService(componentName, blockingServiceConnection, TAG); + if (bindServiceStatus) { + T data = null; + try { + IBinder service = blockingServiceConnection.getService(); + data = binder.getBinderData(service); + } catch (InterruptedException | RemoteException exception) { + Log.d(TAG, "getTokenService: Error on service connection.", exception); + } finally { + gmsClientSupervisor.unbindService(componentName, blockingServiceConnection, TAG); + } + return data; + } else { + throw new IOException("Could not bind to service."); + } + } +} diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/RemoveAccountDataBinder.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/RemoveAccountDataBinder.java new file mode 100644 index 0000000000..6c2ce2ef5a --- /dev/null +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/RemoveAccountDataBinder.java @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +import android.accounts.Account; +import android.os.Bundle; +import android.os.IBinder; +import android.os.IInterface; + +import com.google.android.auth.IAuthManagerService; + +public class RemoveAccountDataBinder implements DataBinder { + + private final Account account; + + public RemoveAccountDataBinder(Account account) { + this.account = account; + } + + @Override + public Bundle getBinderData(IBinder iBinder) throws Exception { + IAuthManagerService service; + if (iBinder == null) { + service = null; + } else { + IInterface queryLocalInterface = iBinder.queryLocalInterface("com.google.android.auth.IAuthManagerService"); + if (queryLocalInterface instanceof IAuthManagerService) { + service = (IAuthManagerService) queryLocalInterface; + } else { + service = IAuthManagerService.Stub.asInterface(iBinder); + } + } + if (service == null) { + return null; + } + return service.removeAccount(account); + } +} diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/TokenData.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/TokenData.java index ac08cfbaef..765112836a 100644 --- a/play-services-auth-base/src/main/java/com/google/android/gms/auth/TokenData.java +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/TokenData.java @@ -16,57 +16,96 @@ package com.google.android.gms.auth; -import com.google.android.gms.common.api.Scope; +import android.os.Bundle; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; import org.microg.gms.common.Hide; -import org.microg.safeparcel.AutoSafeParcelable; -import org.microg.safeparcel.SafeParceled; -import java.util.ArrayList; import java.util.List; @Hide -public class TokenData extends AutoSafeParcelable { +@SafeParcelable.Class +public class TokenData extends AbstractSafeParcelable { @Field(value = 1, versionCode = 1) private int versionCode = 1; - @Field(2) - public final String token; + @Field(value = 2) + private final String token; - @Field(3) - public final Long expiry; + @Field(value = 3) + private final Long expirationTimeSecs; - @Field(5) - public final boolean isOAuth; + @Field(value = 4) + private final boolean isCached; - @Field(6) - public final List scopes; + @Field(value = 5) + private final boolean isSnowballed; - public TokenData() { - token = null; - expiry = null; - isOAuth = false; - scopes = null; - } + @Field(value = 6) + private final List grantedScopes; - public TokenData(String token, Long expiry, boolean isOAuth, List scopes) { - this.token = token; - this.expiry = expiry; - this.isOAuth = isOAuth; - this.scopes = new ArrayList<>(); - if (scopes != null) { - for (Scope scope : scopes) { - this.scopes.add(scope.getScopeUri()); - } + @Field(value = 7) + private final String scopeData; + + @Nullable + public static TokenData getTokenData(Bundle extras, String key) { + Bundle tokenBundle; + if ((tokenBundle = extras.getBundle(key)) == null) { + return null; + } else { + tokenBundle.setClassLoader(TokenData.class.getClassLoader()); + return tokenBundle.getParcelable("TokenData"); } } - public TokenData(String token, Long expiry) { + @Constructor + public TokenData(@Param(value = 1) int versionCode, @Param(value = 2) String token, @Param(value = 3) Long expirationTimeSecs, + @Param(value = 4) boolean isCached, @Param(value = 5) boolean isSnowballed, @Param(value = 6) List grantedScopes, + @Param(value = 7) String scopeData) { + this.versionCode = versionCode; this.token = token; - this.expiry = expiry; - this.isOAuth = false; - this.scopes = null; + this.expirationTimeSecs = expirationTimeSecs; + this.isCached = isCached; + this.isSnowballed = isSnowballed; + this.grantedScopes = grantedScopes; + this.scopeData = scopeData; + } + + public Long getExpirationTimeSecs() { + return expirationTimeSecs; + } + + public boolean isCached() { + return isCached; + } + + public boolean isSnowballed() { + return isSnowballed; + } + + public List getGrantedScopes() { + return grantedScopes; + } + + public String getScopeData() { + return scopeData; + } + + public String getToken() { + return this.token; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); } - public static final Creator CREATOR = new AutoCreator(TokenData.class); + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(TokenData.class); } diff --git a/play-services-auth-base/src/main/java/com/google/android/gms/auth/TokenDataBinder.java b/play-services-auth-base/src/main/java/com/google/android/gms/auth/TokenDataBinder.java new file mode 100644 index 0000000000..0ab8f90d65 --- /dev/null +++ b/play-services-auth-base/src/main/java/com/google/android/gms/auth/TokenDataBinder.java @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.auth; + +import android.accounts.Account; +import android.os.Bundle; +import android.os.IBinder; +import android.os.IInterface; + +import com.google.android.auth.IAuthManagerService; + +public class TokenDataBinder implements DataBinder { + + private final Account account; + private final String scope; + private final Bundle extras; + + public TokenDataBinder(Account account, String scope, Bundle extras) { + this.account = account; + this.scope = scope; + this.extras = extras; + } + + @Override + public TokenData getBinderData(IBinder iBinder) throws Exception { + IAuthManagerService managerService; + if (iBinder == null) { + managerService = null; + } else { + IInterface queryLocalInterface = iBinder.queryLocalInterface("com.google.android.auth.IAuthManagerService"); + if (queryLocalInterface instanceof IAuthManagerService) { + managerService = (IAuthManagerService) queryLocalInterface; + } else { + managerService = IAuthManagerService.Stub.asInterface(iBinder); + } + } + if (managerService == null) { + return null; + } + Bundle bundle = managerService.getTokenWithAccount(account, scope, extras); + return TokenData.getTokenData(bundle, "tokenDetails"); + } +} diff --git a/play-services-base/core/src/main/kotlin/org/microg/gms/utils/BitmapUtils.kt b/play-services-base/core/src/main/kotlin/org/microg/gms/utils/BitmapUtils.kt new file mode 100644 index 0000000000..423b6cb361 --- /dev/null +++ b/play-services-base/core/src/main/kotlin/org/microg/gms/utils/BitmapUtils.kt @@ -0,0 +1,35 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +package org.microg.gms.utils + +import android.graphics.Bitmap +import kotlin.math.sqrt + + +class BitmapUtils { + + companion object { + fun getBitmapSize(bitmap: Bitmap?): Int { + if (bitmap != null) { + return bitmap.height * bitmap.rowBytes + } + return 0 + } + + fun scaledBitmap(bitmap: Bitmap, maxSize: Float): Bitmap { + val height: Int = bitmap.getHeight() + val width: Int = bitmap.getWidth() + val sqrt = + sqrt(((maxSize) / ((width.toFloat()) / (height.toFloat()) * ((bitmap.getRowBytes() / width).toFloat()))).toDouble()) + .toInt() + return Bitmap.createScaledBitmap( + bitmap, + (((sqrt.toFloat()) / (height.toFloat()) * (width.toFloat())).toInt()), + sqrt, + true + ) + } + } +} \ No newline at end of file diff --git a/play-services-api/src/main/aidl/com/google/android/gms/common/data/BitmapTeleporter.aidl b/play-services-base/src/main/aidl/com/google/android/gms/common/data/BitmapTeleporter.aidl similarity index 100% rename from play-services-api/src/main/aidl/com/google/android/gms/common/data/BitmapTeleporter.aidl rename to play-services-base/src/main/aidl/com/google/android/gms/common/data/BitmapTeleporter.aidl diff --git a/play-services-api/src/main/java/com/google/android/gms/common/data/BitmapTeleporter.java b/play-services-base/src/main/java/com/google/android/gms/common/data/BitmapTeleporter.java similarity index 100% rename from play-services-api/src/main/java/com/google/android/gms/common/data/BitmapTeleporter.java rename to play-services-base/src/main/java/com/google/android/gms/common/data/BitmapTeleporter.java diff --git a/play-services-base/src/main/java/com/google/android/gms/common/data/DataBufferRef.java b/play-services-base/src/main/java/com/google/android/gms/common/data/DataBufferRef.java index 3c9b8cf107..09b942a139 100644 --- a/play-services-base/src/main/java/com/google/android/gms/common/data/DataBufferRef.java +++ b/play-services-base/src/main/java/com/google/android/gms/common/data/DataBufferRef.java @@ -6,6 +6,8 @@ package com.google.android.gms.common.data; import android.database.CharArrayBuffer; +import android.net.Uri; + import androidx.annotation.NonNull; import org.microg.gms.common.Hide; @@ -20,6 +22,11 @@ public DataBufferRef(DataHolder dataHolder, int dataRow) { setDataRow(dataRow); } + protected Uri parseUri(String column) { + String imageUri; + return (imageUri = this.dataHolder.getString(column, dataRow, this.windowIndex)) == null ? null : Uri.parse(imageUri); + } + protected void copyToBuffer(@NonNull String column, @NonNull CharArrayBuffer dataOut) { dataHolder.copyToBuffer(column, dataRow, windowIndex, dataOut); } diff --git a/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java b/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java index e347fa0e8b..46bd845899 100644 --- a/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java +++ b/play-services-base/src/main/java/com/google/android/gms/common/images/ImageManager.java @@ -9,6 +9,28 @@ package com.google.android.gms.common.images; import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.collection.LruCache; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** * This class is used to load images from the network and handles local caching for you. @@ -21,6 +43,157 @@ public class ImageManager { * @return A new ImageManager. */ public static ImageManager create(Context context) { - throw new UnsupportedOperationException(); + if (INSTANCE == null) { + synchronized (ImageManager.class) { + if (INSTANCE == null) { + INSTANCE = new ImageManager(context); + } + } + } + return INSTANCE; + } + + public static final String TAG = "ImageManager"; + private static volatile ImageManager INSTANCE; + private final LruCache memoryCache; + private final ExecutorService executorService; + private final Handler handler; + private final Context context; + + private ImageManager(Context context) { + this.context = context.getApplicationContext(); + this.handler = new Handler(Looper.getMainLooper()); + this.executorService = Executors.newFixedThreadPool(4); + + final int cacheSize = (int) (Runtime.getRuntime().maxMemory() / 1024 / 8); + this.memoryCache = new LruCache(cacheSize) { + @Override + protected int sizeOf(@NonNull String key, @NonNull Bitmap bitmap) { + return bitmap.getByteCount() / 1024; + } + }; + } + + /** + * Compress Bitmap + */ + public byte[] compressBitmap(Bitmap bitmap, Bitmap.CompressFormat format, int quality) { + Log.d(TAG, "compressBitmap width: " + bitmap.getWidth() + " height:" + bitmap.getHeight()); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + bitmap.compress(format, quality, byteArrayOutputStream); + byte[] bitmapBytes = byteArrayOutputStream.toByteArray(); + bitmap = BitmapFactory.decodeByteArray(bitmapBytes, 0, bitmapBytes.length); + Log.d(TAG, "compressBitmap compress width: " + bitmap.getWidth() + " height:" + bitmap.getHeight()); + return bitmapBytes; + } + + public byte[] compressBitmap(Bitmap original, int newWidth, int newHeight) { + Log.d(TAG, "compressBitmap width: " + original.getWidth() + " height:" + original.getHeight()); + Bitmap target = Bitmap.createScaledBitmap(original, newWidth, newHeight, true); + Log.d(TAG, "compressBitmap target width: " + target.getWidth() + " height:" + target.getHeight()); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + target.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); + } + + public void loadImage(final String url, final ImageView imageView) { + if (imageView == null) { + Log.d(TAG, "loadImage: imageView is null"); + return; + } + final Bitmap cachedBitmap = getBitmapFromCache(url); + if (cachedBitmap != null) { + Log.d(TAG, "loadImage from cached"); + imageView.setImageBitmap(cachedBitmap); + } else { + Log.d(TAG, "loadImage from net"); + imageView.setTag(url); + executorService.submit(() -> { + final Bitmap bitmap = downloadBitmap(url); + if (bitmap != null) { + addBitmapToCache(url, bitmap); + if (imageView.getTag().equals(url)) { + handler.post(() -> imageView.setImageBitmap(bitmap)); + } + } + }); + } + } + + private Bitmap getBitmapFromCache(String key) { + Bitmap bitmap = memoryCache.get(key); + if (bitmap == null) { + bitmap = getBitmapFromDiskCache(key); + } + return bitmap; + } + + private void addBitmapToCache(String key, Bitmap bitmap) { + if (getBitmapFromCache(key) == null) { + memoryCache.put(key, bitmap); + addBitmapToDiskCache(key, bitmap); + } + } + + private Bitmap getBitmapFromDiskCache(String key) { + File file = getDiskCacheFile(key); + if (file.exists()) { + return BitmapFactory.decodeFile(file.getAbsolutePath()); + } + return null; + } + + private void addBitmapToDiskCache(String key, Bitmap bitmap) { + File file = getDiskCacheFile(key); + try (FileOutputStream outputStream = new FileOutputStream(file)) { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream); + } catch (IOException e) { + Log.e(TAG, "addBitmapToDiskCache: ", e); + } } + + private File getDiskCacheFile(String key) { + File cacheDir = context.getCacheDir(); + return new File(cacheDir, md5(key)); + } + + private String md5(String s) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + digest.update(s.getBytes()); + byte[] messageDigest = digest.digest(); + StringBuilder hexString = new StringBuilder(); + for (byte b : messageDigest) { + StringBuilder h = new StringBuilder(Integer.toHexString(0xFF & b)); + while (h.length() < 2) h.insert(0, "0"); + hexString.append(h); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e(TAG, "md5: ", e); + } + return ""; + } + + @WorkerThread + private Bitmap downloadBitmap(String url) { + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) new URL(url).openConnection(); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + InputStream inputStream = connection.getInputStream(); + return BitmapFactory.decodeStream(inputStream); + } + } catch (IOException e) { + Log.d(TAG, "downloadBitmap: ", e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + return null; + } + + } diff --git a/play-services-base/src/main/java/com/google/android/gms/common/util/IOUtils.java b/play-services-base/src/main/java/com/google/android/gms/common/util/IOUtils.java new file mode 100644 index 0000000000..6939c31c71 --- /dev/null +++ b/play-services-base/src/main/java/com/google/android/gms/common/util/IOUtils.java @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +package com.google.android.gms.common.util; + +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.Preconditions; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public final class IOUtils { + private IOUtils() { + } + + public static void closeQuietly(ParcelFileDescriptor parcelFileDescriptor) { + if (parcelFileDescriptor != null) { + try { + parcelFileDescriptor.close(); + } catch (IOException unused) { + } + } + } + + public static long copyStream(@NonNull InputStream inputStream, @NonNull OutputStream outputStream) throws IOException { + return copyStream(inputStream, outputStream, false, 1024); + } + + public static boolean isGzipByteBuffer(@NonNull byte[] bArr) { + if (bArr.length > 1) { + if ((((bArr[1] & 255) << 8) | (bArr[0] & 255)) == 35615) { + return true; + } + } + return false; + } + + public static byte[] readInputStreamFully(@NonNull InputStream inputStream) throws IOException { + return readInputStreamFully(inputStream, true); + } + + public static byte[] toByteArray(@NonNull InputStream inputStream) throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + Preconditions.checkNotNull(inputStream); + Preconditions.checkNotNull(byteArrayOutputStream); + byte[] bArr = new byte[4096]; + while (true) { + int read = inputStream.read(bArr); + if (read == -1) { + return byteArrayOutputStream.toByteArray(); + } + byteArrayOutputStream.write(bArr, 0, read); + } + } + + + public static void closeQuietly(Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException unused) { + } + } + } + + public static long copyStream(@NonNull InputStream inputStream, @NonNull OutputStream outputStream, boolean z, int i) throws IOException { + byte[] bArr = new byte[i]; + long j = 0; + while (true) { + try { + int read = inputStream.read(bArr, 0, i); + if (read == -1) { + break; + } + j += read; + outputStream.write(bArr, 0, read); + } catch (Throwable th) { + if (z) { + closeQuietly(inputStream); + closeQuietly(outputStream); + } + throw th; + } + } + if (z) { + closeQuietly(inputStream); + closeQuietly(outputStream); + } + return j; + } + + public static byte[] readInputStreamFully(@NonNull InputStream inputStream, boolean z) throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + copyStream(inputStream, byteArrayOutputStream, z, 1024); + return byteArrayOutputStream.toByteArray(); + } + +} diff --git a/play-services-base/src/main/java/org/microg/gms/common/GmsClient.java b/play-services-base/src/main/java/org/microg/gms/common/GmsClient.java index e5b717175c..45c1b6977b 100644 --- a/play-services-base/src/main/java/org/microg/gms/common/GmsClient.java +++ b/play-services-base/src/main/java/org/microg/gms/common/GmsClient.java @@ -29,6 +29,7 @@ import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.api.Api; import com.google.android.gms.common.api.CommonStatusCodes; +import com.google.android.gms.common.api.Scope; import com.google.android.gms.common.internal.ConnectionInfo; import com.google.android.gms.common.internal.GetServiceRequest; import com.google.android.gms.common.internal.IGmsCallbacks; @@ -53,6 +54,7 @@ public abstract class GmsClient implements Api.Client { protected int serviceId = -1; protected Account account = null; + protected Scope[] scopes = null; protected Bundle extras = new Bundle(); public GmsClient(Context context, ConnectionCallbacks callbacks, OnConnectionFailedListener connectionFailedListener, String actionString) { @@ -68,10 +70,12 @@ protected void onConnectedToBroker(IGmsServiceBroker broker, GmsCallbacks callba if (serviceId == -1) { throw new IllegalStateException("Service ID not set in constructor and onConnectedToBroker not implemented"); } + Log.d(TAG, "onConnectedToBroker: " + serviceId); GetServiceRequest request = new GetServiceRequest(serviceId); request.packageName = packageName; request.account = account; request.extras = extras; + request.scopes = scopes; broker.getService(callbacks, request); } diff --git a/play-services-basement/src/main/java/com/google/android/gms/common/BlockingServiceConnection.java b/play-services-basement/src/main/java/com/google/android/gms/common/BlockingServiceConnection.java new file mode 100644 index 0000000000..bf8b9da94a --- /dev/null +++ b/play-services-basement/src/main/java/com/google/android/gms/common/BlockingServiceConnection.java @@ -0,0 +1,53 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common; + +import android.content.ComponentName; +import android.content.ServiceConnection; +import android.os.IBinder; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +public class BlockingServiceConnection implements ServiceConnection { + private boolean connected = false; + private final BlockingQueue blockingQueue = new LinkedBlockingQueue<>(); + + public BlockingServiceConnection() { + } + + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + this.blockingQueue.add(iBinder); + } + + public void onServiceDisconnected(ComponentName componentName) { + } + + public IBinder getServiceWithTimeout(long time, TimeUnit timeUnit) throws InterruptedException, TimeoutException { + if (this.connected) { + throw new IllegalStateException("Cannot call get on this connection more than once"); + } else { + this.connected = true; + IBinder iBinder; + if ((iBinder = this.blockingQueue.poll(time, timeUnit)) == null) { + throw new TimeoutException("Timed out waiting for the service connection"); + } else { + return iBinder; + } + } + } + + public IBinder getService() throws InterruptedException { + if (this.connected) { + throw new IllegalStateException("Cannot call get on this connection more than once"); + } else { + this.connected = true; + return this.blockingQueue.take(); + } + } +} diff --git a/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientServiceConnection.java b/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientServiceConnection.java new file mode 100644 index 0000000000..5996b173b0 --- /dev/null +++ b/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientServiceConnection.java @@ -0,0 +1,133 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.internal; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import android.os.StrictMode; +import android.util.Log; + +import com.google.android.gms.common.stats.ConnectionTracker; + +import java.util.HashSet; +import java.util.Set; + +public class GmsClientServiceConnection implements ServiceConnection { + private static final String TAG = "ClientServiceConnection"; + private final Set serviceConnections; + private int mState; + private boolean connected; + private IBinder serviceBinder; + private final GmsClientSupervisor.ServiceInfo serviceInfo; + private ComponentName mComponentName; + private final GmsClientSupervisorImpl gmsClientSupervisor; + + public GmsClientServiceConnection(GmsClientSupervisorImpl gmsClientSupervisor, GmsClientSupervisor.ServiceInfo serviceInfo) { + this.gmsClientSupervisor = gmsClientSupervisor; + this.serviceInfo = serviceInfo; + this.serviceConnections = new HashSet<>(); + this.mState = 2; + } + + @Override + public void onServiceConnected(ComponentName componentName, IBinder iBinder) { + Log.d(TAG, "onServiceConnected: componentName " + componentName ); + synchronized (GmsClientSupervisor.lock) { + gmsClientSupervisor.mHandler.removeMessages(1, this.serviceInfo); + this.serviceBinder = iBinder; + this.mComponentName = componentName; + for (ServiceConnection serviceConnection : this.serviceConnections) { + serviceConnection.onServiceConnected(componentName, iBinder); + } + this.mState = 1; + } + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + Log.d(TAG, "onServiceDisconnected: componentName " + componentName ); + synchronized (GmsClientSupervisor.lock) { + gmsClientSupervisor.mHandler.removeMessages(1, this.serviceInfo); + this.serviceBinder = null; + this.mComponentName = componentName; + for (ServiceConnection serviceConnection : this.serviceConnections) { + serviceConnection.onServiceDisconnected(componentName); + } + this.mState = 2; + } + } + + @Override + public final void onBindingDied(ComponentName componentName) { + onServiceDisconnected(componentName); + } + + public void bindService(String tag) { + this.mState = 3; + StrictMode.VmPolicy vmPolicy = StrictMode.getVmPolicy(); + try { + ConnectionTracker connectionTracker = gmsClientSupervisor.connectionTracker; + Context context = gmsClientSupervisor.context; + GmsClientSupervisor.ServiceInfo info = this.serviceInfo; + Intent serviceIntent = info.getServiceIntent(); + boolean connect = connectionTracker.bindService(context, tag, serviceIntent, this, Context.BIND_AUTO_CREATE); + this.connected = connect; + Log.d(tag, "bindService: connected: " + connected); + if (connect) { + this.gmsClientSupervisor.mHandler.sendMessageDelayed(this.gmsClientSupervisor.mHandler.obtainMessage(1, info), this.gmsClientSupervisor.delayTime); + } else { + this.mState = 2; + connectionTracker.unbindService(context, this); + } + } finally { + StrictMode.setVmPolicy(vmPolicy); + } + } + + public void unbindService(String tag) { + gmsClientSupervisor.mHandler.removeMessages(1, this.serviceInfo); + gmsClientSupervisor.unbindService(this.serviceInfo, this, tag); + this.connected = false; + this.mState = 2; + } + + public final void addServiceConnection(ServiceConnection connection, String tag) { + Log.d(tag, "addServiceConnection: " + connection); + this.serviceConnections.add(connection); + } + + public final void removeServiceConnection(ServiceConnection connection, String tag) { + Log.d(tag, "removeServiceConnection: " + connection); + this.serviceConnections.remove(connection); + } + + public boolean isBound() { + return this.connected; + } + + public int getState() { + return this.mState; + } + + public boolean serviceConnected(ServiceConnection connection) { + return this.serviceConnections.contains(connection); + } + + public boolean serviceConnectionStatus() { + return this.serviceConnections.isEmpty(); + } + + public IBinder getBinder() { + return this.serviceBinder; + } + + public ComponentName getComponentName() { + return this.mComponentName; + } +} \ No newline at end of file diff --git a/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientSupervisor.java b/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientSupervisor.java new file mode 100644 index 0000000000..40f2e221b6 --- /dev/null +++ b/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientSupervisor.java @@ -0,0 +1,124 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.internal; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.HandlerThread; + +import java.util.Objects; + +public abstract class GmsClientSupervisor { + public static final Object lock = new Object(); + protected HandlerThread handlerThread; + private static GmsClientSupervisor instance; + + public GmsClientSupervisor() { + if (handlerThread == null) { + handlerThread = new HandlerThread("GoogleApi"); + } + handlerThread.start(); + } + + public static GmsClientSupervisor getInstance(Context context) { + synchronized (lock) { + if (instance == null) { + instance = new GmsClientSupervisorImpl(context.getApplicationContext()); + } + } + return instance; + } + + public boolean bindService(String action, ServiceConnection connection, String tag) { + return this.bindService(new ServiceInfo(action), connection, tag); + } + + public boolean bindService(ComponentName componentName, ServiceConnection connection, String tag) { + return this.bindService(new ServiceInfo(componentName), connection, tag); + } + + public void unbindService(String action, ServiceConnection connection, String tag) { + this.unbindService(new ServiceInfo(action), connection, tag); + } + + public void unbindService(ComponentName componentName, ServiceConnection connection, String tag) { + this.unbindService(new ServiceInfo(componentName), connection, tag); + } + + protected abstract boolean bindService(ServiceInfo serviceInfo, ServiceConnection connection, String tag); + + protected abstract void unbindService(ServiceInfo serviceInfo, ServiceConnection connection, String tag); + + protected static final class ServiceInfo { + private final String action; + private final String packageName; + private final ComponentName mComponentName; + + public ServiceInfo(String action) { + this.action = Preconditions.checkNotEmpty(action); + this.packageName = "com.google.android.gms"; + this.mComponentName = null; + } + + public ServiceInfo(ComponentName componentName) { + this.action = null; + this.packageName = "com.google.android.gms"; + this.mComponentName = Preconditions.checkNotNull(componentName); + } + + @Override + public String toString() { + return this.action == null ? this.mComponentName.flattenToString() : this.action; + } + + public String getAction() { + return action; + } + + public String getPackage() { + return this.packageName; + } + + public ComponentName getComponentName() { + return this.mComponentName; + } + + public Intent getServiceIntent() { + Intent intent; + if (this.action != null) { + intent = (new Intent(this.action)).setPackage(this.packageName); + } else { + intent = (new Intent()).setComponent(this.mComponentName); + } + return intent; + } + + @Override + public int hashCode() { + if (action != null) { + return action.hashCode(); + } + return this.mComponentName.flattenToString().hashCode(); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } else if (!(object instanceof ServiceInfo)) { + return false; + } else { + ServiceInfo target = (ServiceInfo) object; + if (this.action != null) { + return Objects.equals(this.action, target.action); + } + return Objects.equals(this.mComponentName.flattenToString(), target.mComponentName.flattenToString()); + } + } + } +} diff --git a/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientSupervisorImpl.java b/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientSupervisorImpl.java new file mode 100644 index 0000000000..14da897485 --- /dev/null +++ b/play-services-basement/src/main/java/com/google/android/gms/common/internal/GmsClientSupervisorImpl.java @@ -0,0 +1,130 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.internal; + +import android.content.ComponentName; +import android.content.Context; +import android.content.ServiceConnection; +import android.os.Handler; +import android.os.Message; +import android.util.Log; + +import androidx.annotation.GuardedBy; + +import com.google.android.gms.common.stats.ConnectionTracker; + +import java.util.HashMap; + +class GmsClientSupervisorImpl extends GmsClientSupervisor implements Handler.Callback { + private final String TAG = "GmsClientSupervisor"; + @GuardedBy("mConnectionStatus") + private final HashMap serviceConnectionHashMap = new HashMap<>(); + public final Context context; + public final Handler mHandler; + public final ConnectionTracker connectionTracker; + public final long delayTime; + + GmsClientSupervisorImpl(Context context) { + this.context = context; + this.mHandler = new Handler(context.getMainLooper(), this); + this.connectionTracker = ConnectionTracker.getInstance(); + this.delayTime = 5000L; + } + + @Override + protected boolean bindService(ServiceInfo serviceInfo, ServiceConnection connection, String tag) { + Preconditions.checkNotNull(connection, "ServiceConnection must not be null"); + Log.d(tag, "bindService: serviceInfo " + serviceInfo + " " + serviceInfo.hashCode()); + synchronized (this.serviceConnectionHashMap) { + GmsClientServiceConnection serviceConnection; + if ((serviceConnection = this.serviceConnectionHashMap.get(serviceInfo)) == null) { + Log.d(tag, "bindService: start 1111"); + (serviceConnection = new GmsClientServiceConnection(this, serviceInfo)).addServiceConnection(connection, tag); + serviceConnection.bindService(tag); + this.serviceConnectionHashMap.put(serviceInfo, serviceConnection); + } else { + Log.d(tag, "bindService: start 2222"); + this.mHandler.removeMessages(0, serviceInfo); + if (serviceConnection.serviceConnected(connection)) { + String info = String.valueOf(serviceInfo); + throw new IllegalStateException("Trying to bind a GmsServiceConnection that was already connected before. config=" + info); + } + serviceConnection.addServiceConnection(connection, tag); + switch (serviceConnection.getState()) { + case 1: + connection.onServiceConnected(serviceConnection.getComponentName(), serviceConnection.getBinder()); + break; + case 2: + serviceConnection.bindService(tag); + } + } + + return serviceConnection.isBound(); + } + } + + @Override + protected void unbindService(ServiceInfo serviceInfo, ServiceConnection connection, String tag) { + Preconditions.checkNotNull(connection, "ServiceConnection must not be null"); + synchronized (this.serviceConnectionHashMap) { + Log.d(tag, "unbindService: serviceInfo " + serviceInfo + " " + serviceInfo.hashCode()); + GmsClientServiceConnection serviceConnection; + String info; + if ((serviceConnection = this.serviceConnectionHashMap.get(serviceInfo)) == null) { + info = String.valueOf(serviceInfo); + Log.w(TAG, "unbindService: " + "Nonexistent connection status for service config: " + info); + } else if (!serviceConnection.serviceConnected(connection)) { + info = String.valueOf(serviceInfo); + Log.w(TAG, "unbindService: " + "Trying to unbind a GmsServiceConnection that was not bound before. config=" + info); + } else { + serviceConnection.removeServiceConnection(connection, tag); + if (serviceConnection.serviceConnectionStatus()) { + Message var6 = this.mHandler.obtainMessage(0, serviceInfo); + this.mHandler.sendMessageDelayed(var6, this.delayTime); + } + } + } + } + + @Override + public boolean handleMessage(Message msg) { + ServiceInfo serviceInfo; + GmsClientServiceConnection serviceConnection; + switch (msg.what) { + case 0: + synchronized (this.serviceConnectionHashMap) { + serviceInfo = (ServiceInfo) msg.obj; + if ((serviceConnection = this.serviceConnectionHashMap.get(serviceInfo)) != null && serviceConnection.serviceConnectionStatus()) { + if (serviceConnection.isBound()) { + serviceConnection.unbindService(TAG); + } + this.serviceConnectionHashMap.remove(serviceInfo); + } + return true; + } + case 1: + synchronized (this.serviceConnectionHashMap) { + serviceInfo = (ServiceInfo) msg.obj; + if ((serviceConnection = this.serviceConnectionHashMap.get(serviceInfo)) != null && serviceConnection.getState() == 3) { + String info = String.valueOf(serviceInfo); + Log.e(TAG, "Timeout waiting for ServiceConnection callback " + info, new Exception()); + ComponentName componentName; + if ((componentName = serviceConnection.getComponentName()) == null) { + componentName = serviceInfo.getComponentName(); + } + if (componentName == null) { + componentName = new ComponentName(serviceInfo.getPackage(), "unknown"); + } + serviceConnection.onServiceDisconnected(componentName); + } + return true; + } + default: + return false; + } + } +} + diff --git a/play-services-basement/src/main/java/com/google/android/gms/common/internal/Preconditions.java b/play-services-basement/src/main/java/com/google/android/gms/common/internal/Preconditions.java new file mode 100644 index 0000000000..15f63742c4 --- /dev/null +++ b/play-services-basement/src/main/java/com/google/android/gms/common/internal/Preconditions.java @@ -0,0 +1,165 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.internal; + +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class Preconditions { + + @NonNull + public static T checkNotNull(@Nullable T value) { + if (value == null) { + throw new NullPointerException("null reference"); + } else { + return value; + } + } + + + public static String checkNotEmpty(String value) { + if (TextUtils.isEmpty(value)) { + throw new IllegalArgumentException("Given String is empty or null"); + } else { + return value; + } + } + + + public static String checkNotEmpty(String value, Object error) { + if (TextUtils.isEmpty(value)) { + throw new IllegalArgumentException(String.valueOf(error)); + } else { + return value; + } + } + + + @NonNull + public static T checkNotNull(T value, Object error) { + if (value == null) { + throw new NullPointerException(String.valueOf(error)); + } else { + return value; + } + } + + + public static int checkNotZero(int value, Object error) { + if (value == 0) { + throw new IllegalArgumentException(String.valueOf(error)); + } else { + return value; + } + } + + + public static int checkNotZero(int value) { + if (value == 0) { + throw new IllegalArgumentException("Given Integer is zero"); + } else { + return value; + } + } + + + public static long checkNotZero(long value, Object error) { + if (value == 0L) { + throw new IllegalArgumentException(String.valueOf(error)); + } else { + return value; + } + } + + + public static long checkNotZero(long value) { + if (value == 0L) { + throw new IllegalArgumentException("Given Long is zero"); + } else { + return value; + } + } + + + public static void checkState(boolean value) { + if (!value) { + throw new IllegalStateException(); + } + } + + + public static void checkState(boolean value, Object error) { + if (!value) { + throw new IllegalStateException(String.valueOf(error)); + } + } + + + public static void checkState(boolean value, String key, Object... data) { + if (!value) { + throw new IllegalStateException(String.format(key, data)); + } + } + + + public static void checkArgument(boolean value, Object data) { + if (!value) { + throw new IllegalArgumentException(String.valueOf(data)); + } + } + + + public static void checkArgument(boolean value, String key, Object... data) { + if (!value) { + throw new IllegalArgumentException(String.format(key, data)); + } + } + + + public static void checkArgument(boolean value) { + if (!value) { + throw new IllegalArgumentException(); + } + } + + private Preconditions() { + throw new AssertionError("Uninstantiable"); + } + + + public static void checkMainThread(String value) { + if (Looper.getMainLooper() != Looper.myLooper()) { + throw new IllegalStateException(value); + } + } + + + public static void checkNotMainThread() { + checkNotMainThread("Must not be called on the main application thread"); + } + + + public static void checkNotMainThread(String value) { + if (Looper.getMainLooper() != Looper.myLooper()) { + throw new IllegalStateException(value); + } + } + + + public static void checkHandlerThread(Handler handler) { + checkHandlerThread(handler, "Must be called on the handler thread"); + } + + public static void checkHandlerThread(Handler handler, String msg) { + if (Looper.myLooper() != handler.getLooper()) { + throw new IllegalStateException(msg); + } + } +} diff --git a/play-services-basement/src/main/java/com/google/android/gms/common/stats/ConnectionTracker.java b/play-services-basement/src/main/java/com/google/android/gms/common/stats/ConnectionTracker.java new file mode 100644 index 0000000000..422f0be523 --- /dev/null +++ b/play-services-basement/src/main/java/com/google/android/gms/common/stats/ConnectionTracker.java @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.stats; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.util.Log; + +public class ConnectionTracker { + private static final Object lock = new Object(); + private static volatile ConnectionTracker instance; + + public static ConnectionTracker getInstance() { + if (instance == null) { + synchronized(lock) { + if (instance == null) { + instance = new ConnectionTracker(); + } + } + } + return instance; + } + + public final boolean bindService(Context context, String className, Intent intent, ServiceConnection connection, int flags) { + ComponentName componentName; + if ((componentName = intent.getComponent()) != null && checkPackageLive(context, componentName.getPackageName())) { + Log.w("ConnectionTracker", "Attempted to bind to a service in a STOPPED package."); + return false; + } else { + Log.d("ConnectionTracker", "bindService: " + intent + " ServiceConnection: " + connection); + return context.bindService(intent, connection, flags); + } + } + + public boolean bindService(Context context, Intent intent, ServiceConnection connection, int flag) { + return this.bindService(context, context.getClass().getName(), intent, connection, flag); + } + + public void unbindService(Context context, ServiceConnection connection) { + context.unbindService(connection); + } + + private boolean checkPackageLive(Context context, String packageName) { + try { + ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(packageName, 0); + return (applicationInfo.flags & 2097152) != 0; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } +} diff --git a/play-services-basement/src/main/java/com/google/android/gms/common/util/DataUtils.java b/play-services-basement/src/main/java/com/google/android/gms/common/util/DataUtils.java new file mode 100644 index 0000000000..dc7450d73f --- /dev/null +++ b/play-services-basement/src/main/java/com/google/android/gms/common/util/DataUtils.java @@ -0,0 +1,32 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.common.util; + +import android.database.CharArrayBuffer; +import android.graphics.Bitmap; +import android.text.TextUtils; + +import java.io.ByteArrayOutputStream; + +public final class DataUtils { + + public static void copyStringToBuffer(String desc, CharArrayBuffer dataOut) { + if (TextUtils.isEmpty(desc)) { + dataOut.sizeCopied = 0; + } else if (dataOut.data != null && dataOut.data.length >= desc.length()) { + desc.getChars(0, desc.length(), dataOut.data, 0); + } else { + dataOut.data = desc.toCharArray(); + } + dataOut.sizeCopied = desc.length(); + } + + public static byte[] loadImageBytes(Bitmap bitmap) { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream); + return byteArrayOutputStream.toByteArray(); + } +} diff --git a/play-services-core-proto/build.gradle b/play-services-core-proto/build.gradle index f3f4a16b88..961fdfa551 100644 --- a/play-services-core-proto/build.gradle +++ b/play-services-core-proto/build.gradle @@ -8,6 +8,7 @@ apply plugin: 'kotlin' dependencies { implementation "com.squareup.wire:wire-runtime:$wireVersion" + api "com.squareup.wire:wire-grpc-client:$wireVersion" } wire { diff --git a/play-services-core-proto/src/main/proto/snapshot.proto b/play-services-core-proto/src/main/proto/snapshot.proto new file mode 100644 index 0000000000..0d75092caa --- /dev/null +++ b/play-services-core-proto/src/main/proto/snapshot.proto @@ -0,0 +1,126 @@ +package google.play.games.games.v1; + +option java_outer_classname = "SnapshotProto"; + +option java_package = "org.microg.gms.games"; +option java_multiple_files = true; + +service SnapshotsExtended { + rpc SyncSnapshots (GetSnapshotRequest) returns (GetSnapshotResponse); + rpc DeleteSnapshot (DeleteSnapshotInfo) returns (EmptyResult); + rpc ResolveSnapshotHead(ResolveSnapshotHeadRequest) returns (ResolveSnapshotHeadResponse); + rpc PrepareSnapshotRevision(PrepareSnapshotRevisionRequest) returns (PrepareSnapshotRevisionResponse); + rpc CommitSnapshotRevision(CommitSnapshotRevisionRequest) returns (EmptyResult); +} + +message ResolveSnapshotHeadResponse { + optional SnapshotMetadata snapshotMetadata = 1; +} + +message PrepareSnapshotRevisionRequest { + optional string title = 1; + repeated ukq c = 2; + optional string randomUUID = 3; +} + +message PrepareSnapshotRevisionResponse { + optional string title = 1; + repeated UploadLinkInfo uploadLinkInfos = 2; +} + +message CommitSnapshotRevisionRequest { + optional string snapshotName = 1; + optional Snapshot snapshot = 3; + optional string unknownFileString2 = 2; + repeated string unknownFileString4 = 4; + optional string randomUUID = 5; + optional string oneofField6 = 6; + optional int32 unknownFileInt7 = 7; +} + +message UploadLinkInfo { + optional int32 id = 2; + optional string url = 3; + optional int32 unknownFileInt4 = 4; +} + +message ukq { + optional int32 unknownFileInt1 = 1; + optional int32 unknownFileInt2 = 2; +} + +message ResolveSnapshotHeadRequest { + optional string snapshotName = 1; + optional int32 unknownFileInt2 = 2; + optional int32 unknownFileInt3 = 3; +} + +message GetSnapshotRequest { + repeated int32 unknownFileIntList3 = 3; + optional int32 unknownFileInt4 = 4; + optional int32 unknownFileInt6 = 6; +} + +message DeleteSnapshotInfo { + optional string snapshotName = 1; + optional string snapshotId = 2; +} + +message EmptyResult { + +} + +message GetSnapshotResponse { + repeated GameSnapshot gameSnapshot = 1; + optional string dataSnapshot = 2; + optional string unknownFileString3 = 3; + optional int32 unknownFileInt4 = 4; +} + +message GameSnapshot { + optional SnapshotMetadata metadata = 1; + optional int32 type = 2; +} + +message SnapshotMetadata { + optional string snapshotName = 1; + optional Snapshot snapshot = 2; + optional int32 type = 3; + repeated Snapshot snapshots = 4; +} + +message Snapshot { + optional string snapshotId = 1; + optional SnapshotContent content = 2; + optional SnapshotContentInfo snapshotContentInfo = 3; + optional SnapshotImage coverImage = 4; +} + +message SnapshotContent { + optional string description = 2; + optional SnapshotTimeInfo snapshotTimeInfo = 3; + optional int64 progressValue = 5; + optional string deviceName = 6; + optional int64 duration = 7; +} + +message SnapshotTimeInfo { + required int64 timestamp = 1; + required int32 playedTime = 2; +} + +message SnapshotContentInfo { + optional string token = 1; + optional string url = 2; + optional string contentHash = 3; + optional int64 size = 4; +} + +message SnapshotImage { + optional string token = 1; + optional string imageUrl = 2; + optional int32 width = 3; + optional int32 height = 4; + optional string contentHash = 5; + optional string mimeType = 6; +} \ No newline at end of file diff --git a/play-services-core/src/main/AndroidManifest.xml b/play-services-core/src/main/AndroidManifest.xml index 5df282cf88..3486a5859d 100644 --- a/play-services-core/src/main/AndroidManifest.xml +++ b/play-services-core/src/main/AndroidManifest.xml @@ -541,6 +541,22 @@ + + + + + + + + + + getScopes(String scope) { + private List getScopes(String scope) { if (!scope.startsWith("oauth2:")) return null; String[] strings = scope.substring(7).split(" "); - List res = new ArrayList(); - for (String string : strings) { - res.add(new Scope(string)); - } - return res; + return new ArrayList<>(Arrays.asList(strings)); } private static CharSequence getPackageLabel(String packageName, PackageManager pm) { @@ -115,7 +112,8 @@ public Bundle getTokenWithAccount(Account account, String scope, Bundle extras) String packageName = extras.getString(KEY_ANDROID_PACKAGE_NAME); if (packageName == null || packageName.isEmpty()) packageName = extras.getString(KEY_CLIENT_PACKAGE_NAME); - packageName = PackageUtils.getAndCheckCallingPackage(context, packageName, extras.getInt(KEY_CALLER_UID, 0), extras.getInt(KEY_CALLER_PID, 0)); + if (TextUtils.isEmpty(packageName)) + packageName = PackageUtils.getAndCheckCallingPackage(context, packageName, extras.getInt(KEY_CALLER_UID, 0), extras.getInt(KEY_CALLER_PID, 0)); boolean notify = extras.getBoolean(KEY_HANDLE_NOTIFICATION, false); scope = Objects.equals(AuthConstants.SCOPE_OAUTH2, scope) ? AuthConstants.SCOPE_EM_OP_PRO : scope; @@ -123,8 +121,6 @@ public Bundle getTokenWithAccount(Account account, String scope, Bundle extras) if (!AuthConstants.SCOPE_GET_ACCOUNT_ID.equals(scope)) Log.d(TAG, "getToken: account:" + account.name + " scope:" + scope + " extras:" + extras + ", notify: " + notify); - scope = Objects.equals(AuthConstants.SCOPE_OAUTH2, scope) ? AuthConstants.SCOPE_EM_OP_PRO : scope; - /* * TODO: This scope seems to be invalid (according to https://developers.google.com/oauthplayground/), * but is used in some applications anyway. Removing it is unlikely a good solution, but works for now. @@ -150,7 +146,8 @@ public Bundle getTokenWithAccount(Account account, String scope, Bundle extras) Log.d(TAG, "getToken: " + res); result.putString(KEY_AUTHTOKEN, res.auth); Bundle details = new Bundle(); - details.putParcelable("TokenData", new TokenData(res.auth, res.expiry, scope.startsWith("oauth2:"), getScopes(res.grantedScopes != null ? res.grantedScopes : scope))); + TokenData value = new TokenData(1, res.auth, res.expiry, false, scope.startsWith("oauth2:"), getScopes(res.grantedScopes != null ? res.grantedScopes : scope), scope); + details.putParcelable("TokenData", value); result.putBundle("tokenDetails", details); result.putString(KEY_ERROR, "OK"); } else { diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/AuthSignInService.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/AuthSignInService.kt index dcab8f3c53..aa224c9427 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/AuthSignInService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/AuthSignInService.kt @@ -16,6 +16,7 @@ package org.microg.gms.auth.signin import android.accounts.Account +import android.accounts.AccountManager import android.content.Context import android.os.Bundle import android.os.Parcel @@ -30,6 +31,7 @@ import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.google.android.gms.auth.api.signin.internal.ISignInCallbacks import com.google.android.gms.auth.api.signin.internal.ISignInService import com.google.android.gms.common.Feature +import com.google.android.gms.common.Scopes import com.google.android.gms.common.api.CommonStatusCodes import com.google.android.gms.common.api.Scope import com.google.android.gms.common.api.Status @@ -37,9 +39,12 @@ import com.google.android.gms.common.internal.ConnectionInfo import com.google.android.gms.common.internal.GetServiceRequest import com.google.android.gms.common.internal.IGmsCallbacks import org.microg.gms.BaseService +import org.microg.gms.auth.AuthConstants import org.microg.gms.auth.AuthPrefs import org.microg.gms.common.GmsService import org.microg.gms.common.PackageUtils +import org.microg.gms.games.GAMES_PACKAGE_NAME +import org.microg.gms.games.GamesConfigurationService import org.microg.gms.utils.singleInstanceOf import org.microg.gms.utils.warnOnTransactionIssues import kotlin.coroutines.resume @@ -77,10 +82,23 @@ class AuthSignInServiceImpl( } lifecycleScope.launchWhenStarted { try { - val account = account ?: options?.account ?: SignInConfigurationService.getDefaultAccount(context, packageName) + var account = account ?: options?.account ?: SignInConfigurationService.getDefaultAccount(context, packageName) + if (account == null && options?.includeGamesScope == true) { + account = GamesConfigurationService.getDefaultAccount(context, packageName) + ?: SignInConfigurationService.getDefaultAccount(context, GAMES_PACKAGE_NAME) + ?: GamesConfigurationService.getDefaultAccount(context, GAMES_PACKAGE_NAME) + if (account == null) { + val accounts = AccountManager.get(context).getAccountsByType(AuthConstants.DEFAULT_ACCOUNT_TYPE) + account = accounts.filter { targetAccount -> + checkAccountAuthStatus(context, GAMES_PACKAGE_NAME, arrayListOf(Scope( + Scopes.GAMES_LITE)), targetAccount) + }.getOrNull(0) + } + } + Log.d(TAG, "$packageName:silentSignIn account:($account)") if (account != null && options?.isForceCodeForRefreshToken != true && options?.includeUnacceptableScope != true) { if (getOAuthManager(context, packageName, options, account).isPermitted || AuthPrefs.isTrustGooglePermitted(context)) { - val googleSignInAccount = performSignIn(context, packageName, options, account) + val googleSignInAccount = performSignIn(context, packageName, options, account, true) if (googleSignInAccount != null) { sendResult(googleSignInAccount, Status(CommonStatusCodes.SUCCESS)) } else { diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt index f6ce68c522..80944f309f 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/signin/extensions.kt @@ -41,6 +41,8 @@ private const val TAG = "AuthSignInExtensions" private val ACCEPTABLE_SCOPES = setOf(Scopes.OPENID, Scopes.EMAIL, Scopes.PROFILE, Scopes.USERINFO_EMAIL, Scopes.USERINFO_PROFILE, Scopes.GAMES_LITE) +private val GAMES_SCOPES = setOf(Scopes.GAMES, Scopes.GAMES_LITE) + private fun Long?.orMaxIfNegative() = this?.takeIf { it >= 0L } ?: Long.MAX_VALUE val GoogleSignInOptions.scopeUris @@ -58,6 +60,9 @@ val GoogleSignInOptions.includeProfile val GoogleSignInOptions.includeUnacceptableScope get() = scopeUris.any { it.scopeUri !in ACCEPTABLE_SCOPES } +val GoogleSignInOptions.includeGamesScope + get() = scopeUris.any { it.scopeUri in GAMES_SCOPES } + val consentRequestOptions: String? get() = runCatching { val sessionId = Base64.encodeToString(ByteArray(16).also { SecureRandom().nextBytes(it) }, Base64.NO_WRAP).trim() @@ -100,6 +105,13 @@ suspend fun checkAppAuthStatus(context: Context, packageName: String, options: G return withContext(Dispatchers.IO) { authManager.requestAuth(true) }.auth != null } +suspend fun checkAccountAuthStatus(context: Context, packageName: String, scopeList: List?, account: Account): Boolean { + val scopes = scopeList.orEmpty().sortedBy { it.scopeUri } + val authManager = AuthManager(context, account.name, packageName, "oauth2:${scopes.joinToString(" ")}") + authManager.ignoreStoredPermission = true + return withContext(Dispatchers.IO) { authManager.requestAuth(true) }.auth != null +} + suspend fun performSignIn(context: Context, packageName: String, options: GoogleSignInOptions?, account: Account, permitted: Boolean = false): GoogleSignInAccount? { val authManager = getOAuthManager(context, packageName, options, account) val authResponse = withContext(Dispatchers.IO) { diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt index 34810ef768..c4d7acdeab 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesConnectService.kt @@ -28,6 +28,7 @@ import com.google.android.gms.games.internal.connect.IGamesConnectService import org.microg.gms.BaseService import org.microg.gms.auth.AuthManager import org.microg.gms.auth.AuthPrefs +import org.microg.gms.auth.signin.SignInConfigurationService import org.microg.gms.common.GmsService import org.microg.gms.common.PackageUtils import org.microg.gms.utils.warnOnTransactionIssues @@ -62,7 +63,7 @@ class GamesConnectServiceImpl(val context: Context, override val lifecycle: Life } 1 -> { // Auto sign-in on start, don't provide resolution if not - callback?.onSignIn(Status(CommonStatusCodes.SIGN_IN_REQUIRED), null) + callback?.onSignIn(Status(CommonStatusCodes.SIGN_IN_REQUIRED, null, resolution), null) } else -> { @@ -74,6 +75,7 @@ class GamesConnectServiceImpl(val context: Context, override val lifecycle: Life try { val account = request?.previousStepResolutionResult?.resultData?.getParcelableExtra(EXTRA_ACCOUNT) ?: GamesConfigurationService.getDefaultAccount(context, packageName) + ?: SignInConfigurationService.getDefaultAccount(context, packageName) ?: return@launchWhenStarted sendSignInRequired() val authManager = AuthManager(context, account.name, packageName, "oauth2:${Scopes.GAMES_LITE}") if (!authManager.isPermitted && !AuthPrefs.isTrustGooglePermitted(context)) return@launchWhenStarted sendSignInRequired() diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt index 9fb6e16bd1..e65bad4f52 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/GamesService.kt @@ -13,6 +13,8 @@ import android.net.Uri import android.os.Bundle import android.os.IBinder import android.os.Parcel +import android.os.ParcelFileDescriptor +import android.text.TextUtils import android.util.Log import androidx.core.app.PendingIntentCompat import androidx.core.os.bundleOf @@ -22,18 +24,23 @@ import androidx.lifecycle.lifecycleScope import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.Scopes import com.google.android.gms.common.api.CommonStatusCodes -import com.google.android.gms.common.api.Scope import com.google.android.gms.common.api.Status import com.google.android.gms.common.data.DataHolder import com.google.android.gms.common.internal.ConnectionInfo import com.google.android.gms.common.internal.GetServiceRequest import com.google.android.gms.common.internal.IGmsCallbacks +import com.google.android.gms.drive.Contents +import com.google.android.gms.drive.DriveId +import com.google.android.gms.games.GameColumns +import com.google.android.gms.games.GamesStatusCodes import com.google.android.gms.games.Player import com.google.android.gms.games.PlayerColumns import com.google.android.gms.games.PlayerEntity import com.google.android.gms.games.internal.IGamesCallbacks import com.google.android.gms.games.internal.IGamesClient import com.google.android.gms.games.internal.IGamesService +import com.google.android.gms.games.snapshot.SnapshotColumns +import com.google.android.gms.games.snapshot.SnapshotMetadataChangeEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -41,9 +48,17 @@ import org.microg.gms.BaseService import org.microg.gms.auth.AuthConstants import org.microg.gms.auth.AuthManager import org.microg.gms.auth.AuthPrefs +import org.microg.gms.auth.signin.SignInConfigurationService +import org.microg.gms.common.Constants import org.microg.gms.common.GmsService import org.microg.gms.common.PackageUtils +import org.microg.gms.games.achievements.AchievementsDataClient +import org.microg.gms.games.snapshot.SnapshotsDataClient import org.microg.gms.utils.warnOnTransactionIssues +import java.io.File +import java.io.FileOutputStream +import java.util.regex.Pattern + private const val TAG = "GamesService" @@ -75,12 +90,14 @@ class GamesService : BaseService(TAG, GmsService.GAMES) { try { val account = request.account?.takeIf { it.name != AuthConstants.DEFAULT_ACCOUNT } ?: GamesConfigurationService.getDefaultAccount(this@GamesService, packageName) + ?: SignInConfigurationService.getDefaultAccount(this@GamesService, packageName) ?: return@launchWhenStarted sendSignInRequired() val scopes = request.scopes.toList().realScopes Log.d(TAG, "handleServiceRequest scopes to ${scopes.joinToString(" ")}") - val authManager = AuthManager(this@GamesService, account.name, packageName, "oauth2:${scopes.joinToString(" ")}") + val oauth2Service = "oauth2:${scopes.joinToString(" ")}" + val authManager = AuthManager(this@GamesService, account.name, packageName, oauth2Service) if (!authManager.isPermitted && !AuthPrefs.isTrustGooglePermitted(this@GamesService)) { Log.d(TAG, "Not permitted to use $account for ${scopes.toList()}, sign in required") return@launchWhenStarted sendSignInRequired() @@ -95,7 +112,7 @@ class GamesService : BaseService(TAG, GmsService.GAMES) { callback.onPostInitCompleteWithConnectionInfo( CommonStatusCodes.SUCCESS, - GamesServiceImpl(this@GamesService, lifecycle, packageName, account, player), + GamesServiceImpl(this@GamesService, lifecycle, packageName, account, player, oauth2Service), ConnectionInfo() ) } catch (e: Exception) { @@ -106,11 +123,15 @@ class GamesService : BaseService(TAG, GmsService.GAMES) { } } -class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, val packageName: String, val account: Account, val player: Player) : +class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, val packageName: String, val account: Account, val player: Player, val oauthService:String) : IGamesService.Stub(), LifecycleOwner { + private val pattern: Pattern = Pattern.compile("[0-9a-zA-Z-._~]{1,100}") + private var saveName: String? = null + override fun clientDisconnecting(clientId: Long) { - Log.d(TAG, "Not yet implemented: clientDisconnecting($clientId)") + Log.d(TAG, "Method clientDisconnecting called:$clientId") + AchievementsDataClient.get(context).release() } override fun signOut(callbacks: IGamesCallbacks?) { @@ -140,8 +161,8 @@ class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, } override fun getCurrentAccountName(): String? { - Log.d(TAG, "Not yet implemented: getCurrentAccountName") - return null + Log.d(TAG, "getCurrentAccountName called: ${account.name}") + return account.name } override fun loadGameplayAclInternal(callbacks: IGamesCallbacks?, gameId: String?) { @@ -161,8 +182,8 @@ class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, } override fun getCurrentPlayerId(): String? { - Log.d(TAG, "Not yet implemented: getCurrentPlayerId") - return null + Log.d(TAG, "Method getCurrentPlayerId Called: ${player.playerId}") + return player.playerId } override fun getCurrentPlayer(): DataHolder? { @@ -221,22 +242,44 @@ class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, } override fun loadAchievements(callbacks: IGamesCallbacks?) { + Log.d(TAG, "Method loadAchievements called") loadAchievementsV2(callbacks, false) } - override fun revealAchievement(callbacks: IGamesCallbacks?, achievementId: String?, windowToken: IBinder?, extraArgs: Bundle?) { - runCatching { extraArgs?.keySet() } - Log.d(TAG, "Not yet implemented: revealAchievement($achievementId, $windowToken, $extraArgs)") + override fun revealAchievement(callbacks: IGamesCallbacks?, achievementId: String, windowToken: IBinder?, extraArgs: Bundle?) { + Log.d(TAG, "Method revealAchievement($achievementId, $windowToken, $extraArgs) Called") + lifecycleScope.launchWhenStarted { + runCatching { + val ret = AchievementsDataClient.get(context).revealAchievement(account, packageName, achievementId) + callbacks?.onAchievementUpdated(if (ret != -1) 0 else ret, achievementId) + }.onFailure { + Log.d(TAG, "revealAchievement: error", it) + } + } } - override fun unlockAchievement(callbacks: IGamesCallbacks?, achievementId: String?, windowToken: IBinder?, extraArgs: Bundle?) { - runCatching { extraArgs?.keySet() } - Log.d(TAG, "Not yet implemented: unlockAchievement($achievementId, $windowToken, $extraArgs") + override fun unlockAchievement(callbacks: IGamesCallbacks?, achievementId: String, windowToken: IBinder?, extraArgs: Bundle?) { + Log.d(TAG, "Method unlockAchievement($achievementId, $windowToken, $extraArgs) Called") + lifecycleScope.launchWhenStarted { + runCatching { + val ret = AchievementsDataClient.get(context).unlockAchievement(account, packageName, achievementId) + callbacks?.onAchievementUpdated(if (ret != -1) 0 else ret, achievementId) + }.onFailure { + Log.d(TAG, "unlockAchievement: error", it) + } + } } - override fun incrementAchievement(callbacks: IGamesCallbacks?, achievementId: String?, numSteps: Int, windowToken: IBinder?, extraArgs: Bundle?) { - runCatching { extraArgs?.keySet() } - Log.d(TAG, "Not yet implemented: incrementAchievement($achievementId, $numSteps, $windowToken, $extraArgs)") + override fun incrementAchievement(callbacks: IGamesCallbacks?, achievementId: String, numSteps: Int, windowToken: IBinder?, extraArgs: Bundle?) { + Log.d(TAG, "Method: incrementAchievement($achievementId, $numSteps, $windowToken, $extraArgs) Called") + lifecycleScope.launchWhenStarted { + runCatching { + val ret = AchievementsDataClient.get(context).incrementAchievement(account, packageName, achievementId, numSteps) + callbacks?.onAchievementUpdated(if (ret != -1) 0 else ret, achievementId) + }.onFailure { + Log.d(TAG, "incrementAchievement: error", it) + } + } } override fun loadGame(callbacks: IGamesCallbacks?) { @@ -465,21 +508,33 @@ class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, } override fun loadAchievementsV2(callbacks: IGamesCallbacks?, forceReload: Boolean) { - Log.d(TAG, "Not yet implemented: loadAchievementsV2($forceReload)") - callbacks?.onAchievementsLoaded(DataHolder.empty(CommonStatusCodes.SUCCESS)) + Log.d(TAG, "Method loadAchievementsV2 called: forceReload:$forceReload") + lifecycleScope.launchWhenStarted { + AchievementsDataClient.get(context).loadAchievementsData(packageName, account, forceReload)?.run { + callbacks?.onAchievementsLoaded(this) + } + } } override fun submitLeaderboardScore(callbacks: IGamesCallbacks?, leaderboardId: String?, score: Long, scoreTag: String?) { Log.d(TAG, "Not yet implemented: submitLeaderboardScore($leaderboardId, $score, $scoreTag)") } - override fun setAchievementSteps(callbacks: IGamesCallbacks?, id: String?, numSteps: Int, windowToken: IBinder?, extras: Bundle?) { - runCatching { extras?.keySet() } - Log.d(TAG, "Not yet implemented: setAchievementSteps($id, $numSteps, $windowToken, $extras)") + override fun setAchievementSteps(callbacks: IGamesCallbacks?, achievementId: String, numSteps: Int, windowToken: IBinder?, extras: Bundle?) { + Log.d(TAG, "Method setAchievementSteps($achievementId, $numSteps, $windowToken, $extras) called") + lifecycleScope.launchWhenStarted { + runCatching { + val ret = AchievementsDataClient.get(context).setAchievementSteps(account, packageName, achievementId, numSteps) + callbacks?.onAchievementUpdated(if (ret != -1) 0 else ret, achievementId) + }.onFailure { + Log.d(TAG, "setAchievementSteps: error", it) + } + } } private fun getGamesIntent(action: String, block: Intent.() -> Unit = {}) = Intent(action).apply { - setPackage(GAMES_PACKAGE_NAME) + setPackage(Constants.GMS_PACKAGE_NAME) + putExtra(EXTRA_ACCOUNT_KEY, Integer.toHexString(account.name.hashCode())) putExtra(EXTRA_GAME_PACKAGE_NAME, packageName) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) block() @@ -491,6 +546,52 @@ class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, override fun getPlayerSearchIntent(): Intent = getGamesIntent(ACTION_PLAYER_SEARCH) + override fun getSelectSnapshotIntent( + title: String?, + allowAddButton: Boolean, + allowDelete: Boolean, + maxSnapshots: Int + ): Intent { + Log.d(TAG, "Method getSelectSnapshotIntent($title, $allowAddButton, $allowDelete, $maxSnapshots) called") + return getGamesIntent(ACTION_VIEW_SNAPSHOTS) { + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_ALLOW_CREATE_SNAPSHOT, allowAddButton) + putExtra(EXTRA_ALLOW_DELETE_SNAPSHOT, allowDelete) + putExtra(EXTRA_MAX_SNAPSHOTS, maxSnapshots) + } + } + + override fun loadSnapshots(callbacks: IGamesCallbacks?, forceReload: Boolean) { + Log.d(TAG, "Method loadSnapshots(forceReload:$forceReload) called") + } + + override fun commitSnapshot( + callbacks: IGamesCallbacks?, + str: String?, + change: SnapshotMetadataChangeEntity?, + contents: Contents? + ) { + Log.d(TAG, "Method commitSnapshot(str:$str, change:$change, dvd:$contents)") + lifecycleScope.launchWhenStarted { + if (change != null && contents?.parcelFileDescriptor != null) { + runCatching { + val result = SnapshotsDataClient.get(context) + .commitSnapshot(packageName, account, saveName, oauthService, change, contents, maxCoverImageSize) + if (result == true) { + callbacks?.commitSnapshotResult(DataHolder.empty(GamesStatusCodes.OK.code)) + } else { + callbacks?.commitSnapshotResult(DataHolder.empty(GamesStatusCodes.SNAPSHOT_COMMIT_FAILED.code)) + } + }.onFailure { + Log.w(TAG, "commitSnapshot: error", it) + callbacks?.commitSnapshotResult(DataHolder.empty(GamesStatusCodes.SNAPSHOT_COMMIT_FAILED.code)) + } + } else { + callbacks?.commitSnapshotResult(DataHolder.empty(GamesStatusCodes.SNAPSHOT_COMMIT_FAILED.code)) + } + } + } + override fun loadEvents(callbacks: IGamesCallbacks?, forceReload: Boolean) { Log.d(TAG, "Not yet implemented: loadEvents($forceReload)") } @@ -499,20 +600,66 @@ class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, Log.d(TAG, "Not yet implemented: incrementEvent($eventId, $incrementAmount)") } + override fun discardAndCloseSnapshot(contents: Contents?) { + Log.d(TAG, "discardAndCloseSnapshot: $contents") + } + override fun loadEventsById(callbacks: IGamesCallbacks?, forceReload: Boolean, eventsIds: Array?) { Log.d(TAG, "Not yet implemented: loadEventsById($forceReload, $eventsIds)") } override fun getMaxDataSize(): Int { + Log.d(TAG, "getMaxDataSize: ") return 3 * 1024 * 1024 } override fun getMaxCoverImageSize(): Int { + Log.d(TAG, "getMaxCoverImageSize: ") return 800 * 1024 } - override fun registerEventClient(callback: IGamesClient?, l: Long) { - Log.d(TAG, "Not yet implemented: registerEventClient($l)") + override fun resolveSnapshotHead(callbacks: IGamesCallbacks, saveName: String?, i: Int) { + Log.d(TAG, "Method saveSnapshot $saveName, $i") + if (TextUtils.isEmpty(saveName)) { + Log.w(TAG, "saveSnapshot: Must provide a non empty fileName!") + return + } + if (!pattern.matcher(saveName).matches()) { + Log.w(TAG, "saveSnapshot: Must provide a valid file name!") + return + } + val driveId = DriveId(null, 30, 0, DriveId.RESOURCE_TYPE_FILE) + val file = File.createTempFile("blob", ".tmp", context.filesDir) + this.saveName = saveName + lifecycleScope.launchWhenStarted { + val resolveSnapshotHeadRequest = ResolveSnapshotHeadRequest.Builder().apply { + this.snapshotName = saveName + unknownFileInt2 = 5 + unknownFileInt3 = 3 + }.build() + val resolveSnapshotHeadResponse = SnapshotsDataClient.get(context).resolveSnapshotHead(packageName, account, resolveSnapshotHeadRequest, oauthService) + val contentUrl = resolveSnapshotHeadResponse?.snapshotMetadata?.snapshot?.snapshotContentInfo?.url + if (contentUrl != null) { + val contentByteArray = SnapshotsDataClient.get(context).getDataFromDrive(packageName, account, contentUrl, oauthService) + val fileOutputStream = FileOutputStream(file) + fileOutputStream.write(contentByteArray) + } + val columns = PlayerColumns.CURRENT_PLAYER_COLUMNS.toTypedArray() + + GameColumns.CURRENT_GAME_COLUMNS.toTypedArray() + + SnapshotColumns.CURRENT_GAME_COLUMNS.toTypedArray() + val dataHolder = if (player is PlayerEntity) { + DataHolder.builder(columns) + .withRow(player.toContentValues()).build(CommonStatusCodes.SUCCESS) + } else { + DataHolder.builder(columns).build(CommonStatusCodes.SIGN_IN_REQUIRED) + } + callbacks.onResolveSnapshotHead(dataHolder, Contents(ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_WRITE), 1, ParcelFileDescriptor.MODE_READ_WRITE, driveId, true, null)) + } + + } + + override fun registerEventClient(callback: IGamesClient?, clientId: Long) { + Log.d(TAG, "Not yet implemented: registerEventClient($clientId)") } private fun getCompareProfileIntent(playerId: String, block: Intent.() -> Unit = {}): Intent = getGamesIntent(ACTION_VIEW_PROFILE) { @@ -529,8 +676,17 @@ class GamesServiceImpl(val context: Context, override val lifecycle: Lifecycle, Log.d(TAG, "Not yet implemented: loadPlayerStats($forceReload)") } + override fun getLeaderboardsScoresIntent(leaderboardId: String?, timeSpan: Int, collection: Int): Intent { + Log.d(TAG, "Method getLeaderboardsScoresIntent Called: timeSpan:$timeSpan collection:$collection") + return getGamesIntent(ACTION_VIEW_LEADERBOARDS_SCORES) { + putExtra(EXTRA_LEADERBOARD_ID, leaderboardId) + putExtra(EXTRA_LEADERBOARD_TIME_SPAN, timeSpan) + putExtra(EXTRA_LEADERBOARD_COLLECTION, collection) + } + } + override fun getCurrentAccount(): Account? { - Log.d(TAG, "Not yet implemented: getCurrentAccount") + Log.d(TAG, "Method getCurrentAccount Called: ${account.name}") return account } diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementResponseKt.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementResponseKt.kt new file mode 100644 index 0000000000..f28bbe93b7 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementResponseKt.kt @@ -0,0 +1,284 @@ +package org.microg.gms.games.achievements + +import android.net.Uri +import com.google.android.gms.games.achievement.Achievement +import com.google.android.gms.games.achievement.AchievementEntity +import org.json.JSONArray +import org.json.JSONObject + +data class AchievementDefinitionsListResponse( + val items: List, + val kind: String?, + val nextPageToken: String? +) { + override fun toString(): String { + return "AchievementDefinitionsListResponse(items=$items, kind='$kind', nextPageToken='$nextPageToken')" + } +} + +data class AchievementDefinition( + val achievementType: Int, + val description: String?, + val experiencePoints: String, + val formattedTotalSteps: String?, + val id: String?, + val initialState: Int, + val isRevealedIconUrlDefault: Boolean?, + val isUnlockedIconUrlDefault: Boolean?, + val kind: String?, + val name: String?, + val revealedIconUrl: String?, + val totalSteps: Int, + val unlockedIconUrl: String? +) { + + fun toAchievementEntity() = AchievementEntity( + id, + achievementType, + name, + description, + Uri.parse(unlockedIconUrl), + unlockedIconUrl, + Uri.parse(revealedIconUrl), + revealedIconUrl, + totalSteps, + formattedTotalSteps, + null, + initialState, + 0, + "0", + System.currentTimeMillis(), + experiencePoints.toLong(), + -1f, + null + ) + + override fun toString(): String { + return "AchievementDefinition(achievementType=$achievementType, description='$description', experiencePoints='$experiencePoints', formattedTotalSteps='$formattedTotalSteps', id='$id', initialState=$initialState, isRevealedIconUrlDefault=$isRevealedIconUrlDefault, isUnlockedIconUrlDefault=$isUnlockedIconUrlDefault, kind='$kind', name='$name', revealedIconUrl='$revealedIconUrl', totalSteps=$totalSteps, unlockedIconUrl='$unlockedIconUrl')" + } +} + +data class PlayerAchievement( + val kind: String?, + val id: String?, + val currentSteps: Int, + val formattedCurrentStepsString: String?, + val achievementState: String, + val lastUpdatedTimestamp: String?, + val experiencePoints: String? +) { + override fun toString(): String { + return "PlayerAchievement(kind=$kind, id=$id, currentSteps=$currentSteps, formattedCurrentStepsString=$formattedCurrentStepsString, achievementState=$achievementState, lastUpdatedTimestamp=$lastUpdatedTimestamp, experiencePoints=$experiencePoints)" + } +} + +data class AchievementIncrementResponse( + val kind: String?, + val currentSteps: Int, + val newlyUnlocked: Boolean +) { + override fun toString(): String { + return "AchievementIncrementResponse(kind=$kind, currentSteps=$currentSteps, newlyUnlocked=$newlyUnlocked)" + } +} + +data class AchievementRevealResponse( + val kind: String?, + val currentState: String, +) { + override fun toString(): String { + return "AchievementRevealResponse(kind=$kind, currentState=$currentState)" + } +} + +data class AchievementUnlockResponse( + val kind: String?, + val newlyUnlocked: Boolean, +) { + override fun toString(): String { + return "AchievementUnlockResponse(kind=$kind, newlyUnlocked=$newlyUnlocked)" + } +} + +data class UpdateMultipleAchievements(val kind: String?, val updates: ArrayList) { + override fun toString(): String { + return "UpdateMultipleAchievements(kind=$kind, updates=$updates)" + } + + fun toJSONObject() = JSONObject().apply { + putOpt("kind", kind) + putOpt("updates", JSONArray().apply { + updates.forEach { put(it.toJSONObject()) } + }) + } +} + +data class UpdateAchievement(val kind: String?, val achievementId: String?, val updateType: Int, val incrementPayload: AchievementIncrement?, val setStepsAtLeastPayload: SetAchievementSteps?) { + override fun toString(): String { + return "UpdateAchievement(kind=$kind, achievementId=$achievementId, updateType=$updateType, incrementPayload=$incrementPayload, setStepsAtLeastPayload=$setStepsAtLeastPayload)" + } + + fun toJSONObject() = JSONObject().apply { + putOpt("kind", kind) + putOpt("achievementId", achievementId) + putOpt("updateType", updateType) + putOpt("incrementPayload", incrementPayload?.toJSONObject()) + putOpt("setStepsAtLeastPayload", setStepsAtLeastPayload?.toJSONObject()) + } +} + +data class AchievementIncrement(val kind: String?, val steps: Int, val requestId: String?) { + override fun toString(): String { + return "AchievementIncrement(kind=$kind, steps=$steps, requestId='$requestId')" + } + + fun toJSONObject() = JSONObject().apply { + putOpt("kind", kind) + putOpt("steps", steps) + putOpt("requestId", requestId) + } +} + +data class SetAchievementSteps(val kind: String?, val steps: Int) { + override fun toString(): String { + return "SetAchievementSteps(kind=$kind, steps=$steps)" + } + + fun toJSONObject() = JSONObject().apply { + putOpt("kind", kind) + putOpt("steps", steps) + } +} + +data class UpdatedAchievement(val kind: String?, val achievementId: String?, val updateOccurred: Boolean, val currentState: String, val currentSteps: Int, val newlyUnlocked: Boolean) { + override fun toString(): String { + return "UpdatedAchievement(kind=$kind, achievementId=$achievementId, updateOccurred=$updateOccurred, currentState='$currentState', currentSteps=$currentSteps, newlyUnlocked=$newlyUnlocked)" + } +} + +data class UpdateMultipleAchievementResponse(val kind: String?, val updatedAchievements: ArrayList) { + override fun toString(): String { + return "UpdateMultipleAchievementResponse(kind=$kind, updatedAchievements=$updatedAchievements)" + } +} + +fun ArrayList.toUpdateMultipleAchievements(): JSONObject { + val updates: ArrayList = ArrayList() + for (i in 0 until size) { + val achievement = get(i) + val updateAchievement = UpdateAchievement( + "games#achievementUpdateRequest", + achievement.achievementId, + achievement.type, + AchievementIncrement("games#GamesAchievementIncrement", achievement.currentSteps, achievement.achievementId.hashCode().toString()), + SetAchievementSteps(" games#GamesAchievementSetStepsAtLeast", achievement.currentSteps) + ) + updates.add(updateAchievement) + } + return UpdateMultipleAchievements("games#achievementUpdateMultipleRequest", updates).toJSONObject() +} + +fun JSONObject.toUpdateMultipleResponse(): UpdateMultipleAchievementResponse { + val list = ArrayList() + val items = optJSONArray("updatedAchievements") + if (items != null) { + for (i in 0.. { + val items = optJSONArray("items") + val achievements = ArrayList() + if (items != null) { + for (i in 0.. { + val items = optJSONArray("items") + val achievements = ArrayList() + if (items != null) { + for (i in 0.. +) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AchievementsHolder { + val itemView = if (viewType == -1) { + LayoutInflater.from(mContext).inflate(R.layout.item_achievement_header_layout, null) + } else { + LayoutInflater.from(mContext).inflate(R.layout.item_achievement_data_layout, null) + } + return AchievementsHolder(itemView, viewType) + } + + override fun getItemCount(): Int { + return achievements.size + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: AchievementsHolder, position: Int) { + val definition = achievements[position] + if (definition.type == -1) { + holder.headerView?.text = definition.name + } else { + holder.achievementTitle?.text = definition.name + holder.achievementDesc?.text = definition.description + holder.achievementContent?.text = mContext.getString( + R.string.games_achievement_extra_text, + definition.xpValue.toString() + ) + val imageUrl = + if (definition.state == Achievement.AchievementState.STATE_UNLOCKED) { + definition.unlockedImageUrl + } else { + definition.revealedImageUrl + } + if (imageUrl != null) { + ImageManager.create(mContext).loadImage(imageUrl, holder.achievementLogo) + } else { + val logoId = if (definition.state == Achievement.AchievementState.STATE_UNLOCKED) { + R.drawable.ic_achievement_unlocked + } else { + R.drawable.ic_achievement_locked + } + holder.achievementLogo?.setImageResource(logoId) + } + } + } + + /** + * There are two display types. + * Now only the unlock display type is displayed, and the progress display type is not displayed yet. + */ + override fun getItemViewType(position: Int): Int { + val definition = achievements[position] + return definition.type + } + +} + +class AchievementsHolder(itemView: View, viewType: Int) : RecyclerView.ViewHolder(itemView) { + + var headerView: TextView? = null + + var achievementLogo: ImageView? = null + var achievementTitle: TextView? = null + var achievementContent: TextView? = null + var achievementDesc: TextView? = null + + init { + if (viewType == -1) { + headerView = itemView.findViewById(R.id.achievements_header_title) + } else { + achievementLogo = itemView.findViewById(R.id.achievement_logo) + achievementTitle = itemView.findViewById(R.id.achievement_title) + achievementContent = itemView.findViewById(R.id.achievement_content) + achievementDesc = itemView.findViewById(R.id.achievement_desc) + } + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementsApiClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementsApiClient.kt new file mode 100644 index 0000000000..0a70af0269 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementsApiClient.kt @@ -0,0 +1,106 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +package org.microg.gms.games.achievements + +import android.content.Context +import com.android.volley.Request.Method +import com.google.android.gms.games.achievement.Achievement +import org.microg.gms.games.requestGamesInfo +import java.util.Locale + +/** + * https://developers.google.com/games/services/web/api/rest#rest-resource:-achievementdefinitions + * https://developers.google.com/games/services/web/api/rest#rest-resource:-achievements + */ +object AchievementsApiClient { + + /** + * Lists all the achievement definitions for your application. + */ + suspend fun requestGameAllAchievements(mContext: Context, oauthToken: String, pageToken: String? = null) = requestGamesInfo(mContext, + Method.GET, + oauthToken, + "https://games.googleapis.com/games/v1/achievements", + HashMap().apply { + put("language", Locale.getDefault().language) + if (pageToken != null) { + put("pageToken", pageToken) + } + }).toAllAchievementListResponse() + + /** + * Lists the progress for all your application's achievements for the currently authenticated player. + */ + suspend fun requestPlayerAllAchievements(mContext: Context, oauthToken: String, pageToken: String? = null) = requestGamesInfo(mContext, + Method.GET, + oauthToken, + "https://games.googleapis.com/games/v1/players/me/achievements", + HashMap().apply { + put("language", Locale.getDefault().language) + if (pageToken != null) { + put("pageToken", pageToken) + } + }).toPlayerAchievementListResponse() + + /** + * Increments the steps of the achievement with the given ID for the currently authenticated player. + */ + suspend fun incrementAchievement(mContext: Context, oauthToken: String, achievementId: String, numSteps: Int) = requestGamesInfo( + mContext, + Method.POST, + oauthToken, + "https://games.googleapis.com/games/v1/achievements/$achievementId/increment", + HashMap().apply { + put("requestId", achievementId.hashCode().toString()) + put("stepsToIncrement", numSteps.toString()) + }).toIncrementResponse() + + /** + * Sets the state of the achievement with the given ID to REVEALED for the currently authenticated player. + */ + suspend fun revealAchievement(mContext: Context, oauthToken: String, achievementId: String) = requestGamesInfo( + mContext, + Method.POST, + oauthToken, + "https://games.googleapis.com/games/v1/achievements/$achievementId/reveal", + null + ).toRevealResponse() + + /** + * Sets the steps for the currently authenticated player towards unlocking an achievement. + * If the steps parameter is less than the current number of steps that the player already gained for the achievement, the achievement is not modified. + */ + suspend fun setStepsAtLeast(mContext: Context, oauthToken: String, achievementId: String, steps: Int) = requestGamesInfo(mContext, + Method.POST, + oauthToken, + "https://games.googleapis.com/games/v1/achievements/$achievementId/setStepsAtLeast", + HashMap().apply { + put("steps", steps.toString()) + }).toIncrementResponse() + + /** + * Unlocks this achievement for the currently authenticated player. + */ + suspend fun unlockAchievement(mContext: Context, oauthToken: String, achievementId: String) = requestGamesInfo( + mContext, + Method.POST, + oauthToken, + "https://games.googleapis.com/games/v1/achievements/$achievementId/unlock", + null + ).toUnlockResponse() + + /** + * Updates multiple achievements for the currently authenticated player. + */ + suspend fun updateMultipleAchievement(mContext: Context, oauthToken: String, list: ArrayList) = requestGamesInfo( + mContext, + Method.POST, + oauthToken, + "https://games.googleapis.com/games/v1/achievements/updateMultiple", + null, + list.toUpdateMultipleAchievements() + ).toUpdateMultipleResponse() + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementsDataClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementsDataClient.kt new file mode 100644 index 0000000000..f8f482ee10 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/achievements/AchievementsDataClient.kt @@ -0,0 +1,195 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.achievements + +import android.accounts.Account +import android.annotation.SuppressLint +import android.content.ContentValues +import android.content.Context +import android.util.Log +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.gms.common.data.DataHolder +import com.google.android.gms.games.Games +import com.google.android.gms.games.achievement.Achievement +import com.google.android.gms.games.achievement.AchievementBuffer +import com.google.android.gms.games.achievement.AchievementColumns.DB_FIELD_LAST_UPDATED_TIMESTAMP +import com.google.android.gms.games.achievement.AchievementColumns.DB_FIELD_STATE +import com.google.android.gms.games.achievement.AchievementColumns.DB_FIELD_TYPE +import com.google.android.gms.games.achievement.AchievementEntity +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean + +class AchievementsDataClient(val context: Context) { + + private val database by lazy { AchievementsDatabase(context) } + private val achievementsDataLoaded = AtomicBoolean(false) + private val activeMutexLock = Mutex() + + suspend fun loadAchievementsData(packageName: String, account: Account, forceReload: Boolean): DataHolder? { + val deferred = activeMutexLock.withLock { CompletableDeferred() } + if (achievementsDataLoaded.compareAndSet(false, true)) { + withContext(Dispatchers.IO) { + val oauthToken = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + Log.d(TAG, "loadAchievementsData executing by $packageName") + val startTime = System.currentTimeMillis() + if (forceReload) { + database.removeAchievements(packageName) + } + + val allAchievements = database.getAchievementsData(packageName) ?: ArrayList() + + Log.d(TAG, "loadAchievementsData: allAchievements " + allAchievements.size) + + val playerAchievements = ArrayList() + var playerPageToken: String? = null + do { + val response = + AchievementsApiClient.requestPlayerAllAchievements(context, oauthToken, playerPageToken) + playerAchievements.addAll(response.items) + playerPageToken = response.nextPageToken + } while (!playerPageToken.isNullOrEmpty()) + + Log.d(TAG, "loadAchievementsData: playerAchievements " + playerAchievements.size) + + if (allAchievements.size != playerAchievements.size) { + database.removeAchievements(packageName) + allAchievements.clear() + var pageToken: String? = null + do { + val response = AchievementsApiClient.requestGameAllAchievements(context, oauthToken, pageToken) + response.items.forEach { allAchievements.add(it.toAchievementEntity()) } + pageToken = response.nextPageToken + } while (!pageToken.isNullOrEmpty()) + } + + if (playerAchievements.isNotEmpty()) { + for (playerAchievement in playerAchievements) { + allAchievements.find { it.achievementId == playerAchievement.id }?.apply { + currentSteps = playerAchievement.currentSteps + state = getAchievementState(playerAchievement.achievementState) + formattedCurrentSteps = + if (playerAchievement.formattedCurrentStepsString.isNullOrEmpty()) formattedCurrentSteps else playerAchievement.formattedCurrentStepsString + lastUpdatedTimestamp = + if (playerAchievement.lastUpdatedTimestamp.isNullOrEmpty()) lastUpdatedTimestamp else playerAchievement.lastUpdatedTimestamp.toLong() + xpValue = + if (playerAchievement.experiencePoints.isNullOrEmpty()) xpValue else playerAchievement.experiencePoints.toLong() + } + } + } + + database.insertAchievements(allAchievements, packageName) + Log.d(TAG, "loadAchievementsData: " + allAchievements.size) + deferred.complete(database.getAchievementsDataHolder(packageName)) + achievementsDataLoaded.set(false) + Log.d(TAG, "loadAchievementsData end cost: ${System.currentTimeMillis() - startTime}") + } + } else { + Log.d(TAG, "loadAchievementsData has already been executed") + return null + } + return deferred.await() + } + + suspend fun setAchievementSteps(account: Account, packageName: String, achievementId: String, numStep: Int) = + withContext(Dispatchers.IO) { + if (numStep < 1 || achievementsDataLoaded.get()) { + Log.d( + TAG, + "setAchievementSteps: The steps field has an invalid value (0). The allowed range is between 1 and 2147483647." + ) + return@withContext -1 + } + database.getAchievementsDataHolder(packageName) ?: return@withContext -1 + + val oauthToken = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + val response = AchievementsApiClient.setStepsAtLeast(context, oauthToken, achievementId, numStep) + Log.d(TAG, "setAchievementSteps: setStepsAtLeast: $response") + val contentValues = ContentValues() + if (response.newlyUnlocked) { + contentValues.put(DB_FIELD_STATE, Achievement.AchievementState.STATE_UNLOCKED) + } + contentValues.put(DB_FIELD_TYPE, if (response.currentSteps == 0) numStep else response.currentSteps) + contentValues.put(DB_FIELD_LAST_UPDATED_TIMESTAMP, System.currentTimeMillis()) + database.updateAchievementData(packageName, achievementId, contentValues) + } + + suspend fun revealAchievement(account: Account, packageName: String, achievementId: String) = + withContext(Dispatchers.IO) { + database.getAchievementsDataHolder(packageName) ?: return@withContext -1 + val oauthToken = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + val response = AchievementsApiClient.revealAchievement(context, oauthToken, achievementId) + Log.d(TAG, "revealAchievement: $response") + val contentValues = ContentValues() + contentValues.put(DB_FIELD_STATE, getAchievementState(response.currentState)) + contentValues.put(DB_FIELD_LAST_UPDATED_TIMESTAMP, System.currentTimeMillis()) + database.updateAchievementData(packageName, achievementId, contentValues) + } + + suspend fun unlockAchievement(account: Account, packageName: String, achievementId: String) = + withContext(Dispatchers.IO) { + database.getAchievementsDataHolder(packageName) ?: return@withContext -1 + val oauthToken = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + val response = AchievementsApiClient.unlockAchievement(context, oauthToken, achievementId) + Log.d(TAG, "unlockAchievement: $response") + val contentValues = ContentValues() + if (response.newlyUnlocked) { + contentValues.put(DB_FIELD_STATE, Achievement.AchievementState.STATE_UNLOCKED) + } + contentValues.put(DB_FIELD_LAST_UPDATED_TIMESTAMP, System.currentTimeMillis()) + database.updateAchievementData(packageName, achievementId, contentValues) + } + + suspend fun incrementAchievement(account: Account, packageName: String, achievementId: String, numStep: Int) = + withContext(Dispatchers.IO) { + database.getAchievementsDataHolder(packageName) ?: return@withContext -1 + val oauthToken = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + val response = AchievementsApiClient.incrementAchievement(context, oauthToken, achievementId, numStep) + Log.d(TAG, "incrementAchievement: $response") + val contentValues = ContentValues() + if (response.newlyUnlocked) { + contentValues.put(DB_FIELD_STATE, Achievement.AchievementState.STATE_UNLOCKED) + } + contentValues.put(DB_FIELD_TYPE, if (response.currentSteps == 0) numStep else response.currentSteps) + contentValues.put(DB_FIELD_LAST_UPDATED_TIMESTAMP, System.currentTimeMillis()) + database.updateAchievementData(packageName, achievementId, contentValues) + } + + suspend fun loadAchievements(packageName: String, account: Account, forceReload: Boolean): ArrayList { + val allAchievements = database.getAchievementsData(packageName) ?: arrayListOf() + if (allAchievements.isEmpty()) { + achievementsDataLoaded.set(false) + val dataHolder = loadAchievementsData(packageName, account, forceReload) + if (dataHolder != null) { + val achievementBuffer = AchievementBuffer(dataHolder) + for (position in 0..(packageName)) + } + + @Synchronized + fun getAchievementsDataHolder(packageName: String): DataHolder? { + Log.d(TAG, "getAchievementsDataHolder packageName: $packageName") + writableDatabase.query(DB_TABLE_ACHIEVEMENTS, null, "$DB_FIELD_GAME_PACKAGE_NAME LIKE ?", arrayOf(packageName), null, null, "$DB_FIELD_LAST_UPDATED_TIMESTAMP ASC", null)?.use { cursor -> + return DataHolder(cursor, 0, null) + } + return null + } + + @Synchronized + fun getAchievementsData(packageName: String): ArrayList? { + Log.d(TAG, "getAchievementsData packageName: $packageName") + val achievementsDataHolder = getAchievementsDataHolder(packageName) + if (achievementsDataHolder != null) { + val data = ArrayList() + val achievementBuffer = AchievementBuffer(achievementsDataHolder) + for (position in 0.. + val dataHolder = DataHolder(cursor, 0, null) + return AchievementBuffer(dataHolder).get(0).freeze() + } + return null + } + + @Synchronized + fun updateAchievementData(packageName: String, achievementId: String, contentValues: ContentValues): Int { + return writableDatabase.update(DB_TABLE_ACHIEVEMENTS, contentValues, "$DB_FIELD_GAME_PACKAGE_NAME LIKE ? AND $DB_FIELD_EXTERNAL_ACHIEVEMENT_ID LIKE ?", arrayOf(packageName, achievementId)) + } + + @Synchronized + fun insertAchievements(achievementList: ArrayList, packageName: String?) { + Log.d(TAG, "insertAchievements packageName: $packageName achievements: ${achievementList.size}") + if (achievementList.isEmpty()) { + return + } + runCatching { + for (entity in achievementList) { + val cv = ContentValues() + cv.put(DB_FIELD_GAME_PACKAGE_NAME, packageName) + cv.put(DB_FIELD_EXTERNAL_ACHIEVEMENT_ID, entity.achievementId) + cv.put(DB_FIELD_EXTERNAL_GAME_ID, "") + cv.put(DB_FIELD_TYPE, entity.type) + cv.put(DB_FIELD_NAME, entity.name) + cv.put(DB_FIELD_DESCRIPTION, entity.description) + cv.put(DB_FIELD_UNLOCKED_ICON_IMAGE_URI, entity.unlockedImageUri.toString()) + cv.put(DB_FIELD_UNLOCKED_ICON_IMAGE_URL, entity.unlockedImageUrl) + cv.put(DB_FIELD_REVEALED_ICON_IMAGE_URI, entity.revealedImageUri.toString()) + cv.put(DB_FIELD_REVEALED_ICON_IMAGE_URL, entity.revealedImageUrl) + cv.put(DB_FIELD_TOTAL_STEPS, entity.totalSteps) + cv.put(DB_FIELD_FORMATTED_TOTAL_STEPS, entity.formattedTotalSteps) + cv.put(DB_FIELD_EXTERNAL_PLAYER_ID, "") + cv.put(DB_FIELD_STATE, entity.state) + cv.put(DB_FIELD_CURRENT_STEPS, 0) + cv.put(DB_FIELD_FORMATTED_CURRENT_STEPS, "0") + cv.put(DB_FIELD_INSTANCE_XP_VALUE, entity.xpValue) + cv.put(DB_FIELD_LAST_UPDATED_TIMESTAMP, entity.lastUpdatedTimestamp) + writableDatabase.insertWithOnConflict(DB_TABLE_ACHIEVEMENTS, null, cv, SQLiteDatabase.CONFLICT_REPLACE) + } + close() + }.onFailure { + Log.d(TAG, "insertAchievements error: ${it.localizedMessage}") + } + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { +// throw IllegalStateException("Upgrades not supported") + } + +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/extensions.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/extensions.kt index 32e581f495..444050775b 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/games/extensions.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/extensions.kt @@ -9,15 +9,28 @@ import android.accounts.Account import android.content.ContentValues import android.content.Context import android.database.Cursor +import android.net.Uri +import android.util.Log import androidx.core.content.contentValuesOf import androidx.core.net.toUri -import com.android.volley.* +import com.android.volley.NetworkResponse +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response import com.android.volley.Response.success +import com.android.volley.VolleyError import com.android.volley.toolbox.JsonObjectRequest import com.android.volley.toolbox.Volley import com.google.android.gms.common.Scopes import com.google.android.gms.common.api.Scope -import com.google.android.gms.games.* +import com.google.android.gms.games.CurrentPlayerInfoEntity +import com.google.android.gms.games.Games +import com.google.android.gms.games.Player +import com.google.android.gms.games.PlayerColumns +import com.google.android.gms.games.PlayerEntity +import com.google.android.gms.games.PlayerLevel +import com.google.android.gms.games.PlayerLevelInfo +import com.google.android.gms.games.PlayerRelationshipInfoEntity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -31,10 +44,11 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine - const val ACTION_START_1P = "com.google.android.play.games.service.START_1P" const val ACTION_VIEW_LEADERBOARDS = "com.google.android.gms.games.VIEW_LEADERBOARDS" +const val ACTION_VIEW_LEADERBOARDS_SCORES = "com.google.android.gms.games.VIEW_LEADERBOARD_SCORES" const val ACTION_VIEW_ACHIEVEMENTS = "com.google.android.gms.games.VIEW_ACHIEVEMENTS" +const val ACTION_VIEW_SNAPSHOTS = "com.google.android.gms.games.SHOW_SELECT_SNAPSHOT" const val ACTION_PLAYER_SEARCH = "com.google.android.gms.games.PLAYER_SEARCH" const val ACTION_VIEW_PROFILE = "com.google.android.gms.games.VIEW_PROFILE" const val ACTION_ADD_FRIEND = "com.google.android.gms.games.ADD_FRIEND" @@ -50,6 +64,17 @@ const val EXTRA_POPUP_GRAVITY = "com.google.android.gms.games.key.connectingPopu const val EXTRA_SELF_IN_GAME_NAME = "com.google.android.gms.games.EXTRA_SELF_IN_GAME_NAME" const val EXTRA_OTHER_PLAYER_IN_GAME_NAME = "com.google.android.gms.games.EXTRA_OTHER_PLAYER_IN_GAME_NAME" +const val EXTRA_MAX_SNAPSHOTS = "com.google.android.gms.games.MAX_SNAPSHOTS" +const val EXTRA_ALLOW_CREATE_SNAPSHOT = "com.google.android.gms.games.ALLOW_CREATE_SNAPSHOT" +const val EXTRA_TITLE = "com.google.android.gms.games.TITLE" +const val EXTRA_ALLOW_DELETE_SNAPSHOT = "com.google.android.gms.games.ALLOW_DELETE_SNAPSHOT" +const val EXTRA_SNAPSHOT_NEW = "com.google.android.gms.games.SNAPSHOT_NEW" + +const val EXTRA_LEADERBOARD_ID = "com.google.android.gms.games.LEADERBOARD_ID" +const val EXTRA_LEADERBOARD_TIME_SPAN = "com.google.android.gms.games.LEADERBOARD_TIME_SPAN" +const val EXTRA_LEADERBOARD_COLLECTION = "com.google.android.gms.games.LEADERBOARD_COLLECTION" + +const val EXTRA_ACCOUNT_KEY = "com.google.android.gms.games.ACCOUNT_KEY" const val GAMES_PACKAGE_NAME = "com.google.android.play.games" val List.realScopes @@ -272,4 +297,33 @@ suspend fun performGamesSignIn( } } return true +} + +suspend fun requestGamesInfo( + context: Context, + method: Int, + oauthToken: String, + url: String, + params: HashMap?, + requestBody: JSONObject? = null, + queue: RequestQueue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) } +): JSONObject = suspendCoroutine { continuation -> + Log.d(Games.TAG, "request: oauthToken: $oauthToken queryParams:$params") + val uriBuilder = Uri.parse(url).buildUpon().apply { + if (!params.isNullOrEmpty()) { + for (key in params.keys) { + appendQueryParameter(key, params[key]) + } + } + } + queue.add(object : JsonObjectRequest(method, uriBuilder.build().toString(), requestBody, { + continuation.resume(it) + }, { + Log.d(Games.TAG, "Error: ${it.networkResponse?.data?.decodeToString() ?: it.message}") + continuation.resumeWithException(RuntimeException(it)) + }) { + override fun getHeaders(): Map = hashMapOf().apply { + put("Authorization", "OAuth $oauthToken") + } + }) } \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardResponseKt.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardResponseKt.kt new file mode 100644 index 0000000000..4e35240a7c --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardResponseKt.kt @@ -0,0 +1,331 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.leaderboards + +import com.google.android.gms.games.PlayerEntity +import com.google.android.gms.games.leaderboard.Leaderboard +import org.json.JSONArray +import org.json.JSONObject +import org.microg.gms.games.toPlayer + +data class LeaderboardListResponse( + val items: List, val kind: String?, val nextPageToken: String? +) { + override fun toString(): String { + return "LeaderboardListResponse(items=$items, kind='$kind', nextPageToken='$nextPageToken')" + } +} + +data class LeaderboardDefinition( + val kind: String?, + val id: String?, + val name: String?, + val iconUrl: String?, + val isIconUrlDefault: Boolean, + val order: String? +) { + override fun toString(): String { + return "LeaderboardDefinition(kind=$kind, id=$id, name=$name, iconUrl=$iconUrl, isIconUrlDefault=$isIconUrlDefault, order=$order)" + } +} + +data class GetLeaderboardScoresResponse( + val kind: String?, val nextPageToken: String?, val player: PlayerEntity?, val items: List? +) { + override fun toString(): String { + return "GetLeaderboardScoresResponse(kind=$kind, nextPageToken=$nextPageToken, player=$player, items=$items)" + } +} + +data class LeaderboardScore( + val kind: String?, + val leaderboardId: String?, + val scoreValue: String?, + val scoreString: String?, + val publicRank: LeaderboardScoreRank?, + val socialRank: LeaderboardScoreRank?, + val friendsRank: LeaderboardScoreRank?, + val timeSpan: String, + val writeTimestamp: String, + val scoreTag: String +) { + override fun toString(): String { + return "LeaderboardScore(kind=$kind, leaderboardId=$leaderboardId, scoreValue=$scoreValue, scoreString=$scoreString, publicRank=$publicRank, socialRank=$socialRank, friendsRank=$friendsRank, timeSpan=$timeSpan, writeTimestamp='$writeTimestamp', scoreTag='$scoreTag')" + } +} + +data class LeaderboardScoreRank( + val kind: String?, + val rank: String?, + val formattedRank: String?, + val numScores: String?, + val formattedNumScores: String?, +) { + override fun toString(): String { + return "LeaderboardScoreRank(kind=$kind, rank=$rank, formattedRank=$formattedRank, numScores=$numScores, formattedNumScores=$formattedNumScores)" + } +} + +data class ListLeaderboardScoresResponse( + val kind: String?, + val nextPageToken: String?, + val prevPageToken: String?, + val numScores: String?, + val playerScore: LeaderboardEntry?, + val items: List?, +) { + override fun toString(): String { + return "ListLeaderboardScoresResponse(kind=$kind, nextPageToken=$nextPageToken, prevPageToken=$prevPageToken, numScores=$numScores, playerScore=$playerScore, items=$items)" + } +} + +data class LeaderboardEntry( + val kind: String?, + val player: PlayerEntity?, + val scoreRank: String?, + val formattedScoreRank: String?, + val scoreValue: String?, + val formattedScore: String?, + val timeSpan: String?, + val writeTimestampMillis: String?, + val scoreTag: String?, +) { + constructor(leaderboardTitle: String?, leaderboardLogoUrl: String?) : this( + null, null, null, null, leaderboardTitle, null, null, null, leaderboardLogoUrl + ) + + override fun toString(): String { + return "LeaderboardEntry(kind=$kind, player=$player, scoreRank=$scoreRank, formattedScoreRank=$formattedScoreRank, scoreValue=$scoreValue, formattedScore=$formattedScore, timeSpan=$timeSpan, writeTimestampMillis=$writeTimestampMillis, scoreTag=$scoreTag)" + } +} + +data class SubmitLeaderboardScoreListResponse( + val kind: String?, + val submittedScores: List?, +) { + override fun toString(): String { + return "SubmitLeaderboardScoreListResponse(kind=$kind, submittedScores=$submittedScores)" + } +} + +data class SubmitLeaderboardScoreResponse( + val kind: String?, + val beatenScoreTimeSpans: List?, + val unbeatenScores: List?, + val formattedScore: String?, + val leaderboardId: String?, + val scoreTag: String?, +) { + override fun toString(): String { + return "SubmitLeaderboardScoreResponse(kind=$kind, beatenScoreTimeSpans=$beatenScoreTimeSpans, unbeatenScores=$unbeatenScores, formattedScore=$formattedScore, leaderboardId=$leaderboardId, scoreTag=$scoreTag)" + } +} + +data class PlayerScore( + val kind: String?, val timeSpan: String?, val score: String?, val formattedScore: String?, val scoreTag: String? +) { + override fun toString(): String { + return "PlayerScore(kind=$kind, timeSpan=$timeSpan, score=$score, formattedScore=$formattedScore, scoreTag=$scoreTag)" + } +} + + +data class PlayerScoreSubmissionList( + val kind: String?, val scores: List +) { + override fun toString(): String { + return "PlayerScoreSubmissionList(kind=$kind, scores=$scores)" + } +} + +data class ScoreSubmission( + val kind: String?, + val leaderboardId: String?, + val score: String?, + val scoreTag: String?, + val signature: String?, +) { + override fun toString(): String { + return "ScoreSubmission(kind=$kind, leaderboardId=$leaderboardId, score=$score, scoreTag=$scoreTag, signature=$signature)" + } +} + +fun PlayerScoreSubmissionList.toJSONObject() = JSONObject().apply { + put("kind", kind) + put("scores", JSONArray().apply { + for (score in scores) { + put(score.toJSONObject()) + } + }) +} + +fun ScoreSubmission.toJSONObject() = JSONObject().apply { + put("kind", kind) + put("leaderboardId", leaderboardId) + put("score", score) + put("scoreTag", scoreTag) + put("signature", signature) +} + +fun JSONObject.toSubmitLeaderboardScoreListResponse() = SubmitLeaderboardScoreListResponse( + optString("kind"), + optJSONArray("submittedScores")?.toSubmitLeaderboardScoreResponseList(), +) + +fun JSONArray.toSubmitLeaderboardScoreResponseList(): List { + val list = arrayListOf() + for (i in 0.. { + val list = arrayListOf() + for (i in 0.. { + val list = arrayListOf() + for (i in 0.. { + val list = arrayListOf() + for (i in 0.. { + val list = arrayListOf() + for (i in 0..() + if (items != null) { + for (i in 0.., +) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LeaderboardScoresHolder { + val view = if(viewType == -1){ + LayoutInflater.from(mContext).inflate(R.layout.item_leaderboard_score_header_layout, parent, false) + } else{ + LayoutInflater.from(mContext).inflate(R.layout.item_leaderboard_score_data_layout, parent, false) + } + return LeaderboardScoresHolder(view, viewType) + } + + override fun getItemCount(): Int { + return leaderboards.size + } + + override fun getItemViewType(position: Int): Int { + val leaderboardEntry = leaderboards[position] + return if(leaderboardEntry.kind == null) -1 else 0 + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: LeaderboardScoresHolder, position: Int) { + val leaderboardEntry = leaderboards[position] + if (leaderboardEntry.kind == null) { + val scoreTag = leaderboardEntry.scoreTag + if (scoreTag != null) { + ImageManager.create(mContext).loadImage(scoreTag, holder.leaderboardLogo) + } else { + holder.leaderboardLogo?.setImageResource(R.drawable.ic_leaderboard_placeholder) + } + holder.leaderboardTitle?.text = leaderboardEntry.scoreValue + return + } + val player = leaderboardEntry.player + val iconUrl = leaderboardEntry.player?.iconImageUrl + if (iconUrl != null) { + ImageManager.create(mContext).loadImage(iconUrl, holder.leaderboardPlayerLogo) + } else { + holder.leaderboardPlayerLogo?.setImageResource(R.drawable.ic_leaderboard_placeholder) + } + holder.leaderboardPlayerName?.text = player?.displayName + holder.leaderboardPlayerScore?.text = mContext.getString(R.string.games_leaderboards_score_label, leaderboardEntry.formattedScore) + holder.leaderboardPlayerRank?.text = leaderboardEntry.formattedScoreRank + if (position == leaderboards.size - 1) { + holder.leaderboardScoreLine?.visibility = View.INVISIBLE + } + } +} + +class LeaderboardScoresHolder(itemView: View, viewType: Int) : RecyclerView.ViewHolder(itemView) { + + var leaderboardPlayerLogo: ImageView? = null + var leaderboardPlayerName: TextView? = null + var leaderboardPlayerScore: TextView? = null + var leaderboardPlayerRank: TextView? = null + var leaderboardScoreLine: View? = null + + var leaderboardTitle: TextView? = null + var leaderboardLogo: ImageView? = null + + init { + if (viewType == -1) { + leaderboardTitle = itemView.findViewById(R.id.leaderboard_header_title) + leaderboardLogo = itemView.findViewById(R.id.leaderboard_header_logo) + } else{ + leaderboardPlayerLogo = itemView.findViewById(R.id.leaderboard_player_logo) + leaderboardPlayerName = itemView.findViewById(R.id.leaderboard_player_name) + leaderboardPlayerScore = itemView.findViewById(R.id.leaderboard_player_score) + leaderboardPlayerRank = itemView.findViewById(R.id.leaderboard_player_rank) + leaderboardScoreLine = itemView.findViewById(R.id.leaderboard_score_line) + } + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsAdapter.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsAdapter.kt new file mode 100644 index 0000000000..7f9e996fa5 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsAdapter.kt @@ -0,0 +1,64 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.leaderboards + +import android.annotation.SuppressLint +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.google.android.gms.R +import com.google.android.gms.common.images.ImageManager + +class LeaderboardsAdapter( + private val mContext: Context, + private val leaderboards: List, + private val dealClick: (LeaderboardDefinition) -> Unit +) : + RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LeaderboardHolder { + val view = LayoutInflater.from(mContext).inflate(R.layout.item_leaderboard_data_layout, parent, false) + return LeaderboardHolder(view) + } + + override fun getItemCount(): Int { + return leaderboards.size + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: LeaderboardHolder, position: Int) { + val leaderboardDefinition = leaderboards[position] + val iconUrl = leaderboardDefinition.iconUrl + if (iconUrl != null) { + ImageManager.create(mContext).loadImage(iconUrl, holder.leaderboardLogo) + } else { + holder.leaderboardLogo?.setImageResource(R.drawable.ic_leaderboard_placeholder) + } + holder.leaderboardTitle?.text = leaderboardDefinition.name + if (position == leaderboards.size - 1) { + holder.leaderboardLine?.visibility = View.INVISIBLE + } + holder.itemView.setOnClickListener { dealClick(leaderboardDefinition) } + } +} + +class LeaderboardHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + var leaderboardLogo: ImageView? = null + var leaderboardTitle: TextView? = null + var leaderboardLine: View? = null + + init { + leaderboardLogo = itemView.findViewById(R.id.leaderboard_logo) + leaderboardTitle = itemView.findViewById(R.id.leaderboard_title) + leaderboardLine = itemView.findViewById(R.id.leaderboard_line) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsApiClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsApiClient.kt new file mode 100644 index 0000000000..31e84094da --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsApiClient.kt @@ -0,0 +1,138 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.leaderboards + +import android.content.Context +import com.android.volley.Request +import org.microg.gms.games.requestGamesInfo +import java.util.Locale +import java.util.UUID + +/** + * https://developers.google.com/games/services/web/api/rest#rest-resource:-leaderboards + * https://developers.google.com/games/services/web/api/rest#rest-resource:-scores + */ +object LeaderboardsApiClient { + + /** + * Lists all the leaderboard metadata for your application. + */ + suspend fun requestAllLeaderboards(mContext: Context, oauthToken: String, pageToken: String? = null) = + requestGamesInfo(mContext, + Request.Method.GET, + oauthToken, + "https://games.googleapis.com/games/v1/leaderboards", + HashMap().apply { + put("language", Locale.getDefault().language) + if (pageToken != null) { + put("pageToken", pageToken) + } + }).toLeaderboardListResponse() + + /** + * Retrieves the metadata of the leaderboard with the given ID. + */ + suspend fun getLeaderboardById(mContext: Context, oauthToken: String, leaderboardId: String) = requestGamesInfo( + mContext, + Request.Method.GET, + oauthToken, + "https://games.googleapis.com/games/v1/leaderboards/${leaderboardId}", + HashMap().apply { + put("language", Locale.getDefault().language) + }).toLeaderboardResponse() + + /** + * Get high scores, and optionally ranks, in leaderboards for the currently authenticated player. + * For a specific time span, leaderboardId can be set to ALL to retrieve data for all leaderboards in a given time span. + * `NOTE: You cannot ask for 'ALL' leaderboards and 'ALL' timeSpans in the same request; only one parameter may be set to 'ALL'. + */ + suspend fun getLeaderboardScoresById( + mContext: Context, oauthToken: String, leaderboardId: String, timeSpan: ScoreTimeSpan, pageToken: String? = null + ) = requestGamesInfo(mContext, + Request.Method.GET, + oauthToken, + "https://games.googleapis.com/games/v1/players/me/leaderboards/$leaderboardId/scores/$timeSpan", + HashMap().apply { + put("language", Locale.getDefault().language) + put("includeRankType", IncludeRankType.PUBLIC.toString()) + if (pageToken != null) { + put("pageToken", pageToken) + } + }).toGetLeaderboardScoresResponse() + + /** + * Lists the scores in a leaderboard, starting from the top. + */ + suspend fun requestLeaderboardScoresById( + mContext: Context, + oauthToken: String, + leaderboardId: String, + pageToken: String? = null + ) = requestGamesInfo(mContext, + Request.Method.GET, + oauthToken, + "https://games.googleapis.com/games/v1/leaderboards/$leaderboardId/scores/${IncludeRankType.PUBLIC}", + HashMap().apply { + put("language", Locale.getDefault().language) + put("timeSpan", ScoreTimeSpan.ALL_TIME.toString()) + if (pageToken != null) { + put("pageToken", pageToken) + } + }).toListLeaderboardScoresResponse() + + /** + * Lists the scores in a leaderboard around (and including) a player's score. + */ + suspend fun requestLeaderboardScoresListWindowById( + mContext: Context, + oauthToken: String, + leaderboardId: String, + collection: IncludeRankType, + timeSpan: ScoreTimeSpan, + pageToken: String? = null + ) = requestGamesInfo(mContext, + Request.Method.GET, + oauthToken, + "https://games.googleapis.com/games/v1/leaderboards/$leaderboardId/window/$collection", + HashMap().apply { + put("language", Locale.getDefault().language) + put("timeSpan", timeSpan.toString()) + put("returnTopIfAbsent", "true") + if (pageToken != null) { + put("pageToken", pageToken) + } + }).toListLeaderboardScoresResponse() + + /** + * Submits a score to the specified leaderboard. + */ + suspend fun submitLeaderboardScores(mContext: Context, oauthToken: String, leaderboardId: String, score: String) = + requestGamesInfo(mContext, + Request.Method.POST, + oauthToken, + "https://games.googleapis.com/games/v1/leaderboards/$leaderboardId/scores", + HashMap().apply { + put("language", Locale.getDefault().language) + put("score", score) + put("scoreTag", UUID.fromString(leaderboardId + score).toString()) + }).toSubmitLeaderboardScoreResponse() + + /** + * Submits multiple scores to leaderboards. + */ + suspend fun submitMultipleLeaderboardScores( + mContext: Context, oauthToken: String, list: PlayerScoreSubmissionList + ) = requestGamesInfo( + mContext, + Request.Method.POST, + oauthToken, + "https://games.googleapis.com/games/v1/leaderboards/scores", + HashMap().apply { + put("language", Locale.getDefault().language) + }, + list.toJSONObject() + ).toSubmitLeaderboardScoreListResponse() +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsDataClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsDataClient.kt new file mode 100644 index 0000000000..8f5ca116b0 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/leaderboards/LeaderboardsDataClient.kt @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.leaderboards + +import android.accounts.Account +import android.annotation.SuppressLint +import android.content.Context +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.gms.games.Games +import com.google.android.gms.games.leaderboard.Leaderboard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class LeaderboardsDataClient(val context: Context) { + + suspend fun loadLeaderboards(packageName: String, account: Account) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + var playerPageToken: String? = null + val leaderboards = arrayListOf() + do { + val response = runCatching { + LeaderboardsApiClient.requestAllLeaderboards( + context, token, playerPageToken + ) + }.getOrNull() + response?.items?.let { leaderboards.addAll(it) } + playerPageToken = response?.nextPageToken + } while (!playerPageToken.isNullOrEmpty()) + return@withContext leaderboards + } + + suspend fun getLeaderboardById(packageName: String, account: Account, leaderboardId:String) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + return@withContext runCatching { LeaderboardsApiClient.getLeaderboardById(context, token, leaderboardId) }.getOrNull() + } + + suspend fun getLeaderboardScoresById( + leaderboardId: String, packageName: String, account: Account + ) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + var playerPageToken: String? = null + val leaderboardScores = arrayListOf() + do { + val response = runCatching { + LeaderboardsApiClient.requestLeaderboardScoresById( + context, token, leaderboardId, playerPageToken + ) + }.getOrNull() + response?.items?.let { leaderboardScores.addAll(it) } + playerPageToken = response?.nextPageToken + } while (!playerPageToken.isNullOrEmpty() && leaderboardScores.size < 60) + return@withContext leaderboardScores + } + + companion object { + @SuppressLint("StaticFieldLeak") + @Volatile + private var instance: LeaderboardsDataClient? = null + fun get(context: Context): LeaderboardsDataClient = instance ?: synchronized(this) { + instance ?: LeaderboardsDataClient(context.applicationContext).also { instance = it } + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotResponseKt.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotResponseKt.kt new file mode 100644 index 0000000000..623bbeb4d6 --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotResponseKt.kt @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.snapshot + +import org.json.JSONArray +import org.json.JSONObject + +data class SnapshotsResponse( + val kind: String?, val nextPageToken: String?, val items: List? +) { + override fun toString(): String { + return "SnapshotsResponse(kind=$kind, nextPageToken=$nextPageToken, items=$items)" + } +} + +data class Snapshot( + val id: String?, + val driveId: String?, + val kind: String?, + val type: String?, + val title: String?, + val description: String?, + val lastModifiedMillis: String?, + val durationMillis: String?, + val coverImage: SnapshotImage?, + val uniqueName: String?, + val progressValue: String?, +) { + + constructor( + id: String?, + title: String?, + description: String?, + lastModifiedMillis: String?, + coverImage: SnapshotImage? + ) : this( + id, null, null, null, title, description, lastModifiedMillis, null, coverImage, null, null + ) + + override fun toString(): String { + return "Snapshot(id=$id, driveId=$driveId, kind=$kind, type=$type, title=$title, description=$description, lastModifiedMillis=$lastModifiedMillis, durationMillis=$durationMillis, coverImage=$coverImage, uniqueName=$uniqueName, progressValue=$progressValue)" + } +} + +data class SnapshotImage( + val width: Int?, + val height: Int?, + val mimeType: String?, + val url: String?, + val kind: String?, +) { + override fun toString(): String { + return "SnapshotImage(width=$width, height=$height, mimeType='$mimeType', url='$url', kind='$kind')" + } +} + +fun JSONObject.toSnapshotsResponse() = SnapshotsResponse( + optString("kind"), + optString("nextPageToken"), + optJSONArray("items")?.toSnapshot() +) + +fun JSONArray.toSnapshot(): List { + val snapshots = arrayListOf() + for (i in 0.., + private val dealClick: (Snapshot, Int) -> Unit +) : + RecyclerView.Adapter() { + + private val snapshotDataList = arrayListOf() + private val allowDelete = callIntent.getBooleanExtra(EXTRA_ALLOW_DELETE_SNAPSHOT, false) + + init { + snapshotDataList.addAll(snapshots) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SnapshotHolder { + val view = LayoutInflater.from(mContext).inflate(R.layout.item_snapshot_data_layout, parent, false) + return SnapshotHolder(view) + } + + override fun getItemCount(): Int { + return snapshotDataList.size + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: SnapshotHolder, position: Int) { + val snapshot = snapshotDataList[position] + val imageUrl = snapshot.coverImage?.url + if (imageUrl != null) { + ImageManager.create(mContext).loadImage(imageUrl, holder.snapshotImage) + } else { + holder.snapshotImage?.setImageResource(R.drawable.ic_snapshot_load_error_image) + } + holder.snapshotTime?.text = snapshot.lastModifiedMillis?.let { + var timestamp = it.toLong() + //Check if the timestamp is greater than 10-digit threshold, indicating milliseconds + timestamp = if (timestamp > 10000000000L) timestamp else timestamp * 1000 + SimpleDateFormat("yyyy/MM/dd HH:mm").format(Date(timestamp)) + } + holder.snapshotDesc?.text = snapshot.description + if (allowDelete) { + holder.snapshotDeleteBtn?.visibility = View.VISIBLE + holder.snapshotDeleteBtn?.setOnClickListener { dealClick(snapshot, 1) } + } else{ + holder.snapshotDeleteBtn?.visibility = View.GONE + } + holder.snapshotChooseBtn?.setOnClickListener { dealClick(snapshot, 0) } + } + + fun update(snapshots: List) { + snapshotDataList.clear() + snapshotDataList.addAll(snapshots) + notifyDataSetChanged() + } + +} + +class SnapshotHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + + var snapshotImage: ImageView? = null + var snapshotTime: TextView? = null + var snapshotDesc: TextView? = null + var snapshotChooseBtn: TextView? = null + var snapshotDeleteBtn: TextView? = null + + init { + snapshotImage = itemView.findViewById(R.id.snapshot_image) + snapshotTime = itemView.findViewById(R.id.snapshot_time) + snapshotDesc = itemView.findViewById(R.id.snapshot_desc) + snapshotChooseBtn = itemView.findViewById(R.id.snapshot_choose_btn) + snapshotDeleteBtn = itemView.findViewById(R.id.snapshot_delete_btn) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotsApiClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotsApiClient.kt new file mode 100644 index 0000000000..02c23e344f --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotsApiClient.kt @@ -0,0 +1,253 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.snapshot + +import android.content.Context +import android.util.Log +import com.android.volley.DefaultRetryPolicy +import com.android.volley.NetworkResponse +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.android.volley.VolleyError +import com.android.volley.toolbox.HttpHeaderParser +import com.google.android.gms.common.BuildConfig +import com.squareup.wire.GrpcClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import org.json.JSONObject +import org.microg.gms.checkin.LastCheckinInfo +import org.microg.gms.games.CommitSnapshotRevisionRequest +import org.microg.gms.games.DeleteSnapshotInfo +import org.microg.gms.games.EmptyResult +import org.microg.gms.games.GetSnapshotRequest +import org.microg.gms.games.SnapshotsExtendedClient +import org.microg.gms.games.GetSnapshotResponse +import org.microg.gms.games.PrepareSnapshotRevisionRequest +import org.microg.gms.games.PrepareSnapshotRevisionResponse +import org.microg.gms.games.ResolveSnapshotHeadRequest +import org.microg.gms.games.ResolveSnapshotHeadResponse +import org.microg.gms.games.requestGamesInfo +import org.microg.gms.games.ukq +import org.microg.gms.profile.Build +import java.util.Locale +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +private const val TAG = "SnapshotsApiClient" + +/** + * https://developers.google.com/games/services/web/api/rest#rest-resource:-snapshots + * + * Google Play Games Services can only support obtaining snapshot lists. + * There is no interface for saving or deleting snapshots. + */ +object SnapshotsApiClient { + + private const val POST_TIMEOUT = 15000 + const val SNAPSHOT_UPLOAD_LINK_DATA = 1 + const val SNAPSHOT_UPLOAD_LINK_IMAGE = 2 + + /** + * Retrieves a list of snapshots created by your application for the player corresponding to the player ID. + */ + suspend fun requestSnapshotsList( + context: Context, + oauthToken: String, + pageToken: String? = null + ) = requestGamesInfo( + context, Request.Method.GET, oauthToken, "https://games.googleapis.com/games/v1/players/me/snapshots", + HashMap().apply { + put("language", Locale.getDefault().language) + if (pageToken != null) { + put("pageToken", pageToken) + } + } + ).toSnapshotsResponse() + + suspend fun prepareSnapshotRevision(context: Context, oauthToken: String, + prepareSnapshotRevisionRequest: PrepareSnapshotRevisionRequest) : PrepareSnapshotRevisionResponse { + val snapshotClient = getGrpcClient(context, oauthToken) + return withContext(Dispatchers.IO) { snapshotClient.PrepareSnapshotRevision().execute(prepareSnapshotRevisionRequest) } + } + + suspend fun uploadDataByUrl(oauthToken: String, url: String, requestQueue: RequestQueue, body: ByteArray): String = suspendCoroutine { continuation -> + requestQueue.add(object : Request(Method.PUT, url, null) { + + override fun deliverResponse(response: String) { + Log.d(TAG, "deliverResponse: $response") + continuation.resume(response) + } + + override fun deliverError(error: VolleyError?) { + error?.let { + continuation.resumeWithException(error) + } + } + + override fun getBody(): ByteArray { + return body + } + + override fun parseNetworkResponse(response: NetworkResponse): Response { + var result = "" + try { + val json = JSONObject(response.data.toString(Charsets.UTF_8)) + result = json.getString("resourceId") + } catch (e: Exception) { + Log.w(TAG, "parseNetworkResponse: ", e) + } + return Response.success(result, HttpHeaderParser.parseCacheHeaders(response)) + } + + override fun getHeaders(): MutableMap { + val headers = HashMap() + headers["authorization"] = "OAuth $oauthToken" + headers["x-goog-upload-command"] = "upload, finalize" + headers["x-goog-upload-protocol"] = "resumable" + headers["Content-Type"] = "application/x-www-form-urlencoded" + headers["x-goog-upload-offset"] = "0" + headers["User-Agent"] = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/${Build.ID};" + return headers + } + }.setRetryPolicy(DefaultRetryPolicy(POST_TIMEOUT, 0, 0.0F))) + } + + suspend fun getDataFromDrive(oauthToken: String, url: String, requestQueue: RequestQueue) : ByteArray = suspendCoroutine { continuation -> + requestQueue.add(object : Request(Method.GET, url, null) { + override fun parseNetworkResponse(response: NetworkResponse): Response { + return Response.success(response.data, HttpHeaderParser.parseCacheHeaders(response)) + } + + override fun deliverResponse(response: ByteArray) { + Log.d(TAG, "deliverResponse: $response") + continuation.resume(response) + } + + override fun deliverError(error: VolleyError?) { + error?.let { + continuation.resumeWithException(error) + } + } + + override fun getHeaders(): MutableMap { + val headers = HashMap() + headers["authorization"] = "OAuth $oauthToken" + headers["User-Agent"] = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/${Build.ID};" + return headers + } + + }.setRetryPolicy(DefaultRetryPolicy(POST_TIMEOUT, 0, 0.0F))) + } + + suspend fun getRealUploadUrl(oauthToken: String, url: String, requestQueue: RequestQueue) : String = suspendCoroutine { continuation -> + requestQueue.add(object : Request(Method.POST, url, null) { + override fun parseNetworkResponse(response: NetworkResponse): Response { + val responseHeaders = response.headers + return Response.success(responseHeaders?.get("X-Goog-Upload-URL"), HttpHeaderParser.parseCacheHeaders(response)) + } + + override fun deliverResponse(response: String?) { + Log.d(TAG, "deliverResponse: $response") + continuation.resume(response?:"") + } + + override fun deliverError(error: VolleyError?) { + error?.let { + continuation.resumeWithException(error) + } + } + + override fun getHeaders(): MutableMap { + val headers = HashMap() + headers["authorization"] = "OAuth $oauthToken" + headers["x-goog-upload-command"] = "start" + headers["x-goog-upload-protocol"] = "resumable" + headers["Content-Type"] = "application/x-www-form-urlencoded" + headers["User-Agent"] = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/${Build.ID};" + return headers + } + + }.setRetryPolicy(DefaultRetryPolicy(POST_TIMEOUT, 0, 0.0F))) + } + + /** + * Get the request content by capturing the packet. + * Currently only supports getting list data. + */ + /** + * Get the request content by capturing the packet. + * Currently only supports getting list data. + */ + suspend fun requestSnapshotList(context: Context, oauthToken: String): GetSnapshotResponse { + val snapshotClient = getGrpcClient(context, oauthToken) + val snapshotRequestBody = GetSnapshotRequest.Builder().apply { + unknownFileIntList3 = listOf(2, 3, 1) + unknownFileInt4 = 25 + unknownFileInt6 = 3 + }.build() + return withContext(Dispatchers.IO) { snapshotClient.SyncSnapshots().execute(snapshotRequestBody) } + } + + suspend fun deleteSnapshot(context: Context, oauthToken: String, snapshot: Snapshot): EmptyResult { + val snapshotClient = getGrpcClient(context, oauthToken) + val deleteSnapshotInfo = DeleteSnapshotInfo.Builder().apply { + snapshotName = snapshot.title + snapshotId = snapshot.id + }.build() + return withContext(Dispatchers.IO) { snapshotClient.DeleteSnapshot().execute(deleteSnapshotInfo) } + } + + suspend fun resolveSnapshotHead(context: Context, oauthToken: String, resolveSnapshotHeadRequest: ResolveSnapshotHeadRequest): ResolveSnapshotHeadResponse { + val snapshotClient = getGrpcClient(context, oauthToken) + return withContext(Dispatchers.IO) { snapshotClient.ResolveSnapshotHead().execute(resolveSnapshotHeadRequest) } + } + + suspend fun commitSnapshotRevision(context: Context, oauthToken: String, + commitSnapshotRevisionRequest: CommitSnapshotRevisionRequest): EmptyResult { + val snapshotClient = getGrpcClient(context, oauthToken) + return withContext(Dispatchers.IO) { snapshotClient.CommitSnapshotRevision().execute(commitSnapshotRevisionRequest) } + } + + private fun getGrpcClient(context: Context, oauthToken: String) : SnapshotsExtendedClient { + val client = OkHttpClient().newBuilder().addInterceptor( + HeaderInterceptor(context, oauthToken) + ).build() + val grpcClient = GrpcClient.Builder().client(client).baseUrl("https://games.googleapis.com").build() + return grpcClient.create(SnapshotsExtendedClient::class) + } + + class HeaderInterceptor( + private val context: Context, + private val oauthToken: String, + ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): okhttp3.Response { + val original = chain.request() + val requestBuilder = original.newBuilder() + .header("authorization", "Bearer $oauthToken") + .header("te", "trailers") + .header("x-play-games-agent", createPlayGamesAgent()) + .header("x-device-id", LastCheckinInfo.read(context).androidId.toString(16)) + .header("user-agent", "grpc-java-okhttp/1.66.0-SNAPSHOT") + val request = requestBuilder.build() + Log.d(TAG, "request: $request") + return chain.proceed(request) + } + + private fun createPlayGamesAgent(): String { + var playGamesAgent = + "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/${Build.ID};" + playGamesAgent += context.packageName + "/" + BuildConfig.VERSION_CODE + ";" + playGamesAgent += "FastParser/1.1; Games Android SDK/1.0-1052947;" + playGamesAgent += "com.google.android.play.games/517322040; (gzip); Games module/242632000" + return playGamesAgent + } + } +} diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotsDataClient.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotsDataClient.kt new file mode 100644 index 0000000000..e68fa9c24e --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/snapshot/SnapshotsDataClient.kt @@ -0,0 +1,306 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.snapshot + +import android.accounts.Account +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.text.TextUtils +import android.util.Log +import com.android.volley.toolbox.Volley +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.gms.common.util.IOUtils +import com.google.android.gms.drive.Contents +import com.google.android.gms.games.Games +import com.google.android.gms.games.snapshot.SnapshotMetadataChangeEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import org.microg.gms.games.CommitSnapshotRevisionRequest +import org.microg.gms.games.PrepareSnapshotRevisionRequest +import org.microg.gms.games.ResolveSnapshotHeadRequest +import org.microg.gms.games.SnapshotContent +import org.microg.gms.games.SnapshotContentInfo +import org.microg.gms.games.SnapshotTimeInfo +import org.microg.gms.games.snapshot.SnapshotsApiClient.SNAPSHOT_UPLOAD_LINK_DATA +import org.microg.gms.games.snapshot.SnapshotsApiClient.SNAPSHOT_UPLOAD_LINK_IMAGE +import org.microg.gms.games.ukq +import org.microg.gms.profile.Build +import org.microg.gms.utils.BitmapUtils +import org.microg.gms.utils.singleInstanceOf +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.UUID + +class SnapshotsDataClient(val context: Context) { + + private val requestQueue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) } + + suspend fun loadSnapshotData(packageName: String, account: Account) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + val snapshots = arrayListOf() + val snapshotsResponse = runCatching { SnapshotsApiClient.requestSnapshotList(context, token) }.getOrNull() + snapshotsResponse?.gameSnapshot?.forEach { + snapshots.add( + Snapshot( + it.metadata?.snapshot?.snapshotId, + it.metadata?.snapshotName, + it.metadata?.snapshot?.content?.description, + it.metadata?.snapshot?.content?.snapshotTimeInfo?.timestamp?.toString(), + SnapshotImage( + it.metadata?.snapshot?.coverImage?.width, + it.metadata?.snapshot?.coverImage?.height, + it.metadata?.snapshot?.coverImage?.mimeType, + it.metadata?.snapshot?.coverImage?.imageUrl, + null + ) + ) + ) + } + return@withContext snapshots + } + + private suspend fun uploadDataByUrl(packageName: String,account: Account, url: String, body: ByteArray, oauthService: String) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, oauthService, packageName) + runCatching { SnapshotsApiClient.uploadDataByUrl(token, url, requestQueue, body) } + } + + suspend fun getDataFromDrive(packageName: String, account: Account, url: String, oauthService: String) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, oauthService, packageName) + runCatching { SnapshotsApiClient.getDataFromDrive(token, url, requestQueue) }.getOrNull() + } + + private suspend fun getRealUploadUrl(packageName: String, account: Account, url: String, oauthService: String) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, oauthService, packageName) + runCatching { SnapshotsApiClient.getRealUploadUrl(token, url, requestQueue) }.getOrNull() + } + + suspend fun resolveSnapshotHead(packageName: String, account: Account, resolveSnapshotHeadRequest: ResolveSnapshotHeadRequest, oauthService: String) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, oauthService, packageName) + runCatching { SnapshotsApiClient.resolveSnapshotHead(context, token, resolveSnapshotHeadRequest) }.getOrNull() + } + + private suspend fun prepareSnapshotRevision(packageName: String, account: Account, oauthService: String, prepareSnapshotRevisionRequest: PrepareSnapshotRevisionRequest) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, oauthService, packageName) + runCatching { SnapshotsApiClient.prepareSnapshotRevision(context, token, prepareSnapshotRevisionRequest) }.getOrNull() + } + + private suspend fun commitSnapshotRevision(packageName: String, account: Account, commitSnapshotRevisionRequest: CommitSnapshotRevisionRequest, oauthService: String) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, oauthService, packageName) + runCatching { SnapshotsApiClient.commitSnapshotRevision(context, token, commitSnapshotRevisionRequest) }.getOrNull() + } + + suspend fun deleteSnapshotData(packageName: String, account: Account, snapshot: Snapshot) = withContext(Dispatchers.IO) { + val token = GoogleAuthUtil.getToken(context, account, Games.SERVICE_GAMES_LITE, packageName) + runCatching { SnapshotsApiClient.deleteSnapshot(context, token, snapshot) }.getOrNull() + } + + + suspend fun commitSnapshot(packageName: String, account: Account, snapshotTitle: String?, oauthService: String, change: SnapshotMetadataChangeEntity, + contents: Contents, maxCoverImageSize: Int) = withContext(Dispatchers.IO) { + runCatching { + if (TextUtils.isEmpty(snapshotTitle)) { + return@runCatching false + } + + //Get data upload link + val ret = prepareSnapshotRevision(packageName, account, oauthService, createPrepareSnapshotRevisionRequest(change, snapshotTitle!!)) + Log.d(TAG, "commitSnapshot ret: $ret") + if (ret != null && ret.uploadLinkInfos.isNotEmpty()) { + val imageUploadTempUrl = ret.uploadLinkInfos.firstOrNull { it.id == SNAPSHOT_UPLOAD_LINK_IMAGE }?.url + + val snapshotDataUploadTempUrl = ret.uploadLinkInfos.firstOrNull { it.id == SNAPSHOT_UPLOAD_LINK_DATA }?.url + if (snapshotDataUploadTempUrl == null) { + Log.w(TAG, "commitSnapshot data upload temp url is null") + return@runCatching false + } + + val deferredGetImageRealUploadUrl = async { + Log.w(TAG, "commitSnapshot image upload temp url is null") + if (imageUploadTempUrl == null) { + null + } else { + getRealUploadUrl(packageName, account, imageUploadTempUrl, oauthService) + } + } + val deferredGetDataRealUploadUrl = async { + getRealUploadUrl(packageName, account, snapshotDataUploadTempUrl, oauthService) + } + + + val deferredUploadSnapshotImage = async { + val snapshotImageUploadUrl = deferredGetImageRealUploadUrl.await() + if (snapshotImageUploadUrl == null) { + Log.w(TAG, "commitSnapshot image upload url is null") + Triple(0, 0, null) + } else { + uploadSnapshotImage(change!!.bitmapTeleporter!!.createTargetBitmap() + , maxCoverImageSize, packageName, account, oauthService, snapshotImageUploadUrl) + } + + } + + val deferredUploadSnapshotData = async { + val snapshotDataUploadUrl = deferredGetDataRealUploadUrl.await() + if (snapshotDataUploadUrl == null) { + Log.w(TAG, "commitSnapshot data upload url is null") + return@async null + } + uploadSnapshotData(contents, packageName, account, snapshotDataUploadUrl, oauthService) + } + + val deferredCommit = async { + val (imageWidth, imageHeight, snapShotImageResourceId:String?) = deferredUploadSnapshotImage.await() + val snapshotDataResourceId = deferredUploadSnapshotData.await() + if (snapshotDataResourceId != null) { + commitSnapshotRevision(packageName, account, createCommitSnapshotRequest(change, snapshotDataResourceId, + snapShotImageResourceId, imageWidth, imageHeight, snapshotTitle), oauthService) + } else { + return@async false + } + } + deferredCommit.await() + return@runCatching true + Log.w(TAG, "commitSnapshot commit finish") + } else { + return@runCatching false + } + }.getOrNull() + } + + private fun createPrepareSnapshotRevisionRequest(change: SnapshotMetadataChangeEntity, snapshotTitle: String) : PrepareSnapshotRevisionRequest{ + val snapshotUpDateLink = mutableListOf() + snapshotUpDateLink.add(ukq.Builder().apply { + unknownFileInt1 = SNAPSHOT_UPLOAD_LINK_DATA + unknownFileInt2 = 1 + }.build()) + if (change.bitmapTeleporter != null) { + snapshotUpDateLink.add(ukq.Builder().apply { + unknownFileInt1 = SNAPSHOT_UPLOAD_LINK_IMAGE + unknownFileInt2 = 1 + }.build()) + } + val prepareSnapshotRevisionRequest = PrepareSnapshotRevisionRequest.Builder().apply { + title = snapshotTitle + c = snapshotUpDateLink + randomUUID = UUID.randomUUID().toString() + }.build() + return prepareSnapshotRevisionRequest + } + + private fun createCommitSnapshotRequest(change: SnapshotMetadataChangeEntity, snapshotDataResourceId: String, + snapShotImageResourceId: String?, imageWidth: Int, imageHeight: Int, snapshotTitle: String) : CommitSnapshotRevisionRequest { + Log.d(TAG, "createCommitSnapshotRequest: ") + val snapshotTimeInfo = getSnapshotTimeInfo() + val snapshotContent = SnapshotContent.Builder().apply { + description = change.description + this.snapshotTimeInfo = snapshotTimeInfo + progressValue = change.progressValue ?: -1 + this.deviceName = Build.DEVICE + duration = change.progressValue ?: -1 + }.build() + val snapshotContentInfo = SnapshotContentInfo.Builder().apply { + token = snapshotDataResourceId + }.build() + + val snapshotImage : org.microg.gms.games.SnapshotImage? = if (!TextUtils.isEmpty(snapShotImageResourceId)) { + org.microg.gms.games.SnapshotImage.Builder().apply { + this.token = snapShotImageResourceId + this.width = imageWidth + this.height = imageHeight + }.build() + } else { + null + } + + val snapshotBuilder = org.microg.gms.games.Snapshot.Builder().apply { + content = snapshotContent + this.snapshotContentInfo = snapshotContentInfo + } + if (snapshotImage != null) { + snapshotBuilder.coverImage = snapshotImage + } + val commitSnapshotRevisionRequest = CommitSnapshotRevisionRequest.Builder().apply { + this.snapshotName = snapshotTitle + this.snapshot = snapshotBuilder.build() + this.randomUUID = UUID.randomUUID().toString() + this.unknownFileInt7 = 3 + }.build() + return commitSnapshotRevisionRequest + } + + private suspend fun uploadSnapshotData(contents: Contents, packageName: String, account: Account, snapshotDataUploadUrl: String, oauthService: String) : String? { + Log.d(TAG, "uploadSnapshotData: $snapshotDataUploadUrl") + val readInputStreamFully: ByteArray + val fileInputStream = contents.inputStream + val bufferedInputStream = BufferedInputStream(fileInputStream) + var snapshotDataResourceId : String ? = null + try { + fileInputStream.channel.position(0L) + readInputStreamFully = + IOUtils.readInputStreamFully(bufferedInputStream, false) + fileInputStream.channel.position(0L) + snapshotDataResourceId = uploadDataByUrl(packageName, + account, snapshotDataUploadUrl, readInputStreamFully, oauthService).getOrNull() + fileInputStream.close() + } catch (e: IOException) { + Log.w("SnapshotContentsEntity", "Failed to read snapshot data", e) + } + return snapshotDataResourceId + } + + private suspend fun uploadSnapshotImage(bitmap:Bitmap, maxCoverImageSize: Int, packageName: String, + account: Account, oauthService: String, imageUploadUrl: String) : Triple { + Log.d(TAG, "uploadSnapshotImage imageUploadUrl: $imageUploadUrl") + var snapshotBitmap = bitmap + val bitmapSize = BitmapUtils.getBitmapSize(snapshotBitmap) + if (bitmapSize > maxCoverImageSize) { + Log.w(TAG, "commitSnapshot Snapshot cover image is too large. Currently at $bitmapSize bytes; max is $maxCoverImageSize Image will be scaled") + snapshotBitmap = BitmapUtils.scaledBitmap(snapshotBitmap, maxCoverImageSize.toFloat()) + Log.d(TAG, "commitSnapshot scaledBitmap: ${snapshotBitmap.width} ${snapshotBitmap.height}") + } + Log.d(TAG, "commitSnapshot snapshotBitmap: $snapshotBitmap") + val byteArrayOutputStream = ByteArrayOutputStream() + snapshotBitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutputStream) + val snapShotImageResourceId = uploadDataByUrl(packageName, account + , imageUploadUrl, byteArrayOutputStream.toByteArray(), oauthService).getOrNull() + snapshotBitmap.recycle() + bitmap.recycle() + withContext(Dispatchers.IO) { + byteArrayOutputStream.close() + } + var imageWidth = snapshotBitmap.width + var imageHeight = snapshotBitmap.height + if (imageWidth > imageHeight) { + val temp = imageWidth + imageWidth = imageHeight + imageHeight = temp + } + Log.d(TAG, "commitSnapshot $imageWidth: $imageHeight") + return Triple(imageWidth, imageHeight, snapShotImageResourceId) + } + + private fun getSnapshotTimeInfo() : SnapshotTimeInfo { + val timestamp = System.currentTimeMillis() + return SnapshotTimeInfo.Builder().apply { + this.timestamp = timestamp / 1000 + this.playedTime = ((timestamp % 1000) * 1000000).toInt() + }.build() + } + + companion object { + private const val TAG = "SnapshotsDataClient" + + @SuppressLint("StaticFieldLeak") + @Volatile + private var instance: SnapshotsDataClient? = null + fun get(context: Context): SnapshotsDataClient = instance ?: synchronized(this) { + instance ?: SnapshotsDataClient(context.applicationContext).also { instance = it } + } + } +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/ui/GamesUiFragment.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/ui/GamesUiFragment.kt new file mode 100644 index 0000000000..d87a43fc7d --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/ui/GamesUiFragment.kt @@ -0,0 +1,374 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.ui + +import android.accounts.Account +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewStub +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.RelativeLayout +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity.RESULT_OK +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.gms.R +import com.google.android.gms.games.SnapshotsClient.EXTRA_SNAPSHOT_METADATA +import com.google.android.gms.games.achievement.Achievement +import com.google.android.gms.games.achievement.AchievementEntity +import com.google.android.gms.games.snapshot.SnapshotMetadataEntity +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.floatingactionbutton.FloatingActionButton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.microg.gms.auth.signin.SignInConfigurationService +import org.microg.gms.common.Constants +import org.microg.gms.games.ACTION_VIEW_ACHIEVEMENTS +import org.microg.gms.games.ACTION_VIEW_LEADERBOARDS +import org.microg.gms.games.ACTION_VIEW_LEADERBOARDS_SCORES +import org.microg.gms.games.ACTION_VIEW_SNAPSHOTS +import org.microg.gms.games.EXTRA_ACCOUNT_KEY +import org.microg.gms.games.EXTRA_ALLOW_CREATE_SNAPSHOT +import org.microg.gms.games.EXTRA_ALLOW_DELETE_SNAPSHOT +import org.microg.gms.games.EXTRA_GAME_PACKAGE_NAME +import org.microg.gms.games.EXTRA_LEADERBOARD_ID +import org.microg.gms.games.EXTRA_MAX_SNAPSHOTS +import org.microg.gms.games.EXTRA_TITLE +import org.microg.gms.games.EXTRA_SNAPSHOT_NEW +import org.microg.gms.games.GamesConfigurationService +import org.microg.gms.games.snapshot.Snapshot +import org.microg.gms.games.achievements.AchievementsAdapter +import org.microg.gms.games.achievements.AchievementsDataClient +import org.microg.gms.games.leaderboards.LeaderboardEntry +import org.microg.gms.games.leaderboards.LeaderboardScoresAdapter +import org.microg.gms.games.leaderboards.LeaderboardsAdapter +import org.microg.gms.games.leaderboards.LeaderboardsDataClient +import org.microg.gms.games.snapshot.SnapshotsAdapter +import org.microg.gms.games.snapshot.SnapshotsDataClient +import org.microg.gms.people.PeopleManager +import org.microg.gms.profile.ProfileManager + +class GamesUiFragment( + private val clientPackageName: String, + private val account: Account?, + private val callerIntent: Intent, +) : BottomSheetDialogFragment() { + + companion object { + const val TAG = "GamesUiFragment" + } + + private var playerLogo: ImageView? = null + private var uiTitle: TextView? = null + private var actionBtn: FloatingActionButton? = null + private var refreshBtn: ImageView? = null + private var cancelBtn: ImageView? = null + private var recyclerView: RecyclerView? = null + private var loadingView: ProgressBar? = null + private var errorView: TextView? = null + private var contentVb: ViewStub? = null + + private var currentAccount: Account? = null + private var snapshotsAdapter: SnapshotsAdapter? = null + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + ProfileManager.ensureInitialized(requireContext()) + lifecycleScope.launch { + currentAccount = account ?: GamesConfigurationService.getDefaultAccount(requireContext(), clientPackageName) + ?: SignInConfigurationService.getDefaultAccount(requireContext(), clientPackageName) + + if (currentAccount == null) { + showErrorMsg(requireContext().getString(R.string.games_api_access_denied)) + return@launch + } + + withContext(Dispatchers.IO) { + PeopleManager.getOwnerAvatarBitmap( + requireContext(), currentAccount!!.name, false + ) + }?.also { + playerLogo?.setImageBitmap(it) + } + + runCatching { + when (callerIntent.action) { + ACTION_VIEW_ACHIEVEMENTS -> loadLocalAchievements() + ACTION_VIEW_LEADERBOARDS -> loadLocalLeaderboards() + ACTION_VIEW_SNAPSHOTS -> loadSnapshots() + ACTION_VIEW_LEADERBOARDS_SCORES -> loadLocalLeaderboardScores() + else -> { + showErrorMsg("Not yet implemented") + } + } + }.onFailure { + Log.d(TAG, "show error: ", it) + activity?.finish() + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + dialog.setOnShowListener { + dialog.behavior.state = BottomSheetBehavior.STATE_EXPANDED + dialog.behavior.skipCollapsed = true + dialog.setCanceledOnTouchOutside(false) + } + dialog.setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { + dialog.dismiss() + return@setOnKeyListener true + } + return@setOnKeyListener false + } + return dialog + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + Log.d(TAG, "onCreateView") + return layoutInflater.inflate(R.layout.fragment_games_ui_layout, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Log.d(TAG, "onViewCreated") + uiTitle = view.findViewById(R.id.games_ui_title) + actionBtn = view.findViewById(R.id.games_ui_action_button) + refreshBtn = view.findViewById(R.id.games_ui_refresh) + cancelBtn = view.findViewById(R.id.games_ui_cancel) + cancelBtn?.setOnClickListener { dismiss() } + recyclerView = view.findViewById(R.id.games_ui_recyclerview) + loadingView = view.findViewById(R.id.games_ui_loading) + playerLogo = view.findViewById(R.id.games_ui_player_logo) + errorView = view.findViewById(R.id.games_ui_error_tips) + contentVb = view.findViewById(R.id.games_ui_achievements_vb) + } + + override fun onDismiss(dialog: DialogInterface) { + activity?.finish() + super.onDismiss(dialog) + } + + private fun showErrorMsg(error: String) { + loadingView?.visibility = View.GONE + recyclerView?.visibility = View.GONE + errorView?.visibility = View.VISIBLE + errorView?.text = error + } + + private suspend fun loadSnapshots() { + uiTitle?.text = callerIntent.getStringExtra(EXTRA_TITLE) + refreshBtn?.visibility = View.VISIBLE + refreshBtn?.setOnClickListener { + loadingView?.visibility = View.VISIBLE + lifecycleScope.launch { + val snapshots = + SnapshotsDataClient.get(requireContext()).loadSnapshotData(clientPackageName, currentAccount!!) + if (snapshots.isEmpty()) { + showErrorMsg(requireContext().getString(R.string.games_snapshot_empty_text)) + } else snapshotsAdapter?.update(snapshots) + addSnapshotBtnDetail(snapshots) + } + } + val snapshots = SnapshotsDataClient.get(requireContext()).loadSnapshotData(clientPackageName, currentAccount!!) + addSnapshotBtnDetail(snapshots) + if (snapshots.isEmpty()) { + showErrorMsg(requireContext().getString(R.string.games_snapshot_empty_text)) + return + } + recyclerView?.apply { + layoutManager = LinearLayoutManager(requireContext()) + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + errorView?.visibility = View.GONE + loadingView?.visibility = View.GONE + } + }?.adapter = snapshotsAdapter ?: SnapshotsAdapter(requireContext(), callerIntent, snapshots) { snapshot, i -> + lifecycleScope.launch { + if (i == 0) { + val intent = Intent() + val snapshotMetadataEntity = SnapshotMetadataEntity(null, null, snapshot.id, null, + snapshot.coverImage?.url, snapshot.title, snapshot.description, snapshot.lastModifiedMillis?.toLong() ?: 0, + 0, 1f, snapshot.title, false, 0, "") + intent.putExtra(EXTRA_SNAPSHOT_METADATA, snapshotMetadataEntity) + activity?.setResult(RESULT_OK, intent) + activity?.finish() + } else { + AlertDialog.Builder(requireContext()).apply { + setTitle(getString(R.string.games_delete_snapshot_dialog_title)) + setMessage(getString(R.string.games_delete_snapshot_dialog_message)) + }.setNegativeButton(getString(R.string.games_delete_snapshot_dialog_cancel)) { dialog, _ -> + dialog.dismiss() + }.setPositiveButton(getString(R.string.games_delete_snapshot_dialog_ok)) { dialog, _ -> + dialog.dismiss() + lifecycleScope.launch { + val snapshotData = SnapshotsDataClient.get(requireContext()) + .deleteSnapshotData(clientPackageName, currentAccount!!, snapshot) + if (snapshotData != null) { + refreshBtn?.performClick() + } else { + Toast.makeText( + requireContext(), + getString(R.string.games_delete_snapshot_error), Toast.LENGTH_SHORT + ).show() + } + } + }.show() + } + } + }.also { + snapshotsAdapter = it + } + } + + private fun addSnapshotBtnDetail(snapshots: List) { + val allowCreate = callerIntent.getBooleanExtra(EXTRA_ALLOW_CREATE_SNAPSHOT, true) + val maxSnapshot = callerIntent.getIntExtra(EXTRA_MAX_SNAPSHOTS, -1) + if (allowCreate && (maxSnapshot != -1 && snapshots.size < maxSnapshot)) { + actionBtn?.visibility = View.VISIBLE + actionBtn?.setOnClickListener { + val resultIntent = Intent() + resultIntent.putExtra(EXTRA_SNAPSHOT_NEW, true) + activity?.setResult(RESULT_OK, resultIntent) + activity?.finish() + } + } else { + actionBtn?.visibility = View.INVISIBLE + } + } + + private suspend fun loadLocalLeaderboards() { + uiTitle?.text = requireContext().getString(R.string.games_leaderboard_list_title) + val loadLeaderboards = + LeaderboardsDataClient.get(requireContext()).loadLeaderboards(clientPackageName, currentAccount!!) + if (loadLeaderboards.isEmpty()) { + showErrorMsg(requireContext().getString(R.string.games_leaderboard_empty_text)) + return + } + recyclerView?.apply { + layoutManager = LinearLayoutManager(requireContext()) + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + loadingView?.visibility = View.GONE + } + }?.adapter = LeaderboardsAdapter(requireContext(), loadLeaderboards) { leaderboard -> + val intent = Intent(ACTION_VIEW_LEADERBOARDS_SCORES) + intent.setPackage(Constants.GMS_PACKAGE_NAME) + intent.putExtra(EXTRA_GAME_PACKAGE_NAME, clientPackageName) + intent.putExtra(EXTRA_ACCOUNT_KEY, Integer.toHexString(currentAccount?.name.hashCode())) + intent.putExtra(EXTRA_LEADERBOARD_ID, leaderboard.id) + requireActivity().startActivity(intent) + } + } + + private suspend fun loadLocalLeaderboardScores() { + val leaderboardId = callerIntent.getStringExtra(EXTRA_LEADERBOARD_ID) + val leaderboardScores = LeaderboardsDataClient.get(requireContext()) + .getLeaderboardScoresById(leaderboardId!!, clientPackageName, currentAccount!!) + if (leaderboardScores.isEmpty()) { + showErrorMsg(requireContext().getString(R.string.games_leaderboard_empty_text)) + return + } + val leaderboardDefinition = LeaderboardsDataClient.get(requireContext()) + .getLeaderboardById(clientPackageName, currentAccount!!, leaderboardId) + val leaderboardEntries = arrayListOf() + leaderboardEntries.add(LeaderboardEntry(leaderboardDefinition?.name, leaderboardDefinition?.iconUrl)) + leaderboardEntries.addAll(leaderboardScores) + recyclerView?.apply { + layoutManager = LinearLayoutManager(requireContext()) + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + loadingView?.visibility = View.GONE + } + addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + val layoutManager = recyclerView.layoutManager + if (layoutManager is LinearLayoutManager) { + val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() + uiTitle?.text = if (firstVisibleItemPosition != 0) leaderboardDefinition?.name else "" + } + } + }) + }?.adapter = LeaderboardScoresAdapter(requireContext(), leaderboardEntries) + } + + private suspend fun loadLocalAchievements() { + uiTitle?.text = requireContext().getString(R.string.games_achievement_list_title) + val allAchievements = withContext(Dispatchers.IO) { + AchievementsDataClient.get(requireContext()).loadAchievements(clientPackageName, currentAccount!!, true) + } + + if (allAchievements.isEmpty()) { + showErrorMsg(requireContext().getString(R.string.games_achievements_empty_text)) + return + } + + val targetList = ArrayList() + val unlockList = ArrayList() + val revealedList = ArrayList() + + for (definition in allAchievements) { + when (definition.state) { + Achievement.AchievementState.STATE_REVEALED -> { + revealedList.add(definition) + } + + Achievement.AchievementState.STATE_UNLOCKED -> { + unlockList.add(definition) + } + } + } + + if (unlockList.isNotEmpty()) { + targetList.add( + AchievementEntity( + requireContext().getString(R.string.games_achievement_unlocked_content_description), -1 + ) + ) + targetList.addAll(unlockList) + } + if (revealedList.isNotEmpty()) { + targetList.add( + AchievementEntity( + requireContext().getString(R.string.games_achievement_locked_content_description), -1 + ) + ) + targetList.addAll(revealedList) + } + + val inflatedView = contentVb?.inflate() + inflatedView?.findViewById(R.id.achievements_counter_text)?.text = + String.format("${unlockList.size} / ${unlockList.size + revealedList.size}") + + recyclerView?.apply { + layoutManager = LinearLayoutManager(requireContext()) + inflatedView?.id?.let { + val layoutParams = layoutParams as RelativeLayout.LayoutParams + layoutParams.addRule(RelativeLayout.BELOW, it) + setLayoutParams(layoutParams) + } + addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + loadingView?.visibility = View.GONE + } + }?.adapter = AchievementsAdapter(requireContext(), targetList) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/kotlin/org/microg/gms/games/ui/InGameUiActivity.kt b/play-services-core/src/main/kotlin/org/microg/gms/games/ui/InGameUiActivity.kt new file mode 100644 index 0000000000..e4fc56dbdb --- /dev/null +++ b/play-services-core/src/main/kotlin/org/microg/gms/games/ui/InGameUiActivity.kt @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.games.ui + +import android.accounts.AccountManager +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.google.android.gms.R +import com.google.android.gms.games.Games +import org.microg.gms.auth.AuthConstants +import org.microg.gms.games.EXTRA_ACCOUNT_KEY +import org.microg.gms.games.EXTRA_GAME_PACKAGE_NAME + +class InGameUiActivity : AppCompatActivity() { + + private val clientPackageName: String? + get() = runCatching { + intent?.extras?.getString(EXTRA_GAME_PACKAGE_NAME) + }.getOrNull() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setTheme(R.style.ThemeTranslucentCommon) + Log.d(Games.TAG, "InGameUiActivity onCreate: clientPackageName:$clientPackageName") + if (clientPackageName == null) { + Log.d(Games.TAG, "InGameUiActivity finishResult: params invalid") + finish() + return + } + val accountKey = intent.getStringExtra(EXTRA_ACCOUNT_KEY) + val account = AccountManager.get(this).accounts.filter { + it.type == AuthConstants.DEFAULT_ACCOUNT_TYPE && Integer.toHexString(it.name.hashCode()) == accountKey + }.getOrNull(0) + GamesUiFragment(clientPackageName!!, account, intent).show(supportFragmentManager, GamesUiFragment.TAG) + } + +} \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_achievement_locked.xml b/play-services-core/src/main/res/drawable/ic_achievement_locked.xml new file mode 100644 index 0000000000..5e5fffd193 --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_achievement_locked.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_achievement_logo.xml b/play-services-core/src/main/res/drawable/ic_achievement_logo.xml new file mode 100644 index 0000000000..003f0e59a7 --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_achievement_logo.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_achievement_unlocked.xml b/play-services-core/src/main/res/drawable/ic_achievement_unlocked.xml new file mode 100644 index 0000000000..3f997ab03e --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_achievement_unlocked.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_leaderboard_placeholder.xml b/play-services-core/src/main/res/drawable/ic_leaderboard_placeholder.xml new file mode 100644 index 0000000000..87f168a24c --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_leaderboard_placeholder.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_refresh.xml b/play-services-core/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000000..23d10bcc9f --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_snapshot_choose_fill.xml b/play-services-core/src/main/res/drawable/ic_snapshot_choose_fill.xml new file mode 100644 index 0000000000..581eb9441c --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_snapshot_choose_fill.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_snapshot_choose_stroke.xml b/play-services-core/src/main/res/drawable/ic_snapshot_choose_stroke.xml new file mode 100644 index 0000000000..5c5d1dd412 --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_snapshot_choose_stroke.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/drawable/ic_snapshot_load_error_image.xml b/play-services-core/src/main/res/drawable/ic_snapshot_load_error_image.xml new file mode 100644 index 0000000000..bf59227319 --- /dev/null +++ b/play-services-core/src/main/res/drawable/ic_snapshot_load_error_image.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/fragment_games_ui_layout.xml b/play-services-core/src/main/res/layout/fragment_games_ui_layout.xml new file mode 100644 index 0000000000..30c01ced1a --- /dev/null +++ b/play-services-core/src/main/res/layout/fragment_games_ui_layout.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/item_achievement_data_layout.xml b/play-services-core/src/main/res/layout/item_achievement_data_layout.xml new file mode 100644 index 0000000000..a1de563da7 --- /dev/null +++ b/play-services-core/src/main/res/layout/item_achievement_data_layout.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/item_achievement_header_layout.xml b/play-services-core/src/main/res/layout/item_achievement_header_layout.xml new file mode 100644 index 0000000000..b3e046eef4 --- /dev/null +++ b/play-services-core/src/main/res/layout/item_achievement_header_layout.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/item_achievements_counter.xml b/play-services-core/src/main/res/layout/item_achievements_counter.xml new file mode 100644 index 0000000000..a3cbf2dd4d --- /dev/null +++ b/play-services-core/src/main/res/layout/item_achievements_counter.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/item_leaderboard_data_layout.xml b/play-services-core/src/main/res/layout/item_leaderboard_data_layout.xml new file mode 100644 index 0000000000..358c28373c --- /dev/null +++ b/play-services-core/src/main/res/layout/item_leaderboard_data_layout.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/item_leaderboard_score_data_layout.xml b/play-services-core/src/main/res/layout/item_leaderboard_score_data_layout.xml new file mode 100644 index 0000000000..dcef85327d --- /dev/null +++ b/play-services-core/src/main/res/layout/item_leaderboard_score_data_layout.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/item_leaderboard_score_header_layout.xml b/play-services-core/src/main/res/layout/item_leaderboard_score_header_layout.xml new file mode 100644 index 0000000000..f9a830de57 --- /dev/null +++ b/play-services-core/src/main/res/layout/item_leaderboard_score_header_layout.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/layout/item_snapshot_data_layout.xml b/play-services-core/src/main/res/layout/item_snapshot_data_layout.xml new file mode 100644 index 0000000000..b96cbe9661 --- /dev/null +++ b/play-services-core/src/main/res/layout/item_snapshot_data_layout.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/play-services-core/src/main/res/values-zh-rCN/strings.xml b/play-services-core/src/main/res/values-zh-rCN/strings.xml index 86b09f4fe0..9d125acb4d 100644 --- a/play-services-core/src/main/res/values-zh-rCN/strings.xml +++ b/play-services-core/src/main/res/values-zh-rCN/strings.xml @@ -238,4 +238,24 @@ microG GmsCore 内置一套自由的 SafetyNet 实现,但是官方服务器要 选择账号 以继续使用 %1$s 使用 Google 账号登录 + + 成就 + 此游戏没有成就 + 此设备上没有可访问 Games API 的账号 + 未解锁 + 已解锁 + %1$s XP + 此游戏没有排行榜 + 不可思议!居然还没有人公开创下这个游戏的高分记录! + 排行榜 + 得分:%1$s + 目前尚未保存任何游戏存档 + 选择 + 删除 + 删除游戏存档 + 确定要删除这个游戏存档吗? + 取消 + 确定 + 删除失败,请稍后重试 + \ No newline at end of file diff --git a/play-services-core/src/main/res/values/strings.xml b/play-services-core/src/main/res/values/strings.xml index bbe47e70a9..9f09522e71 100644 --- a/play-services-core/src/main/res/values/strings.xml +++ b/play-services-core/src/main/res/values/strings.xml @@ -281,4 +281,23 @@ This can take a couple of minutes." to continue to %1$s Sign in with Google + Achievements + No achievements for this game + No account on this device can access the Games APIs + locked + unlocked + %1$s XP + No leaderboards for this game + Unbelievable! There are no public high scores for this game. + Rankings + Score:%1$s + No game saves have been saved yet + select + delete + Delete saved game + Are you sure you want to delete this saved game? + Cancel + OK + Deletion failed, please try again later + diff --git a/play-services-drive/build.gradle b/play-services-drive/build.gradle index 3c42374551..977786859b 100644 --- a/play-services-drive/build.gradle +++ b/play-services-drive/build.gradle @@ -39,4 +39,5 @@ dependencies { api project(':play-services-base') api project(':play-services-basement') api project(':play-services-tasks') + annotationProcessor project(':safe-parcel-processor') } diff --git a/play-services-drive/src/main/aidl/com/google/android/gms/drive/Contents.aidl b/play-services-drive/src/main/aidl/com/google/android/gms/drive/Contents.aidl new file mode 100644 index 0000000000..695b298ba9 --- /dev/null +++ b/play-services-drive/src/main/aidl/com/google/android/gms/drive/Contents.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.drive; + +parcelable Contents; diff --git a/play-services-drive/src/main/aidl/com/google/android/gms/drive/DriveId.aidl b/play-services-drive/src/main/aidl/com/google/android/gms/drive/DriveId.aidl new file mode 100644 index 0000000000..dca5be51d2 --- /dev/null +++ b/play-services-drive/src/main/aidl/com/google/android/gms/drive/DriveId.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.drive; + +parcelable DriveId; diff --git a/play-services-drive/src/main/java/com/google/android/gms/drive/Contents.java b/play-services-drive/src/main/java/com/google/android/gms/drive/Contents.java new file mode 100644 index 0000000000..19abc1bafb --- /dev/null +++ b/play-services-drive/src/main/java/com/google/android/gms/drive/Contents.java @@ -0,0 +1,80 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.drive; + +import android.os.Parcel; +import android.os.ParcelFileDescriptor; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; + +@SafeParcelable.Class +public class Contents extends AbstractSafeParcelable { + + @Field(value = 2) + private final ParcelFileDescriptor fileDescriptor; + @Field(value = 3) + final int requestId; + @Field(value = 4) + private final int mode; + @Field(value = 5) + private final DriveId driveId; + @Field(value = 7) + private final boolean unknownBooleanFile7; + @Field(value = 8) + @Nullable + private final String unknownStringFile8; + + @Constructor + public Contents(@Param(value = 2) ParcelFileDescriptor var1, @Param(value = 3) int var2, @Param(value = 4) int var3, @Param(value = 5) DriveId var4, @Param(value = 7) boolean var5, @Param(value = 8) @Nullable String var6) { + this.fileDescriptor = var1; + this.requestId = var2; + this.mode = var3; + this.driveId = var4; + this.unknownBooleanFile7 = var5; + this.unknownStringFile8 = var6; + } + + public ParcelFileDescriptor getParcelFileDescriptor() { + return this.fileDescriptor; + } + + public final DriveId getDriveId() { + return this.driveId; + } + + public final FileInputStream getInputStream() { + return new FileInputStream(this.fileDescriptor.getFileDescriptor()); + } + + public final OutputStream getOutputStream() { + return new FileOutputStream(this.fileDescriptor.getFileDescriptor()); + } + + public final int getMode() { + return this.mode; + } + + public final int getRequestId() { + return this.requestId; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(Contents.class); +} diff --git a/play-services-drive/src/main/java/com/google/android/gms/drive/DriveId.java b/play-services-drive/src/main/java/com/google/android/gms/drive/DriveId.java new file mode 100644 index 0000000000..2b8d47d290 --- /dev/null +++ b/play-services-drive/src/main/java/com/google/android/gms/drive/DriveId.java @@ -0,0 +1,61 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.drive; + +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.Preconditions; +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class DriveId extends AbstractSafeParcelable { + public static final int RESOURCE_TYPE_UNKNOWN = -1; + public static final int RESOURCE_TYPE_FILE = 0; + public static final int RESOURCE_TYPE_FOLDER = 1; + + @Field(value = 2) + private final String resourceId; + @Field(value = 3) + private final long unknownLongFile3; + @Field(value = 4) + private final long unknownLongFile4; + @Field(value = 5) + private final int resourceType; + + + @Nullable + public String getResourceId() { + return this.resourceId; + } + + public int getResourceType() { + return this.resourceType; + } + + @Constructor + public DriveId(@Param(value = 2) String var1, @Param(value = 3) long var2, @Param(value = 4) long var4, @Param(value = 5) int var6) { + this.resourceId = var1; + Preconditions.checkArgument(!"".equals(var1)); + Preconditions.checkArgument(var1 != null || var2 != -1L); + this.unknownLongFile3 = var2; + this.unknownLongFile4 = var4; + this.resourceType = var6; + } + + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(DriveId.class); +} + diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/achievement/AchievementEntity.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/achievement/AchievementEntity.aidl new file mode 100644 index 0000000000..49e6e20eb0 --- /dev/null +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/achievement/AchievementEntity.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.achievement; + +parcelable AchievementEntity; diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesCallbacks.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesCallbacks.aidl index 08b60e8352..5e317c1f0a 100644 --- a/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesCallbacks.aidl +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesCallbacks.aidl @@ -2,6 +2,7 @@ package com.google.android.gms.games.internal; import com.google.android.gms.common.api.Status; import com.google.android.gms.common.data.DataHolder; +import com.google.android.gms.drive.Contents; import com.google.android.gms.games.multiplayer.realtime.RealTimeMessage; interface IGamesCallbacks { @@ -45,5 +46,7 @@ interface IGamesCallbacks { /* @deprecated */ void onGameMuteStatusLoaded(in DataHolder data) = 5037; /* @deprecated */ void onContactSettingsLoaded(in DataHolder data) = 5038; /* @deprecated */ void onContactSettingsUpdated(int statusCode) = 5039; + void onResolveSnapshotHead(in DataHolder data, in Contents contents) = 12003; + void commitSnapshotResult(in DataHolder data) = 12004; void onServerAuthCode(in Status status, String serverAuthCode) = 25002; } diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesClient.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesClient.aidl index 24c61c3af8..f3a7efc730 100644 --- a/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesClient.aidl +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesClient.aidl @@ -1,4 +1,7 @@ package com.google.android.gms.games.internal; +import com.google.android.gms.games.internal.popup.PopupLocationInfoParcelable; + interface IGamesClient { + PopupLocationInfoParcelable getPopupLocationInfoParcelable() = 1000; } diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesService.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesService.aidl index 010978f624..42dbf2b08e 100644 --- a/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesService.aidl +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/IGamesService.aidl @@ -5,10 +5,12 @@ import android.os.Bundle; import android.os.IBinder; import com.google.android.gms.common.data.DataHolder; -//import com.google.android.gms.drive.Contents; +import com.google.android.gms.drive.Contents; import com.google.android.gms.games.PlayerEntity; import com.google.android.gms.games.internal.IGamesCallbacks; import com.google.android.gms.games.internal.IGamesClient; +import com.google.android.gms.games.snapshot.SnapshotMetadataChangeEntity; + interface IGamesService { void clientDisconnecting(long clientId) = 5000; @@ -91,19 +93,22 @@ interface IGamesService { Intent getAllLeaderboardsIntent() = 9002; Intent getAchievementsIntent() = 9004; Intent getPlayerSearchIntent() = 9009; - + Intent getSelectSnapshotIntent(String title, boolean allowAddButton, boolean allowDelete, int maxSnapshots) = 12000; + void loadSnapshots(IGamesCallbacks callbacks, boolean forceReload) = 12001; + void commitSnapshot(IGamesCallbacks callbacks, String str, in SnapshotMetadataChangeEntity change, in Contents contents) = 12006; void loadEvents(IGamesCallbacks callbacks, boolean forceReload) = 12015; void incrementEvent(String eventId, int incrementAmount) = 12016; -// void discardAndCloseSnapshot(in Contents contents) = 12018; + void discardAndCloseSnapshot(in Contents contents) = 12018; void loadEventsById(IGamesCallbacks callbacks, boolean forceReload, in String[] eventsIds) = 12030; // void resolveSnapshotConflict(IGamesCallbacks callbacks, String conflictId, String snapshotId, in SnapshotMetadataChangeEntity metadata, in Contents contents) = 12032; int getMaxDataSize() = 12034; int getMaxCoverImageSize() = 12035; - + void resolveSnapshotHead(IGamesCallbacks callbacks, String saveName, int i) = 15000; void registerEventClient(IGamesClient callback, long l) = 15500; Intent getCompareProfileIntentForPlayer(in PlayerEntity player) = 15502; void loadPlayerStats(IGamesCallbacks callbacks, boolean forceReload) = 17000; + Intent getLeaderboardsScoresIntent(String leaderboardId, int timeSpan, int collection) = 18000; Account getCurrentAccount() = 21000; diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/internal/popup/PopupLocationInfoParcelable.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/popup/PopupLocationInfoParcelable.aidl new file mode 100644 index 0000000000..5430a305fb --- /dev/null +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/internal/popup/PopupLocationInfoParcelable.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.internal.popup; + +parcelable PopupLocationInfoParcelable; \ No newline at end of file diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotEntity.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotEntity.aidl new file mode 100644 index 0000000000..ffffcd968e --- /dev/null +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotEntity.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +parcelable SnapshotEntity; diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotMetadataChangeEntity.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotMetadataChangeEntity.aidl new file mode 100644 index 0000000000..c81f49016e --- /dev/null +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotMetadataChangeEntity.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +parcelable SnapshotMetadataChangeEntity; diff --git a/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotMetadataEntity.aidl b/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotMetadataEntity.aidl new file mode 100644 index 0000000000..917ed9915f --- /dev/null +++ b/play-services-games/src/main/aidl/com/google/android/gms/games/snapshot/SnapshotMetadataEntity.aidl @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +parcelable SnapshotMetadataEntity; diff --git a/play-services-games/src/main/java/com/google/android/gms/games/AchievementsClient.java b/play-services-games/src/main/java/com/google/android/gms/games/AchievementsClient.java new file mode 100644 index 0000000000..f5c608780c --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/AchievementsClient.java @@ -0,0 +1,139 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.app.Activity; +import android.content.Intent; +import android.os.RemoteException; + +import com.google.android.gms.common.data.AbstractDataBuffer; +import com.google.android.gms.games.achievement.AchievementBuffer; +import com.google.android.gms.tasks.Task; + +/** + * A client to interact with achievements functionality. + */ +public interface AchievementsClient { + + /** + * Returns a {@link Task} which asynchronously loads an {@link Intent} to show the list of achievements for a game. + * Note that the Intent returned from the {@code Task} must be invoked with {@link Activity#startActivityForResult(Intent, int)}, + * so that the identity of the calling package can be established. + *

+ * The returned {@code Task} can fail with a {@link RemoteException}. + */ + Task getAchievementsIntent(); + + /** + * Increments an achievement by the given number of steps. + * The achievement must be an incremental achievement. Once an achievement reaches at least the maximum number of steps, + * it will be unlocked automatically. Any further increments will be ignored. + *

+ * This is the fire-and-forget form of the API. + * Use this form if you don't need to know the status of the operation immediately. + * For most applications, this will be the preferred API to use, though note that the update may not be sent to the server until the next sync. + * See {@link AchievementsClient#incrementImmediate(String, int)} if you need the operation to attempt to communicate to the server immediately or need to have the status code delivered to your application. + * + * @param id The achievement ID to increment. + * @param numSteps The number of steps to increment by. Must be greater than 0. + */ + void increment(String id, int numSteps); + + /** + * Returns a {@link Task} which asynchronously increments an achievement by the given number of steps. + * The achievement must be an incremental achievement. Once an achievement reaches at least the maximum number of steps, + * it will be unlocked automatically. Any further increments will be ignored. + *

+ * This form of the API will attempt to update the user's achievement on the server immediately. + * The {@link Boolean} in a successful response indicates whether the achievement is now unlocked. + * + * @param id The ID of the achievement to increment. + * @param numSteps The number of steps to increment by. Must be greater than 0. + */ + Task incrementImmediate(String id, int numSteps); + + /** + * Returns a {@link Task} which asynchronously loads an annotated {@link AchievementBuffer} that represents the achievement data for the currently signed-in player. + *

+ * {@link AbstractDataBuffer#release()} should be called to release resources after usage. + * + * @param forceReload If true, this call will clear any locally cached data and attempt to fetch the latest data from the server. + * This would commonly be used for something like a user-initiated refresh. + * Normally, this should be set to false to gain advantages of data caching. + */ + Task> load(boolean forceReload); + + /** + * Reveals a hidden achievement to the currently signed-in player. If the achievement has already been unlocked, this will have no effect. + *

+ * This is the fire-and-forget form of the API. Use this form if you don't need to know the status of the operation immediately. + * For most applications, this will be the preferred API to use, though note that the update may not be sent to the server until the next sync. + * See {@link #revealImmediate(String)} if you need the operation to attempt to communicate to the server immediately or need to have the status code delivered to your application. + * + * @param id The achievement ID to reveal. + */ + void reveal(String id); + + /** + * Returns a {@link Task} which asynchronously reveals a hidden achievement to the currently signed in player. + * If the achievement is already visible, this will have no effect. + *

+ * This form of the API will attempt to update the user's achievement on the server immediately. + * The Task will complete successfully when the server has been updated. + * + * @param id The ID of the achievement to reveal. + */ + Task revealImmediate(String id); + + /** + * Sets an achievement to have at least the given number of steps completed. + * Calling this method while the achievement already has more steps than the provided value is a no-op. + * Once the achievement reaches the maximum number of steps, the achievement will automatically be unlocked, and any further mutation operations will be ignored. + *

+ * This is the fire-and-forget form of the API. Use this form if you don't need to know the status of the operation immediately. + * For most applications, this will be the preferred API to use, though note that the update may not be sent to the server until the next sync. + * See {@link #setStepsImmediate(String, int)} if you need the operation to attempt to communicate to the server immediately or need to have the status code delivered to your application. + * + * @param id The ID of the achievement to modify. + * @param numSteps The number of steps to set the achievement to. Must be greater than 0. + */ + void setSteps(String id, int numSteps); + + /** + * Returns a {@link Task} which asynchronously sets an achievement to have at least the given number of steps completed. + * Calling this method while the achievement already has more steps than the provided value is a no-op. + * Once the achievement reaches the maximum number of steps, the achievement will automatically be unlocked, and any further mutation operations will be ignored. + *

+ * This form of the API will attempt to update the user's achievement on the server immediately. + * The {@link Boolean} in a successful response indicates whether the achievement is now unlocked. + * + * @param id The ID of the achievement to modify. + * @param numSteps The number of steps to set the achievement to. Must be greater than 0. + */ + Task setStepsImmediate(String id, int numSteps); + + /** + * Unlocks an achievement for the currently signed in player. If the achievement is hidden this will reveal it to the player. + *

+ * This is the fire-and-forget form of the API. Use this form if you don't need to know the status of the operation immediately. + * For most applications, this will be the preferred API to use, though note that the update may not be sent to the server until the next sync. + * See {@link #unlockImmediate(String)} if you need the operation to attempt to communicate to the server immediately or need to have the status code delivered to your application. + * + * @param id The achievement ID to unlock. + */ + void unlock(String id); + + /** + * Returns a {@link Task} which asynchronously unlocks an achievement for the currently signed in player. + * If the achievement is hidden this will reveal it to the player. + *

+ * This form of the API will attempt to update the user's achievement on the server immediately. + * The {@link Task} will complete successfully when the server has been updated. + * + * @param id The ID of the achievement to unlock. + */ + Task unlockImmediate(String id); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/AchievementsClientImpl.java b/play-services-games/src/main/java/com/google/android/gms/games/AchievementsClientImpl.java new file mode 100644 index 0000000000..a368d4c388 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/AchievementsClientImpl.java @@ -0,0 +1,130 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Context; +import android.content.Intent; +import android.os.RemoteException; +import android.util.Log; + +import com.google.android.gms.common.api.GoogleApi; +import com.google.android.gms.common.data.DataHolder; +import com.google.android.gms.games.achievement.AchievementBuffer; +import com.google.android.gms.games.internal.IGamesCallbacks; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; + +import org.microg.gms.common.api.ReturningGoogleApiCall; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public class AchievementsClientImpl extends GoogleApi implements AchievementsClient { + + public AchievementsClientImpl(Context context, Games.GamesOptions options) { + super(context, Games.API, options); + Log.d(Games.TAG, "AchievementsClientImpl: options: " + options); + } + + @Override + public Task getAchievementsIntent() { + return scheduleTask((ReturningGoogleApiCall) (client) -> client.getServiceInterface().getAchievementsIntent()); + } + + @Override + public void increment(String id, int numSteps) { + Tasks.withTimeout(incrementImmediate(id, numSteps), 5, TimeUnit.SECONDS); + } + + @Override + public Task incrementImmediate(String id, int numSteps) { + return scheduleTask((ReturningGoogleApiCall) (client) -> { + CountDownLatch countDownLatch = new CountDownLatch(1); + AtomicBoolean atomicBoolean = new AtomicBoolean(); + client.getServiceInterface().incrementAchievement(new IGamesCallbacks.Default() { + @Override + public void onAchievementUpdated(int statusCode, String achievementId) throws RemoteException { + super.onAchievementUpdated(statusCode, achievementId); + atomicBoolean.set(statusCode == 0); + countDownLatch.countDown(); + } + }, id, numSteps, null, null); + countDownLatch.await(5, TimeUnit.SECONDS); + return atomicBoolean.get(); + }); + } + + @Override + public Task> load(boolean forceReload) { + return scheduleTask((ReturningGoogleApiCall, GamesGmsClientImpl>) (client) -> { + CountDownLatch countDownLatch = new CountDownLatch(1); + AtomicReference> annotatedDataAtomicReference = new AtomicReference<>(); + client.getServiceInterface().loadAchievementsV2(new IGamesCallbacks.Default() { + @Override + public void onAchievementsLoaded(DataHolder data) throws RemoteException { + super.onAchievementsLoaded(data); + AchievementBuffer achievementBuffer = new AchievementBuffer(data); + AnnotatedData annotatedData = new AnnotatedData<>(achievementBuffer, false); + annotatedDataAtomicReference.set(annotatedData); + countDownLatch.countDown(); + } + }, forceReload); + countDownLatch.await(5, TimeUnit.SECONDS); + return annotatedDataAtomicReference.get(); + }); + } + + @Override + public void reveal(String id) { + Tasks.withTimeout(revealImmediate(id), 5, TimeUnit.SECONDS); + } + + @Override + public Task revealImmediate(String id) { + return scheduleTask((ReturningGoogleApiCall) (client) -> { + client.getServiceInterface().revealAchievement(null, id, null, null); + return Void.TYPE.newInstance(); + }); + } + + @Override + public void setSteps(String id, int numSteps) { + Tasks.withTimeout(setStepsImmediate(id, numSteps), 5, TimeUnit.SECONDS); + } + + @Override + public Task setStepsImmediate(String id, int numSteps) { + return scheduleTask((ReturningGoogleApiCall) (client) -> { + CountDownLatch countDownLatch = new CountDownLatch(1); + AtomicBoolean atomicBoolean = new AtomicBoolean(); + client.getServiceInterface().setAchievementSteps(new IGamesCallbacks.Default() { + @Override + public void onAchievementUpdated(int statusCode, String achievementId) throws RemoteException { + super.onAchievementUpdated(statusCode, achievementId); + atomicBoolean.set(statusCode == 0); + countDownLatch.countDown(); + } + }, id, numSteps, null, null); + countDownLatch.await(5, TimeUnit.SECONDS); + return atomicBoolean.get(); + }); + } + + @Override + public void unlock(String id) { + Tasks.withTimeout(unlockImmediate(id), 5, TimeUnit.SECONDS); + } + + @Override + public Task unlockImmediate(String id) { + return scheduleTask((ReturningGoogleApiCall) (client) -> { + client.getServiceInterface().unlockAchievement(null, id, null, null); + return Void.TYPE.newInstance(); + }); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/Game.java b/play-services-games/src/main/java/com/google/android/gms/games/Game.java new file mode 100644 index 0000000000..fcb4b9b638 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/Game.java @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcelable; + +import com.google.android.gms.common.data.Freezable; + +public interface Game extends Parcelable, Freezable { + String getApplicationId(); + + String getDisplayName(); + + void getDisplayName(CharArrayBuffer buffer); + + String getPrimaryCategory(); + + String getSecondaryCategory(); + + String getDescription(); + + void getDescription(CharArrayBuffer buffer); + + String getDeveloperName(); + + void getDeveloperName(CharArrayBuffer buffer); + + Uri getIconImageUri(); + + /** @deprecated */ + @Deprecated + String getIconImageUrl(); + + Uri getHiResImageUri(); + + /** @deprecated */ + @Deprecated + String getHiResImageUrl(); + + Uri getFeaturedImageUri(); + + /** @deprecated */ + @Deprecated + String getFeaturedImageUrl(); + + boolean isPlayEnabledGame(); + + boolean isMuted(); + + boolean isIdentitySharingConfirmed(); + + boolean isInstanceInstalled(); + + String getInstancePackageName(); + + int getGameplayAclStatus(); + + int getAchievementTotalCount(); + + int getLeaderboardCount(); + + boolean isRealTimeMultiplayerEnabled(); + + boolean isTurnBasedMultiplayerEnabled(); + + boolean areSnapshotsEnabled(); + + String getThemeColor(); + + boolean hasGamepadSupport(); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/GameBuffer.java b/play-services-games/src/main/java/com/google/android/gms/games/GameBuffer.java new file mode 100644 index 0000000000..ca75299cc3 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/GameBuffer.java @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import com.google.android.gms.common.data.AbstractDataBuffer; +import com.google.android.gms.common.data.DataHolder; + +public final class GameBuffer extends AbstractDataBuffer { + public GameBuffer(DataHolder dataHolder) { + super(dataHolder); + } + + public Game get(int position) { + return new GameRef(this.dataHolder, position); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/GameColumns.java b/play-services-games/src/main/java/com/google/android/gms/games/GameColumns.java new file mode 100644 index 0000000000..c302da8fc4 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/GameColumns.java @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +package com.google.android.gms.games; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class GameColumns { + public static final String EXTERNAL_GAME_ID = "external_game_id"; + public static final String DISPLAY_NAME = "display_name"; + public static final String PRIMARY_CATEGORY = "primary_category"; + public static final String SECONDARY_CATEGORY = "secondary_category"; + public static final String GAME_DESCRIPTION = "game_description"; + public static final String DEVELOPER_NAME = "developer_name"; + public static final String GAME_ICON_IMAGE_URI = "game_icon_image_uri"; + public static final String GAME_ICON_IMAGE_URL = "game_icon_image_url"; + public static final String GAME_HI_RES_IMAGE_URI = "game_hi_res_image_uri"; + public static final String GAME_HI_RES_IMAGE_URL = "game_hi_res_image_url"; + public static final String FEATURED_IMAGE_URI = "featured_image_uri"; + public static final String FEATURED_IMAGE_URL = "featured_image_url"; + public static final String PLAY_ENABLED_GAME = "play_enabled_game"; + public static final String MUTED = "muted"; + public static final String IDENTITY_SHARING_CONFIRMED = "identity_sharing_confirmed"; + public static final String INSTALLED = "installed"; + public static final String PACKAGE_NAME = "package_name"; + public static final String ACHIEVEMENT_TOTAL_COUNT = "achievement_total_count"; + public static final String LEADERBOARD_COUNT = "leaderboard_count"; + public static final String REAL_TIME_SUPPORT = "real_time_support"; + public static final String TURN_BASED_SUPPORT = "turn_based_support"; + public static final String SNAPSHOTS_ENABLED = "snapshots_enabled"; + public static final String THEME_COLOR = "theme_color"; + public static final String GAMEPAD_SUPPORT = "gamepad_support"; + public static final List CURRENT_GAME_COLUMNS = Collections.unmodifiableList(Arrays.asList( + EXTERNAL_GAME_ID, DISPLAY_NAME, PRIMARY_CATEGORY, SECONDARY_CATEGORY, GAME_DESCRIPTION, DEVELOPER_NAME, GAME_ICON_IMAGE_URI, GAME_ICON_IMAGE_URL, + GAME_HI_RES_IMAGE_URI, GAME_HI_RES_IMAGE_URL, FEATURED_IMAGE_URI, FEATURED_IMAGE_URL, PLAY_ENABLED_GAME, MUTED, IDENTITY_SHARING_CONFIRMED, INSTALLED, + PACKAGE_NAME, ACHIEVEMENT_TOTAL_COUNT, LEADERBOARD_COUNT, REAL_TIME_SUPPORT, TURN_BASED_SUPPORT, SNAPSHOTS_ENABLED, THEME_COLOR, GAMEPAD_SUPPORT + )); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/GameEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/GameEntity.java new file mode 100644 index 0000000000..090e1eb740 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/GameEntity.java @@ -0,0 +1,253 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; +import com.google.android.gms.common.util.DataUtils; + +@SafeParcelable.Class +public class GameEntity extends AbstractSafeParcelable implements Game { + @Field(value = 1, getterName = "getApplicationId") + private final String applicationId; + @Field(value = 2, getterName = "getDisplayName") + private final String displayName; + @Field(value = 3, getterName = "getPrimaryCategory") + private final String primaryCategory; + @Field(value = 4, getterName = "getSecondaryCategory") + private final String secondaryCategory; + @Field(value = 5, getterName = "getDescription") + private final String description; + @Field(value = 6, getterName = "getDeveloperName") + private final String developerName; + @Field(value = 7, getterName = "getIconImageUri") + private final Uri iconImageUri; + @Field(value = 8, getterName = "getHiResImageUri") + private final Uri hiResImageUri; + @Field(value = 9, getterName = "getFeaturedImageUri") + private final Uri featuredImageUri; + @Field(value = 10, getterName = "isPlayEnabledGame") + private final boolean isPlayEnabledGame; + @Field(value = 11, getterName = "isInstanceInstalled") + private final boolean isInstanceInstalled; + @Field(value = 12, getterName = "getInstancePackageName") + private final String instancePackageName; + @Field(value = 13, getterName = "getGameplayAclStatus") + private final int gameplayAclStatus; + @Field(value = 14, getterName = "getAchievementTotalCount") + private final int achievementTotalCount; + @Field(value = 15, getterName = "getLeaderboardCount") + private final int leaderboardCount; + @Field(value = 16, getterName = "isRealTimeMultiplayerEnabled") + private final boolean isRealTimeMultiplayerEnabled; + @Field(value = 17, getterName = "isTurnBasedMultiplayerEnabled") + private final boolean isTurnBasedMultiplayerEnabled; + @Field(value = 18, getterName = "getIconImageUrl") + private final String iconImageUrl; + @Field(value = 19, getterName = "getHiResImageUrl") + private final String hiResImageUrl; + @Field(value = 20, getterName = "getFeaturedImageUrl") + private final String featuredImageUrl; + @Field(value = 21, getterName = "isMuted") + private final boolean isMuted; + @Field(value = 22, getterName = "isIdentitySharingConfirmed") + private final boolean isIdentitySharingConfirmed; + @Field(value = 23, getterName = "areSnapshotsEnabled") + private final boolean areSnapshotsEnabled; + @Field(value = 24, getterName = "getThemeColor") + private final String getThemeColor; + @Field(value = 25, getterName = "hasGamepadSupport") + private final boolean hasGamepadSupport; + + public GameEntity(Game game) { + this.applicationId = game.getApplicationId(); + this.primaryCategory = game.getPrimaryCategory(); + this.secondaryCategory = game.getSecondaryCategory(); + this.description = game.getDescription(); + this.developerName = game.getDeveloperName(); + this.displayName = game.getDisplayName(); + this.iconImageUri = game.getIconImageUri(); + this.iconImageUrl = game.getIconImageUrl(); + this.hiResImageUri = game.getHiResImageUri(); + this.hiResImageUrl = game.getHiResImageUrl(); + this.featuredImageUri = game.getFeaturedImageUri(); + this.featuredImageUrl = game.getFeaturedImageUrl(); + this.isPlayEnabledGame = game.isPlayEnabledGame(); + this.isInstanceInstalled = game.isInstanceInstalled(); + this.instancePackageName = game.getInstancePackageName(); + this.gameplayAclStatus = 1; + this.achievementTotalCount = game.getAchievementTotalCount(); + this.leaderboardCount = game.getLeaderboardCount(); + this.isRealTimeMultiplayerEnabled = game.isRealTimeMultiplayerEnabled(); + this.isTurnBasedMultiplayerEnabled = game.isTurnBasedMultiplayerEnabled(); + this.isMuted = game.isMuted(); + this.isIdentitySharingConfirmed = game.isIdentitySharingConfirmed(); + this.areSnapshotsEnabled = game.areSnapshotsEnabled(); + this.getThemeColor = game.getThemeColor(); + this.hasGamepadSupport = game.hasGamepadSupport(); + } + + @Constructor + GameEntity(@Param(value = 1) String var1, @Param(value = 2) String var2, @Param(value = 3) String var3, @Param(value = 4) String var4, @Param(value = 5) String var5, @Param(value = 6) String var6, @Param(value = 7) Uri var7, @Param(value = 8) Uri var8, @Param(value = 9) Uri var9, @Param(value = 10) boolean var10, @Param(value = 11) boolean var11, @Param(value = 12) String var12, @Param(value = 13) int var13, @Param(value = 14) int var14, @Param(value = 15) int var15, @Param(value = 16) boolean var16, @Param(value = 17) boolean var17, @Param(value = 18) String var18, @Param(value = 19) String var19, @Param(value = 20) String var20, @Param(value = 21) boolean var21, @Param(value = 22) boolean var22, @Param(value = 23) boolean var23, @Param(value = 24) String var24, @Param(value = 25) boolean var25) { + this.applicationId = var1; + this.displayName = var2; + this.primaryCategory = var3; + this.secondaryCategory = var4; + this.description = var5; + this.developerName = var6; + this.iconImageUri = var7; + this.iconImageUrl = var18; + this.hiResImageUri = var8; + this.hiResImageUrl = var19; + this.featuredImageUri = var9; + this.featuredImageUrl = var20; + this.isPlayEnabledGame = var10; + this.isInstanceInstalled = var11; + this.instancePackageName = var12; + this.gameplayAclStatus = var13; + this.achievementTotalCount = var14; + this.leaderboardCount = var15; + this.isRealTimeMultiplayerEnabled = var16; + this.isTurnBasedMultiplayerEnabled = var17; + this.isMuted = var21; + this.isIdentitySharingConfirmed = var22; + this.areSnapshotsEnabled = var23; + this.getThemeColor = var24; + this.hasGamepadSupport = var25; + } + + public final String getApplicationId() { + return this.applicationId; + } + + public final String getDisplayName() { + return this.displayName; + } + + public final void getDisplayName(CharArrayBuffer var1) { + DataUtils.copyStringToBuffer(this.displayName, var1); + } + + public final String getPrimaryCategory() { + return this.primaryCategory; + } + + public final String getSecondaryCategory() { + return this.secondaryCategory; + } + + public final String getDescription() { + return this.description; + } + + public final void getDescription(CharArrayBuffer var1) { + DataUtils.copyStringToBuffer(this.description, var1); + } + + public final String getDeveloperName() { + return this.developerName; + } + + public final void getDeveloperName(CharArrayBuffer var1) { + DataUtils.copyStringToBuffer(this.developerName, var1); + } + + public final Uri getIconImageUri() { + return this.iconImageUri; + } + + public final String getIconImageUrl() { + return this.iconImageUrl; + } + + public final Uri getHiResImageUri() { + return this.hiResImageUri; + } + + public final String getHiResImageUrl() { + return this.hiResImageUrl; + } + + public final Uri getFeaturedImageUri() { + return this.featuredImageUri; + } + + public final String getFeaturedImageUrl() { + return this.featuredImageUrl; + } + + public final boolean isMuted() { + return this.isMuted; + } + + public final boolean isIdentitySharingConfirmed() { + return this.isIdentitySharingConfirmed; + } + + public final boolean isPlayEnabledGame() { + return this.isPlayEnabledGame; + } + + public final boolean isInstanceInstalled() { + return this.isInstanceInstalled; + } + + public final String getInstancePackageName() { + return this.instancePackageName; + } + + public final int getGameplayAclStatus() { return gameplayAclStatus; } + + public final int getAchievementTotalCount() { + return this.achievementTotalCount; + } + + public final int getLeaderboardCount() { + return this.leaderboardCount; + } + + public final boolean isRealTimeMultiplayerEnabled() { + return this.isRealTimeMultiplayerEnabled; + } + + public final boolean isTurnBasedMultiplayerEnabled() { + return this.isTurnBasedMultiplayerEnabled; + } + + public final boolean areSnapshotsEnabled() { + return this.areSnapshotsEnabled; + } + + public final String getThemeColor() { + return this.getThemeColor; + } + + public final boolean hasGamepadSupport() { + return this.hasGamepadSupport; + } + + public final Game freeze() { + return this; + } + + public final boolean isDataValid() { + return true; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(GameEntity.class); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/GameRef.java b/play-services-games/src/main/java/com/google/android/gms/games/GameRef.java new file mode 100644 index 0000000000..fe3cd29d16 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/GameRef.java @@ -0,0 +1,175 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import static com.google.android.gms.games.GameColumns.ACHIEVEMENT_TOTAL_COUNT; +import static com.google.android.gms.games.GameColumns.DEVELOPER_NAME; +import static com.google.android.gms.games.GameColumns.DISPLAY_NAME; +import static com.google.android.gms.games.GameColumns.EXTERNAL_GAME_ID; +import static com.google.android.gms.games.GameColumns.FEATURED_IMAGE_URI; +import static com.google.android.gms.games.GameColumns.FEATURED_IMAGE_URL; +import static com.google.android.gms.games.GameColumns.GAMEPAD_SUPPORT; +import static com.google.android.gms.games.GameColumns.GAME_DESCRIPTION; +import static com.google.android.gms.games.GameColumns.GAME_HI_RES_IMAGE_URI; +import static com.google.android.gms.games.GameColumns.GAME_HI_RES_IMAGE_URL; +import static com.google.android.gms.games.GameColumns.GAME_ICON_IMAGE_URI; +import static com.google.android.gms.games.GameColumns.GAME_ICON_IMAGE_URL; +import static com.google.android.gms.games.GameColumns.IDENTITY_SHARING_CONFIRMED; +import static com.google.android.gms.games.GameColumns.INSTALLED; +import static com.google.android.gms.games.GameColumns.LEADERBOARD_COUNT; +import static com.google.android.gms.games.GameColumns.MUTED; +import static com.google.android.gms.games.GameColumns.PACKAGE_NAME; +import static com.google.android.gms.games.GameColumns.PLAY_ENABLED_GAME; +import static com.google.android.gms.games.GameColumns.PRIMARY_CATEGORY; +import static com.google.android.gms.games.GameColumns.REAL_TIME_SUPPORT; +import static com.google.android.gms.games.GameColumns.SECONDARY_CATEGORY; +import static com.google.android.gms.games.GameColumns.SNAPSHOTS_ENABLED; +import static com.google.android.gms.games.GameColumns.THEME_COLOR; +import static com.google.android.gms.games.GameColumns.TURN_BASED_SUPPORT; + +import android.annotation.SuppressLint; +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.data.DataBufferRef; +import com.google.android.gms.common.data.DataHolder; + +@SuppressLint("ParcelCreator") +public class GameRef extends DataBufferRef implements Game { + public GameRef(DataHolder var1, int var2) { + super(var1, var2); + } + + public String getApplicationId() { + return this.getString(EXTERNAL_GAME_ID); + } + + public String getDisplayName() { + return this.getString(DISPLAY_NAME); + } + + public void getDisplayName(CharArrayBuffer var1) { + this.copyToBuffer(DISPLAY_NAME, var1); + } + + public String getPrimaryCategory() { + return this.getString(PRIMARY_CATEGORY); + } + + public String getSecondaryCategory() { + return this.getString(SECONDARY_CATEGORY); + } + + public String getDescription() { + return this.getString(GAME_DESCRIPTION); + } + + public void getDescription(CharArrayBuffer var1) { + this.copyToBuffer(GAME_DESCRIPTION, var1); + } + + public String getDeveloperName() { + return this.getString(DEVELOPER_NAME); + } + + public void getDeveloperName(CharArrayBuffer var1) { + this.copyToBuffer(DEVELOPER_NAME, var1); + } + + public Uri getIconImageUri() { + return this.parseUri(GAME_ICON_IMAGE_URI); + } + + public String getIconImageUrl() { + return this.getString(GAME_ICON_IMAGE_URL); + } + + public Uri getHiResImageUri() { + return this.parseUri(GAME_HI_RES_IMAGE_URI); + } + + public String getHiResImageUrl() { + return this.getString(GAME_HI_RES_IMAGE_URL); + } + + public Uri getFeaturedImageUri() { + return this.parseUri(FEATURED_IMAGE_URI); + } + + public String getFeaturedImageUrl() { + return this.getString(FEATURED_IMAGE_URL); + } + + public boolean isPlayEnabledGame() { + return this.getBoolean(PLAY_ENABLED_GAME); + } + + public boolean isMuted() { + return this.getBoolean(MUTED); + } + + public boolean isIdentitySharingConfirmed() { + return this.getBoolean(IDENTITY_SHARING_CONFIRMED); + } + + public boolean isInstanceInstalled() { + return this.getInteger(INSTALLED) > 0; + } + + public String getInstancePackageName() { + return this.getString(PACKAGE_NAME); + } + + @Override + public int getGameplayAclStatus() { + return 0; + } + + public int getAchievementTotalCount() { + return this.getInteger(ACHIEVEMENT_TOTAL_COUNT); + } + + public int getLeaderboardCount() { + return this.getInteger(LEADERBOARD_COUNT); + } + + public boolean isRealTimeMultiplayerEnabled() { + return this.getInteger(REAL_TIME_SUPPORT) > 0; + } + + public boolean isTurnBasedMultiplayerEnabled() { + return this.getInteger(TURN_BASED_SUPPORT) > 0; + } + + public boolean areSnapshotsEnabled() { + return this.getInteger(SNAPSHOTS_ENABLED) > 0; + } + + public String getThemeColor() { + return this.getString(THEME_COLOR); + } + + public boolean hasGamepadSupport() { + return this.getInteger(GAMEPAD_SUPPORT) > 0; + } + + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + this.freeze().writeToParcel(dest, flags); + } + + @Override + public Game freeze() { + return new GameEntity(this); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/Games.java b/play-services-games/src/main/java/com/google/android/gms/games/Games.java new file mode 100644 index 0000000000..78bae571a2 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/Games.java @@ -0,0 +1,236 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.Gravity; + +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.android.gms.common.api.Api; +import com.google.android.gms.common.api.Scope; + +import java.util.ArrayList; +import java.util.Collections; + +public class Games { + + public static final String TAG = "GamesAPI"; + public static final String EXTRA_PLAYER_IDS = "players"; + public static final String EXTRA_STATUS = "status"; + public static final String SERVICE_GAMES = "oauth2:https://www.googleapis.com/auth/games"; + public static final String SERVICE_GAMES_LITE = "oauth2:https://www.googleapis.com/auth/games_lite"; + public static final String SERVICE_GAMES_SNAPSHOTS = "oauth2:https://www.googleapis.com/auth/drive.appdata https://www.googleapis.com/auth/games_lite"; + public static final Scope SCOPE_GAMES = new Scope("https://www.googleapis.com/auth/games"); + public static final Scope SCOPE_GAMES_LITE = new Scope("https://www.googleapis.com/auth/games_lite"); + public static final Scope SCOPE_GAMES_SNAPSHOTS = new Scope("https://www.googleapis.com/auth/drive.appdata"); + public static final Scope SCOPE_GAMES_FIRST_PARTY = new Scope("https://www.googleapis.com/auth/games.firstparty"); + + public static final Api API = new Api<>(new GamesApiClientBuilder(Collections.singletonList(Games.SCOPE_GAMES))); + public static final Api API_1P = new Api<>(new GamesApiClientBuilder(Collections.singletonList(Games.SCOPE_GAMES_FIRST_PARTY))); + + public static final class GamesOptions implements Api.ApiOptions.HasGoogleSignInAccountOptions { + + public boolean isHeadless; + public boolean showConnectingPopup; + public int connectingPopupGravity; + public int sdkVariant; + public String forceResolveAccountKey; + public ArrayList proxyApis; + public boolean unauthenticated; + public boolean skipPgaCheck; + public boolean skipWelcomePopup; + public GoogleSignInAccount googleSignInAccount; + public String realClientPackageName; + public int unknownIntValue; + public int API_VERSION; + + public static final class Builder { + private boolean isHeadless; + private boolean showConnectingPopup; + private int connectingPopupGravity; + private int sdkVariant; + private String forceResolveAccountKey; + private ArrayList proxyApis; + private boolean unauthenticated; + private boolean skipPgaCheck; + private boolean skipWelcomePopup; + private GoogleSignInAccount googleSignInAccount; + private String realClientPackageName; + private int unknownIntValue; + private int API_VERSION; + + public Builder() { + this.isHeadless = false; + this.showConnectingPopup = true; + this.connectingPopupGravity = 17; + this.sdkVariant = 4368; + this.forceResolveAccountKey = null; + this.proxyApis = new ArrayList<>(); + this.unauthenticated = false; + this.skipPgaCheck = false; + this.skipWelcomePopup = false; + this.googleSignInAccount = null; + this.realClientPackageName = null; + this.unknownIntValue = 0; + this.API_VERSION = 9; + } + + public Builder setRealClientPackageName(String packageName) { + this.realClientPackageName = packageName; + return this; + } + + public void isHeadless() { + this.isHeadless = true; + } + + public void skipWelcomePopup(Boolean bool) { + this.skipWelcomePopup = bool; + } + + public GamesOptions build() { + return new GamesOptions(this.isHeadless, this.showConnectingPopup, this.connectingPopupGravity, this.sdkVariant, this.forceResolveAccountKey, this.proxyApis, this.unauthenticated, this.skipPgaCheck, this.skipWelcomePopup, this.googleSignInAccount, this.realClientPackageName, this.unknownIntValue, this.API_VERSION); + } + + public void unauthenticated() { + this.unauthenticated = true; + } + + public Builder setSdkVariant(int sdkVariant) { + this.sdkVariant = sdkVariant; + return this; + } + + public Builder setShowConnectingPopup(boolean showConnectingPopup) { + this.showConnectingPopup = showConnectingPopup; + this.connectingPopupGravity = Gravity.CENTER; + return this; + } + + public Builder(GamesOptions gamesOptions) { + this.isHeadless = false; + this.showConnectingPopup = true; + this.connectingPopupGravity = Gravity.CENTER; + this.sdkVariant = 4368; + this.forceResolveAccountKey = null; + this.proxyApis = new ArrayList<>(); + this.unauthenticated = false; + this.skipPgaCheck = false; + this.skipWelcomePopup = false; + this.googleSignInAccount = null; + this.realClientPackageName = null; + this.unknownIntValue = 0; + this.API_VERSION = 9; + if (gamesOptions != null) { + this.isHeadless = gamesOptions.isHeadless; + this.showConnectingPopup = gamesOptions.showConnectingPopup; + this.connectingPopupGravity = gamesOptions.connectingPopupGravity; + this.sdkVariant = gamesOptions.sdkVariant; + this.forceResolveAccountKey = gamesOptions.forceResolveAccountKey; + this.proxyApis = gamesOptions.proxyApis; + this.unauthenticated = gamesOptions.unauthenticated; + this.skipPgaCheck = gamesOptions.skipPgaCheck; + this.skipWelcomePopup = gamesOptions.skipWelcomePopup; + this.googleSignInAccount = gamesOptions.googleSignInAccount; + this.realClientPackageName = gamesOptions.realClientPackageName; + this.unknownIntValue = gamesOptions.unknownIntValue; + this.API_VERSION = gamesOptions.API_VERSION; + } + } + + public Builder setShowConnectingPopup(boolean showConnectingPopup, int connectingPopupGravity) { + this.showConnectingPopup = showConnectingPopup; + this.connectingPopupGravity = connectingPopupGravity; + return this; + } + } + + public GamesOptions(boolean isHeadless, boolean showConnectingPopup, int connectingPopupGravity, int sdkVariant, String forceResolveAccountKey, ArrayList proxyApis, boolean unauthenticated, boolean skipPgaCheck, boolean skipWelcomePopup, GoogleSignInAccount googleSignInAccount, String realClientPackageName, int unknownIntValue, int API_VERSION) { + this.isHeadless = isHeadless; + this.showConnectingPopup = showConnectingPopup; + this.connectingPopupGravity = connectingPopupGravity; + this.sdkVariant = sdkVariant; + this.forceResolveAccountKey = forceResolveAccountKey; + this.proxyApis = proxyApis; + this.unauthenticated = unauthenticated; + this.skipPgaCheck = skipPgaCheck; + this.skipWelcomePopup = skipWelcomePopup; + this.googleSignInAccount = googleSignInAccount; + this.realClientPackageName = realClientPackageName; + this.unknownIntValue = unknownIntValue; + this.API_VERSION = API_VERSION; + } + + public static Builder builder() { + return new Builder(); + } + + public static Builder builder(GoogleSignInAccount googleSignInAccount) { + Builder builder = new Builder(null); + builder.googleSignInAccount = googleSignInAccount; + return builder; + } + + @Override + public GoogleSignInAccount getGoogleSignInAccount() { + return googleSignInAccount; + } + + public Bundle getGamesOptions() { + Bundle bundle = new Bundle(); + bundle.putBoolean("com.google.android.gms.games.key.isHeadless", this.isHeadless); + bundle.putBoolean("com.google.android.gms.games.key.showConnectingPopup", this.showConnectingPopup); + bundle.putInt("com.google.android.gms.games.key.connectingPopupGravity", this.connectingPopupGravity); + bundle.putBoolean("com.google.android.gms.games.key.retryingSignIn", false); + bundle.putInt("com.google.android.gms.games.key.sdkVariant", this.sdkVariant); + bundle.putString("com.google.android.gms.games.key.forceResolveAccountKey", this.forceResolveAccountKey); + bundle.putStringArrayList("com.google.android.gms.games.key.proxyApis", this.proxyApis); + bundle.putBoolean("com.google.android.gms.games.key.unauthenticated", this.unauthenticated); + bundle.putBoolean("com.google.android.gms.games.key.skipPgaCheck", this.skipPgaCheck); + bundle.putBoolean("com.google.android.gms.games.key.skipWelcomePopup", this.skipWelcomePopup); + bundle.putParcelable("com.google.android.gms.games.key.googleSignInAccount", this.googleSignInAccount); + bundle.putString("com.google.android.gms.games.key.realClientPackageName", this.realClientPackageName); + bundle.putInt("com.google.android.gms.games.key.API_VERSION", this.API_VERSION); + bundle.putString("com.google.android.gms.games.key.gameRunToken", null); + return bundle; + } + + @Override + public String toString() { + return "GamesOptions{" + "isHeadless=" + isHeadless + ", showConnectingPopup=" + showConnectingPopup + ", connectingPopupGravity=" + connectingPopupGravity + ", sdkVariant=" + sdkVariant + ", forceResolveAccountKey='" + forceResolveAccountKey + '\'' + ", proxyApis=" + proxyApis + ", unauthenticated=" + unauthenticated + ", skipPgaCheck=" + skipPgaCheck + ", skipWelcomePopup=" + skipWelcomePopup + ", googleSignInAccount=" + googleSignInAccount + ", realClientPackageName='" + realClientPackageName + '\'' + ", unknownIntValue=" + unknownIntValue + ", API_VERSION=" + API_VERSION + '}'; + } + } + + public static AchievementsClient getAchievementsClient(Context context, GoogleSignInAccount googleSignInAccount) { + Log.d(Games.TAG, "getAchievementsClient: start"); + if (googleSignInAccount == null) { + Log.d(Games.TAG, "getAchievementsClient: googleSignInAccount must not be null"); + return null; + } + return new AchievementsClientImpl(context, GamesOptions.builder(googleSignInAccount).build()); + } + + public static LeaderboardsClient getLeaderboardsClient(Context context, GoogleSignInAccount googleSignInAccount) { + Log.d(Games.TAG, "getLeaderboardsClient: start"); + if (googleSignInAccount == null) { + Log.d(Games.TAG, "getLeaderboardsClient: googleSignInAccount must not be null"); + return null; + } + return new LeaderboardsClientImpl(context, GamesOptions.builder(googleSignInAccount).build()); + } + + public static SnapshotsClient getSnapshotsClient(Context context, GoogleSignInAccount googleSignInAccount) { + Log.d(Games.TAG, "getSnapshotsClient: start"); + if (googleSignInAccount == null) { + Log.d(Games.TAG, "getSnapshotsClient: googleSignInAccount must not be null"); + return null; + } + return new SnapshotsClientImpl(context, GamesOptions.builder(googleSignInAccount).build()); + } + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/GamesApiClientBuilder.java b/play-services-games/src/main/java/com/google/android/gms/games/GamesApiClientBuilder.java new file mode 100644 index 0000000000..4554b98b91 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/GamesApiClientBuilder.java @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Context; +import android.os.Looper; +import android.util.Log; + +import com.google.android.gms.common.api.Api; +import com.google.android.gms.common.api.Scope; + +import org.microg.gms.common.api.ApiClientBuilder; +import org.microg.gms.common.api.ApiClientSettings; +import org.microg.gms.common.api.ConnectionCallbacks; +import org.microg.gms.common.api.OnConnectionFailedListener; + +import java.util.ArrayList; +import java.util.List; + +public class GamesApiClientBuilder implements ApiClientBuilder { + + private final List scopeList = new ArrayList<>(); + + public GamesApiClientBuilder(List scopes) { + scopeList.addAll(scopes); + } + + @Override + public Api.Client build(Games.GamesOptions options, Context context, Looper looper, ApiClientSettings clientSettings, ConnectionCallbacks callbacks, OnConnectionFailedListener connectionFailedListener) { + Log.d(Games.TAG, "GamesGmsClientImpl build options: " + options.toString()); + return new GamesGmsClientImpl(context, options, callbacks, connectionFailedListener); + } + + public List getScopes(Games.GamesOptions options) { + return scopeList; + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/GamesGmsClientImpl.java b/play-services-games/src/main/java/com/google/android/gms/games/GamesGmsClientImpl.java new file mode 100644 index 0000000000..b0c777fa32 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/GamesGmsClientImpl.java @@ -0,0 +1,41 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Context; +import android.os.IBinder; +import android.util.Log; + +import com.google.android.gms.common.api.Scope; +import com.google.android.gms.games.internal.IGamesService; + +import org.microg.gms.common.GmsClient; +import org.microg.gms.common.GmsService; +import org.microg.gms.common.api.ConnectionCallbacks; +import org.microg.gms.common.api.OnConnectionFailedListener; + +public class GamesGmsClientImpl extends GmsClient { + private final Context context; + private final Games.GamesOptions mGamesOptions; + + public GamesGmsClientImpl(Context context, Games.GamesOptions gamesOptions, ConnectionCallbacks callbacks, OnConnectionFailedListener connectionFailedListener) { + super(context, callbacks, connectionFailedListener, GmsService.GAMES.ACTION); + Log.d(Games.TAG, "GamesGmsClientImpl: connect"); + this.context = context; + packageName = gamesOptions.realClientPackageName; + account = gamesOptions.googleSignInAccount.getAccount(); + scopes = gamesOptions.googleSignInAccount.getGrantedScopes().toArray(new Scope[0]); + serviceId = GmsService.GAMES.SERVICE_ID; + + mGamesOptions = gamesOptions; + } + + @Override + protected IGamesService interfaceFromBinder(IBinder binder) { + return IGamesService.Stub.asInterface(binder); + } + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/GamesStatusCodes.java b/play-services-games/src/main/java/com/google/android/gms/games/GamesStatusCodes.java new file mode 100644 index 0000000000..c50ae42452 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/GamesStatusCodes.java @@ -0,0 +1,90 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +package com.google.android.gms.games; + +import android.app.PendingIntent; +import com.google.android.gms.common.api.Status; +import java.util.Locale; + +public enum GamesStatusCodes { + ACHIEVEMENT_NOT_INCREMENTAL(3002, "Achievement not incremental"), + ACHIEVEMENT_UNKNOWN(3001, "Achievement unknown"), + ACHIEVEMENT_UNLOCKED(3003, "Achievement unlocked"), + ACHIEVEMENT_UNLOCK_FAILURE(3000, "Achievement unlock failure"), + APP_MISCONFIGURED(8, "App misconfigured"), + CLIENT_RECONNECT_REQUIRED(2, "Client reconnect required"), + GAME_NOT_FOUND(9, "Game not found"), + INTERNAL_ERROR(1, "Internal error"), + INTERRUPTED(14, "Interrupted"), + INVALID_REAL_TIME_ROOM_ID(7002, "Invalid real time room ID"), + LICENSE_CHECK_FAILED(7, "License check failed"), + MATCH_ERROR_ALREADY_REMATCHED(6505, "Match error already rematched"), + MATCH_ERROR_INACTIVE_MATCH(6501, "Match error inactive match"), + MATCH_ERROR_INVALID_MATCH_RESULTS(6504, "Match error invalid match results"), + MATCH_ERROR_INVALID_MATCH_STATE(6502, "Match error invalid match state"), + MATCH_ERROR_INVALID_PARTICIPANT_STATE(6500, "Match error invalid participant state"), + MATCH_ERROR_LOCALLY_MODIFIED(6507, "Match error locally modified"), + MATCH_ERROR_OUT_OF_DATE_VERSION(6503, "Match error out of date version"), + MATCH_NOT_FOUND(6506, "Match not found"), + MILESTONE_CLAIMED_PREVIOUSLY(8000, "Milestone claimed previously"), + MILESTONE_CLAIM_FAILED(8001, "Milestone claim failed"), + MULTIPLAYER_DISABLED(6003, "Multiplayer disabled"), + MULTIPLAYER_ERROR_CREATION_NOT_ALLOWED(6000, "Multiplayer error creation not allowed"), + MULTIPLAYER_ERROR_INVALID_MULTIPLAYER_TYPE(6002, "Multiplayer error invalid multiplayer type"), + MULTIPLAYER_ERROR_INVALID_OPERATION(6004, "Multiplayer error invalid operation"), + MULTIPLAYER_ERROR_NOT_TRUSTED_TESTER(6001, "Multiplayer error not trusted tester"), + NETWORK_ERROR_NO_DATA(4, "Network error no data"), + NETWORK_ERROR_OPERATION_DEFERRED(5, "Network error operation deferred"), + NETWORK_ERROR_OPERATION_FAILED(6, "Network error operation failed"), + NETWORK_ERROR_STALE_DATA(3, "Network error stale data"), + OK(0, "OK"), + OPERATION_IN_FLIGHT(7007, "Operation in flight"), + PARTICIPANT_NOT_CONNECTED(7003, "Participant not connected"), + QUEST_NOT_STARTED(8003, "Quest not started"), + QUEST_NO_LONGER_AVAILABLE(8002, "Quest no longer available"), + REAL_TIME_CONNECTION_FAILED(7000, "Real time connection failed"), + REAL_TIME_INACTIVE_ROOM(7005, "Real time inactive room"), + REAL_TIME_MESSAGE_SEND_FAILED(7001, "Real time message send failed"), + REAL_TIME_ROOM_NOT_JOINED(7004, "Real time room not joined"), + REQUEST_TOO_MANY_RECIPIENTS(2002, "Request too many recipients"), + REQUEST_UPDATE_PARTIAL_SUCCESS(2000, "Request update partial success"), + REQUEST_UPDATE_TOTAL_FAILURE(2001, "Request update total failure"), + SNAPSHOT_COMMIT_FAILED(4003, "Snapshot commit failed"), + SNAPSHOT_CONFLICT(4004, "Snapshot conflict"), + SNAPSHOT_CONFLICT_MISSING(4006, "Snapshot conflict missing"), + SNAPSHOT_CONTENTS_UNAVAILABLE(4002, "Snapshot contents unavailable"), + SNAPSHOT_CREATION_FAILED(4001, "Snapshot creation failed"), + SNAPSHOT_FOLDER_UNAVAILABLE(4005, "Snapshot folder unavailable"), + SNAPSHOT_NOT_FOUND(4000, "Snapshot not found"), + TIMEOUT(15, "Timeout"), + VIDEO_ALREADY_CAPTURING(9006, "Video already capturing"), + VIDEO_NOT_ACTIVE(9000, "Video not active"), + VIDEO_OUT_OF_DISK_SPACE(9009, "Video out of disk space"), + VIDEO_PERMISSION_ERROR(9002, "Video permission error"), + VIDEO_STORAGE_ERROR(9003, "Video storage error"), + VIDEO_UNEXPECTED_CAPTURE_ERROR(9004, "Video unexpected capture error"), + VIDEO_UNSUPPORTED(9001, "Video unsupported"); + + private final int code; + private final String description; + + GamesStatusCodes(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + + public static Status createStatus(GamesStatusCodes gamesStatusCodes) { + return new Status(gamesStatusCodes.code, gamesStatusCodes.description); + } + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/LeaderboardsClient.java b/play-services-games/src/main/java/com/google/android/gms/games/LeaderboardsClient.java new file mode 100644 index 0000000000..6fd73510f0 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/LeaderboardsClient.java @@ -0,0 +1,224 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Intent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.api.Releasable; +import com.google.android.gms.games.leaderboard.Leaderboard; +import com.google.android.gms.games.leaderboard.LeaderboardBuffer; +import com.google.android.gms.games.leaderboard.LeaderboardScore; +import com.google.android.gms.games.leaderboard.LeaderboardScoreBuffer; +import com.google.android.gms.tasks.Task; + +public interface LeaderboardsClient { + + /** + * Returns a Task which asynchronously loads an Intent to show the list of leaderboards for a game. + * Note that this must be invoked with Activity.startActivityForResult(Intent, int), so that the identity of the calling package can be established. + */ + Task getAllLeaderboardsIntent(); + + /** + * Returns a Task which asynchronously loads an Intent to show a leaderboard for a game specified by a leaderboardId. + * Note that the Intent returned from the Task must be invoked with Activity.startActivityForResult(Intent, int), so that the identity of the calling package can be established. + * + * @param leaderboardId The ID of the leaderboard to view. + * @param timeSpan Time span to retrieve data for. Valid values are LeaderboardVariant.TIME_SPAN_DAILY, LeaderboardVariant.TIME_SPAN_WEEKLY, or LeaderboardVariant.TIME_SPAN_ALL_TIME. + */ + Task getLeaderboardIntent(String leaderboardId, int timeSpan); + + /** + * Returns a Task which asynchronously loads an Intent to show a leaderboard for a game specified by a leaderboardId. + * Note that the Intent returned from the Task must be invoked with Activity.startActivityForResult(Intent, int), so that the identity of the calling package can be established. + * + * @param leaderboardId The ID of the leaderboard to view. + */ + Task getLeaderboardIntent(String leaderboardId); + + /** + * Returns a Task which asynchronously loads an Intent to show a leaderboard for a game specified by a leaderboardId. + * Note that the Intent returned from the Task must be invoked with Activity.startActivityForResult(Intent, int), so that the identity of the calling package can be established. + * + * @param leaderboardId The ID of the leaderboard to view. + * @param timeSpan Time span to retrieve data for. Valid values are LeaderboardVariant.TIME_SPAN_DAILY, LeaderboardVariant.TIME_SPAN_WEEKLY, or LeaderboardVariant.TIME_SPAN_ALL_TIME. + * @param collection The collection to show by default. Valid values are LeaderboardVariant.COLLECTION_PUBLIC or LeaderboardVariant.COLLECTION_FRIENDS. + */ + Task getLeaderboardIntent(String leaderboardId, int timeSpan, int collection); + + /** + * Returns a Task which asynchronously loads an annotated LeaderboardScore that represents the signed-in player's score for the leaderboard specified by leaderboardId. + *

+ * For LeaderboardVariant.COLLECTION_FRIENDS, this call will fail with FriendsResolutionRequiredException if the user has not granted the game access to their friends list. The exception result can be used to ask for consent. + * + * @param leaderboardId ID of the leaderboard to load the score from. + * @param span Time span to retrieve data for. Valid values are LeaderboardVariant.TIME_SPAN_DAILY, LeaderboardVariant.TIME_SPAN_WEEKLY, or LeaderboardVariant.TIME_SPAN_ALL_TIME. + * @param leaderboardCollection The leaderboard collection to retrieve scores for. Valid values are either LeaderboardVariant.COLLECTION_PUBLIC or LeaderboardVariant.COLLECTION_FRIENDS. + */ + Task> loadCurrentPlayerLeaderboardScore(String leaderboardId, int span, int leaderboardCollection); + + /** + * Returns a Task which asynchronously loads an annotated LeaderboardBuffer that represents a list of leaderboards metadata for this game. + * + * @param forceReload If true, this call will clear any locally cached data and attempt to fetch the latest data from the server. + * This would commonly be used for something like a user-initiated refresh. + * Normally, this should be set to false to gain advantages of data caching. + */ + Task> loadLeaderboardMetadata(boolean forceReload); + + /** + * Returns a Task which asynchronously loads an annotated Leaderboard specified by leaderboardId. + * + * @param leaderboardId ID of the leaderboard to load metadata for. + * @param forceReload If true, this call will clear any locally cached data and attempt to fetch the latest data from the server. + * This would commonly be used for something like a user-initiated refresh. + * Normally, this should be set to false to gain advantages of data caching. + */ + Task> loadLeaderboardMetadata(String leaderboardId, boolean forceReload); + + /** + * Returns a Task which asynchronously loads an annotated LeaderboardsClient.LeaderboardScores that represents an additional page of score data for the given score buffer. + * A new score buffer will be delivered that replaces the given buffer. + *

+ * LeaderboardsClient.LeaderboardScores.release() should be called to release resources after usage. + *

+ * For LeaderboardVariant.COLLECTION_FRIENDS, this call will fail with FriendsResolutionRequiredException if the user has not granted the game access to their friends list. + * The exception result can be used to ask for consent. + * + * @param buffer The existing buffer that will be expanded. The buffer is allowed to be closed prior to being passed in to this method. + * @param maxResults The maximum number of scores to fetch per page. Must be between 1 and 25. Note that the number of scores returned here may be greater than this value, depending on how much data is cached on the device. + * @param pageDirection The direction to expand the buffer. Values are defined in PageDirection. + */ + Task> loadMoreScores(LeaderboardScoreBuffer buffer, int maxResults, int pageDirection); + + /** + * Returns a Task which asynchronously loads an annotated LeaderboardsClient.LeaderboardScores that represents the player-centered page of scores for the leaderboard specified by leaderboardId. + * If the player does not have a score on this leaderboard, this call will return the top page instead. + *

+ * LeaderboardsClient.LeaderboardScores.release() should be called to release resources after usage. + *

+ * For LeaderboardVariant.COLLECTION_FRIENDS, this call will fail with FriendsResolutionRequiredException if the user has not granted the game access to their friends list. The exception result can be used to ask for consent. + * + * @param leaderboardId ID of the leaderboard. + * @param span Time span to retrieve data for. Valid values are LeaderboardVariant.TIME_SPAN_DAILY, LeaderboardVariant.TIME_SPAN_WEEKLY, or LeaderboardVariant.TIME_SPAN_ALL_TIME. + * @param leaderboardCollection The leaderboard collection to retrieve scores for. Valid values are either LeaderboardVariant.COLLECTION_PUBLIC or LeaderboardVariant.COLLECTION_FRIENDS. + * @param maxResults The maximum number of scores to fetch per page. Must be between 1 and 25. + * @param forceReload If true, this call will clear any locally cached data and attempt to fetch the latest data from the server. This would commonly be used for something like a user-initiated refresh. Normally, this should be set to false to gain advantages of data caching. + */ + Task> loadPlayerCenteredScores(String leaderboardId, int span, int leaderboardCollection, int maxResults, boolean forceReload); + + /** + * Returns a Task which asynchronously loads an annotated LeaderboardsClient.LeaderboardScores that represents the player-centered page of scores for the leaderboard specified by leaderboardId. + * If the player does not have a score on this leaderboard, this call will return the top page instead. + *

+ * LeaderboardsClient.LeaderboardScores.release() should be called to release resources after usage. + *

+ * For LeaderboardVariant.COLLECTION_FRIENDS, this call will fail with FriendsResolutionRequiredException if the user has not granted the game access to their friends list. The exception result can be used to ask for consent. + * + * @param leaderboardId ID of the leaderboard. + * @param span Time span to retrieve data for. Valid values are LeaderboardVariant.TIME_SPAN_DAILY, LeaderboardVariant.TIME_SPAN_WEEKLY, or LeaderboardVariant.TIME_SPAN_ALL_TIME. + * @param leaderboardCollection The leaderboard collection to retrieve scores for. Valid values are either LeaderboardVariant.COLLECTION_PUBLIC or LeaderboardVariant.COLLECTION_FRIENDS. + * @param maxResults The maximum number of scores to fetch per page. Must be between 1 and 25. + */ + Task> loadPlayerCenteredScores(String leaderboardId, int span, int leaderboardCollection, int maxResults); + + /** + * Returns a Task which asynchronously loads an annotated LeaderboardsClient.LeaderboardScores that represents the top page of scores for a given leaderboard specified by leaderboardId. + *

+ * LeaderboardsClient.LeaderboardScores.release() should be called to release resources after usage. + *

+ * For LeaderboardVariant.COLLECTION_FRIENDS, this call will fail with FriendsResolutionRequiredException if the user has not granted the game access to their friends list. + * The exception result can be used to ask for consent. + * + * @param leaderboardId ID of the leaderboard. + * @param span Time span to retrieve data for. Valid values are LeaderboardVariant.TIME_SPAN_DAILY, LeaderboardVariant.TIME_SPAN_WEEKLY, or LeaderboardVariant.TIME_SPAN_ALL_TIME. + * @param leaderboardCollection The leaderboard collection to retrieve scores for. Valid values are either LeaderboardVariant.COLLECTION_PUBLIC or LeaderboardVariant.COLLECTION_FRIENDS. + * @param maxResults The maximum number of scores to fetch per page. Must be between 1 and 25. + */ + Task> loadTopScores(String leaderboardId, int span, int leaderboardCollection, int maxResults); + + /** + * Returns a Task which asynchronously loads an annotated LeaderboardsClient.LeaderboardScores that represents the top page of scores for the leaderboard specified by leaderboardId. + *

+ * LeaderboardsClient.LeaderboardScores.release() should be called to release resources after usage. + *

+ * For LeaderboardVariant.COLLECTION_FRIENDS, this call will fail with FriendsResolutionRequiredException if the user has not granted the game access to their friends list. + * The exception result can be used to ask for consent. + * + * @param leaderboardId ID of the leaderboard. + * @param span Time span to retrieve data for. Valid values are LeaderboardVariant.TIME_SPAN_DAILY, LeaderboardVariant.TIME_SPAN_WEEKLY, or LeaderboardVariant.TIME_SPAN_ALL_TIME. + * @param leaderboardCollection The leaderboard collection to retrieve scores for. Valid values are either LeaderboardVariant.COLLECTION_PUBLIC or LeaderboardVariant.COLLECTION_FRIENDS. + * @param maxResults The maximum number of scores to fetch per page. Must be between 1 and 25. + * @param forceReload If true, this call will clear any locally cached data and attempt to fetch the latest data from the server. + * This would commonly be used for something like a user-initiated refresh. Normally, this should be set to false to gain advantages of data caching. + */ + Task> loadTopScores(String leaderboardId, int span, int leaderboardCollection, int maxResults, boolean forceReload); + + /** + * Submit a score to a leaderboard for the currently signed-in player. The score is ignored if it is worse (as defined by the leaderboard configuration) than a previously submitted score for the same player. + *

+ * This form of the API is a fire-and-forget form. Use this if you do not need to be notified of the results of submitting the score, though note that the update may not be sent to the server until the next sync. + *

+ * The meaning of the score value depends on the formatting of the leaderboard established in the developer console. Leaderboards support the following score formats: + *

+ * Fixed-point: score represents a raw value, and will be formatted based on the number of decimal places configured. A score of 1000 would be formatted as 1000, 100.0, or 10.00 for 0, 1, or 2 decimal places. + * Time: score represents an elapsed time in milliseconds. The value will be formatted as an appropriate time value. + * Currency: score represents a value in micro units. For example, in USD, a score of 100 would display as $0.0001, while a score of 1000000 would display as $1.00 + * For more details, please see Leaderboard Concepts. + * + * @param leaderboardId The leaderboard to submit the score to. + * @param score The raw score value. + * @param scoreTag Optional metadata about this score. The value may contain no more than 64 URI-safe characters as defined by section 2.3 of RFC 3986. + */ + void submitScore(String leaderboardId, long score, String scoreTag); + + /** + * Submit a score to a leaderboard for the currently signed-in player. The score is ignored if it is worse (as defined by the leaderboard configuration) than a previously submitted score for the same player. + *

+ * This form of the API is a fire-and-forget form. Use this if you do not need to be notified of the results of submitting the score, though note that the update may not be sent to the server until the next sync. + *

+ * The meaning of the score value depends on the formatting of the leaderboard established in the developer console. Leaderboards support the following score formats: + *

+ * Fixed-point: score represents a raw value, and will be formatted based on the number of decimal places configured. A score of 1000 would be formatted as 1000, 100.0, or 10.00 for 0, 1, or 2 decimal places. + * Time: score represents an elapsed time in milliseconds. The value will be formatted as an appropriate time value. + * Currency: score represents a value in micro units. For example, in USD, a score of 100 would display as $0.0001, while a score of 1000000 would display as $1.00 + * For more details, please see Leaderboard Concepts. + * + * @param leaderboardId The leaderboard to submit the score to. + * @param score The raw score value. + */ + void submitScore(String leaderboardId, long score); + + /** + * Result delivered when leaderboard scores have been loaded. + */ + class LeaderboardScores implements Releasable { + private final Leaderboard leaderboard; + private final LeaderboardScoreBuffer leaderboardScores; + + public LeaderboardScores(@Nullable Leaderboard leaderboard, @NonNull LeaderboardScoreBuffer leaderboardScores) { + this.leaderboard = leaderboard; + this.leaderboardScores = leaderboardScores; + } + + @Nullable + public Leaderboard getLeaderboard() { + return this.leaderboard; + } + + @NonNull + public LeaderboardScoreBuffer getScores() { + return this.leaderboardScores; + } + + public void release() { + this.leaderboardScores.release(); + } + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/LeaderboardsClientImpl.java b/play-services-games/src/main/java/com/google/android/gms/games/LeaderboardsClientImpl.java new file mode 100644 index 0000000000..73b078a3dd --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/LeaderboardsClientImpl.java @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Context; +import android.content.Intent; +import android.util.Log; + +import com.google.android.gms.common.api.GoogleApi; +import com.google.android.gms.games.leaderboard.Leaderboard; +import com.google.android.gms.games.leaderboard.LeaderboardBuffer; +import com.google.android.gms.games.leaderboard.LeaderboardScore; +import com.google.android.gms.games.leaderboard.LeaderboardScoreBuffer; +import com.google.android.gms.tasks.Task; + +public class LeaderboardsClientImpl extends GoogleApi implements LeaderboardsClient { + + public LeaderboardsClientImpl(Context context, Games.GamesOptions options) { + super(context, Games.API, options); + Log.d(Games.TAG, "LeaderboardsClientImpl: options: " + options); + } + + @Override + public Task getAllLeaderboardsIntent() { + return null; + } + + @Override + public Task getLeaderboardIntent(String leaderboardId, int timeSpan) { + return null; + } + + @Override + public Task getLeaderboardIntent(String leaderboardId) { + return null; + } + + @Override + public Task getLeaderboardIntent(String leaderboardId, int timeSpan, int collection) { + return null; + } + + @Override + public Task> loadCurrentPlayerLeaderboardScore(String leaderboardId, int span, int leaderboardCollection) { + return null; + } + + @Override + public Task> loadLeaderboardMetadata(boolean forceReload) { + return null; + } + + @Override + public Task> loadLeaderboardMetadata(String leaderboardId, boolean forceReload) { + return null; + } + + @Override + public Task> loadMoreScores(LeaderboardScoreBuffer buffer, int maxResults, int pageDirection) { + return null; + } + + @Override + public Task> loadPlayerCenteredScores(String leaderboardId, int span, int leaderboardCollection, int maxResults, boolean forceReload) { + return null; + } + + @Override + public Task> loadPlayerCenteredScores(String leaderboardId, int span, int leaderboardCollection, int maxResults) { + return null; + } + + @Override + public Task> loadTopScores(String leaderboardId, int span, int leaderboardCollection, int maxResults) { + return null; + } + + @Override + public Task> loadTopScores(String leaderboardId, int span, int leaderboardCollection, int maxResults, boolean forceReload) { + return null; + } + + @Override + public void submitScore(String leaderboardId, long score, String scoreTag) { + + } + + @Override + public void submitScore(String leaderboardId, long score) { + + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/PlayerColumns.java b/play-services-games/src/main/java/com/google/android/gms/games/PlayerColumns.java index 6d08957bab..ffe8641d7f 100644 --- a/play-services-games/src/main/java/com/google/android/gms/games/PlayerColumns.java +++ b/play-services-games/src/main/java/com/google/android/gms/games/PlayerColumns.java @@ -61,9 +61,12 @@ public class PlayerColumns { public static final String alwaysAutoSignIn = "always_auto_sign_in"; public static final String profileCreationTimestamp = "profile_creation_timestamp"; public static final String gamePlayerId = "game_player_id"; + public static final String externalGameId = "external_game_id"; + public static final String primaryCategory = "primary_category"; + public static final String secondaryCategory = "secondary_category"; public static final List CURRENT_PLAYER_COLUMNS = Collections.unmodifiableList(Arrays.asList( - externalPlayerId, + externalPlayerId,externalGameId,primaryCategory,secondaryCategory, profileIconImageId, profileHiResImageId, profileIconImageUri, profileIconImageUrl, profileHiResImageUri, profileHiResImageUrl, profileName, lastUpdated, isInCircles, hasAllPublicAcls, hasDebugAccess, isProfileVisible, currentXpTotal, currentLevel, currentLevelMinXp, currentLevelMaxXp, nextLevel, nextLevelMaxXp, lastLevelUpTimestamp, @@ -77,4 +80,125 @@ public class PlayerColumns { gamerFriendStatus, gamerFriendUpdateTimestamp, isMuted, gamePlayerId )); + + public static final List XX = Collections.unmodifiableList(Arrays.asList( + "owner_id", + "profile_icon_image_id", + "is_profile_visible", + "is_muted", + "banner_image_portrait_url", + "profileless_recall_enabled_v3", + "primary_category", + "player_title", + "next_level_max_xp", + "gamer_friend_status", + "screenshot_image_uris", + "gamepad_support", + "last_updated", + "gamer_friend_update_timestamp", + "most_recent_game_hi_res_uri", + "snapshot_content_download_status", + "game_hi_res_image_uri", + "game_hi_res_image_url", + "banner_image_portrait_uri", + "turn_based_support", + "gamer_tag", + "game_description", + "cover_icon_image_url", + "sync_status", + "snapshot_content_download_url", + "achievement_total_count", + "cover_icon_image_uri", + "banner_image_landscape_id", + "external_snapshot_id", + "profile_creation_timestamp", + "most_recent_game_featured_uri", + "gameplay_acl_status", + "real_time_support", + "modification_token", + "most_recent_game_featured_id", + "game_hi_res_image_id", + "featured_image_uri", + "current_level_min_xp", + "featured_image_url", + "muted", + "identity_sharing_confirmed", + "metadata_version", + "revision_id", + "content_id", + "drive_resource_id_string", + "total_unlocked_achievements", + "screenshot_image_heights", + "pending_change_count", + "most_recent_game_icon_uri", + "most_recent_activity_timestamp", + "theme_color", + "external_game_id", + "sync_token", + "current_level", + "developer_name", + "current_xp_total", + "has_debug_access", + "has_all_public_acls", + "profile_name", + "screenshot_image_widths", + "cover_icon_image_width", + "play_together_nickname", + "is_in_circles", + "next_level", + "profile_icon_image_url", + "external_player_id", + "profile_icon_image_uri", + "always_auto_sign_in", + "featured_image_id", + "last_played_server_time", + "game_icon_image_id", + "last_level_up_timestamp", + "progress_value", + "visible", + "friends_list_visibility", + "most_recent_game_hi_res_id", + "snapshot_content_size", + "display_name", + "most_recent_game_icon_id", + "banner_image_portrait_id", + "cover_icon_image_id", + "secondary_category", + "unique_name", + "last_connection_local_time", + "drive_resolved_id_string", + "banner_image_landscape_uri", + "_id", + "banner_image_landscape_url", + "most_recent_external_game_id", + "snapshot_content_filename", + "installed", + "target_instance", + "most_recent_game_name", + "last_modified_timestamp", + "description", + "real_name", + "title", + "snapshots_enabled", + "cover_icon_image_height", + "game_icon_image_url", + "duration", + "game_icon_image_uri", + "device_name", + "video_url", + "current_level_max_xp", + "leaderboard_count", + "game_id", + "screenshot_image_ids", + "play_together_invitation_nickname", + "play_enabled_game", + "nickname_abuse_report_token", + "play_together_friend_status", + "profile_hi_res_image_url", + "profile_hi_res_image_uri", + "conflict_id", + "package_name", + "last_synced_local_time", + "profile_hi_res_image_id" + )); } diff --git a/play-services-games/src/main/java/com/google/android/gms/games/SnapshotsClient.java b/play-services-games/src/main/java/com/google/android/gms/games/SnapshotsClient.java new file mode 100644 index 0000000000..4cdf842284 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/SnapshotsClient.java @@ -0,0 +1,278 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Intent; +import android.os.Bundle; + +import com.google.android.gms.common.api.ApiException; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.games.snapshot.Snapshot; +import com.google.android.gms.games.snapshot.SnapshotContents; +import com.google.android.gms.games.snapshot.SnapshotMetadata; +import com.google.android.gms.games.snapshot.SnapshotMetadataBuffer; +import com.google.android.gms.games.snapshot.SnapshotMetadataChange; +import com.google.android.gms.tasks.Task; + +/** + * A client to interact with Snapshots. + */ +public interface SnapshotsClient { + /** + * Intent extra used to pass a {@link SnapshotMetadata}. + */ + String EXTRA_SNAPSHOT_METADATA = "com.google.android.gms.games.SNAPSHOT_METADATA"; + /** + * Intent extra used to indicate the user wants to create a new snapshot. + */ + String EXTRA_SNAPSHOT_NEW = "com.google.android.gms.games.SNAPSHOT_NEW"; + /** + * Constant passed to {@link SnapshotsClient#getSelectSnapshotIntent(String, boolean, boolean, int)} indicating that the UI should not cap the number of displayed snapshots. + */ + int DISPLAY_LIMIT_NONE = -1; + /** + * In the case of a conflict, the snapshot with the highest progress value will be used. + */ + int RESOLUTION_POLICY_HIGHEST_PROGRESS = 4; + /** + * In the case of a conflict, the last known good version of this snapshot will be used. + */ + int RESOLUTION_POLICY_LAST_KNOWN_GOOD = 2; + /** + * In the case of a conflict, the snapshot with the longest played time will be used. + */ + int RESOLUTION_POLICY_LONGEST_PLAYTIME = 1; + /** + * In the case of a conflict, the result will be returned to the app for resolution. + */ + int RESOLUTION_POLICY_MANUAL = -1; + /** + * In the case of a conflict, the most recently modified version of this snapshot will be used. + */ + int RESOLUTION_POLICY_MOST_RECENTLY_MODIFIED = 3; + + /** + * This method fails a Task with an exception when called with a snapshot that was not opened or has already been committed/discarded. + *

+ * Note that the total size of the contents of snapshot may not exceed the size provided by getMaxDataSize(). + * + * @param snapshot The snapshot to commit the data for. + * @param metadataChange The set of changes to apply to the metadata for the snapshot. Use SnapshotMetadataChange.EMPTY_CHANGE to preserve the existing metadata. + * @return Returns a Task which asynchronously commits any modifications in SnapshotMetadataChange made to the Snapshot and loads a SnapshotMetadata. The Task returned by this method is complete once the changes are synced locally and the background sync request for this data has been requested. + */ + Task commitAndClose(Snapshot snapshot, SnapshotMetadataChange metadataChange); + + /** + * @param metadata The metadata of the snapshot to delete. + * @return Returns a Task which asynchronously deletes the specified by SnapshotMetadata snapshot and loads the deleted snapshot ID. This will delete the data of the snapshot locally and on the server. + */ + Task delete(SnapshotMetadata metadata); + + /** + * This method fails a Task with an exception when called with a snapshot that was not opened or has already been committed/discarded. + * + * @param snapshot The snapshot to discard the data for. + * @return Returns a Task which asynchronously discards the contents of the Snapshot and closes it. This will discard all changes made to the file, and close the snapshot to future changes until it is re-opened. The file will not be modified on the server. + */ + Task discardAndClose(Snapshot snapshot); + + /** + * The returned Task can fail with a RemoteException. + * + * @return Returns a Task which asynchronously loads the maximum data size per snapshot cover image in bytes. Guaranteed to be at least 800 KB. May increase in the future. + */ + Task getMaxCoverImageSize(); + + /** + * The returned Task can fail with a RemoteException. + * + * @return Returns a Task which asynchronously loads the maximum data size per snapshot in bytes. Guaranteed to be at least 3 MB. May increase in the future. + */ + Task getMaxDataSize(); + + /** + * The returned Task can fail with a RemoteException. + *

+ * If the user canceled without selecting a snapshot, the result will be Activity.RESULT_CANCELED. If the user selected a snapshot from the list, the result will be Activity.RESULT_OK and the data intent will contain the selected Snapshot as a parcelable object in EXTRA_SNAPSHOT_METADATA. If the user pressed the add button, the result will be Activity.RESULT_OK and the data intent will contain a true boolean value in EXTRA_SNAPSHOT_NEW. + *

+ * Note that if you have modified an open snapshot, the changes will not appear in this UI until you call commitAndClose(Snapshot, SnapshotMetadataChange) on the snapshot. + * + * @param title The title to display in the action bar of the returned Activity. + * @param allowAddButton Whether or not to display a "create new snapshot" option in the selection UI. + * @param allowDelete Whether or not to provide a delete overflow menu option for each snapshot in the selection UI. + * @param maxSnapshots The maximum number of snapshots to display in the UI. Use DISPLAY_LIMIT_NONE to display all snapshots. + * @return Returns a Task which asynchronously loads an Intent that will let the user select a snapshot. Note that the Intent returned from the Task must be invoked with Activity.startActivityForResult(Intent, int), so that the identity of the calling package can be established. + */ + Task getSelectSnapshotIntent(String title, boolean allowAddButton, boolean allowDelete, int maxSnapshots); + + /** + * This method takes a Bundle object and extracts the Snapshot provided. If the Bundle is invalid or does not contain the correct data, this method returns null. + * + * @param extras The Bundle to parse for a Snapshot object. + * @return A SnapshotMetadata object that was provided for action. + */ + SnapshotMetadata getSnapshotFromBundle(Bundle extras); + + /** + * AbstractDataBuffer.release() should be called to release resources after usage. + * + * @param forceReload If true, this call will clear any locally cached data and attempt to fetch the latest data from the server. This would commonly be used for something like a user-initiated refresh. Normally, this should be set to false to gain advantages of data caching. + * @return Returns a Task which asynchronously loads an annotated SnapshotMetadataBuffer that represents the snapshot data for the currently signed-in player. + */ + Task> load(boolean forceReload); + + /** + * This will open the snapshot using RESOLUTION_POLICY_MANUAL as a conflict policy. If a conflict occurred, the result's SnapshotsClient.DataOrConflict.isConflict() will return true, and the conflict will need to be resolved using resolveConflict(String, Snapshot) to continue with opening the snapshot. + *

+ * If the snapshot's contents are unavailable, the Task will fail with SnapshotsClient.SnapshotContentUnavailableApiException. + * + * @param metadata The metadata of the existing snapshot to load. + * @return Returns a Task which asynchronously opens a snapshot with the given SnapshotMetadata (usually returned from load(boolean). To succeed, the snapshot must exist; i.e. this call will fail if the snapshot was deleted between the load and open calls. + */ + Task> open(SnapshotMetadata metadata); + + /** + * If a conflict occurred, the result's SnapshotsClient.DataOrConflict.isConflict() will return true, and the conflict will need to be resolved using resolveConflict(String, Snapshot) to continue with opening the snapshot. + *

+ * If the snapshot's contents are unavailable, the Task will fail with SnapshotsClient.SnapshotContentUnavailableApiException. + * + * @param metadata The metadata of the existing snapshot to load. + * @param conflictPolicy The conflict resolution policy to use for this snapshot. + * @return Returns a Task which asynchronously opens a snapshot with the given SnapshotMetadata (usually returned from load(boolean). To succeed, the snapshot must exist; i.e. this call will fail if the snapshot was deleted between the load and open calls. + */ + Task> open(SnapshotMetadata metadata, int conflictPolicy); + + /** + * If a conflict occurred, the result's SnapshotsClient.DataOrConflict.isConflict() will return true, and the conflict will need to be resolved using resolveConflict(String, Snapshot) to continue with opening the snapshot. + *

+ * If the snapshot's contents are unavailable, the Task will fail with SnapshotsClient.SnapshotContentUnavailableApiException. + * + * @param fileName The name of the snapshot file to open. Must be between 1 and 100 non-URL-reserved characters (a-z, A-Z, 0-9, or the symbols "-", ".", "_", or "~"). + * @param createIfNotFound If true, the snapshot will be created if one cannot be found. + * @param conflictPolicy The conflict resolution policy to use for this snapshot. + * @return Returns a Task which asynchronously opens a snapshot with the given fileName. If createIfNotFound is set to true, the specified snapshot will be created if it does not already exist. + */ + Task> open(String fileName, boolean createIfNotFound, int conflictPolicy); + + /** + * This will open the snapshot using RESOLUTION_POLICY_MANUAL as a conflict policy. If a conflict occurred, the result's SnapshotsClient.DataOrConflict.isConflict() will return true, and the conflict will need to be resolved using resolveConflict(String, Snapshot) to continue with opening the snapshot. + *

+ * If the snapshot's contents are unavailable, the Task will fail with SnapshotsClient.SnapshotContentUnavailableApiException. + * + * @param fileName The name of the snapshot file to open. Must be between 1 and 100 non-URL-reserved characters (a-z, A-Z, 0-9, or the symbols "-", ".", "_", or "~"). + * @param createIfNotFound If true, the snapshot will be created if one cannot be found. + * @return Returns a Task which asynchronously opens a snapshot with the given fileName. If createIfNotFound is set to true, the specified snapshot will be created if it does not already exist. + */ + Task> open(String fileName, boolean createIfNotFound); + + /** + * Values which are not included in the metadata change will be resolved to the version currently on the server. + *

+ * If a conflict occurred, the result's SnapshotsClient.DataOrConflict.isConflict() will return true, and the conflict will need to be resolved again using resolveConflict(String, Snapshot) to continue with opening the snapshot. + *

+ * Note that the total size of contents may not exceed the size provided by getMaxDataSize(). + *

+ * Calling this method with a snapshot that has already been committed or that was not opened via open(SnapshotMetadata) will throw an exception. + *

+ * If the resolved snapshot's contents are unavailable, the Task will fail with SnapshotsClient.SnapshotContentUnavailableApiException. + * + * @param conflictId The ID of the conflict to resolve. Must come from SnapshotsClient.SnapshotConflict.getConflictId(). + * @param snapshotId The ID of the snapshot to resolve the conflict for. + * @param metadataChange The set of changes to apply to the metadata for the snapshot. + * @param snapshotContents The SnapshotContents to replace the snapshot data with. + * @return Returns a Task which asynchronously resolves a conflict using the provided data. This will replace the data on the server with the specified SnapshotMetadataChange and SnapshotContents. Note that it is possible for this operation to result in a conflict itself, in which case resolution should be repeated. + */ + Task> resolveConflict(String conflictId, String snapshotId, SnapshotMetadataChange metadataChange, SnapshotContents snapshotContents); + + /** + * If a conflict occurred, the result's SnapshotsClient.DataOrConflict.isConflict() will return true, and the conflict will need to be resolved again using resolveConflict(String, Snapshot) to continue with opening the snapshot. + *

+ * Note that the total size of the contents of snapshot may not exceed the size provided by getMaxDataSize(). + *

+ * This method fails a Task with an exception when called with a snapshot that was not opened or has already been committed/discarded. + *

+ * If the resolved snapshot's contents are unavailable, the Task will fail with SnapshotsClient.SnapshotContentUnavailableApiException. + * + * @param conflictId The ID of the conflict to resolve. Must come from SnapshotsClient.SnapshotConflict.getConflictId(). + * @param snapshot The snapshot to use to resolve the conflict. + * @return Returns a Task which asynchronously resolves a conflict using the data from the provided Snapshot. This will replace the data on the server with the specified Snapshot. Note that it is possible for this operation to result in a conflict itself, in which case resolution should be repeated. + */ + Task> resolveConflict(String conflictId, Snapshot snapshot); + + /** + * Represents the result of attempting to open a snapshot or resolve a conflict from a previous attempt. + */ + class DataOrConflict { + private final SnapshotConflict snapshotConflict; + private final T t; + + public DataOrConflict(T t, SnapshotConflict snapshotConflict) { + this.snapshotConflict = snapshotConflict; + this.t = t; + } + + public SnapshotConflict getConflict() { + return snapshotConflict; + } + + public boolean isConflict() { + return snapshotConflict != null; + } + + public T getData() { + return t; + } + } + + /** + * Result delivered when a conflict was detected during {@link SnapshotsClient#open(SnapshotMetadata)} or {@link SnapshotsClient#resolveConflict(String, Snapshot)}. + */ + class SnapshotConflict { + private final String conflictId; + private final Snapshot conflictionSnapshot; + private final SnapshotContents snapshotContents; + private final Snapshot snapshot; + + public SnapshotConflict(String conflictId, Snapshot conflictionSnapshot, SnapshotContents snapshotContents, Snapshot snapshot) { + this.conflictId = conflictId; + this.conflictionSnapshot = conflictionSnapshot; + this.snapshotContents = snapshotContents; + this.snapshot = snapshot; + } + + public String getConflictId() { + return conflictId; + } + + public Snapshot getConflictingSnapshot() { + return conflictionSnapshot; + } + + public SnapshotContents getResolutionSnapshotContents() { + return snapshotContents; + } + + public Snapshot getSnapshot() { + return snapshot; + } + } + + /** + * Indicates that the snapshot contents are unavailable at the moment, but the SnapshotMetadata is available through {@link SnapshotContentUnavailableApiException#getSnapshotMetadata()}. + */ + class SnapshotContentUnavailableApiException extends ApiException { + protected final SnapshotMetadata metadata; + + public SnapshotContentUnavailableApiException(Status status, SnapshotMetadata metadata) { + super(status); + this.metadata = metadata; + } + + public SnapshotMetadata getSnapshotMetadata() { + return metadata; + } + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/SnapshotsClientImpl.java b/play-services-games/src/main/java/com/google/android/gms/games/SnapshotsClientImpl.java new file mode 100644 index 0000000000..6307fe4bd5 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/SnapshotsClientImpl.java @@ -0,0 +1,97 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +import com.google.android.gms.common.api.GoogleApi; +import com.google.android.gms.games.snapshot.Snapshot; +import com.google.android.gms.games.snapshot.SnapshotContents; +import com.google.android.gms.games.snapshot.SnapshotMetadata; +import com.google.android.gms.games.snapshot.SnapshotMetadataBuffer; +import com.google.android.gms.games.snapshot.SnapshotMetadataChange; +import com.google.android.gms.tasks.Task; + +public class SnapshotsClientImpl extends GoogleApi implements SnapshotsClient{ + + public SnapshotsClientImpl(Context context, Games.GamesOptions options) { + super(context, Games.API, options); + Log.d(Games.TAG, "SnapshotsClientImpl: options: " + options); + } + + @Override + public Task commitAndClose(Snapshot snapshot, SnapshotMetadataChange metadataChange) { + return null; + } + + @Override + public Task delete(SnapshotMetadata metadata) { + return null; + } + + @Override + public Task discardAndClose(Snapshot snapshot) { + return null; + } + + @Override + public Task getMaxCoverImageSize() { + return null; + } + + @Override + public Task getMaxDataSize() { + return null; + } + + @Override + public Task getSelectSnapshotIntent(String title, boolean allowAddButton, boolean allowDelete, int maxSnapshots) { + return null; + } + + @Override + public SnapshotMetadata getSnapshotFromBundle(Bundle extras) { + return null; + } + + @Override + public Task> load(boolean forceReload) { + return null; + } + + @Override + public Task> open(SnapshotMetadata metadata) { + return null; + } + + @Override + public Task> open(SnapshotMetadata metadata, int conflictPolicy) { + return null; + } + + @Override + public Task> open(String fileName, boolean createIfNotFound, int conflictPolicy) { + return null; + } + + @Override + public Task> open(String fileName, boolean createIfNotFound) { + return null; + } + + @Override + public Task> resolveConflict(String conflictId, String snapshotId, SnapshotMetadataChange metadataChange, SnapshotContents snapshotContents) { + return null; + } + + @Override + public Task> resolveConflict(String conflictId, Snapshot snapshot) { + return null; + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/achievement/Achievement.java b/play-services-games/src/main/java/com/google/android/gms/games/achievement/Achievement.java new file mode 100644 index 0000000000..a41cae99e4 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/achievement/Achievement.java @@ -0,0 +1,206 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.achievement; + +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcelable; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.data.Freezable; +import com.google.android.gms.common.images.ImageManager; +import com.google.android.gms.games.Player; + +/** + * Data interface for retrieving achievement information. + */ +public interface Achievement extends Freezable, Parcelable { + + @IntDef({AchievementState.STATE_UNLOCKED, AchievementState.STATE_REVEALED, AchievementState.STATE_HIDDEN}) + @interface AchievementState { + + /** + * Constant indicating an unlocked achievement. + */ + int STATE_UNLOCKED = 0; + + /** + * Constant indicating a revealed achievement. + */ + int STATE_REVEALED = 1; + + /** + * Constant indicating a hidden achievement. + */ + int STATE_HIDDEN = 2; + } + + @IntDef({AchievementType.TYPE_STANDARD, AchievementType.TYPE_INCREMENTAL}) + @interface AchievementType { + + /** + * Constant indicating a standard achievement. + */ + int TYPE_STANDARD = 0; + + /** + * Constant indicating an incremental achievement. + */ + int TYPE_INCREMENTAL = 1; + } + + /** + * Retrieves the ID of this achievement. + * + * @return The achievement ID. + */ + String getAchievementId(); + + /** + * Retrieves the number of steps this user has gone toward unlocking this achievement; + * only applicable for {@link AchievementType#TYPE_INCREMENTAL} achievement types. + * + * @return The number of steps this user has gone toward unlocking this achievement. + */ + int getCurrentSteps(); + + /** + * Retrieves the description for this achievement. + * + * @return The achievement description. + */ + String getDescription(); + + /** + * Loads the achievement description into the given {@link CharArrayBuffer}. + * + * @param dataOut The buffer to load the data into. + */ + void getDescription(CharArrayBuffer dataOut); + + /** + * Retrieves the number of steps this user has gone toward unlocking this achievement (formatted for the user's locale) into the given {@link CharArrayBuffer}. + * + * @param dataOut The buffer to load the data into. + */ + void getFormattedCurrentSteps(CharArrayBuffer dataOut); + + /** + * Retrieves the number of steps this user has gone toward unlocking this achievement (formatted for the user's locale); + * only applicable for {@link AchievementType#TYPE_INCREMENTAL} achievement types. + * + * @return The formatted number of steps this user has gone toward unlocking this achievement or null if this information is unavailable. + */ + String getFormattedCurrentSteps(); + + /** + * Loads the total number of steps necessary to unlock this achievement (formatted for the user's locale) into the given CharArrayBuffer; + * only applicable for {@link AchievementType#TYPE_INCREMENTAL} achievement types. + * + * @param dataOut The buffer to load the data into. + */ + void getFormattedTotalSteps(CharArrayBuffer dataOut); + + /** + * Retrieves the total number of steps necessary to unlock this achievement, formatted for the user's locale; + * only applicable for {@link AchievementType#TYPE_INCREMENTAL} achievement types. + * + * @return The total number of steps necessary to unlock this achievement or null if this information is unavailable. + */ + String getFormattedTotalSteps(); + + /** + * Retrieves the timestamp (in millseconds since epoch) at which this achievement was last updated. + * If the achievement has never been updated, this will return -1. + * + * @return Timestamp at which this achievement was last updated. + */ + long getLastUpdatedTimestamp(); + + /** + * Loads the achievement name into the given {@link CharArrayBuffer}. + * + * @param dataOut The buffer to load the data into. + */ + void getName(CharArrayBuffer dataOut); + + /** + * Retrieves the name of this achievement. + * + * @return The achievement name. + */ + String getName(); + + /** + * Retrieves the player information associated with this achievement. + *

+ * Note that this object is a volatile representation, so it is not safe to cache the output of this directly. + * Instead, cache the result of {@link Freezable#freeze()}. + * + * @return The player associated with this achievement. + */ + Player getPlayer(); + + @Nullable + Player getPlayerInternal(); + + /** + * Retrieves a URI that can be used to load the achievement's revealed image icon. Returns null if the achievement has no revealed image. + *

+ * To retrieve the Image from the {@link Uri}, use {@link ImageManager}. + * + * @return The image URI for the achievement's revealed image icon, or null if the achievement has no revealed image. + */ + Uri getRevealedImageUri(); + + @Deprecated + @Nullable + String getRevealedImageUrl(); + + /** + * Returns the {@link AchievementState} of the achievement. + */ + int getState(); + + /** + * Retrieves the total number of steps necessary to unlock this achievement; + * only applicable for {@link AchievementType#TYPE_INCREMENTAL} achievement types. + * + * @return The total number of steps necessary to unlock this achievement. + */ + int getTotalSteps(); + + /** + * Returns the {@link Achievement.AchievementType} of this achievement. + */ + int getType(); + + /** + * Retrieves a URI that can be used to load the achievement's unlocked image icon. Returns null if the achievement has no unlocked image. + *

+ * To retrieve the Image from the {@link Uri}, use {@link ImageManager}. + * + * @return The image URI for the achievement's unlocked image icon, or null if the achievement has no unlocked image. + */ + Uri getUnlockedImageUri(); + + @Deprecated + @Nullable + String getUnlockedImageUrl(); + + /** + * Retrieves the XP value of this achievement. + * + * @return XP value given to players for unlocking this achievement. + */ + long getXpValue(); + + float getRarityPercent(); + + String getApplicationId(); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementBuffer.java b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementBuffer.java new file mode 100644 index 0000000000..8ebf42a715 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementBuffer.java @@ -0,0 +1,27 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.achievement; + +import com.google.android.gms.common.data.AbstractDataBuffer; +import com.google.android.gms.common.data.DataHolder; + +import org.microg.gms.common.Hide; + +/** + * Data structure providing access to a list of achievements. + */ +public class AchievementBuffer extends AbstractDataBuffer { + + @Hide + public AchievementBuffer(DataHolder dataHolder) { + super(dataHolder); + } + + @Override + public Achievement get(int position) { + return new AchievementRef(dataHolder, position); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementColumns.java b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementColumns.java new file mode 100644 index 0000000000..92588fc387 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementColumns.java @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.achievement; + +public class AchievementColumns { + public static String DB_TABLE_ACHIEVEMENTS = "achievements"; + public static String DB_FIELD_GAME_PACKAGE_NAME = "game_package_name"; + public static String DB_FIELD_EXTERNAL_ACHIEVEMENT_ID = "external_achievement_id"; + public static String DB_FIELD_EXTERNAL_GAME_ID = "external_game_id"; + public static String DB_FIELD_TYPE = "type"; + public static String DB_FIELD_NAME = "name"; + public static String DB_FIELD_DESCRIPTION = "description"; + public static String DB_FIELD_UNLOCKED_ICON_IMAGE_URI = "unlocked_icon_image_uri"; + public static String DB_FIELD_UNLOCKED_ICON_IMAGE_URL = "unlocked_icon_image_url"; + public static String DB_FIELD_REVEALED_ICON_IMAGE_URI = "revealed_icon_image_uri"; + public static String DB_FIELD_REVEALED_ICON_IMAGE_URL = "revealed_icon_image_url"; + public static String DB_FIELD_TOTAL_STEPS = "total_steps"; + public static String DB_FIELD_FORMATTED_TOTAL_STEPS = "formatted_total_steps"; + public static String DB_FIELD_EXTERNAL_PLAYER_ID = "external_player_id"; + public static String DB_FIELD_STATE = "state"; + public static String DB_FIELD_CURRENT_STEPS = "current_steps"; + public static String DB_FIELD_FORMATTED_CURRENT_STEPS = "formatted_current_steps"; + public static String DB_FIELD_INSTANCE_XP_VALUE = "instance_xp_value"; + public static String DB_FIELD_LAST_UPDATED_TIMESTAMP = "last_updated_timestamp"; + public static String DB_FIELD_RARITY_PERCENT= "rarity_percent"; +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementEntity.java new file mode 100644 index 0000000000..9ba691e4c4 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementEntity.java @@ -0,0 +1,366 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.achievement; + +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; +import com.google.android.gms.common.util.DataUtils; +import com.google.android.gms.games.Player; +import com.google.android.gms.games.PlayerEntity; + +@SafeParcelable.Class +public class AchievementEntity extends AbstractSafeParcelable implements Achievement { + + @Field(value = 1, getterName = "getAchievementId") + private String id; + + @Field(value = 2, getterName = "getType") + private int type; + + @Field(value = 3, getterName = "getName") + private String name; + + @Field(value = 4, getterName = "getDescription") + private String description; + + @Field(value = 5, getterName = "getUnlockedImageUri") + private Uri unlockedImageUri; + + @Field(value = 6, getterName = "getUnlockedImageUrl") + private String unlockedImageUrl; + + @Field(value = 7, getterName = "getRevealedImageUri") + private Uri revealedImageUri; + + @Field(value = 8, getterName = "getRevealedImageUrl") + private String revealedImageUrl; + + @Field(value = 9, getterName = "getTotalSteps") + private int totalSteps; + + @Field(value = 10, getterName = "getFormattedTotalSteps") + private String formattedTotalSteps; + + @Field(value = 11, getterName = "getPlayer") + private PlayerEntity player; + + @Field(value = 12, getterName = "getState") + private int state; + + @Field(value = 13, getterName = "getCurrentSteps") + private int currentSteps; + + @Field(value = 14, getterName = "getFormattedCurrentSteps") + private String formattedCurrentSteps; + + @Field(value = 15, getterName = "getLastUpdatedTimestamp") + private long lastUpdatedTimestamp; + + @Field(value = 16, getterName = "getXpValue") + private long xpValue; + + @Field(value = 17, getterName = "getRarityPercent") + private float rarityPercent; + + @Field(value = 18, getterName = "getApplicationId") + private String applicationId; + + public AchievementEntity(String name, int type) { + this.name = name; + this.type = type; + } + + public AchievementEntity(Achievement achievement) { + this.id = achievement.getAchievementId(); + this.type = achievement.getType(); + this.name = achievement.getName(); + this.description = achievement.getDescription(); + this.unlockedImageUri = achievement.getUnlockedImageUri(); + this.unlockedImageUrl = achievement.getUnlockedImageUrl(); + this.revealedImageUri = achievement.getRevealedImageUri(); + this.revealedImageUrl = achievement.getRevealedImageUrl(); + if (achievement.getPlayerInternal() != null) { + player = (PlayerEntity) achievement.getPlayerInternal().freeze(); + } else { + player = null; + } + this.state = achievement.getState(); + this.lastUpdatedTimestamp = achievement.getLastUpdatedTimestamp(); + this.xpValue = achievement.getXpValue(); + this.rarityPercent = achievement.getRarityPercent(); + this.applicationId = achievement.getApplicationId(); + if (achievement.getType() == AchievementType.TYPE_INCREMENTAL) { + this.totalSteps = achievement.getTotalSteps(); + this.formattedTotalSteps = achievement.getFormattedTotalSteps(); + this.currentSteps = achievement.getCurrentSteps(); + this.formattedCurrentSteps = achievement.getFormattedCurrentSteps(); + } else { + this.totalSteps = 0; + this.formattedTotalSteps = null; + this.currentSteps = 0; + this.formattedCurrentSteps = null; + } + } + + @Constructor + public AchievementEntity(@Param(value = 1) String id, @Param(value = 2) int type, @Param(value = 3) String name, @Param(value = 4) String description, @Param(value = 5) Uri unlockedImageUri, @Param(value = 6) String unlockedImageUrl, @Param(value = 7) Uri revealedImageUri, @Param(value = 8) String revealedImageUrl, @Param(value = 9) int totalSteps, @Param(value = 10) String formattedTotalSteps, @Param(value = 11) @Nullable PlayerEntity player, @Param(value = 12) int state, @Param(value = 13) int currentSteps, @Param(value = 14) String formattedCurrentSteps, @Param(value = 15) long lastUpdatedTimestamp, @Param(value = 16) long xpValue, @Param(value = 17) float rarityPercent, @Param(value = 18) String applicationId) { + this.id = id; + this.type = type; + this.name = name; + this.description = description; + this.unlockedImageUri = unlockedImageUri; + this.unlockedImageUrl = unlockedImageUrl; + this.revealedImageUri = revealedImageUri; + this.revealedImageUrl = revealedImageUrl; + this.totalSteps = totalSteps; + this.formattedTotalSteps = formattedTotalSteps; + this.player = player; + this.state = state; + this.currentSteps = currentSteps; + this.formattedCurrentSteps = formattedCurrentSteps; + this.lastUpdatedTimestamp = lastUpdatedTimestamp; + this.xpValue = xpValue; + this.rarityPercent = rarityPercent; + this.applicationId = applicationId; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static SafeParcelableCreatorAndWriter CREATOR = findCreator(AchievementEntity.class); + + @Override + public float getRarityPercent() { + return rarityPercent; + } + + @Override + public String getApplicationId() { + return applicationId; + } + + @Override + public Achievement freeze() { + return this; + } + + @Override + public boolean isDataValid() { + return true; + } + + @Override + public String getAchievementId() { + return id; + } + + @Override + public int getCurrentSteps() { + return currentSteps; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public void getDescription(CharArrayBuffer dataOut) { + DataUtils.copyStringToBuffer(this.description, dataOut); + } + + @Override + public void getFormattedCurrentSteps(CharArrayBuffer dataOut) { + DataUtils.copyStringToBuffer(this.formattedCurrentSteps, dataOut); + } + + @Override + public String getFormattedCurrentSteps() { + return formattedCurrentSteps; + } + + @Override + public void getFormattedTotalSteps(CharArrayBuffer dataOut) { + DataUtils.copyStringToBuffer(this.formattedTotalSteps, dataOut); + } + + @Override + public String getFormattedTotalSteps() { + return formattedTotalSteps; + } + + @Override + public long getLastUpdatedTimestamp() { + return lastUpdatedTimestamp; + } + + @Override + public void getName(CharArrayBuffer dataOut) { + DataUtils.copyStringToBuffer(this.name, dataOut); + } + + @Override + public String getName() { + return name; + } + + @Override + public PlayerEntity getPlayer() { + return player; + } + + @Nullable + @Override + public Player getPlayerInternal() { + return player; + } + + @Override + public Uri getRevealedImageUri() { + return revealedImageUri; + } + + @Override + public int getState() { + return state; + } + + @Override + public int getTotalSteps() { + return totalSteps; + } + + @Override + public int getType() { + return type; + } + + @Override + public Uri getUnlockedImageUri() { + return unlockedImageUri; + } + + @Override + public long getXpValue() { + return xpValue; + } + + @Override + public String getRevealedImageUrl() { + return revealedImageUrl; + } + + @Override + public String getUnlockedImageUrl() { + return unlockedImageUrl; + } + + @Override + public String toString() { + return "AchievementEntity{" + + "id='" + id + '\'' + + ", type=" + type + + ", name='" + name + '\'' + + ", description='" + description + '\'' + + ", unlockedImageUri=" + unlockedImageUri + + ", unlockedImageUrl='" + unlockedImageUrl + '\'' + + ", revealedImageUri=" + revealedImageUri + + ", revealedImageUrl='" + revealedImageUrl + '\'' + + ", totalSteps=" + totalSteps + + ", formattedTotalSteps='" + formattedTotalSteps + '\'' + + ", player=" + player + + ", state=" + state + + ", currentSteps=" + currentSteps + + ", formattedCurrentSteps='" + formattedCurrentSteps + '\'' + + ", lastUpdatedTimestamp=" + lastUpdatedTimestamp + + ", xpValue=" + xpValue + + ", rarityPercent=" + rarityPercent + + ", applicationId='" + applicationId + '\'' + + '}'; + } + + public void setId(String id) { + this.id = id; + } + + public void setType(int type) { + this.type = type; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setUnlockedImageUri(Uri unlockedImageUri) { + this.unlockedImageUri = unlockedImageUri; + } + + public void setUnlockedImageUrl(String unlockedImageUrl) { + this.unlockedImageUrl = unlockedImageUrl; + } + + public void setRevealedImageUri(Uri revealedImageUri) { + this.revealedImageUri = revealedImageUri; + } + + public void setRevealedImageUrl(String revealedImageUrl) { + this.revealedImageUrl = revealedImageUrl; + } + + public void setTotalSteps(int totalSteps) { + this.totalSteps = totalSteps; + } + + public void setFormattedTotalSteps(String formattedTotalSteps) { + this.formattedTotalSteps = formattedTotalSteps; + } + + public void setPlayer(PlayerEntity player) { + this.player = player; + } + + public void setState(int state) { + this.state = state; + } + + public void setCurrentSteps(int currentSteps) { + this.currentSteps = currentSteps; + } + + public void setFormattedCurrentSteps(String formattedCurrentSteps) { + this.formattedCurrentSteps = formattedCurrentSteps; + } + + public void setLastUpdatedTimestamp(long lastUpdatedTimestamp) { + this.lastUpdatedTimestamp = lastUpdatedTimestamp; + } + + public void setXpValue(long xpValue) { + this.xpValue = xpValue; + } + + public void setRarityPercent(float rarityPercent) { + this.rarityPercent = rarityPercent; + } + + public void setApplicationId(String applicationId) { + this.applicationId = applicationId; + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementRef.java b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementRef.java new file mode 100644 index 0000000000..1305603b4a --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/achievement/AchievementRef.java @@ -0,0 +1,162 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.achievement; + +import static com.google.android.gms.games.achievement.AchievementColumns.*; + +import android.annotation.SuppressLint; +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcel; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.data.DataBufferRef; +import com.google.android.gms.common.data.DataHolder; +import com.google.android.gms.games.Player; +import com.google.android.gms.games.PlayerRef; + +@SuppressLint("ParcelCreator") +public class AchievementRef extends DataBufferRef implements Achievement { + + AchievementRef(DataHolder dataHolder, int dataRow) { + super(dataHolder, dataRow); + Log.d("AchievementRef", "AchievementRef: " + dataHolder); + } + + @Override + public final String getApplicationId() { + return this.getString(DB_FIELD_EXTERNAL_GAME_ID); + } + + @Override + public String getAchievementId() { + return this.getString(DB_FIELD_EXTERNAL_ACHIEVEMENT_ID); + } + + @Override + public int getCurrentSteps() { + return this.getInteger(DB_FIELD_CURRENT_STEPS); + } + + @Override + public String getDescription() { + return this.getString(DB_FIELD_DESCRIPTION); + } + + @Override + public void getDescription(CharArrayBuffer dataOut) { + this.copyToBuffer(DB_FIELD_DESCRIPTION, dataOut); + } + + @Override + public void getFormattedCurrentSteps(CharArrayBuffer dataOut) { + this.copyToBuffer(DB_FIELD_FORMATTED_CURRENT_STEPS, dataOut); + } + + @Override + public String getFormattedCurrentSteps() { + return this.getString(DB_FIELD_FORMATTED_CURRENT_STEPS); + } + + @Override + public void getFormattedTotalSteps(CharArrayBuffer dataOut) { + this.copyToBuffer(DB_FIELD_FORMATTED_TOTAL_STEPS, dataOut); + } + + @Override + public String getFormattedTotalSteps() { + return this.getString(DB_FIELD_FORMATTED_TOTAL_STEPS); + } + + @Override + public long getLastUpdatedTimestamp() { + return this.getLong(DB_FIELD_LAST_UPDATED_TIMESTAMP); + } + + @Override + public void getName(CharArrayBuffer dataOut) { + this.copyToBuffer(DB_FIELD_NAME, dataOut); + } + + @Override + public String getName() { + return this.getString(DB_FIELD_NAME); + } + + @Override + public Player getPlayer() { + return getPlayerInternal(); + } + + @Nullable + @Override + public Player getPlayerInternal() { + return this.hasNull(DB_FIELD_EXTERNAL_PLAYER_ID) ? null : new PlayerRef(dataHolder, dataRow); + } + + @Override + public Uri getRevealedImageUri() { + return this.parseUri(DB_FIELD_REVEALED_ICON_IMAGE_URI); + } + + @Override + public final String getRevealedImageUrl() { + return this.getString(DB_FIELD_REVEALED_ICON_IMAGE_URL); + } + + @Override + public int getState() { + return this.getInteger(DB_FIELD_STATE); + } + + @Override + public int getTotalSteps() { + return this.getInteger(DB_FIELD_TOTAL_STEPS); + } + + @Override + public int getType() { + return this.getInteger(DB_FIELD_TYPE); + } + + @Override + public Uri getUnlockedImageUri() { + return this.parseUri(DB_FIELD_UNLOCKED_ICON_IMAGE_URI); + } + + @Override + public final String getUnlockedImageUrl() { + return this.getString(DB_FIELD_UNLOCKED_ICON_IMAGE_URL); + } + + @Override + public long getXpValue() { + return this.getLong(DB_FIELD_INSTANCE_XP_VALUE); + } + + @Override + public float getRarityPercent() { + return this.hasColumn(DB_FIELD_RARITY_PERCENT) && !this.hasNull(DB_FIELD_RARITY_PERCENT) ? this.getFloat(DB_FIELD_RARITY_PERCENT) : -1.0F; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + this.freeze().writeToParcel(dest, flags); + } + + @Override + public Achievement freeze() { + return new AchievementEntity(this); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/internal/popup/PopupLocationInfoParcelable.java b/play-services-games/src/main/java/com/google/android/gms/games/internal/popup/PopupLocationInfoParcelable.java new file mode 100644 index 0000000000..a3e9acfe83 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/internal/popup/PopupLocationInfoParcelable.java @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.internal.popup; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +import android.os.Bundle; +import android.os.IBinder; + +@SafeParcelable.Class +public class PopupLocationInfoParcelable extends AbstractSafeParcelable { + + @Field(1) + public final Bundle popupLocationInfoBundle; + + @Field(2) + public final IBinder gamesClientBinder; + + @Constructor + public PopupLocationInfoParcelable(@Param(1) Bundle popupLocationInfoBundle, @Param(2) IBinder gamesClientBinder) { + this.popupLocationInfoBundle = popupLocationInfoBundle; + this.gamesClientBinder = gamesClientBinder; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(PopupLocationInfoParcelable.class); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/Leaderboard.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/Leaderboard.java new file mode 100644 index 0000000000..dac6954c0e --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/Leaderboard.java @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import android.database.CharArrayBuffer; +import android.net.Uri; + +import com.google.android.gms.common.data.Freezable; +import com.google.android.gms.common.images.ImageManager; + +import java.util.ArrayList; + +/** + * Data interface for leaderboard metadata. + */ +public interface Leaderboard extends Freezable { + + /** + * Score order constant for leaderboards where scores are sorted in descending order. + */ + int SCORE_ORDER_LARGER_IS_BETTER = 1; + + /** + * Score order constant for leaderboards where scores are sorted in ascending order. + */ + int SCORE_ORDER_SMALLER_IS_BETTER = 0; + + /** + * Retrieves the display name of this leaderboard. + * + * @return Display name of this leaderboard. + */ + String getDisplayName(); + + /** + * Loads this leaderboard's display name into the given {@link CharArrayBuffer}. + * + * @param dataOut The buffer to load the data into. + */ + void getDisplayName(CharArrayBuffer dataOut); + + /** + * Retrieves an image URI that can be used to load this leaderboard's icon, or null if there was a problem retrieving the icon. + *

+ * To retrieve the Image from the {@link Uri}, use {@link ImageManager}. + * + * @return A URI that can be used to load this leaderboard's icon, or null if there was a problem retrieving the icon. + */ + Uri getIconImageUri(); + + /** + * Retrieves the ID of this leaderboard. + * + * @return The ID of this leaderboard. + */ + String getLeaderboardId(); + + /** + * Retrieves the sort order of scores for this leaderboard. + * Possible values are {@link Leaderboard#SCORE_ORDER_LARGER_IS_BETTER} or {@link Leaderboard#SCORE_ORDER_SMALLER_IS_BETTER} . + * + * @return The score order used by this leaderboard. + */ + int getScoreOrder(); + + /** + * Retrieves the {@link LeaderboardVariant}s for this leaderboard. These will be returned sorted by time span first, then by variant type. + *

+ * Note that these variants are volatile, and are tied to the lifetime of the original buffer. + * + * @return A list containing the {@link LeaderboardVariant}s for this leaderboard. + */ + ArrayList getVariants(); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardBuffer.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardBuffer.java new file mode 100644 index 0000000000..a993cdf5a8 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardBuffer.java @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import com.google.android.gms.common.data.AbstractDataBuffer; +import com.google.android.gms.common.data.DataHolder; + +public class LeaderboardBuffer extends AbstractDataBuffer { + + public LeaderboardBuffer(DataHolder dataHolder) { + super(dataHolder); + } + + @Override + public Leaderboard get(int position) { + return new LeaderboardRef(dataHolder, position, 0); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardColumns.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardColumns.java new file mode 100644 index 0000000000..de3caae181 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardColumns.java @@ -0,0 +1,16 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +public class LeaderboardColumns { + public static String DB_TABLE_LEADERBOARD = "leaderboards"; + public static String DB_FIELD_GAME_PACKAGE_NAME = "game_package_name"; + public static String DB_FIELD_EXTERNAL_LEADERBOARD_ID = "external_leaderboard_id"; + public static String DB_FIELD_NAME= "name"; + public static String DB_FIELD_BOARD_ICON_IMAGE_URI = "board_icon_image_uri"; + public static String DB_FIELD_SCORE_ORDER = "score_order"; + public static String DB_FIELD_SORTING_RANK = "sorting_rank"; +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardEntity.java new file mode 100644 index 0000000000..332e82e955 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardEntity.java @@ -0,0 +1,67 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import android.database.CharArrayBuffer; +import android.net.Uri; + +import com.google.android.gms.common.util.DataUtils; + +import java.util.ArrayList; + +public final class LeaderboardEntity implements Leaderboard { + private final String leaderboardId; + private final String displayName; + private final Uri icomImageUri; + private final int scoreOrder; + private final ArrayList leaderboardVariantEntities; + + public LeaderboardEntity(Leaderboard leaderboard) { + this.leaderboardId = leaderboard.getLeaderboardId(); + this.displayName = leaderboard.getDisplayName(); + this.icomImageUri = leaderboard.getIconImageUri(); + this.scoreOrder = leaderboard.getScoreOrder(); + ArrayList variants; + int size = (variants = leaderboard.getVariants()).size(); + this.leaderboardVariantEntities = new ArrayList<>(size); + for (int i = 0; i < size; ++i) { + this.leaderboardVariantEntities.add((LeaderboardVariantEntity) ((LeaderboardVariant) variants.get(i)).freeze()); + } + } + + public String getLeaderboardId() { + return this.leaderboardId; + } + + public String getDisplayName() { + return this.displayName; + } + + public void getDisplayName(CharArrayBuffer var1) { + DataUtils.copyStringToBuffer(this.displayName, var1); + } + + public Uri getIconImageUri() { + return this.icomImageUri; + } + + public int getScoreOrder() { + return this.scoreOrder; + } + + public ArrayList getVariants() { + return new ArrayList<>(this.leaderboardVariantEntities); + } + + public boolean isDataValid() { + return true; + } + + @Override + public Leaderboard freeze() { + return this; + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardRef.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardRef.java new file mode 100644 index 0000000000..b845f6ea45 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardRef.java @@ -0,0 +1,59 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import android.database.CharArrayBuffer; +import android.net.Uri; + +import com.google.android.gms.common.data.DataBufferRef; +import com.google.android.gms.common.data.DataHolder; + +import java.util.ArrayList; + +public final class LeaderboardRef extends DataBufferRef implements Leaderboard { + + private final int variantSize; + + LeaderboardRef(DataHolder dataHolder, int dataRow, int variantSize) { + super(dataHolder, dataRow); + this.variantSize = variantSize; + } + + public String getLeaderboardId() { + return this.getString(LeaderboardColumns.DB_FIELD_EXTERNAL_LEADERBOARD_ID); + } + + public String getDisplayName() { + return this.getString(LeaderboardColumns.DB_FIELD_NAME); + } + + public void getDisplayName(CharArrayBuffer var1) { + this.copyToBuffer(LeaderboardColumns.DB_FIELD_NAME, var1); + } + + public Uri getIconImageUri() { + return this.parseUri(LeaderboardColumns.DB_FIELD_BOARD_ICON_IMAGE_URI); + } + + public int getScoreOrder() { + return this.getInteger(LeaderboardColumns.DB_FIELD_SCORE_ORDER); + } + + public ArrayList getVariants() { + ArrayList leaderboardVariants = new ArrayList<>(this.variantSize); + + for (int i = 0; i < this.variantSize; ++i) { + leaderboardVariants.add(new LeaderboardVariantRef(this.dataHolder, this.dataRow + i)); + } + + return leaderboardVariants; + } + + @Override + public Leaderboard freeze() { + return new LeaderboardEntity(this); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScore.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScore.java new file mode 100644 index 0000000000..d2989656f0 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScore.java @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import android.database.CharArrayBuffer; +import android.net.Uri; + +import com.google.android.gms.common.data.Freezable; +import com.google.android.gms.common.images.ImageManager; +import com.google.android.gms.games.Player; + +/** + * Data interface representing a single score on a leaderboard. + */ +public interface LeaderboardScore extends Freezable { + + /** + * Constant indicating that the score holder's rank was not known. + */ + int LEADERBOARD_RANK_UNKNOWN = -1; + + /** + * Load the formatted display rank into the given {@link CharArrayBuffer}. + * + * @param dataOut The buffer to load the data into. + */ + void getDisplayRank(CharArrayBuffer dataOut); + + /** + * Retrieves a formatted string to display for this rank. This handles appropriate localization and formatting. + * + * @return Formatted string to display. + */ + String getDisplayRank(); + + /** + * Retrieves a formatted string to display for this score. + * The details of the formatting are specified by the developer in their dev console. + * + * @return Formatted string to display. + */ + String getDisplayScore(); + + /** + * Loads the formatted display score into the given {@link CharArrayBuffer}. + * + * @param dataOut The buffer to load the data into. + */ + void getDisplayScore(CharArrayBuffer dataOut); + + /** + * Retrieves the rank returned from the server for this score. + * Note that this may not be exact and that multiple scores can have identical ranks. + * Lower ranks indicate a better score, with rank 1 being the best score on the board. + *

+ * If the score holder's rank cannot be determined, this will return {@link LeaderboardScore#LEADERBOARD_RANK_UNKNOWN}. + * + * @return Rank of score. + */ + long getRank(); + + /** + * Retrieves the raw score value. + * + * @return The raw score value. + */ + long getRawScore(); + + /** + * Retrieves the player that scored this particular score. + * The return value here may be null if the current player is not authorized to see information about the holder of this score. + *

+ * Note that this object is a volatile representation, so it is not safe to cache the output of this directly. + * Instead, cache the result of {@link Freezable#freeze()}. + * + * @return The player associated with this leaderboard score. + */ + Player getScoreHolder(); + + /** + * Load the display name of the player who scored this score into the provided {@link CharArrayBuffer}. + * + * @param dataOut The buffer to load the data into. + */ + void getScoreHolderDisplayName(CharArrayBuffer dataOut); + + /** + * Retrieves the name to display for the player who scored this score. + * If the identity of the player is unknown, this will return an anonymous name to display. + * + * @return The display name of the holder of this score. + */ + String getScoreHolderDisplayName(); + + /** + * Retrieves the URI of the hi-res image to display for the player who scored this score. + * If the identity of the player is unknown, this will return null. It may also be null if the player simply has no image. + *

+ * To retrieve the Image from the {@link Uri}, use {@link ImageManager}. + * + * @return The URI of the hi-res image to display for this score. + */ + Uri getScoreHolderHiResImageUri(); + + /** + * Retrieves the URI of the icon image to display for the player who scored this score. + * If the identity of the player is unknown, this will return an anonymous image for the player. + * It may also be null if the player simply has no image. + *

+ * To retrieve the Image from the {@link Uri}, use {@link ImageManager}. + * + * @return The URI of the icon image to display for this score. + */ + Uri getScoreHolderIconImageUri(); + + /** + * Retrieve the optional score tag associated with this score, if any. + * + * @return The score tag associated with this score. + */ + String getScoreTag(); + + /** + * Retrieves the timestamp (in milliseconds from epoch) at which this score was achieved. + * + * @return Timestamp when this score was achieved. + */ + long getTimestampMillis(); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreBuffer.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreBuffer.java new file mode 100644 index 0000000000..58d96abc7c --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreBuffer.java @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import com.google.android.gms.common.data.AbstractDataBuffer; +import com.google.android.gms.common.data.DataHolder; + +public class LeaderboardScoreBuffer extends AbstractDataBuffer { + + public LeaderboardScoreBuffer(DataHolder dataHolder) { + super(dataHolder); + } + + @Override + public LeaderboardScore get(int position) { + return new LeaderboardScoreRef(dataHolder, position); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreColumns.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreColumns.java new file mode 100644 index 0000000000..6cc32c8a64 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreColumns.java @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +public class LeaderboardScoreColumns { + public static String DB_TABLE_LEADERBOARD_SCORES = "leaderboard_scores"; + public static String DB_FIELD_GAME_PACKAGE_NAME = "game_package_name"; + public static String DB_FIELD_RANK = "rank"; + public static String DB_FIELD_DISPLAY_RANK = "display_rank"; + public static String DB_FIELD_DISPLAY_SCORE = "display_score"; + public static String DB_FIELD_RAW_SCORE = "raw_score"; + public static String DB_FIELD_ACHIEVED_TIMESTAMP = "achieved_timestamp"; + public static String DB_FIELD_EXTERNAL_PLAYER_ID = "external_player_id"; + public static String DB_FIELD_DEFAULT_DISPLAY_NAME = "default_display_name"; + public static String DB_FIELD_DEFAULT_DISPLAY_IMAGE_URI = "default_display_image_uri"; + public static String DB_FIELD_SCORE_TAG = "score_tag"; + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreEntity.java new file mode 100644 index 0000000000..e14d810aec --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreEntity.java @@ -0,0 +1,107 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import android.database.CharArrayBuffer; +import android.net.Uri; + +import com.google.android.gms.common.internal.Preconditions; +import com.google.android.gms.common.util.DataUtils; +import com.google.android.gms.games.Player; +import com.google.android.gms.games.PlayerEntity; + +public final class LeaderboardScoreEntity implements LeaderboardScore { + private final long rank; + private final String displayRank; + private final String displayScore; + private final long rawScore; + private final long timestampMillis; + private final String scoreHolderDisplayName; + private final Uri scoreHolderIconImageUri; + private final Uri scoreHolderHiResImageUri; + private final PlayerEntity playerEntity; + private final String scoreTag; + + public LeaderboardScoreEntity(LeaderboardScore leaderboardScore) { + this.rank = leaderboardScore.getRank(); + this.displayRank = (String) Preconditions.checkNotNull(leaderboardScore.getDisplayRank()); + this.displayScore = (String) Preconditions.checkNotNull(leaderboardScore.getDisplayScore()); + this.rawScore = leaderboardScore.getRawScore(); + this.timestampMillis = leaderboardScore.getTimestampMillis(); + this.scoreHolderDisplayName = leaderboardScore.getScoreHolderDisplayName(); + this.scoreHolderIconImageUri = leaderboardScore.getScoreHolderIconImageUri(); + this.scoreHolderHiResImageUri = leaderboardScore.getScoreHolderHiResImageUri(); + Player player = leaderboardScore.getScoreHolder(); + this.playerEntity = player == null ? null : (PlayerEntity) player.freeze(); + this.scoreTag = leaderboardScore.getScoreTag(); + } + + public long getRank() { + return this.rank; + } + + public String getDisplayRank() { + return this.displayRank; + } + + public void getDisplayRank(CharArrayBuffer var1) { + DataUtils.copyStringToBuffer(this.displayRank, var1); + } + + public String getDisplayScore() { + return this.displayScore; + } + + public void getDisplayScore(CharArrayBuffer var1) { + DataUtils.copyStringToBuffer(this.displayScore, var1); + } + + public long getRawScore() { + return this.rawScore; + } + + public long getTimestampMillis() { + return this.timestampMillis; + } + + public String getScoreHolderDisplayName() { + return this.playerEntity == null ? this.scoreHolderDisplayName : this.playerEntity.getDisplayName(); + } + + public void getScoreHolderDisplayName(CharArrayBuffer var1) { + if (this.playerEntity == null) { + DataUtils.copyStringToBuffer(this.scoreHolderDisplayName, var1); + } else { + this.playerEntity.getDisplayName(var1); + } + } + + public Uri getScoreHolderIconImageUri() { + return this.playerEntity == null ? this.scoreHolderIconImageUri : this.playerEntity.getIconImageUri(); + } + + public Uri getScoreHolderHiResImageUri() { + return this.playerEntity == null ? this.scoreHolderHiResImageUri : this.playerEntity.getHiResImageUri(); + } + + public Player getScoreHolder() { + return this.playerEntity; + } + + public String getScoreTag() { + return this.scoreTag; + } + + public boolean isDataValid() { + return true; + } + + @Override + public LeaderboardScore freeze() { + return this; + } + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreRef.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreRef.java new file mode 100644 index 0000000000..78011c756a --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardScoreRef.java @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import android.database.CharArrayBuffer; +import android.net.Uri; + +import com.google.android.gms.common.data.DataBufferRef; +import com.google.android.gms.common.data.DataHolder; +import com.google.android.gms.games.Player; +import com.google.android.gms.games.PlayerRef; + +public class LeaderboardScoreRef extends DataBufferRef implements LeaderboardScore { + private final PlayerRef playerRef; + + LeaderboardScoreRef(DataHolder dataHolder, int dataRow) { + super(dataHolder, dataRow); + this.playerRef = new PlayerRef(dataHolder, dataRow); + } + + public final long getRank() { + return this.getLong(LeaderboardScoreColumns.DB_FIELD_RANK); + } + + public final String getDisplayRank() { + return this.getString(LeaderboardScoreColumns.DB_FIELD_DISPLAY_RANK); + } + + public final void getDisplayRank(CharArrayBuffer var1) { + this.copyToBuffer(LeaderboardScoreColumns.DB_FIELD_DISPLAY_RANK, var1); + } + + public final String getDisplayScore() { + return this.getString(LeaderboardScoreColumns.DB_FIELD_DISPLAY_SCORE); + } + + public final void getDisplayScore(CharArrayBuffer var1) { + this.copyToBuffer(LeaderboardScoreColumns.DB_FIELD_DISPLAY_SCORE, var1); + } + + public final long getRawScore() { + return this.getLong(LeaderboardScoreColumns.DB_FIELD_RAW_SCORE); + } + + public final long getTimestampMillis() { + return this.getLong(LeaderboardScoreColumns.DB_FIELD_ACHIEVED_TIMESTAMP); + } + + public final String getScoreHolderDisplayName() { + return this.hasNull(LeaderboardScoreColumns.DB_FIELD_EXTERNAL_PLAYER_ID) ? this.getString(LeaderboardScoreColumns.DB_FIELD_DEFAULT_DISPLAY_NAME) : this.playerRef.getDisplayName(); + } + + public final void getScoreHolderDisplayName(CharArrayBuffer var1) { + if (this.hasNull(LeaderboardScoreColumns.DB_FIELD_EXTERNAL_PLAYER_ID)) { + this.copyToBuffer(LeaderboardScoreColumns.DB_FIELD_DEFAULT_DISPLAY_NAME, var1); + } else { + this.playerRef.getDisplayName(var1); + } + } + + public final Uri getScoreHolderIconImageUri() { + return this.hasNull(LeaderboardScoreColumns.DB_FIELD_EXTERNAL_PLAYER_ID) ? this.parseUri(LeaderboardScoreColumns.DB_FIELD_DEFAULT_DISPLAY_IMAGE_URI) : this.playerRef.getIconImageUri(); + } + + public final Uri getScoreHolderHiResImageUri() { + return this.hasNull(LeaderboardScoreColumns.DB_FIELD_EXTERNAL_PLAYER_ID) ? null : this.playerRef.getHiResImageUri(); + } + + public final Player getScoreHolder() { + return this.hasNull(LeaderboardScoreColumns.DB_FIELD_EXTERNAL_PLAYER_ID) ? null : this.playerRef; + } + + public final String getScoreTag() { + return this.getString(LeaderboardScoreColumns.DB_FIELD_SCORE_TAG); + } + + @Override + public LeaderboardScore freeze() { + return new LeaderboardScoreEntity(this); + } +} \ No newline at end of file diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariant.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariant.java new file mode 100644 index 0000000000..cb8816745d --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariant.java @@ -0,0 +1,154 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import androidx.annotation.IntDef; + +import com.google.android.gms.common.data.Freezable; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Data interface for a specific variant of a leaderboard; + * a variant is defined by the combination of the leaderboard's collection (public or friends) and time span (daily, weekly, or all-time). + */ +public interface LeaderboardVariant extends Freezable { + + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {Collection.COLLECTION_FRIENDS, Collection.COLLECTION_PUBLIC}) + @interface Collection { + /** + * Collection constant for public leaderboards. + * Public leaderboards contain the scores of players who are sharing their gameplay activity publicly. + */ + int COLLECTION_PUBLIC = 0; + /** + * Collection constant for friends leaderboards. + * These leaderboards contain the scores of players in the viewing player's friends list. + */ + int COLLECTION_FRIENDS = 3; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {TimeSpan.TIME_SPAN_DAILY, TimeSpan.TIME_SPAN_WEEKLY, TimeSpan.TIME_SPAN_ALL_TIME, TimeSpan.NUM_TIME_SPANS}) + @interface TimeSpan { + /** + * Scores are reset every day. + * The reset occurs at 11:59PM PST. + */ + int TIME_SPAN_DAILY = 0; + /** + * Scores are reset once per week. + * The reset occurs at 11:59PM PST on Sunday. + */ + int TIME_SPAN_WEEKLY = 1; + /** + * Scores are never reset. + */ + int TIME_SPAN_ALL_TIME = 2; + /** + * Number of time spans that exist. + * Needs to be updated if we ever have more. + */ + int NUM_TIME_SPANS = 3; + } + + /** + * Constant returned when a player's score for this variant is unknown. + */ + int PLAYER_SCORE_UNKNOWN = -1; + + /** + * Constant returned when the total number of scores for this variant is unknown. + */ + int NUM_SCORES_UNKNOWN = -1; + + /** + * Constant returned when a player's rank for this variant is unknown. + */ + int PLAYER_RANK_UNKNOWN = -1; + + /** + * Retrieves the collection of scores contained by this variant. + * Possible values are {@link Collection#COLLECTION_PUBLIC} or {@link Collection#COLLECTION_FRIENDS}. + * + * @return The collection of scores contained by this variant. + */ + int getCollection(); + + /** + * Retrieves the viewing player's formatted rank for this variant, if any. + * Note that this value is only accurate if {@link LeaderboardVariant#hasPlayerInfo()} returns true. + * + * @return The String representation of the viewing player's rank, or {@code null) if the player has no rank for this variant. + */ + String getDisplayPlayerRank(); + + /** + * Retrieves the viewing player's score for this variant, if any. + * Note that this value is only accurate if {@link LeaderboardVariant#hasPlayerInfo()} returns true. + * + * @return The String representation of the viewing player's score, or null if the player has no score for this variant. + */ + String getDisplayPlayerScore(); + + /** + * Retrieves the total number of scores for this variant. Not all of these scores will always be present on the local device. + * Note that if scores for this variant have not been loaded, this method will return {@link LeaderboardVariant#NUM_SCORES_UNKNOWN}. + * + * @return The number of scores for this variant, or {@link LeaderboardVariant#NUM_SCORES_UNKNOWN}. + */ + long getNumScores(); + + /** + * Retrieves the viewing player's rank for this variant, if any. + * Note that this value is only accurate if {@link LeaderboardVariant#hasPlayerInfo()} returns true. + * + * @return The long representation of the viewing player's rank, or {@link LeaderboardVariant#PLAYER_RANK_UNKNOWN} + * if the player has no rank for this variant. + */ + long getPlayerRank(); + + /** + * Retrieves the viewing player's score tag for this variant, if any. + * Note that this value is only accurate if {@link LeaderboardVariant#hasPlayerInfo()} returns true. + * + * @return The score tag associated with the viewing player's score, or null if the player has no score for this variant. + */ + String getPlayerScoreTag(); + + /** + * Retrieves the viewing player's score for this variant, if any. + * Note that this value is only accurate if {@link LeaderboardVariant#hasPlayerInfo()} returns true. + * + * @return The long representation of the viewing player's score, or {@link LeaderboardVariant#PLAYER_SCORE_UNKNOWN} + * if the player has no score for this variant. + */ + long getRawPlayerScore(); + + /** + * Retrieves the time span that the scores for this variant are drawn from. + * Possible values are {@link TimeSpan#TIME_SPAN_ALL_TIME}, {@link TimeSpan#TIME_SPAN_WEEKLY}, or {@link TimeSpan#TIME_SPAN_DAILY}. + * + * @return The time span that the scores for this variant are drawn from. + */ + int getTimeSpan(); + + /** + * Get whether or not this variant contains score information for the viewing player or not. + * There are several possible reasons why this might be false. + * If the scores for this variant have never been loaded, we won't know if the player has a score or not. Similarly, + * if the player has not submitted a score for this variant, this will return false. + *

+ * It is possible to have a score but no rank. For instance, on leaderboard variants of {@link Collection#COLLECTION_PUBLIC}, + * players who are not sharing their scores publicly will never have a rank. + * + * @return Whether or not this variant contains score information for the viewing player. + */ + boolean hasPlayerInfo(); + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariantEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariantEntity.java new file mode 100644 index 0000000000..a08586865b --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariantEntity.java @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +public class LeaderboardVariantEntity implements LeaderboardVariant { + private final int timeSpan; + private final int collection; + private final boolean hasPlayerInfo; + private final long rawPlayerScore; + private final String displayPlayerScore; + private final long playerRank; + private final String displayPlayerRank; + private final String playerScoreTag; + private final long numScores; + + public LeaderboardVariantEntity(LeaderboardVariant variant) { + this.timeSpan = variant.getTimeSpan(); + this.collection = variant.getCollection(); + this.hasPlayerInfo = variant.hasPlayerInfo(); + this.rawPlayerScore = variant.getRawPlayerScore(); + this.displayPlayerScore = variant.getDisplayPlayerScore(); + this.playerRank = variant.getPlayerRank(); + this.displayPlayerRank = variant.getDisplayPlayerRank(); + this.playerScoreTag = variant.getPlayerScoreTag(); + this.numScores = variant.getNumScores(); + } + + public int getTimeSpan() { + return this.timeSpan; + } + + public int getCollection() { + return this.collection; + } + + public boolean hasPlayerInfo() { + return this.hasPlayerInfo; + } + + public long getRawPlayerScore() { + return this.rawPlayerScore; + } + + public String getDisplayPlayerScore() { + return this.displayPlayerScore; + } + + public long getPlayerRank() { + return this.playerRank; + } + + public String getDisplayPlayerRank() { + return this.displayPlayerRank; + } + + public String getPlayerScoreTag() { + return this.playerScoreTag; + } + + public long getNumScores() { + return this.numScores; + } + + @Override + public LeaderboardVariant freeze() { + return this; + } + + public boolean isDataValid() { + return true; + } + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariantRef.java b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariantRef.java new file mode 100644 index 0000000000..57e8ccf426 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/leaderboard/LeaderboardVariantRef.java @@ -0,0 +1,57 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.leaderboard; + +import com.google.android.gms.common.data.DataBufferRef; +import com.google.android.gms.common.data.DataHolder; + +public class LeaderboardVariantRef extends DataBufferRef implements LeaderboardVariant { + + LeaderboardVariantRef(DataHolder dataHolder, int dataRow) { + super(dataHolder, dataRow); + } + + public final int getTimeSpan() { + return this.getInteger("timespan"); + } + + public final int getCollection() { + return this.getInteger("collection"); + } + + public final boolean hasPlayerInfo() { + return !this.hasNull("player_raw_score"); + } + + public final long getRawPlayerScore() { + return this.hasNull("player_raw_score") ? -1L : this.getLong("player_raw_score"); + } + + public final String getDisplayPlayerScore() { + return this.getString("player_display_score"); + } + + public final long getPlayerRank() { + return this.hasNull("player_rank") ? -1L : this.getLong("player_rank"); + } + + public final String getDisplayPlayerRank() { + return this.getString("player_display_rank"); + } + + public final String getPlayerScoreTag() { + return this.getString("player_score_tag"); + } + + public final long getNumScores() { + return this.hasNull("total_scores") ? -1L : this.getLong("total_scores"); + } + + @Override + public LeaderboardVariant freeze() { + return new LeaderboardVariantEntity(this); + } +} \ No newline at end of file diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/Snapshot.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/Snapshot.java new file mode 100644 index 0000000000..69fbc7d678 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/Snapshot.java @@ -0,0 +1,16 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.os.Parcelable; + +import com.google.android.gms.common.data.Freezable; + +public interface Snapshot extends Parcelable, Freezable { + SnapshotMetadata getMetadata(); + + SnapshotContents getSnapshotContents(); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotColumns.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotColumns.java new file mode 100644 index 0000000000..e3377e8c43 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotColumns.java @@ -0,0 +1,31 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ +package com.google.android.gms.games.snapshot; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class SnapshotColumns { + public static final String EXTERNAL_SNAPSHOT_ID = "external_snapshot_id"; + public static final String COVER_ICON_IMAGE_URI = "cover_icon_image_uri"; + public static final String COVER_ICON_IMAGE_URL = "cover_icon_image_url"; + public static final String COVER_ICON_IMAGE_HEIGHT = "cover_icon_image_height"; + public static final String COVER_ICON_IMAGE_WIDTH = "cover_icon_image_width"; + public static final String UNIQUE_NAME = "unique_name"; + public static final String TITLE = "title"; + public static final String DESCRIPTION = "description"; + public static final String LAST_MODIFIED_TIMESTAMP = "last_modified_timestamp"; + public static final String DURATION = "duration"; + public static final String PENDING_CHANGE_COUNT = "pending_change_count"; + public static final String PROGRESS_VALUE = "progress_value"; + public static final String DEVICE_NAME = "device_name"; + + public static final List CURRENT_GAME_COLUMNS = Collections.unmodifiableList(Arrays.asList( + EXTERNAL_SNAPSHOT_ID, COVER_ICON_IMAGE_URI, COVER_ICON_IMAGE_URL, COVER_ICON_IMAGE_HEIGHT, + COVER_ICON_IMAGE_WIDTH, UNIQUE_NAME, TITLE, DESCRIPTION, LAST_MODIFIED_TIMESTAMP, DURATION, + PENDING_CHANGE_COUNT, PROGRESS_VALUE, DEVICE_NAME + )); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotContents.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotContents.java new file mode 100644 index 0000000000..cbd751b753 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotContents.java @@ -0,0 +1,30 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.os.ParcelFileDescriptor; +import android.os.Parcelable; + + +import com.google.android.gms.drive.Contents; + +import java.io.IOException; + +public interface SnapshotContents extends Parcelable { + ParcelFileDescriptor getParcelFileDescriptor(); + + Contents getContents(); + + void close(); + + boolean isClosed(); + + byte[] readFully() throws IOException; + + boolean writeBytes(byte[] var1); + + boolean modifyBytes(int var1, byte[] var2, int var3, int var4); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotContentsEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotContentsEntity.java new file mode 100644 index 0000000000..1f142272d9 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotContentsEntity.java @@ -0,0 +1,139 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.Preconditions; +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; +import com.google.android.gms.drive.Contents; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.FileChannel; + +@SafeParcelable.Class +public class SnapshotContentsEntity extends AbstractSafeParcelable implements SnapshotContents { + private static final Object LOCK = new Object(); + + @Field(value = 1, getterName = "getContents") + private Contents contents; + + @Constructor + public SnapshotContentsEntity(@Param(value = 1) Contents contents) { + this.contents = contents; + } + + public final ParcelFileDescriptor getParcelFileDescriptor() { + Preconditions.checkState(!this.isClosed(), "Cannot mutate closed contents!"); + return this.contents.getParcelFileDescriptor(); + } + + public final Contents getContents() { + return this.contents; + } + + public final void close() { + this.contents = null; + } + + public final boolean isClosed() { + return this.contents == null; + } + + public final byte[] readFully() throws IOException { + Preconditions.checkState(!this.isClosed(), "Must provide a previously opened Snapshot"); + synchronized (LOCK) { + ParcelFileDescriptor parcelFileDescriptor = this.contents.getParcelFileDescriptor(); + FileInputStream fileInputStream = new FileInputStream(parcelFileDescriptor.getFileDescriptor()); + BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream); + + byte[] bytes; + try { + fileInputStream.getChannel().position(0L); + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + copyStream(bufferedInputStream, byteArrayOutputStream); + byte[] byteArray = byteArrayOutputStream.toByteArray(); + fileInputStream.getChannel().position(0L); + bytes = byteArray; + } catch (IOException ioException) { + Log.d("SnapshotContentsEntity", "Failed to read snapshot data", ioException); + throw ioException; + } + + return bytes; + } + } + + private void copyStream(InputStream inputStream, OutputStream outputStream) throws IOException { + byte[] bytes = new byte[1024]; + long l = 0L; + + int i; + try { + while((i = inputStream.read(bytes, 0, 1024)) != -1) { + l += (long)i; + outputStream.write(bytes, 0, i); + } + } catch (IOException ioException) { + Log.d("SnapshotContentsEntity", "Failed to copyStream ", ioException); + throw ioException; + } + } + + public final boolean writeBytes(byte[] bytes) { + return this.doWrite(0, bytes, 0, bytes.length, true); + } + + public final boolean modifyBytes(int var1, byte[] var2, int var3, int var4) { + return this.doWrite(var1, var2, var3, var2.length, false); + } + + private boolean doWrite(int var1, byte[] var2, int var3, int var4, boolean var5) { + Preconditions.checkState(!this.isClosed(), "Must provide a previously opened SnapshotContents"); + synchronized (LOCK) { + ParcelFileDescriptor parcelFileDescriptor = this.contents.getParcelFileDescriptor(); + FileOutputStream fileOutputStream = new FileOutputStream(parcelFileDescriptor.getFileDescriptor()); + BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream); + + try { + FileChannel fileChannel; + (fileChannel = fileOutputStream.getChannel()).position((long) var1); + bufferedOutputStream.write(var2, var3, var4); + if (var5) { + fileChannel.truncate((long) var2.length); + } + + bufferedOutputStream.flush(); + } catch (IOException var12) { + Log.d("SnapshotContentsEntity", "Failed to write snapshot data", var12); + return false; + } + + return true; + } + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SnapshotContentsEntity.class); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotEntity.java new file mode 100644 index 0000000000..988ec764c0 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotEntity.java @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class SnapshotEntity extends AbstractSafeParcelable implements Snapshot { + + @Field(value = 1, getterName = "getMetadata") + private final SnapshotMetadataEntity metadataEntity; + @Field(value = 3, getterName = "getSnapshotContents") + private final SnapshotContentsEntity snapshotContents; + + @Constructor + public SnapshotEntity(@Param(value = 1) SnapshotMetadata var1, @Param(value = 3) SnapshotContentsEntity var2) { + this.metadataEntity = new SnapshotMetadataEntity(var1); + this.snapshotContents = var2; + } + + public final SnapshotMetadataEntity getMetadata() { + return this.metadataEntity; + } + + public final SnapshotContentsEntity getSnapshotContents() { + return this.snapshotContents.isClosed() ? null : this.snapshotContents; + } + + public final Snapshot freeze() { + return this; + } + + public final boolean isDataValid() { + return true; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SnapshotEntity.class); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadata.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadata.java new file mode 100644 index 0000000000..866677aa37 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadata.java @@ -0,0 +1,55 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcelable; + +import androidx.annotation.Nullable; + +import com.google.android.gms.common.data.Freezable; +import com.google.android.gms.games.Game; +import com.google.android.gms.games.Player; + +public interface SnapshotMetadata extends Parcelable, Freezable { + long PLAYED_TIME_UNKNOWN = -1L; + long PROGRESS_VALUE_UNKNOWN = -1L; + + Game getGame(); + + Player getOwner(); + + String getSnapshotId(); + + @Nullable + Uri getCoverImageUri(); + + /** @deprecated */ + @Deprecated + @Nullable + String getCoverImageUrl(); + + float getCoverImageAspectRatio(); + + String getUniqueName(); + + String getTitle(); + + String getDescription(); + + void getDescription(CharArrayBuffer var1); + + long getLastModifiedTimestamp(); + + long getPlayedTime(); + + boolean hasChangePending(); + + long getProgressValue(); + + String getDeviceName(); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataBuffer.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataBuffer.java new file mode 100644 index 0000000000..89b8f7a2c8 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataBuffer.java @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import com.google.android.gms.common.data.AbstractDataBuffer; +import com.google.android.gms.common.data.DataHolder; + +public final class SnapshotMetadataBuffer extends AbstractDataBuffer { + + public SnapshotMetadataBuffer(DataHolder dataHolder) { + super(dataHolder); + } + + public SnapshotMetadata get(int position) { + return new SnapshotMetadataRef(this.dataHolder, position); + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataChange.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataChange.java new file mode 100644 index 0000000000..69f9a244eb --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataChange.java @@ -0,0 +1,85 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.graphics.Bitmap; +import android.net.Uri; + +import androidx.annotation.Nullable; + +import com.google.android.gms.common.data.BitmapTeleporter; + +public interface SnapshotMetadataChange { + SnapshotMetadataChange EMPTY_CHANGE = new SnapshotMetadataChangeEntity(); + + @Nullable + String getDescription(); + + @Nullable + Long getPlayedTimeMillis(); + + @Nullable + BitmapTeleporter getBitmapTeleporter(); + + @Nullable + Bitmap getCoverImage(); + + @Nullable + Long getProgressValue(); + + class Builder { + private String description; + private Long playedTime; + private Long progressValue; + private BitmapTeleporter coverImageTeleporter; + private Uri coverImageUri; + + public Builder() { + } + + public Builder setDescription(String var1) { + this.description = var1; + return this; + } + + public Builder setPlayedTimeMillis(long var1) { + this.playedTime = var1; + return this; + } + + public Builder setProgressValue(long var1) { + this.progressValue = var1; + return this; + } + + public Builder setCoverImage(Bitmap var1) { + this.coverImageTeleporter = new BitmapTeleporter(var1); + this.coverImageUri = null; + return this; + } + + public Builder fromMetadata(SnapshotMetadata var1) { + this.description = var1.getDescription(); + this.playedTime = var1.getPlayedTime(); + this.progressValue = var1.getProgressValue(); + if (this.playedTime == -1L) { + this.playedTime = null; + } + + this.coverImageUri = var1.getCoverImageUri(); + if (this.coverImageUri != null) { + this.coverImageTeleporter = null; + } + + return this; + } + + public SnapshotMetadataChange build() { + return new SnapshotMetadataChangeEntity(this.description, this.playedTime, this.coverImageTeleporter, this.coverImageUri, this.progressValue); + } + } + +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataChangeEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataChangeEntity.java new file mode 100644 index 0000000000..1d10cd214e --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataChangeEntity.java @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.graphics.Bitmap; +import android.net.Uri; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.data.BitmapTeleporter; +import com.google.android.gms.common.internal.Preconditions; +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; + +@SafeParcelable.Class +public class SnapshotMetadataChangeEntity extends AbstractSafeParcelable implements SnapshotMetadataChange { + + @Field(value = 1, getterName = "getDescription") + private final String description; + @Field(value = 2, getterName = "getPlayedTimeMillis") + private final Long playedTimeMillis; + @Field(value = 4, getterName = "getCoverImageUri") + private final Uri coverImageUri; + @Field(value = 5, getterName = "getBitmapTeleporter") + private BitmapTeleporter coverImageTeleporter; + @Field(value = 6, getterName = "getProgressValue") + private final Long progressValue; + + SnapshotMetadataChangeEntity() { + this((String) null, (Long) null, (BitmapTeleporter) null, (Uri) null, (Long) null); + } + + @Constructor + SnapshotMetadataChangeEntity(@Param(value = 1) String var1, @Param(value = 2) Long var2, @Param(value = 5) BitmapTeleporter var3, @Param(value = 4) Uri var4, @Param(value = 6) Long var5) { + this.description = var1; + this.playedTimeMillis = var2; + this.coverImageTeleporter = var3; + this.coverImageUri = var4; + this.progressValue = var5; + Preconditions.checkState(this.coverImageTeleporter == null || var4 == null, "Cannot set both a URI and an image"); + } + + @Nullable + public final String getDescription() { + return this.description; + } + + @Nullable + public final Long getPlayedTimeMillis() { + return this.playedTimeMillis; + } + + @Nullable + public final Long getProgressValue() { + return this.progressValue; + } + + @Nullable + public final BitmapTeleporter getBitmapTeleporter() { + return this.coverImageTeleporter; + } + + @Nullable + public final Bitmap getCoverImage() { + return this.coverImageTeleporter == null ? null : this.coverImageTeleporter.createTargetBitmap(); + } + + public Uri getCoverImageUri() { + return coverImageUri; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SnapshotMetadataChangeEntity.class); + + @Override + public String toString() { + return "SnapshotMetadataChangeEntity{" + + "description='" + description + '\'' + + ", playedTimeMillis=" + playedTimeMillis + + ", coverImageUri=" + coverImageUri + + ", coverImageTeleporter=" + coverImageTeleporter + + ", progressValue=" + progressValue + + '}'; + } +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataEntity.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataEntity.java new file mode 100644 index 0000000000..36616f65e9 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataEntity.java @@ -0,0 +1,179 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.common.internal.safeparcel.AbstractSafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelable; +import com.google.android.gms.common.internal.safeparcel.SafeParcelableCreatorAndWriter; +import com.google.android.gms.common.util.DataUtils; +import com.google.android.gms.games.Game; +import com.google.android.gms.games.GameEntity; +import com.google.android.gms.games.Player; +import com.google.android.gms.games.PlayerEntity; + +@SafeParcelable.Class +public class SnapshotMetadataEntity extends AbstractSafeParcelable implements SnapshotMetadata { + + @Field(value = 1, getterName = "getGame") + private final GameEntity gameEntity; + @Field(value = 2, getterName = "getOwner") + private final PlayerEntity playerEntity; + @Field(value = 3, getterName = "getSnapshotId") + private final String snapshotId; + @Field(value = 5, getterName = "getCoverImageUri") + @Nullable + private final Uri coverImageUri; + @Field(value = 6, getterName = "getCoverImageUrl") + @Nullable + private final String coverImageUrl; + @Field(value = 7, getterName = "getTitle") + private final String title; + @Field(value = 8, getterName = "getDescription") + private final String description; + @Field(value = 9, getterName = "getLastModifiedTimestamp") + private final long lastModifiedTimestamp; + @Field(value = 10, getterName = "getPlayedTime") + private final long playedTime; + @Field(value = 11, getterName = "getCoverImageAspectRatio") + private final float coverImageAspectRatio; + @Field(value = 12, getterName = "getUniqueName") + private final String uniqueName; + @Field(value = 13, getterName = "hasChangePending") + private final boolean hasChangePending; + @Field(value = 14, getterName = "getProgressValue") + private final long progressValue; + @Field(value = 15, getterName = "getDeviceName") + @Nullable + private final String deviceName; + + public SnapshotMetadataEntity(SnapshotMetadata var1) { + this(var1, new PlayerEntity(var1.getOwner())); + } + + private SnapshotMetadataEntity(SnapshotMetadata var1, PlayerEntity var2) { + this.gameEntity = new GameEntity(var1.getGame()); + this.playerEntity = var2; + this.snapshotId = var1.getSnapshotId(); + this.coverImageUri = var1.getCoverImageUri(); + this.coverImageUrl = var1.getCoverImageUrl(); + this.coverImageAspectRatio = var1.getCoverImageAspectRatio(); + this.title = var1.getTitle(); + this.description = var1.getDescription(); + this.lastModifiedTimestamp = var1.getLastModifiedTimestamp(); + this.playedTime = var1.getPlayedTime(); + this.uniqueName = var1.getUniqueName(); + this.hasChangePending = var1.hasChangePending(); + this.progressValue = var1.getProgressValue(); + this.deviceName = var1.getDeviceName(); + } + + @Constructor + public SnapshotMetadataEntity(@Param(value = 1) GameEntity gameEntity, @Param(value = 2) PlayerEntity playerEntity, + @Param(value = 3) String snapshotId, @Param(value = 5) @Nullable Uri coverImageUri, + @Param(value = 6) @Nullable String coverImageUrl, @Param(value = 7) String title, + @Param(value = 8) String description, @Param(value = 9) long lastModifiedTimestamp, @Param(value = 10) + long playedTime, @Param(value = 11) float coverImageAspectRatio, @Param(value = 12) String uniqueName, + @Param(value = 13) boolean hasChangePending, @Param(value = 14) long progressValue, @Param(value = 15) @Nullable String deviceName) { + this.gameEntity = gameEntity; + this.playerEntity = playerEntity; + this.snapshotId = snapshotId; + this.coverImageUri = coverImageUri; + this.coverImageUrl = coverImageUrl; + this.coverImageAspectRatio = coverImageAspectRatio; + this.title = title; + this.description = description; + this.lastModifiedTimestamp = lastModifiedTimestamp; + this.playedTime = playedTime; + this.uniqueName = uniqueName; + this.hasChangePending = hasChangePending; + this.progressValue = progressValue; + this.deviceName = deviceName; + } + + public final GameEntity getGame() { + return this.gameEntity; + } + + public final PlayerEntity getOwner() { + return this.playerEntity; + } + + public final String getSnapshotId() { + return this.snapshotId; + } + + @Nullable + public final Uri getCoverImageUri() { + return this.coverImageUri; + } + + @Nullable + public final String getCoverImageUrl() { + return this.coverImageUrl; + } + + public final float getCoverImageAspectRatio() { + return this.coverImageAspectRatio; + } + + public final String getUniqueName() { + return this.uniqueName; + } + + public final String getTitle() { + return this.title; + } + + public final String getDescription() { + return this.description; + } + + public final void getDescription(CharArrayBuffer var1) { + DataUtils.copyStringToBuffer(this.description, var1); + } + + public final long getLastModifiedTimestamp() { + return this.lastModifiedTimestamp; + } + + public final long getPlayedTime() { + return this.playedTime; + } + + public final boolean hasChangePending() { + return this.hasChangePending; + } + + public final long getProgressValue() { + return this.progressValue; + } + + public final String getDeviceName() { + return this.deviceName; + } + + public final SnapshotMetadata freeze() { + return this; + } + + public final boolean isDataValid() { + return true; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + CREATOR.writeToParcel(this, dest, flags); + } + + public static final SafeParcelableCreatorAndWriter CREATOR = findCreator(SnapshotMetadataEntity.class); +} diff --git a/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataRef.java b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataRef.java new file mode 100644 index 0000000000..33123a02f1 --- /dev/null +++ b/play-services-games/src/main/java/com/google/android/gms/games/snapshot/SnapshotMetadataRef.java @@ -0,0 +1,123 @@ +/** + * SPDX-FileCopyrightText: 2024 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.android.gms.games.snapshot; + +import static com.google.android.gms.games.snapshot.SnapshotColumns.COVER_ICON_IMAGE_HEIGHT; +import static com.google.android.gms.games.snapshot.SnapshotColumns.COVER_ICON_IMAGE_URI; +import static com.google.android.gms.games.snapshot.SnapshotColumns.COVER_ICON_IMAGE_URL; +import static com.google.android.gms.games.snapshot.SnapshotColumns.COVER_ICON_IMAGE_WIDTH; +import static com.google.android.gms.games.snapshot.SnapshotColumns.DESCRIPTION; +import static com.google.android.gms.games.snapshot.SnapshotColumns.DEVICE_NAME; +import static com.google.android.gms.games.snapshot.SnapshotColumns.DURATION; +import static com.google.android.gms.games.snapshot.SnapshotColumns.EXTERNAL_SNAPSHOT_ID; +import static com.google.android.gms.games.snapshot.SnapshotColumns.PENDING_CHANGE_COUNT; +import static com.google.android.gms.games.snapshot.SnapshotColumns.PROGRESS_VALUE; +import static com.google.android.gms.games.snapshot.SnapshotColumns.TITLE; +import static com.google.android.gms.games.snapshot.SnapshotColumns.UNIQUE_NAME; +import static com.google.android.gms.games.snapshot.SnapshotColumns.LAST_MODIFIED_TIMESTAMP; + +import android.annotation.SuppressLint; +import android.database.CharArrayBuffer; +import android.net.Uri; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import com.google.android.gms.common.data.DataBufferRef; +import com.google.android.gms.common.data.DataHolder; +import com.google.android.gms.games.Game; +import com.google.android.gms.games.GameRef; +import com.google.android.gms.games.Player; +import com.google.android.gms.games.PlayerRef; + +@SuppressLint("ParcelCreator") +public class SnapshotMetadataRef extends DataBufferRef implements SnapshotMetadata { + + private final Game game; + private final Player player; + + public SnapshotMetadataRef(DataHolder var1, int var2) { + super(var1, var2); + this.game = new GameRef(var1, var2); + this.player = new PlayerRef(var1, var2); + } + + public final Game getGame() { + return this.game; + } + + public final Player getOwner() { + return this.player; + } + + public final String getSnapshotId() { + return this.getString(EXTERNAL_SNAPSHOT_ID); + } + + public final Uri getCoverImageUri() { + return this.parseUri(COVER_ICON_IMAGE_URI); + } + + public final String getCoverImageUrl() { + return this.getString(COVER_ICON_IMAGE_URL); + } + + public final float getCoverImageAspectRatio() { + float var1 = this.getFloat(COVER_ICON_IMAGE_HEIGHT); + float var2 = this.getFloat(COVER_ICON_IMAGE_WIDTH); + return var1 == 0.0F ? 0.0F : var2 / var1; + } + + public final String getUniqueName() { + return this.getString(UNIQUE_NAME); + } + + public final String getTitle() { + return this.getString(TITLE); + } + + public final String getDescription() { + return this.getString(DESCRIPTION); + } + + public final void getDescription(CharArrayBuffer var1) { + this.copyToBuffer(DESCRIPTION, var1); + } + + public final long getLastModifiedTimestamp() { + return this.getLong(LAST_MODIFIED_TIMESTAMP); + } + + public final long getPlayedTime() { + return this.getLong(DURATION); + } + + public final boolean hasChangePending() { + return this.getInteger(PENDING_CHANGE_COUNT) > 0; + } + + public final long getProgressValue() { + return this.getLong(PROGRESS_VALUE); + } + + public final String getDeviceName() { + return this.getString(DEVICE_NAME); + } + + public final int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + this.freeze().writeToParcel(dest, flags); + } + + @Override + public SnapshotMetadata freeze() { + return new SnapshotMetadataEntity(this); + } +}