Saturday, July 12, 2014

Rejected from the Apple Store for using default Google Play Game Services sign in implementation

So... yeah. The title says it all. I'm frustrated and on the verge of giving up, so forgive me if my tone is off on this one.

Several weeks ago, I submitted my latest game, made in Cocos2D-x, to Apple for approval. I was rejected with the following message, citing Apple 10.6 (dealing with UI):

"Your app takes the user out to Safari when they opt-in to signing in with Google"

I explained the situation: I didn't build that part, it's build in to the GPGS code. All I call is [[GPPSignIn sharedInstance] authenticate]; . Google even says in the documentation, for iOS they try to use the Google Plus app if it's available, then try Safari. There's no way to do it in-app as provided by Google.

I was still rejected, and lost my appeal, even though I've seen and used several apps that use the same implementation. I've also seen the same style used for GREE logins (i.e., the official 1 vs 100 app). It's not an unknown thing, and it's not new. Yes, I'm aware that's not a reason to approve it. But as it stands Apple is (or "should be") rejecting ALL apps that use Google Play Games Services. Unless there's some way of signing in in without leaving the app that I simply can't get working. I'd think something that large would warrant a message and/or a fix from Apple or Google addressing the situation.

After doing a ton of digging and only coming up with a handful of people who've had similar issues (and no solutions), I decided that perhaps my issue was the reviewer and not the actual policy. Again, rejected, same thing: though it did reference my previous rejection, so if it's related to that I'm not sure.

Then, just this week Google announced an update to Google Play Game Services, and I was hopeful that there might be a solution in there. Especially after this post came out about a Cocos2D-X implementation being available. Unfortunately, using the code from the sample the relevant call GPGSManager::BeginUserInitiatedSignIn(); still opens up Safari.

So, I'm done. I've spent weeks researching and submitting and getting rejected in the name of using a unified system for achievements and leaderboards. If someone has advice or a tip, feel free to chime in. But at this point I'm probably giving up and implementing Game Center for iOS and keeping GPGS only for Android.

Saturday, April 19, 2014

Integrating Google Play Game Services into Cocos2D-X

I’m making a few mobile games, and finally decided it was time to move to a cross-platform engine. One piece that seemed daunting, and didn’t have a TON of documentation, was integrating a Cocos2D-X game with the Google Play Game Services APIs. GPGS has some great features, like cloud saves, achievements, and leaderboards, and I wanted those.

This is by no means an “expert” opinion, and I fully acknowledge that I use hacky/subpar code in places. Feel free to disagree with my design decisions, etc. This is less a “this is how it’s done” and more of a “this worked for me” article. Coder beware, and all that.

I used Cocos2D-X 2.2.3, the current non-beta version. I have no idea what might be different if you use 3.x, or any other version.

I also borrowed some from the Avalon game center example (link below the post). I kept the “Game Center” name, but little of the code beyond the concept and some JNI helpers.

I didn’t do cloud saves for this one, only Achievements and Leaderboards. If I do the cloud saves later, I’ll post an update.

I initially started looking at the C++ library available for GPGS, and decided that was a mistake. While it certainly CAN be done that way, you lose some of the handier integrations with iOS and Android for things like launching the popups/activites for viewing achievements and leaderboards. The solution I used, at a high level, looks like this:

  • A header file (GameCenter.h) that both Android and iOS see.
  • A .mm file (GameCenter.mm) stored outside the main Classes folder structure, so I can make it so only Xcode can see it.
  • A .cpp file (GameCenter.cpp) stored in the Classes folder structure, but only included in Eclipse.

...you can see where this is going, I hope. Both the .mm and the .cpp file implement the header contract. The .mm file points at the obj-C functionality, while the .cpp file uses JNI to pass code to GameCenter.java static methods.

While I know it’s complex, I’m going to skip over the initial setup of GPGS. Follow the guidelines Google publishes for adding GPGS to your iOS and Android projects. A couple tips when using Cocos2D-X:

  • Grab the BaseGameActivity (and the related classes and helpers) from the Android sample. Set your main activity to inherit from it, and change BaseGameActivity to inherit from Cocos2dxActivity. I know it’s not a FragmentActivity... that doesn’t seem to cause issues.
  • Use the RootViewController in Xcode. For me, the viewDidLoad stuff wouldn’t fire when launching a Cocos2D-X project, so I put my initial launch code in initWithNibName.
  • I used a static reference to my Activity to launch the GPGS methods from my static java Game Center. Yes, I know this is leaky and bad.

Without any further ado, the code:

GameCenter.h
 #ifndef __XXXXX__GameCenter__  
 #define __XXXXX__GameCenter__  
 class GameCenter  
 {  
 public:  
 void showAchievements();  
 void unlockAchievement(const char* achievementId);  
 void showLeaderboard(const char* leaderboardId);  
 void postToLeaderboard(const char* leaderboardId, int score);  
 bool isSignedIn();  
 void signIn();  
 };  
 #endif /* defined(__XXXXX__GameCenter__) */  

GameCenter.mm
 #include "GameCenter.h"   
 #include "RootViewController.h"   
 bool GameCenter::isSignedIn()   
 {   
 return [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] isSignedIn];   
 }   
 void GameCenter::signIn()   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] startGoogleGamesSignIn];   
 }   
 void GameCenter::showAchievements()   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] showAchievements];   
 }   
 void GameCenter::unlockAchievement(const char* achievementId)   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] unlockAchievement: [NSString stringWithUTF8String:achievementId]];   
 }   
 void GameCenter::showLeaderboard(const char* leaderboardId)   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] showLeaderboard: [NSString stringWithUTF8String:leaderboardId]];   
 }   
 void GameCenter::postToLeaderboard(const char* leaderboardId, int score)   
 {   
 [(RootViewController*)[[[UIApplication sharedApplication] keyWindow] rootViewController] postToLeaderboard: [NSString stringWithUTF8String:leaderboardId] withScore:score];   
 }  

GameCenter.cpp
 #include "GameCenter.h"  
 #include "cocos2d.h"  
 #include "platform/android/jni/JniHelper.h"  
 #include <jni.h>  
 namespace helper {  
 namespace gamecenter {  
 const char* const CLASS_NAME = "com/bloodhoundstudios/gridinfect/GameCenter";  
 void callStaticVoidMethod(const char* name)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "()V")) {  
     t.env->CallStaticVoidMethod(t.classID, t.methodID);  
     t.env->DeleteLocalRef(t.classID);  
   }  
 }  
 bool callStaticBoolMethod(const char* name)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "()Z")) {  
     bool result = (t.env->CallStaticBooleanMethod(t.classID, t.methodID) == JNI_TRUE);  
     t.env->DeleteLocalRef(t.classID);  
     return result;  
   } else {  
     return false;  
   }  
 }  
 void callStaticVoidMethodWithString(const char* name, const char* idName)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "(Ljava/lang/String;)V")) {  
     jstring jIdName = t.env->NewStringUTF(idName);  
     t.env->CallStaticVoidMethod(t.classID, t.methodID, jIdName);  
     t.env->DeleteLocalRef(t.classID);  
     t.env->DeleteLocalRef(jIdName);  
   }  
 }  
 void callStaticVoidMethodWithStringAndInt(const char* name, const char* idName, const int score)  
 {  
   cocos2d::JniMethodInfo t;  
   if (cocos2d::JniHelper::getStaticMethodInfo(t, CLASS_NAME, name, "(Ljava/lang/String;I)V")) {  
     jstring jIdName = t.env->NewStringUTF(idName);  
     t.env->CallStaticVoidMethod(t.classID, t.methodID, jIdName, (jint)score);  
     t.env->DeleteLocalRef(t.classID);  
     t.env->DeleteLocalRef(jIdName);  
   }  
 }  
 } // namespace gamecenter  
 } // namespace helper  
 bool GameCenter::isSignedIn()  
 {  
 try  
 {  
 return helper::gamecenter::callStaticBoolMethod("isSignedIn");  
 }  
 catch (std::exception& e)  
 {  
 }  
 return false;  
 }  
 void GameCenter::signIn()  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethod("signIn");  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::showAchievements()  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethod("showAchievements");  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::unlockAchievement(const char* achievementId)  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethodWithString("unlockAchievement", achievementId);  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::showLeaderboard(const char* leaderboardId)  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethodWithString("showLeaderboard", leaderboardId);  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  
 void GameCenter::postToLeaderboard(const char* leaderboardId, int score)  
 {  
 try  
 {  
 helper::gamecenter::callStaticVoidMethodWithStringAndInt("postToLeaderboard", leaderboardId, score);  
 }  
 catch (std::exception& e)  
 {  
 }  
 }  

RootViewController.mm (the relevant parts)
 #import "RootViewController.h"   
 #import <GooglePlayGames/GooglePlayGames.h>   
 @implementation RootViewController   
 static NSString * const kDeclinedGooglePreviously = @"UserDidDeclineGoogleSignIn";   
 static NSInteger const kErrorCodeFromUserDecliningSignIn = -1;   
 - (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {   
 if ((self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil])) {   
 // Custom initialization   
 GPPSignIn *signIn = [GPPSignIn sharedInstance];   
 signIn.clientID = kClientID;   
 signIn.scopes = [NSArray arrayWithObjects:   
 @"https://www.googleapis.com/auth/games",   
 @"https://www.googleapis.com/auth/appstate",   
 nil];   
 signIn.language = [[NSLocale preferredLanguages] objectAtIndex:0];   
 signIn.delegate = self;   
 signIn.shouldFetchGoogleUserID =YES;   
 self.currentlySigningIn = [[GPPSignIn sharedInstance] trySilentAuthentication];   
 }   
 return self;   
 }   
 (...)   
 -(void)finishedWithAuth:(GTMOAuth2Authentication *)auth error:(NSError *)error   
 {   
 self.currentlySigningIn = NO;   
 if (error.code == 0 && auth) {   
 NSLog(@"Success signing in to Google! Auth is %@", auth);   
 [self startGoogleGamesSignIn];   
 } else {   
 NSLog(@"Failed to log into Google\n\tError=%@\n\tAuthObj=%@", [error localizedDescription],   
 auth);   
 if ([error code] == kErrorCodeFromUserDecliningSignIn) {   
 [[NSUserDefaults standardUserDefaults] setBool:YES forKey:kDeclinedGooglePreviously];   
 [[NSUserDefaults standardUserDefaults] synchronize];   
 }   
 }   
 }   
 -(bool)isSignedIn   
 {   
 return self.currentlySigningIn || [[GPGManager sharedInstance] isSignedIn];   
 }   
 -(void)startGoogleGamesSignIn   
 {   
 if ([[GPGManager sharedInstance] isSignedIn]) {   
 [[GPGManager sharedInstance] signOut];   
 } else {   
 [[GPGManager sharedInstance] signIn:[GPPSignIn sharedInstance]   
 reauthorizeHandler:^(BOOL requiresKeychainWipe, NSError *error)   
 {   
 if (requiresKeychainWipe)   
 {   
 [[GPPSignIn sharedInstance] signOut];   
 }   
 [[GPPSignIn sharedInstance] authenticate];   
 }];   
 }   
 }   
 - (void)showAchievements {   
 GPGAchievementController *achController = [[GPGAchievementController alloc] init];   
 achController.achievementDelegate = self;   
 [self presentViewController:achController animated:YES completion:nil];   
 }   
 - (void)achievementViewControllerDidFinish:(GPGAchievementController *)viewController {   
 [self dismissModalViewControllerAnimated:YES];   
 }   
 - (void)unlockAchievement:(NSString*)achievementId {   
 GPGAchievement *unlockMe = [GPGAchievement achievementWithId:achievementId];   
 [unlockMe unlockAchievementWithCompletionHandler:^(BOOL newlyUnlocked, NSError *error)   
 {   
 if (error)   
 {   
 }   
 else if (!newlyUnlocked)   
 {   
 }   
 else   
 {   
 NSLog(@"Hooray! Achievement unlocked!");   
 }   
 }];   
 }   
 - (void)showLeaderboard:(NSString*)leaderboardId {   
 GPGLeaderboardController *leadController = [[GPGLeaderboardController alloc] initWithLeaderboardId:leaderboardId];   
 leadController.leaderboardDelegate = self;   
 [self presentViewController:leadController animated:YES completion:nil];   
 }   
 - (void)leaderboardViewControllerDidFinish:(GPGLeaderboardController *)viewController {   
 [self dismissModalViewControllerAnimated:YES];   
 }   
 - (void)postToLeaderboard:(NSString*)leaderboardId withScore:(int)score {   
 GPGScore *myScore = [[GPGScore alloc] initWithLeaderboardId:leaderboardId];   
 myScore.value = score;   
 [myScore submitScoreWithCompletionHandler: ^(GPGScoreReport *report, NSError *error)   
 {   
 if (error)   
 {   
 }   
 else   
 {   
 }   
 }];   
 }   
 @end  

GameCenter.java (the toast doesn't work, I just hadn't pulled it yet)
 import android.widget.Toast;  
 import com.google.android.gms.games.Games;  
 public class GameCenter {  
   static void signIn() {  
    try {  
    if (XXXXX.Instance != null) {  
     if (!XXXXX.Instance.isSignedIn()) {  
     XXXXX.Instance.beginUserInitiatedSignIn();  
     } else {  
     XXXXX.Instance.signOut();  
     Toast.makeText(XXXXX.Instance, "Signed out", Toast.LENGTH_SHORT).show();  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
  static void showAchievements() {  
  try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     XXXXX.Instance.startActivityForResult(  
      Games.Achievements.getAchievementsIntent(  
       XXXXX.Instance.getApiClient()),   
       41267);  
     }  
    }  
    } catch (Exception ex) {  
    }  
  }  
   static void unlockAchievement(String achievementId) {  
    try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     Games.Achievements.unlock(XXXXX.Instance.getApiClient(), achievementId);  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
   static void showLeaderboard(String leaderboardId) {  
  try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     XXXXX.Instance.startActivityForResult(  
      Games.Leaderboards.getLeaderboardIntent(  
       XXXXX.Instance.getApiClient(), leaderboardId),  
       76789);  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
   static void postToLeaderboard(String leaderboardId, int score) {  
  try {  
    if (XXXXX.Instance != null) {  
     if (XXXXX.Instance.isSignedIn()) {  
     Games.Leaderboards.submitScore(XXXXX.Instance.getApiClient(), leaderboardId, score);  
     }  
    }  
    } catch (Exception ex) {  
    }  
   }  
 }  

That should be enough to get you going! Happy Coding!
Google Play Game Services
Cocos2D-X
Avalon (GitHub)

Sunday, March 30, 2014

Cocos2D-X Android UnsatisfiedLinkError: Couldn't load cocos2dcpp

So this stumped me for several hours, and in an effort to help others who might have the same issue, I'm posting this here. Hopefully someone finds this in their frantic Google searching.

I always use the Intel emulators, because they are much faster (especially with HAXM installed!). When developing with the Android NDK and Cocos2D-X, however, that's apparently an issue.

So, if you're stuck with that linker error, try making an emulator using ARM and see if that works. Solved it for me.

Happy coding!