36

I'm on the edge of finishing my first app, and one last remaining thing is to implement IAP billing, so that's why I am currently reading quite a lot about the topic (including security concerns like encryption, obfuscation and stuff).

My app is a free version, with the ability to upgrade to full verison via IAP, so there would be just one managed purchase item "premium". I have a few questions about this:

In the Google IAP API example (trivialdrivesample), there's always the IAP check in MainActivity to see if the user bought the premium version, done via

mHelper.queryInventoryAsync(mGotInventoryListener);

My first concern: This does mean that the user always needs to have an internet/data connection at app-startup, to be able switch to the premium version right? What if the user doesn't have an internet connection? He would go with the lite version I guess, which I would find annoying.

So I thought about how to save the isPremium status locally, either in the SharedPrefs or in the app database. Now, I know you can't stop a hacker to reverse engineer the app, no matter what, even so because I don't own a server to do some server-side validation.

Nevertheless, one simply can't save an "isPremium" flag somewhere, since that would be too easy to spot.

So I was thinking about something like this:

  • User buys Premium
  • App gets the IMEI/Device-ID and XOR encodes it with a hardcoded String key, saves that locally in the app database.

Now when the user starts the app again:

  • App gets encoded String from database, decodes it and checks if decodedString == IMEI. If yes -> premium
  • If no, then the normal queryInventoryAsync will be called to see if the user bought premium.

What do you think about that approach? I know it's not supersecure, but for me it's more important that the user isn't annoyed (like with mandatory internet connection), than that the app will be unhackable (which is impossible anyway). Do you have some other tips?

Another thing, which I currently don't have a clue about, is how to restore the transaction status when the user uninstalls/reinstalls the app. I know the API has some mechanism for that, and aditionally my database can be exported and imported through the app (so the encoded isPremium flag would be exportable/importable as well). Ok, I guess that would be another question, when the time is right ;-)

Any thoughts and comments to this approach are welcome, do you think that's a good solution? Or am I missing something/heading into some wrong direction?

Toni Kanoni
  • 2,265
  • 4
  • 23
  • 29
  • Storing the IMEI XOR'd with a constant is a very bad idea. It would not takes someone long to figure out how to generate that and stuff it in the database, so the only real security you have is the security of the database. A better idea would be to hash a few things and compare, but again no matter what you do the weakest link is the security of wherever you store the setting. Without a TPM and trusted/trecharous computing, there is no way to completely eliminate the risk of someone hacking your app. – alex.forencich Jan 20 '14 at 02:27
  • Did you find your solution?! Is it mHelper.queryInventoryAsync(false, mGotInventoryListener) ? Cause it's not work for me! ( i'm running app in eclispe, and does not export apk yet ) – Dr.jacky Feb 24 '15 at 16:18
  • There is still not an "accepted" answer to this question, however there is a good one from ne0. I refactored ne0's solution into a static method and provided what I think should be the answer here: http://stackoverflow.com/a/31930802/1103584 – DiscDev Aug 10 '15 at 23:33
  • 1
    @Toni, Did you find the solution to your problem finally? I am stuck at a similar place and your question matches my specifications exactly. I so want to repost the question, but as it is with SO now-a-days, it will be closed of as duplicate within seconds even though this needs much more attention :( – Kaushik NP Mar 09 '17 at 13:41
  • I would suggest not to use XOR. If you XOR together the encoded IMEI and the actual IMEI (known to the user by other means, e.g. from settings) you'll get the encoding key! :-) – Gianluca Ghettini Jan 20 '19 at 11:31

4 Answers4

18

I too was making the same investigations, but during my testing I figured out that you do not need to store it, as Google do all the caching you need and I suspect (though I have not investigated it) that they are doing so as securely as possible (seeing as it in their interest too!)

So here is what i do

// Done in onCreate
mHelper = new IabHelper(this, getPublicKey());

mHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() {
public void onIabSetupFinished(IabResult result) {
      if (!result.isSuccess()) {
         // Oh noes, there was a problem.
         Log("Problem setting up In-app Billing: " + result);
      } else {
         Log("onIabSetupFinished " + result.getResponse());
         mHelper.queryInventoryAsync(mGotInventoryListener);
     }
    }
});

// Called by button press
private void buyProUpgrade() {
    mHelper.launchPurchaseFlow(this, "android.test.purchased", 10001,   
           mPurchaseFinishedListener, ((TelephonyManager)this.getSystemService(Context.TELEPHONY_SERVICE)).getDeviceId());
}

// Get purchase response
private IabHelper.OnIabPurchaseFinishedListener mPurchaseFinishedListener = new IabHelper.OnIabPurchaseFinishedListener() {
    public void onIabPurchaseFinished(IabResult result, Purchase purchase) 
    {
       if (result.isFailure()) {
          Log("Error purchasing: " + result);
          return;
       }      
       else if (purchase.getSku().equals("android.test.purchased")) {
      Log("onIabPurchaseFinished GOT A RESPONSE.");
              mHelper.queryInventoryAsync(mGotInventoryListener);
      }
    }
};

// Get already purchased response
private IabHelper.QueryInventoryFinishedListener mGotInventoryListener = new IabHelper.QueryInventoryFinishedListener() {
    public void onQueryInventoryFinished(IabResult result,
       Inventory inventory) {

       if (result.isFailure()) {
         // handle error here
           Log("Error checking inventory: " + result); 
       }
       else {
         // does the user have the premium upgrade?        
         mIsPremium = inventory.hasPurchase("android.test.purchased");        
         setTheme();

         Log("onQueryInventoryFinished GOT A RESPONSE (" + mIsPremium + ").");
       }
    }
};

So what happens here?

The IAB is set up and calls startSetup, on a successful completion (as long as it has been run once with an internet connection and is set up correctly it will always succeed) we call queryInventoryAsync to find out what is already purchased (again if this has been called while online it always works while offline).

So if a purchase is completed successfully (as can only be done while online) we call queryInventoryAsync to ensure that it has been called while online.

Now there is no need to store anything to do with purchases and makes your app a lot less hackable.

I have tested this many ways, flight mode, turning the devices off an on again and the only thing that messes it up is clearing data in some of the Google apps on the phone (Not likely to happen!).

Please contribute to this if you have different experiences, my app is still in early testing stage.

Ne0
  • 2,688
  • 3
  • 35
  • 49
  • 4
    It seems that the info gets cached temporarily, but does not persist across a reboot of the device. Can you confirm this? – Jean-Philippe Pellet Sep 21 '13 at 12:37
  • My tests passed even in a flight mode reboot. Unless I manually cleared the cache of Google applications it never seemed to fail. – Ne0 Sep 23 '13 at 15:26
  • 1
    I agree with Jean-Philippe. onQueryInventoryFinished() succeeds if wifi is turned off just after making a purchase. But if you reboot the device with wifi turned off then onQueryInventoryFinished() fails because result.isFailure() is true. In fact, result.toString() is "IabResult: Error refreshing inventory (querying prices of items). (response: 6:Error)". This suggests the cache queried by IabHelper is wiped on reboot, as Jean-Philippe says. So does anyone have a solution that allows an app to query purchased items successfully even without a network connection? – snark Nov 01 '14 at 15:59
  • 8
    Ok, got it. Don't call mHelper.queryInventoryAsync(mGotInventoryListener). Instead call mHelper.queryInventoryAsync(false, mGotInventoryListener). If you debug into the IabHelper code you'll see that mService.getPurchases() will be called whereas mService.getSkuDetails() will not be called if you set the flag to false. If you omit the flag then it will be set to true by IabHelper. getPurchases() successfully queries the local cache of purchased items even when your device has no network connection, and even across device reboots. – snark Nov 02 '14 at 18:32
  • @snark But it's not work for me (mHelper.queryInventoryAsync(false, mGotInventoryListener) --- Does it work on Debug mode ( means i don't export apk yet and run app on eclipse with connected device )? or just on release mode?! – Dr.jacky Feb 24 '15 at 16:16
  • @Ne0 What about first and second answer in this question: http://stackoverflow.com/questions/15471131 ? ---- Of course those does not work for me! – Dr.jacky Feb 24 '15 at 18:42
  • 1
    @Mr.Hyde I'm a little rusty on this now but, if you're following the procedure at http://developer.android.com/google/play/billing/billing_testing.html#billing-testing-static remember to replace your SKU id with android.test.purchased and to test your device with your developer account. Also, you can temporarily edit com.android.vending.billing.helper.Security.java so that it’s verifyPurchase() method returns true when debugging. See http://stackoverflow.com/a/19735453 for details. Remember to undo this change (and revert to your proper SKU ids too!) before releasing for production. – snark Feb 25 '15 at 20:10
  • I want to add that this approach is based on a callback which is executed AFTER UI is visible. So if you have some special design for Premium account - you should know from the start whether it's Premium or not. For such case you can easily use SharedPreferences with PRIVATE flag to check if it's Premium or not, but don't forget to update the value on onQueryInventoryFinished() method. So that even if Premium subscription have just finished but you already showed design for Premium - next time you will start the app - it will have fresh value – Kirill Karmazin May 17 '17 at 13:41
7

I refactored ne0's answer into a static method, including the comments from snark.

I call this method when my app starts - you'll need to enable your features at the TODO

/**
 * This is how you check with Google if the user previously purchased a non-consumable IAP
 * @param context App Context
 */
public static void queryPlayStoreForPurchases(Context context)
{
    final IabHelper helper = new IabHelper(context, getPublicKey());

    helper.startSetup(new IabHelper.OnIabSetupFinishedListener() 
    {
         public void onIabSetupFinished(IabResult result) 
         {
               if (!result.isSuccess()) 
               {
                   Log.d("InApp", "In-app Billing setup failed: " + result);
               } 
               else 
               {  
                    helper.queryInventoryAsync(false, new IabHelper.QueryInventoryFinishedListener()
                    {
                        public void onQueryInventoryFinished(IabResult result, Inventory inventory)
                        {
                            // If the user has IAP'd the Pro version, let 'em have it.
                            if (inventory.hasPurchase(PRO_VERSION_SKU))
                            {
                                //TODO: ENABLE YOUR PRO FEATURES!! 

                                Log.d("IAP Check", "IAP Feature enabled!");
                            }
                            else
                            {
                                Log.d("IAP Check", "User has not purchased Pro version, not enabling features.");

                            }
                        }
                    });
               }
         }
    });
}

This will work across reboots and without a network connection, provided the user purchased the item.

DiscDev
  • 38,652
  • 20
  • 117
  • 133
4

Since you already know that it's impossible to make it unhackable using this system, I would recommend not attempting to prevent hacking. What you are proposing is known as "Security through obscurity" and is usually a bad idea.

My advice would be to try queryInventoryAsync() first, and only check your 'isPremium' flag if there is no internet connection.

There are also a few potential other ways of going about this, such as having separate free and premium apps, instead of an in app purchase. How other people handle this and the tools Google makes available might warrant an investigation.

queryInventoryAsync will automatically take into account uninstall and reinstalls, as it tracks purchases for the logged in user.

Drew
  • 12,578
  • 11
  • 58
  • 98
  • Even if I upvoted the security by obscurity part I don't agree with checking a kind of isPremium flag when offline. It simply makes it **too simple** for a hacker to hack the application. Since Android is open source and can be rooted IAB is not bulletproof but at least hackers will have bad days – usr-local-ΕΨΗΕΛΩΝ Apr 09 '13 at 10:35
0

Yes the purchases can be retrieved offline. Also, I'm thinking about counting how many times the user opens the app as a mechanism before showing the billing UI.

fullmoon
  • 8,030
  • 5
  • 43
  • 58