package moe.yuuta.gplicense; import android.annotation.SuppressLint; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager.NameNotFoundException; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.RemoteException; import android.provider.Settings.Secure; import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; import moe.yuuta.ext.*; import moe.yuuta.gplicense.util.Base64; import moe.yuuta.gplicense.util.Base64DecoderException; import moe.yuuta.workmode.BuildConfig; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.security.spec.X509EncodedKeySpec; import java.util.*; /** * Client library for Google Play license verifications. *

* The LicenseChecker is configured via a {@link Policy} which contains the logic to determine * whether a user should have access to the application. For example, the Policy can define a * threshold for allowable number of server or client failures before the library reports the user * as not having access. *

* Must also provide the Base64-encoded RSA public key associated with your developer account. The * public key is obtainable from the publisher site. */ public class LicenseChecker implements ConnCallback { private static final Logger logger = XLog.tag("LCK").build(); private static final String KEY_FACTORY_ALGORITHM = "RSA"; // Timeout value (in milliseconds) for calls to service. private static final int TIMEOUT_MS = 10 * 1000; private static final SecureRandom RANDOM = new SecureRandom(); private static final boolean DEBUG_LICENSE_ERROR = BuildConfig.DEBUG; private IService mService; private PublicKey mPublicKey; private final Context mContext; private final Policy mPolicy; /** * A handler for running tasks on a background thread. We don't want license processing to block * the UI thread. */ private Handler mHandler; private final String mPackageName; private final String mVersionCode; private final Set mChecksInProgress = new HashSet(); private final Queue mPendingChecks = new LinkedList(); private LicServiceConn mConn; /** * @param context a Context * @param policy implementation of Policy * @param encodedPublicKey Base64-encoded RSA public key * @throws IllegalArgumentException if encodedPublicKey is invalid */ public LicenseChecker(Context context, Policy policy, String encodedPublicKey) { mContext = context; mPolicy = policy; mPublicKey = generatePublicKey(encodedPublicKey); mPackageName = mContext.getPackageName(); mVersionCode = getVersionCode(context, mPackageName); HandlerThread handlerThread = new HandlerThread("background thread"); handlerThread.start(); mHandler = new Handler(handlerThread.getLooper()); } /** * Generates a PublicKey instance from a string containing the Base64-encoded public key. * * @param encodedPublicKey Base64-encoded public key * @throws IllegalArgumentException if encodedPublicKey is invalid */ private static PublicKey generatePublicKey(String encodedPublicKey) { try { byte[] decodedKey = Base64.decode(encodedPublicKey); KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM); return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey)); } catch (NoSuchAlgorithmException e) { // This won't happen in an Android-compatible environment. throw new RuntimeException(e); } catch (Base64DecoderException e) { logger.e("Could not decode from Base64."); throw new IllegalArgumentException(e); } catch (InvalidKeySpecException e) { logger.e("Invalid key specification."); throw new IllegalArgumentException(e); } } /** * Checks if the user should have access to the app. Binds the service if necessary. *

* NOTE: This call uses a trivially obfuscated string (base64-encoded). For best security, we * recommend obfuscating the string that is passed into bindService using another method of your * own devising. *

* source string: "com.android.vending.licensing.IService" *

* * @param callback */ public synchronized void checkAccess(LicenseCheckerCallback callback) { // If we have a valid recent LICENSED response, we can skip asking // Market. if (mPolicy.allowAccess()) { logger.i("Using cached response"); callback.allow(Policy.LICENSED); } else { LicenseValidator validator = new LicenseValidator(mPolicy, new NullDeviceLimiter(), callback, generateNonce(), mPackageName, mVersionCode); if (mService == null) { logger.i("Binding to service."); try { mConn = new LicServiceConn(this); boolean bindResult = mContext .bindService( new Intent( new String( // Base64 encoded - // com.android.vending.licensing.IService // Consider encoding this in another way in your // code to improve security Base64.decode( "Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U="))) // As of Android 5.0, implicit // Service Intents are no longer // allowed because it's not // possible for the user to // participate in disambiguating // them. This does mean we break // compatibility with Android // Cupcake devices with this // release, since setPackage was // added in Donut. .setPackage( new String( // Base64 // encoded - // com.android.vending Base64.decode( "Y29tLmFuZHJvaWQudmVuZGluZw=="))), mConn, // ServiceConnection. Context.BIND_AUTO_CREATE); if (bindResult) { mPendingChecks.offer(validator); } else { logger.e("Could not bind to service."); handleServiceConnectionError(validator); } } catch (SecurityException e) { callback.applicationError(LicenseCheckerCallback.ERROR_MISSING_PERMISSION); } catch (Base64DecoderException e) { e.printStackTrace(); } } else { mPendingChecks.offer(validator); runChecks(); } } } private void runChecks() { LicenseValidator validator; while ((validator = mPendingChecks.poll()) != null) { try { logger.i("Executing on service for " + validator.getPackageName()); mService.exec( validator.getNonce(), validator.getPackageName(), new IPCResultListener(new ResultListener(validator))); mChecksInProgress.add(validator); } catch (RemoteException e) { logger.w("RemoteException in exec call.", e); handleServiceConnectionError(validator); } } } private synchronized void finishCheck(LicenseValidator validator) { mChecksInProgress.remove(validator); if (mChecksInProgress.isEmpty()) { cleanupService(); } } private class ResultListener implements ResultCallback { private final LicenseValidator mValidator; private Runnable mOnTimeout; public ResultListener(LicenseValidator validator) { mValidator = validator; mOnTimeout = () -> { logger.i("Check timed out."); handleServiceConnectionError(mValidator); finishCheck(mValidator); }; startTimeout(); } private static final int ERROR_CONTACTING_SERVER = 0x101; private static final int ERROR_INVALID_PACKAGE_NAME = 0x102; private static final int ERROR_NON_MATCHING_UID = 0x103; // Runs in IPC thread pool. Post it to the Handler, so we can guarantee // either this or the timeout runs. @Override public void verifyLicense(final int responseCode, final String signedData, final String signature) { mHandler.post(() -> { logger.i("Received response. (code: " + responseCode + ")"); // Make sure it hasn't already timed out. if (mChecksInProgress.contains(mValidator)) { clearTimeout(); mValidator.verify(mPublicKey, responseCode, signedData, signature); finishCheck(mValidator); } if (DEBUG_LICENSE_ERROR) { boolean logResponse; String stringError = null; switch (responseCode) { case ERROR_CONTACTING_SERVER: logResponse = true; stringError = "ERROR_CONTACTING_SERVER"; break; case ERROR_INVALID_PACKAGE_NAME: logResponse = true; stringError = "ERROR_INVALID_PACKAGE_NAME"; break; case ERROR_NON_MATCHING_UID: logResponse = true; stringError = "ERROR_NON_MATCHING_UID"; break; default: logResponse = false; } if (logResponse) { @SuppressLint("HardwareIds") String android_id = Secure.getString(mContext.getContentResolver(), Secure.ANDROID_ID); Date date = new Date(); logger.d("Server Failure: " + stringError); logger.d("Android ID: " + android_id); logger.d("Time: " + date.toGMTString()); } } }); } private void startTimeout() { logger.i("Start monitoring timeout."); mHandler.postDelayed(mOnTimeout, TIMEOUT_MS); } private void clearTimeout() { logger.i("Clearing timeout."); mHandler.removeCallbacks(mOnTimeout); } } @Override public synchronized void onServiceConnected(ComponentName name, IBinder service) { mService = IService.Stub.asInterface(service); runChecks(); } @Override public synchronized void onServiceDisconnected(ComponentName name) { // Called when the connection with the service has been // unexpectedly disconnected. That is, Market crashed. // If there are any checks in progress, the timeouts will handle them. logger.w("Service unexpectedly disconnected."); mService = null; } /** * Generates policy response for service connection errors, as a result of disconnections or * timeouts. */ private synchronized void handleServiceConnectionError(LicenseValidator validator) { mPolicy.processServerResponse(Policy.RETRY, null); if (mPolicy.allowAccess()) { validator.getCallback().allow(Policy.RETRY); } else { validator.getCallback().dontAllow(Policy.RETRY); } } /** Unbinds service if necessary and removes reference to it. */ private void cleanupService() { if (mService != null) { try { mContext.unbindService(mConn); } catch (IllegalArgumentException e) { // Somehow we've already been unbound. This is a non-fatal // error. logger.e("Unable to unbind from licensing service (already unbound)"); } mService = null; } } /** * Inform the library that the hostContext is about to be destroyed, so that any open connections * can be cleaned up. *

* Failure to call this method can result in a crash under certain circumstances, such as during * screen rotation if an Activity requests the license check or when the user exits the * application. */ public synchronized void onDestroy() { cleanupService(); mHandler.getLooper().quit(); } /** Generates a nonce (number used once). */ private int generateNonce() { return RANDOM.nextInt(); } /** * Get version code for the application package name. * * @param context * @param packageName application package name * @return the version code or empty string if package not found */ private static String getVersionCode(Context context, String packageName) { try { return String.valueOf( context.getPackageManager().getPackageInfo(packageName, 0).versionCode); } catch (NameNotFoundException e) { logger.e("Package not found. could not get version code."); return ""; } } }