From 39de35e09424c573670d4c56742c17a3bdbe8108 Mon Sep 17 00:00:00 2001 From: YuutaW <17158086+Trumeet@users.noreply.github.com> Date: Thu, 28 Feb 2019 19:50:55 -0800 Subject: feat(app): implement Google Play App Licensing Signed-off-by: YuutaW <17158086+Trumeet@users.noreply.github.com> --- app/build.gradle | 9 + app/src/main/AndroidManifest.xml | 1 + .../java/moe/yuuta/gplicense/AESObfuscator.java | 94 ++++ .../java/moe/yuuta/gplicense/DeviceLimiter.java | 31 ++ .../java/moe/yuuta/gplicense/LicenseChecker.java | 358 +++++++++++++ .../yuuta/gplicense/LicenseCheckerCallback.java | 51 ++ .../java/moe/yuuta/gplicense/LicenseValidator.java | 219 ++++++++ .../moe/yuuta/gplicense/NullDeviceLimiter.java | 16 + .../main/java/moe/yuuta/gplicense/Obfuscator.java | 32 ++ app/src/main/java/moe/yuuta/gplicense/Policy.java | 49 ++ .../moe/yuuta/gplicense/PreferenceObfuscator.java | 63 +++ .../java/moe/yuuta/gplicense/ResponseData.java | 65 +++ .../moe/yuuta/gplicense/ServerManagedPolicy.java | 284 +++++++++++ .../moe/yuuta/gplicense/ValidationException.java | 17 + .../gplicense/ipc/ILicenseResultListener.java | 101 ++++ .../moe/yuuta/gplicense/ipc/ILicensingService.java | 101 ++++ .../main/java/moe/yuuta/gplicense/util/Base64.java | 556 +++++++++++++++++++++ .../gplicense/util/Base64DecoderException.java | 18 + .../moe/yuuta/gplicense/util/URIQueryDecoder.java | 42 ++ .../main/java/moe/yuuta/workmode/MainActivity.kt | 31 +- .../moe/yuuta/workmode/access/AccessorStarter.kt | 23 +- .../main/java/moe/yuuta/workmode/gpl/GPLicenser.kt | 45 ++ .../workmode/suspend/data/SuspendedStorage.kt | 38 ++ 23 files changed, 2222 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/moe/yuuta/gplicense/AESObfuscator.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/DeviceLimiter.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/LicenseChecker.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/LicenseCheckerCallback.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/LicenseValidator.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/NullDeviceLimiter.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/Obfuscator.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/Policy.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/PreferenceObfuscator.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/ResponseData.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/ServerManagedPolicy.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/ValidationException.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/ipc/ILicenseResultListener.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/ipc/ILicensingService.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/util/Base64.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/util/Base64DecoderException.java create mode 100644 app/src/main/java/moe/yuuta/gplicense/util/URIQueryDecoder.java create mode 100644 app/src/main/java/moe/yuuta/workmode/gpl/GPLicenser.kt (limited to 'app') diff --git a/app/build.gradle b/app/build.gradle index 29ce216..3c9d7d6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,6 +28,12 @@ android { if (fabricKey == null) fabricKey = "disabled" buildConfigField "String", "FABRIC_KEY", "\"" + fabricKey + "\"" manifestPlaceholders.fabricKey = fabricKey + + // Google Play Licensing + def googlePlayLicensingKey = privateProperties['google.play.licensing.key'] + if (googlePlayLicensingKey == null) googlePlayLicensingKey = System.getenv("GOOGLE_PLAY_LICENSING_KEY") + if (googlePlayLicensingKey == null) googlePlayLicensingKey = "disabled" + buildConfigField "String", "GOOGLE_PLAY_LICENSING_KEY", "\"" + googlePlayLicensingKey + "\"" } signingConfigs { general { @@ -89,4 +95,7 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.0.0' implementation 'com.google.android.gms:play-services-oss-licenses:16.0.2' implementation 'com.crashlytics.sdk.android:crashlytics:2.9.8' + def lifecycle_version = "2.0.0" + implementation "androidx.lifecycle:lifecycle-runtime:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 062de4b..6977fae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ package="moe.yuuta.workmode"> + + * The LICENSED response from the server contains a user identifier unique to + * the <application, user> pair. The developer can send this identifier + * to their own server along with some device identifier (a random number + * generated and stored once per application installation, + * {@link android.telephony.TelephonyManager#getDeviceId getDeviceId}, + * {@link android.provider.Settings.Secure#ANDROID_ID ANDROID_ID}, etc). + * The more sources used to identify the device, the harder it will be for an + * attacker to spoof. + *

+ * The server can look at the <application, user, device id> tuple and + * restrict a user's application license to run on at most 10 different devices + * in a week (for example). We recommend not being too restrictive because a + * user might legitimately have multiple devices or be in the process of + * changing phones. This will catch egregious violations of multiple people + * sharing one license. + */ +public interface DeviceLimiter { + + /** + * Checks if this device is allowed to use the given user's license. + * + * @param userId the user whose license the server responded with + * @return LICENSED if the device is allowed, NOT_LICENSED if not, RETRY if an error occurs + */ + int isDeviceAllowed(String userId); +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/LicenseChecker.java b/app/src/main/java/moe/yuuta/gplicense/LicenseChecker.java new file mode 100644 index 0000000..92986b8 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/LicenseChecker.java @@ -0,0 +1,358 @@ +package moe.yuuta.gplicense; + +import android.annotation.SuppressLint; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +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 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.Date; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.Queue; +import java.util.Set; + +import moe.yuuta.gplicense.ipc.ILicenseResultListener; +import moe.yuuta.gplicense.ipc.ILicensingService; +import moe.yuuta.gplicense.util.Base64; +import moe.yuuta.gplicense.util.Base64DecoderException; +import moe.yuuta.workmode.BuildConfig; + +/** + * 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 ServiceConnection { + 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 ILicensingService 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(); + + /** + * @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.ILicensingService" + *

+ * + * @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 license 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 { + boolean bindResult = mContext + .bindService( + new Intent( + new String( + // Base64 encoded - + // com.android.vending.licensing.ILicensingService + // 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=="))), + this, // 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.checkLicense( + validator.getNonce(), validator.getPackageName(), + new ResultListener(validator)); + mChecksInProgress.add(validator); + } catch (RemoteException e) { + logger.w("RemoteException in checkLicense call.", e); + handleServiceConnectionError(validator); + } + } + } + + private synchronized void finishCheck(LicenseValidator validator) { + mChecksInProgress.remove(validator); + if (mChecksInProgress.isEmpty()) { + cleanupService(); + } + } + + private class ResultListener extends ILicenseResultListener.Stub { + 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. + 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); + } + } + + public synchronized void onServiceConnected(ComponentName name, IBinder service) { + mService = ILicensingService.Stub.asInterface(service); + runChecks(); + } + + 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(this); + } 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 context 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 ""; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/LicenseCheckerCallback.java b/app/src/main/java/moe/yuuta/gplicense/LicenseCheckerCallback.java new file mode 100644 index 0000000..214f152 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/LicenseCheckerCallback.java @@ -0,0 +1,51 @@ +package moe.yuuta.gplicense; + +/** + * Callback for the license checker library. + *

+ * Upon checking with the Market server and conferring with the {@link Policy}, + * the library calls the appropriate callback method to communicate the result. + *

+ * The callback does not occur in the original checking thread. Your + * application should post to the appropriate handling thread or lock + * accordingly. + *

+ * The reason that is passed back with allow/dontAllow is the base status handed + * to the policy for allowed/disallowing the license. Policy.RETRY will call + * allow or dontAllow depending on other statistics associated with the policy, + * while in most cases Policy.NOT_LICENSED will call dontAllow and + * Policy.LICENSED will Allow. + */ +public interface LicenseCheckerCallback { + + /** + * Allow use. App should proceed as normal. + * + * @param reason Policy.LICENSED or Policy.RETRY typically. (although in + * theory the policy can return Policy.NOT_LICENSED here as well) + */ + void allow(int reason); + + /** + * Don't allow use. App should inform user and take appropriate action. + * + * @param reason Policy.NOT_LICENSED or Policy.RETRY. (although in theory + * the policy can return Policy.LICENSED here as well --- + * perhaps the call to the LVL took too long, for example) + */ + void dontAllow(int reason); + + /** Application error codes. */ + int ERROR_INVALID_PACKAGE_NAME = 1; + int ERROR_NON_MATCHING_UID = 2; + int ERROR_NOT_MARKET_MANAGED = 3; + int ERROR_CHECK_IN_PROGRESS = 4; + int ERROR_INVALID_PUBLIC_KEY = 5; + int ERROR_MISSING_PERMISSION = 6; + + /** + * Error in application code. Caller did not call or set up license checker + * correctly. Should be considered fatal. + */ + void applicationError(int errorCode); +} diff --git a/app/src/main/java/moe/yuuta/gplicense/LicenseValidator.java b/app/src/main/java/moe/yuuta/gplicense/LicenseValidator.java new file mode 100644 index 0000000..142f350 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/LicenseValidator.java @@ -0,0 +1,219 @@ +package moe.yuuta.gplicense; + +import android.text.TextUtils; + +import com.elvishew.xlog.Logger; +import com.elvishew.xlog.XLog; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; + +import moe.yuuta.gplicense.util.Base64; +import moe.yuuta.gplicense.util.Base64DecoderException; + +/** + * Contains data related to a licensing request and methods to verify + * and process the response. + */ +class LicenseValidator { + private final Logger logger = XLog.tag("LVter").build(); + + // Server response codes. + private static final int LICENSED = 0x0; + private static final int NOT_LICENSED = 0x1; + private static final int LICENSED_OLD_KEY = 0x2; + private static final int ERROR_NOT_MARKET_MANAGED = 0x3; + private static final int ERROR_SERVER_FAILURE = 0x4; + private static final int ERROR_OVER_QUOTA = 0x5; + + 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; + + private final Policy mPolicy; + private final LicenseCheckerCallback mCallback; + private final int mNonce; + private final String mPackageName; + private final String mVersionCode; + private final DeviceLimiter mDeviceLimiter; + + LicenseValidator(Policy policy, DeviceLimiter deviceLimiter, LicenseCheckerCallback callback, + int nonce, String packageName, String versionCode) { + mPolicy = policy; + mDeviceLimiter = deviceLimiter; + mCallback = callback; + mNonce = nonce; + mPackageName = packageName; + mVersionCode = versionCode; + } + + public LicenseCheckerCallback getCallback() { + return mCallback; + } + + public int getNonce() { + return mNonce; + } + + public String getPackageName() { + return mPackageName; + } + + private static final String SIGNATURE_ALGORITHM = "SHA1withRSA"; + + /** + * Verifies the response from server and calls appropriate callback method. + * + * @param publicKey public key associated with the developer account + * @param responseCode server response code + * @param signedData signed data from server + * @param signature server signature + */ + public void verify(PublicKey publicKey, int responseCode, String signedData, String signature) { + String userId = null; + // Skip signature check for unsuccessful requests + ResponseData data = null; + if (responseCode == LICENSED || responseCode == NOT_LICENSED || + responseCode == LICENSED_OLD_KEY) { + // Verify signature. + try { + if (TextUtils.isEmpty(signedData)) { + logger.e("Signature verification failed: signedData is empty. " + + "(Device not signed-in to any Google accounts?)"); + handleInvalidResponse(); + return; + } + + Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM); + sig.initVerify(publicKey); + sig.update(signedData.getBytes()); + + if (!sig.verify(Base64.decode(signature))) { + logger.e("Signature verification failed."); + handleInvalidResponse(); + return; + } + } catch (NoSuchAlgorithmException e) { + // This can't happen on an Android compatible device. + throw new RuntimeException(e); + } catch (InvalidKeyException e) { + handleApplicationError(LicenseCheckerCallback.ERROR_INVALID_PUBLIC_KEY); + return; + } catch (SignatureException e) { + throw new RuntimeException(e); + } catch (Base64DecoderException e) { + logger.e("Could not Base64-decode signature."); + handleInvalidResponse(); + return; + } + + // Parse and validate response. + try { + data = ResponseData.parse(signedData); + } catch (IllegalArgumentException e) { + logger.e("Could not parse response."); + handleInvalidResponse(); + return; + } + + if (data.responseCode != responseCode) { + logger.e("Response codes don't match."); + handleInvalidResponse(); + return; + } + + if (data.nonce != mNonce) { + logger.e("Nonce doesn't match."); + handleInvalidResponse(); + return; + } + + if (!data.packageName.equals(mPackageName)) { + logger.e("Package name doesn't match."); + handleInvalidResponse(); + return; + } + + if (!data.versionCode.equals(mVersionCode)) { + logger.e("Version codes don't match."); + handleInvalidResponse(); + return; + } + + // Application-specific user identifier. + userId = data.userId; + if (TextUtils.isEmpty(userId)) { + logger.e("User identifier is empty."); + handleInvalidResponse(); + return; + } + } + + logger.d("Final code: " + responseCode); + switch (responseCode) { + case LICENSED: + case LICENSED_OLD_KEY: + int limiterResponse = mDeviceLimiter.isDeviceAllowed(userId); + handleResponse(limiterResponse, data); + break; + case NOT_LICENSED: + handleResponse(Policy.NOT_LICENSED, data); + break; + case ERROR_CONTACTING_SERVER: + logger.w("Error contacting licensing server."); + handleResponse(Policy.RETRY, data); + break; + case ERROR_SERVER_FAILURE: + logger.w("An error has occurred on the licensing server."); + handleResponse(Policy.RETRY, data); + break; + case ERROR_OVER_QUOTA: + logger.w("Licensing server is refusing to talk to this device, over quota."); + handleResponse(Policy.RETRY, data); + break; + case ERROR_INVALID_PACKAGE_NAME: + handleApplicationError(LicenseCheckerCallback.ERROR_INVALID_PACKAGE_NAME); + break; + case ERROR_NON_MATCHING_UID: + handleApplicationError(LicenseCheckerCallback.ERROR_NON_MATCHING_UID); + break; + case ERROR_NOT_MARKET_MANAGED: + handleApplicationError(LicenseCheckerCallback.ERROR_NOT_MARKET_MANAGED); + break; + default: + logger.e("Unknown response code for license check."); + handleInvalidResponse(); + } + } + + /** + * Confers with policy and calls appropriate callback method. + * + * @param response + * @param rawData + */ + private void handleResponse(int response, ResponseData rawData) { + logger.d("handle: " + response); + // Update policy data and increment retry counter (if needed) + mPolicy.processServerResponse(response, rawData); + + // Given everything we know, including cached data, ask the policy if we should grant + // access. + if (mPolicy.allowAccess()) { + mCallback.allow(response); + } else { + mCallback.dontAllow(response); + } + } + + private void handleApplicationError(int code) { + mCallback.applicationError(code); + } + + private void handleInvalidResponse() { + mCallback.dontAllow(Policy.NOT_LICENSED); + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/NullDeviceLimiter.java b/app/src/main/java/moe/yuuta/gplicense/NullDeviceLimiter.java new file mode 100644 index 0000000..63e7ae3 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/NullDeviceLimiter.java @@ -0,0 +1,16 @@ +package moe.yuuta.gplicense; + +/** + * A DeviceLimiter that doesn't limit the number of devices that can use a + * given user's license. + *

+ * Unless you have reason to believe that your application is being pirated + * by multiple users using the same license (signing in to Market as the same + * user), we recommend you use this implementation. + */ +public class NullDeviceLimiter implements DeviceLimiter { + + public int isDeviceAllowed(String userId) { + return Policy.LICENSED; + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/Obfuscator.java b/app/src/main/java/moe/yuuta/gplicense/Obfuscator.java new file mode 100644 index 0000000..e89030c --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/Obfuscator.java @@ -0,0 +1,32 @@ +package moe.yuuta.gplicense; + +/** + * Interface used as part of a {@link Policy} to allow application authors to obfuscate + * licensing data that will be stored into a SharedPreferences file. + *

+ * Any transformation scheme must be reversable. Implementing classes may optionally implement an + * integrity check to further prevent modification to preference data. Implementing classes + * should use device-specific information as a key in the obfuscation algorithm to prevent + * obfuscated preferences from being shared among devices. + */ +public interface Obfuscator { + + /** + * Obfuscate a string that is being stored into shared preferences. + * + * @param original The data that is to be obfuscated. + * @param key The key for the data that is to be obfuscated. + * @return A transformed version of the original data. + */ + String obfuscate(String original, String key); + + /** + * Undo the transformation applied to data by the obfuscate() method. + * + * @param obfuscated The data that is to be un-obfuscated. + * @param key The key for the data that is to be un-obfuscated. + * @return The original data transformed by the obfuscate() method. + * @throws ValidationException Optionally thrown if a data integrity check fails. + */ + String unobfuscate(String obfuscated, String key) throws ValidationException; +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/Policy.java b/app/src/main/java/moe/yuuta/gplicense/Policy.java new file mode 100644 index 0000000..c261d82 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/Policy.java @@ -0,0 +1,49 @@ +package moe.yuuta.gplicense; + +/** + * Policy used by {@link LicenseChecker} to determine whether a user should have + * access to the application. + */ +public interface Policy { + + /** + * Change these values to make it more difficult for tools to automatically + * strip LVL protection from your APK. + */ + + /** + * LICENSED means that the server returned back a valid license response + */ + int LICENSED = 0x0100; + /** + * NOT_LICENSED means that the server returned back a valid license response + * that indicated that the user definitively is not licensed + */ + int NOT_LICENSED = 0x0231; + /** + * RETRY means that the license response was unable to be determined --- + * perhaps as a result of faulty networking + */ + int RETRY = 0x0123; + + /** + * Provide results from contact with the license server. Retry counts are + * incremented if the current value of response is RETRY. Results will be + * used for any future policy decisions. + * + * @param response the result from validating the server response + * @param rawData the raw server response data, can be null for RETRY + */ + void processServerResponse(int response, ResponseData rawData); + + /** + * Check if the user should be allowed access to the application. + */ + boolean allowAccess(); + + /** + * Gets the licensing URL returned by the server that can enable access for unlicensed apps (e.g. + * buy app on the Play Store). + */ + String getLicensingUrl(); +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/PreferenceObfuscator.java b/app/src/main/java/moe/yuuta/gplicense/PreferenceObfuscator.java new file mode 100644 index 0000000..0b02276 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/PreferenceObfuscator.java @@ -0,0 +1,63 @@ +package moe.yuuta.gplicense; + +import android.content.SharedPreferences; + +import com.elvishew.xlog.Logger; +import com.elvishew.xlog.XLog; + +/** + * An wrapper for SharedPreferences that transparently performs data obfuscation. + */ +public class PreferenceObfuscator { + + private static final Logger logger = XLog.tag("PrefObfs").build(); + + private final SharedPreferences mPreferences; + private final Obfuscator mObfuscator; + private SharedPreferences.Editor mEditor; + + /** + * Constructor. + * + * @param sp A SharedPreferences instance provided by the system. + * @param o The Obfuscator to use when reading or writing data. + */ + public PreferenceObfuscator(SharedPreferences sp, Obfuscator o) { + mPreferences = sp; + mObfuscator = o; + mEditor = null; + } + + public void putString(String key, String value) { + if (mEditor == null) { + mEditor = mPreferences.edit(); + } + String obfuscatedValue = mObfuscator.obfuscate(value, key); + mEditor.putString(key, obfuscatedValue); + } + + public String getString(String key, String defValue) { + String result; + String value = mPreferences.getString(key, null); + if (value != null) { + try { + result = mObfuscator.unobfuscate(value, key); + } catch (ValidationException e) { + // Unable to unobfuscate, data corrupt or tampered + logger.w("Validation error while reading preference: " + key); + result = defValue; + } + } else { + // Preference not found + result = defValue; + } + return result; + } + + public void commit() { + if (mEditor != null) { + mEditor.commit(); + mEditor = null; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/ResponseData.java b/app/src/main/java/moe/yuuta/gplicense/ResponseData.java new file mode 100644 index 0000000..e588644 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/ResponseData.java @@ -0,0 +1,65 @@ +package moe.yuuta.gplicense; + +import android.text.TextUtils; + +import java.util.regex.Pattern; + +/** + * ResponseData from licensing server. + */ +public class ResponseData { + + public int responseCode; + public int nonce; + public String packageName; + public String versionCode; + public String userId; + public long timestamp; + /** Response-specific data. */ + public String extra; + + /** + * Parses response string into ResponseData. + * + * @param responseData response data string + * @throws IllegalArgumentException upon parsing error + * @return ResponseData object + */ + public static ResponseData parse(String responseData) { + // Must parse out main response data and response-specific data. + int index = responseData.indexOf(':'); + String mainData, extraData; + if (-1 == index) { + mainData = responseData; + extraData = ""; + } else { + mainData = responseData.substring(0, index); + extraData = index >= responseData.length() ? "" : responseData.substring(index + 1); + } + + String[] fields = TextUtils.split(mainData, Pattern.quote("|")); + if (fields.length < 6) { + throw new IllegalArgumentException("Wrong number of fields."); + } + + ResponseData data = new ResponseData(); + data.extra = extraData; + data.responseCode = Integer.parseInt(fields[0]); + data.nonce = Integer.parseInt(fields[1]); + data.packageName = fields[2]; + data.versionCode = fields[3]; + // Application-specific user identifier. + data.userId = fields[4]; + data.timestamp = Long.parseLong(fields[5]); + + return data; + } + + @Override + public String toString() { + return TextUtils.join("|", new Object[] { + responseCode, nonce, packageName, versionCode, + userId, timestamp + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/ServerManagedPolicy.java b/app/src/main/java/moe/yuuta/gplicense/ServerManagedPolicy.java new file mode 100644 index 0000000..8355cb8 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/ServerManagedPolicy.java @@ -0,0 +1,284 @@ +package moe.yuuta.gplicense; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.elvishew.xlog.Logger; +import com.elvishew.xlog.XLog; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.Map; + +import moe.yuuta.gplicense.util.URIQueryDecoder; + +/** + * Default policy. All policy decisions are based off of response data received + * from the licensing service. Specifically, the licensing server sends the + * following information: response validity period, error retry period, + * error retry count and a URL for restoring app access in unlicensed cases. + *

+ * These values will vary based on the the way the application is configured in + * the Google Play publishing console, such as whether the application is + * marked as free or is within its refund period, as well as how often an + * application is checking with the licensing service. + *

+ * Developers who need more fine grained control over their application's + * licensing policy should implement a custom Policy. + */ +public class ServerManagedPolicy implements Policy { + private final Logger logger = XLog.tag("DefPolicy").build(); + private static final String PREFS_FILE = "DefPol"; + private static final String PREF_LAST_RESPONSE = "la_resp"; + private static final String PREF_VALIDITY_TIMESTAMP = "ts"; + private static final String PREF_RETRY_UNTIL = "ret_utl"; + private static final String PREF_MAX_RETRIES = "max_ret"; + private static final String PREF_RETRY_COUNT = "ret_am"; + private static final String PREF_LICENSING_URL = "l_url"; + private static final String DEFAULT_VALIDITY_TIMESTAMP = "0"; + private static final String DEFAULT_RETRY_UNTIL = "0"; + private static final String DEFAULT_MAX_RETRIES = "0"; + private static final String DEFAULT_RETRY_COUNT = "0"; + + private static final long MILLIS_PER_MINUTE = 60 * 1000; + + private long mValidityTimestamp; + private long mRetryUntil; + private long mMaxRetries; + private long mRetryCount; + private long mLastResponseTime = 0; + private int mLastResponse; + private String mLicensingUrl; + private PreferenceObfuscator mPreferences; + + /** + * @param context The context for the current application + * @param obfuscator An obfuscator to be used with preferences. + */ + public ServerManagedPolicy(Context context, Obfuscator obfuscator) { + // Import old values + SharedPreferences sp = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE); + mPreferences = new PreferenceObfuscator(sp, obfuscator); + mLastResponse = Integer.parseInt( + mPreferences.getString(PREF_LAST_RESPONSE, Integer.toString(Policy.RETRY))); + mValidityTimestamp = Long.parseLong(mPreferences.getString(PREF_VALIDITY_TIMESTAMP, + DEFAULT_VALIDITY_TIMESTAMP)); + mRetryUntil = Long.parseLong(mPreferences.getString(PREF_RETRY_UNTIL, DEFAULT_RETRY_UNTIL)); + mMaxRetries = Long.parseLong(mPreferences.getString(PREF_MAX_RETRIES, DEFAULT_MAX_RETRIES)); + mRetryCount = Long.parseLong(mPreferences.getString(PREF_RETRY_COUNT, DEFAULT_RETRY_COUNT)); + mLicensingUrl = mPreferences.getString(PREF_LICENSING_URL, null); + } + + /** + * Process a new response from the license server. + *

+ * This data will be used for computing future policy decisions. The + * following parameters are processed: + *

    + *
  • VT: the timestamp that the client should consider the response valid + * until + *
  • GT: the timestamp that the client should ignore retry errors until + *
  • GR: the number of retry errors that the client should ignore + *
  • LU: a deep link URL that can enable access for unlicensed apps (e.g. + * buy app on the Play Store) + *
+ * + * @param response the result from validating the server response + * @param rawData the raw server response data + */ + public void processServerResponse(int response, ResponseData rawData) { + + // Update retry counter + if (response != Policy.RETRY) { + setRetryCount(0); + } else { + setRetryCount(mRetryCount + 1); + } + + // Update server policy data + Map extras = decodeExtras(rawData); + if (response == Policy.LICENSED) { + mLastResponse = response; + // Reset the licensing URL since it is only applicable for NOT_LICENSED responses. + setLicensingUrl(null); + setValidityTimestamp(extras.get("VT")); + setRetryUntil(extras.get("GT")); + setMaxRetries(extras.get("GR")); + } else if (response == Policy.NOT_LICENSED) { + // Clear out stale retry params + setValidityTimestamp(DEFAULT_VALIDITY_TIMESTAMP); + setRetryUntil(DEFAULT_RETRY_UNTIL); + setMaxRetries(DEFAULT_MAX_RETRIES); + // Update the licensing URL + setLicensingUrl(extras.get("LU")); + } + + setLastResponse(response); + mPreferences.commit(); + } + + /** + * Set the last license response received from the server and add to + * preferences. You must manually call PreferenceObfuscator.commit() to + * commit these changes to disk. + * + * @param l the response + */ + private void setLastResponse(int l) { + mLastResponseTime = System.currentTimeMillis(); + mLastResponse = l; + mPreferences.putString(PREF_LAST_RESPONSE, Integer.toString(l)); + } + + /** + * Set the current retry count and add to preferences. You must manually + * call PreferenceObfuscator.commit() to commit these changes to disk. + * + * @param c the new retry count + */ + private void setRetryCount(long c) { + mRetryCount = c; + mPreferences.putString(PREF_RETRY_COUNT, Long.toString(c)); + } + + public long getRetryCount() { + return mRetryCount; + } + + /** + * Set the last validity timestamp (VT) received from the server and add to + * preferences. You must manually call PreferenceObfuscator.commit() to + * commit these changes to disk. + * + * @param validityTimestamp the VT string received + */ + private void setValidityTimestamp(String validityTimestamp) { + Long lValidityTimestamp; + try { + lValidityTimestamp = Long.parseLong(validityTimestamp); + } catch (NumberFormatException e) { + // No response or not parsable, expire in one minute. + logger.w("License validity timestamp (VT) missing, caching for a minute"); + lValidityTimestamp = System.currentTimeMillis() + MILLIS_PER_MINUTE; + validityTimestamp = Long.toString(lValidityTimestamp); + } + + mValidityTimestamp = lValidityTimestamp; + mPreferences.putString(PREF_VALIDITY_TIMESTAMP, validityTimestamp); + } + + public long getValidityTimestamp() { + return mValidityTimestamp; + } + + /** + * Set the retry until timestamp (GT) received from the server and add to + * preferences. You must manually call PreferenceObfuscator.commit() to + * commit these changes to disk. + * + * @param retryUntil the GT string received + */ + private void setRetryUntil(String retryUntil) { + Long lRetryUntil; + try { + lRetryUntil = Long.parseLong(retryUntil); + } catch (NumberFormatException e) { + // No response or not parsable, expire immediately + logger.w("License retry timestamp (GT) missing, grace period disabled"); + retryUntil = "0"; + lRetryUntil = 0l; + } + + mRetryUntil = lRetryUntil; + mPreferences.putString(PREF_RETRY_UNTIL, retryUntil); + } + + public long getRetryUntil() { + return mRetryUntil; + } + + /** + * Set the max retries value (GR) as received from the server and add to + * preferences. You must manually call PreferenceObfuscator.commit() to + * commit these changes to disk. + * + * @param maxRetries the GR string received + */ + private void setMaxRetries(String maxRetries) { + Long lMaxRetries; + try { + lMaxRetries = Long.parseLong(maxRetries); + } catch (NumberFormatException e) { + // No response or not parsable, expire immediately + logger.w("Licence retry count (GR) missing, grace period disabled"); + maxRetries = "0"; + lMaxRetries = 0l; + } + + mMaxRetries = lMaxRetries; + mPreferences.putString(PREF_MAX_RETRIES, maxRetries); + } + + public long getMaxRetries() { + return mMaxRetries; + } + + /** + * Set the license URL value (LU) as received from the server and add to preferences. You must + * manually call PreferenceObfuscator.commit() to commit these changes to disk. + * + * @param url the LU string received + */ + private void setLicensingUrl(String url) { + mLicensingUrl = url; + mPreferences.putString(PREF_LICENSING_URL, url); + } + + public String getLicensingUrl() { + return mLicensingUrl; + } + + /** + * {@inheritDoc} + * + * This implementation allows access if either:
+ *
    + *
  1. a LICENSED response was received within the validity period + *
  2. a RETRY response was received in the last minute, and we are under + * the RETRY count or in the RETRY period. + *
+ */ + public boolean allowAccess() { + long ts = System.currentTimeMillis(); + if (mLastResponse == Policy.LICENSED) { + // Check if the LICENSED response occurred within the validity timeout. + if (ts <= mValidityTimestamp) { + // Cached LICENSED response is still valid. + return true; + } + } else if (mLastResponse == Policy.RETRY && + ts < mLastResponseTime + MILLIS_PER_MINUTE) { + // Only allow access if we are within the retry period or we haven't used up our + // max retries. + return (ts <= mRetryUntil || mRetryCount <= mMaxRetries); + } + return false; + } + + private Map decodeExtras(ResponseData rawData) { + Map results = new HashMap(); + if (rawData == null) { + return results; + } + + try { + URI rawExtras = new URI("?" + rawData.extra); + URIQueryDecoder.DecodeQuery(rawExtras, results); + } catch (URISyntaxException e) { + logger.w("Invalid syntax error while decoding extras data from server."); + } + return results; + } + +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/ValidationException.java b/app/src/main/java/moe/yuuta/gplicense/ValidationException.java new file mode 100644 index 0000000..76ff49c --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/ValidationException.java @@ -0,0 +1,17 @@ +package moe.yuuta.gplicense; + +/** + * Indicates that an error occurred while validating the integrity of data managed by an + * {@link Obfuscator}.} + */ +public class ValidationException extends Exception { + public ValidationException() { + super(); + } + + public ValidationException(String s) { + super(s); + } + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/ipc/ILicenseResultListener.java b/app/src/main/java/moe/yuuta/gplicense/ipc/ILicenseResultListener.java new file mode 100644 index 0000000..a7683b6 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/ipc/ILicenseResultListener.java @@ -0,0 +1,101 @@ +package moe.yuuta.gplicense.ipc; + +import android.os.Binder; +import android.os.IBinder; +import android.os.IInterface; +import android.os.Parcel; +import android.os.RemoteException; + +import androidx.annotation.NonNull; + +import moe.yuuta.gplicense.util.Base64; +import moe.yuuta.gplicense.util.Base64DecoderException; + +public interface ILicenseResultListener extends IInterface { + abstract class Stub extends Binder implements ILicenseResultListener { + private static final String DESCRIPTOR; + + static { + try { + // Base64 encoded - + // com.android.vending.licensing.ILicenseResultListener + DESCRIPTOR = new String(Base64.decode( + "Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2VSZXN1bHRMaXN0ZW5lcg==")); + } catch (Base64DecoderException e) { + throw new RuntimeException(e); + } + } + + protected Stub() { + this.attachInterface(this, DESCRIPTOR); + } + + static ILicenseResultListener asInterface(IBinder obj) { + if ((obj == null)) { + return null; + } + IInterface iin = obj.queryLocalInterface(DESCRIPTOR); + if (iin instanceof ILicenseResultListener) { + return (ILicenseResultListener) iin; + } + return new Stub.Proxy(obj); + } + + @Override + public IBinder asBinder() { + return this; + } + + @Override + public boolean onTransact(int code, @NonNull Parcel data, Parcel reply, int flags) throws RemoteException { + switch (code) { + case INTERFACE_TRANSACTION: { + reply.writeString(DESCRIPTOR); + return true; + } + case TRANSACTION_verifyLicense: { + data.enforceInterface(DESCRIPTOR); + int responseCode = data.readInt(); + String signedData = data.readString(); + String signature = data.readString(); + this.verifyLicense(responseCode, signedData, signature); + return true; + } + default: { + return super.onTransact(code, data, reply, flags); + } + } + } + + private static class Proxy implements ILicenseResultListener { + private IBinder mRemote; + + Proxy(IBinder remote) { + mRemote = remote; + } + + @Override + public IBinder asBinder() { + return mRemote; + } + + @Override + public void verifyLicense(int responseCode, String signedData, String signature) throws RemoteException { + Parcel args = Parcel.obtain(); + try { + args.writeInterfaceToken(DESCRIPTOR); + args.writeInt(responseCode); + args.writeString(signedData); + args.writeString(signature); + mRemote.transact(TRANSACTION_verifyLicense, args, null, IBinder.FLAG_ONEWAY); + } finally { + args.recycle(); + } + } + } + + static final int TRANSACTION_verifyLicense = IBinder.FIRST_CALL_TRANSACTION; + } + + void verifyLicense(int responseCode, String signedData, String signature) throws RemoteException; +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/ipc/ILicensingService.java b/app/src/main/java/moe/yuuta/gplicense/ipc/ILicensingService.java new file mode 100644 index 0000000..14e27f4 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/ipc/ILicensingService.java @@ -0,0 +1,101 @@ +package moe.yuuta.gplicense.ipc; + +import android.os.Binder; +import android.os.IBinder; +import android.os.IInterface; +import android.os.Parcel; +import android.os.RemoteException; + +import androidx.annotation.NonNull; + +import moe.yuuta.gplicense.util.Base64; +import moe.yuuta.gplicense.util.Base64DecoderException; + +public interface ILicensingService extends IInterface { + abstract class Stub extends Binder implements ILicensingService { + private static final String DESCRIPTOR; + + static { + try { + // Base64 encoded - + // com.android.vending.licensing.ILicensingService + DESCRIPTOR = new String(Base64.decode( + "Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U=")); + } catch (Base64DecoderException e) { + throw new RuntimeException(e); + } + } + + public Stub() { + this.attachInterface(this, DESCRIPTOR); + } + + public static ILicensingService asInterface(IBinder obj) { + if ((obj == null)) { + return null; + } + IInterface iin = obj.queryLocalInterface(DESCRIPTOR); + if (iin instanceof ILicensingService) { + return (ILicensingService) iin; + } + return new ILicensingService.Stub.Proxy(obj); + } + + @Override + public IBinder asBinder() { + return this; + } + + @Override + public boolean onTransact(int code, @NonNull Parcel data, Parcel reply, int flags) throws RemoteException { + switch (code) { + case INTERFACE_TRANSACTION: { + reply.writeString(DESCRIPTOR); + return true; + } + case TRANSACTION_checkLicense: { + data.enforceInterface(DESCRIPTOR); + long nonce = data.readLong(); + String packageName = data.readString(); + ILicenseResultListener listener = ILicenseResultListener.Stub.asInterface(data.readStrongBinder()); + this.checkLicense(nonce, packageName, listener); + return true; + } + default: { + return super.onTransact(code, data, reply, flags); + } + } + } + + private static class Proxy implements ILicensingService { + private IBinder mRemote; + + Proxy(IBinder remote) { + mRemote = remote; + } + + @Override + public IBinder asBinder() { + return mRemote; + } + + @Override + public void checkLicense(long nonce, String packageName, ILicenseResultListener listener) throws RemoteException { + Parcel args = Parcel.obtain(); + try { + args.writeInterfaceToken(DESCRIPTOR); + args.writeLong(nonce); + args.writeString(packageName); + args.writeStrongBinder(listener != null ? listener.asBinder() : null); + mRemote.transact(TRANSACTION_checkLicense, args, null, IBinder.FLAG_ONEWAY); + } finally { + args.recycle(); + } + } + } + + static final int TRANSACTION_checkLicense = IBinder.FIRST_CALL_TRANSACTION; + } + + void checkLicense(long nonce, String packageName, ILicenseResultListener listener) throws RemoteException; +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/util/Base64.java b/app/src/main/java/moe/yuuta/gplicense/util/Base64.java new file mode 100644 index 0000000..0e87d17 --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/util/Base64.java @@ -0,0 +1,556 @@ +package moe.yuuta.gplicense.util; + +// This code was converted from code at http://iharder.sourceforge.net/base64/ +// Lots of extraneous features were removed. +/* The original code said: + *

+ * I am placing this code in the Public Domain. Do with it as you will. + * This software comes with no guarantees or warranties but with + * plenty of well-wishing instead! + * Please visit + * http://iharder.net/xmlizable + * periodically to check for updates or to contribute improvements. + *

+ * + * @author Robert Harder + * @author rharder@usa.net + * @version 1.3 + */ + +/** + * Base64 converter class. This code is not a full-blown MIME encoder; + * it simply converts binary data to base64 data and back. + * + *

Note {@link CharBase64} is a GWT-compatible implementation of this + * class. + */ +public class Base64 { + /** Specify encoding (value is {@code true}). */ + public final static boolean ENCODE = true; + + /** Specify decoding (value is {@code false}). */ + public final static boolean DECODE = false; + + /** The equals sign (=) as a byte. */ + private final static byte EQUALS_SIGN = (byte) '='; + + /** The new line character (\n) as a byte. */ + private final static byte NEW_LINE = (byte) '\n'; + + /** + * The 64 valid Base64 values. + */ + private final static byte[] ALPHABET = + {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F', + (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K', + (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P', + (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U', + (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z', + (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e', + (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j', + (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o', + (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't', + (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y', + (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3', + (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8', + (byte) '9', (byte) '+', (byte) '/'}; + + /** + * The 64 valid web safe Base64 values. + */ + private final static byte[] WEBSAFE_ALPHABET = + {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F', + (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K', + (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P', + (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U', + (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z', + (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e', + (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j', + (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o', + (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't', + (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y', + (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3', + (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8', + (byte) '9', (byte) '-', (byte) '_'}; + + /** + * Translates a Base64 value to either its 6-bit reconstruction value + * or a negative number indicating some other meaning. + **/ + private final static byte[] DECODABET = {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 0 - 8 + -5, -5, // Whitespace: Tab and Linefeed + -9, -9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26 + -9, -9, -9, -9, -9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 42 + 62, // Plus sign at decimal 43 + -9, -9, -9, // Decimal 44 - 46 + 63, // Slash at decimal 47 + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine + -9, -9, -9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9, -9, -9, // Decimal 62 - 64 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N' + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z' + -9, -9, -9, -9, -9, -9, // Decimal 91 - 96 + 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm' + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z' + -9, -9, -9, -9, -9 // Decimal 123 - 127 + /* ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + /** The web safe decodabet */ + private final static byte[] WEBSAFE_DECODABET = + {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 0 - 8 + -5, -5, // Whitespace: Tab and Linefeed + -9, -9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26 + -9, -9, -9, -9, -9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 44 + 62, // Dash '-' sign at decimal 45 + -9, -9, // Decimal 46-47 + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine + -9, -9, -9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9, -9, -9, // Decimal 62 - 64 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N' + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z' + -9, -9, -9, -9, // Decimal 91-94 + 63, // Underscore '_' at decimal 95 + -9, // Decimal 96 + 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm' + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z' + -9, -9, -9, -9, -9 // Decimal 123 - 127 + /* ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + // Indicates white space in encoding + private final static byte WHITE_SPACE_ENC = -5; + // Indicates equals sign in encoding + private final static byte EQUALS_SIGN_ENC = -1; + + /** Defeats instantiation. */ + private Base64() { + } + + /* ******** E N C O D I N G M E T H O D S ******** */ + + /** + * Encodes up to three bytes of the array source + * and writes the resulting four Base64 bytes to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accommodate srcOffset + 3 for + * the source array or destOffset + 4 for + * the destination array. + * The actual number of significant bytes in your array is + * given by numSigBytes. + * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param numSigBytes the number of significant bytes in your array + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @param alphabet is the encoding alphabet + * @return the destination array + * @since 1.3 + */ + private static byte[] encode3to4(byte[] source, int srcOffset, + int numSigBytes, byte[] destination, int destOffset, byte[] alphabet) { + // 1 2 3 + // 01234567890123456789012345678901 Bit position + // --------000000001111111122222222 Array position from threeBytes + // --------| || || || | Six bit groups to index alphabet + // >>18 >>12 >> 6 >> 0 Right shift necessary + // 0x3f 0x3f 0x3f Additional AND + + // Create buffer with zero-padding if there are only one or two + // significant bytes passed in the array. + // We have to shift left 24 in order to flush out the 1's that appear + // when Java treats a value as negative that is cast from a byte to an int. + int inBuff = + (numSigBytes > 0 ? ((source[srcOffset] << 24) >>> 8) : 0) + | (numSigBytes > 1 ? ((source[srcOffset + 1] << 24) >>> 16) : 0) + | (numSigBytes > 2 ? ((source[srcOffset + 2] << 24) >>> 24) : 0); + + switch (numSigBytes) { + case 3: + destination[destOffset] = alphabet[(inBuff >>> 18)]; + destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f]; + destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f]; + destination[destOffset + 3] = alphabet[(inBuff) & 0x3f]; + return destination; + case 2: + destination[destOffset] = alphabet[(inBuff >>> 18)]; + destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f]; + destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f]; + destination[destOffset + 3] = EQUALS_SIGN; + return destination; + case 1: + destination[destOffset] = alphabet[(inBuff >>> 18)]; + destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f]; + destination[destOffset + 2] = EQUALS_SIGN; + destination[destOffset + 3] = EQUALS_SIGN; + return destination; + default: + return destination; + } // end switch + } // end encode3to4 + + /** + * Encodes a byte array into Base64 notation. + * Equivalent to calling + * {@code encodeBytes(source, 0, source.length)} + * + * @param source The data to convert + * @since 1.4 + */ + public static String encode(byte[] source) { + return encode(source, 0, source.length, ALPHABET, true); + } + + /** + * Encodes a byte array into web safe Base64 notation. + * + * @param source The data to convert + * @param doPadding is {@code true} to pad result with '=' chars + * if it does not fall on 3 byte boundaries + */ + public static String encodeWebSafe(byte[] source, boolean doPadding) { + return encode(source, 0, source.length, WEBSAFE_ALPHABET, doPadding); + } + + /** + * Encodes a byte array into Base64 notation. + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param alphabet is the encoding alphabet + * @param doPadding is {@code true} to pad result with '=' chars + * if it does not fall on 3 byte boundaries + * @since 1.4 + */ + public static String encode(byte[] source, int off, int len, byte[] alphabet, + boolean doPadding) { + byte[] outBuff = encode(source, off, len, alphabet, Integer.MAX_VALUE); + int outLen = outBuff.length; + + // If doPadding is false, set length to truncate '=' + // padding characters + while (doPadding == false && outLen > 0) { + if (outBuff[outLen - 1] != '=') { + break; + } + outLen -= 1; + } + + return new String(outBuff, 0, outLen); + } + + /** + * Encodes a byte array into Base64 notation. + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param alphabet is the encoding alphabet + * @param maxLineLength maximum length of one line. + * @return the BASE64-encoded byte array + */ + public static byte[] encode(byte[] source, int off, int len, byte[] alphabet, + int maxLineLength) { + int lenDiv3 = (len + 2) / 3; // ceil(len / 3) + int len43 = lenDiv3 * 4; + byte[] outBuff = new byte[len43 // Main 4:3 + + (len43 / maxLineLength)]; // New lines + + int d = 0; + int e = 0; + int len2 = len - 2; + int lineLength = 0; + for (; d < len2; d += 3, e += 4) { + + // The following block of code is the same as + // encode3to4( source, d + off, 3, outBuff, e, alphabet ); + // but inlined for faster encoding (~20% improvement) + int inBuff = + ((source[d + off] << 24) >>> 8) + | ((source[d + 1 + off] << 24) >>> 16) + | ((source[d + 2 + off] << 24) >>> 24); + outBuff[e] = alphabet[(inBuff >>> 18)]; + outBuff[e + 1] = alphabet[(inBuff >>> 12) & 0x3f]; + outBuff[e + 2] = alphabet[(inBuff >>> 6) & 0x3f]; + outBuff[e + 3] = alphabet[(inBuff) & 0x3f]; + + lineLength += 4; + if (lineLength == maxLineLength) { + outBuff[e + 4] = NEW_LINE; + e++; + lineLength = 0; + } // end if: end of line + } // end for: each piece of array + + if (d < len) { + encode3to4(source, d + off, len - d, outBuff, e, alphabet); + + lineLength += 4; + if (lineLength == maxLineLength) { + // Add a last newline + outBuff[e + 4] = NEW_LINE; + e++; + } + e += 4; + } + + assert (e == outBuff.length); + return outBuff; + } + + + /* ******** D E C O D I N G M E T H O D S ******** */ + + + /** + * Decodes four bytes from array source + * and writes the resulting bytes (up to three of them) + * to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accommodate srcOffset + 4 for + * the source array or destOffset + 3 for + * the destination array. + * This method returns the actual number of bytes that + * were converted from the Base64 encoding. + * + * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @param decodabet the decodabet for decoding Base64 content + * @return the number of decoded bytes converted + * @since 1.3 + */ + private static int decode4to3(byte[] source, int srcOffset, + byte[] destination, int destOffset, byte[] decodabet) { + // Example: Dk== + if (source[srcOffset + 2] == EQUALS_SIGN) { + int outBuff = + ((decodabet[source[srcOffset]] << 24) >>> 6) + | ((decodabet[source[srcOffset + 1]] << 24) >>> 12); + + destination[destOffset] = (byte) (outBuff >>> 16); + return 1; + } else if (source[srcOffset + 3] == EQUALS_SIGN) { + // Example: DkL= + int outBuff = + ((decodabet[source[srcOffset]] << 24) >>> 6) + | ((decodabet[source[srcOffset + 1]] << 24) >>> 12) + | ((decodabet[source[srcOffset + 2]] << 24) >>> 18); + + destination[destOffset] = (byte) (outBuff >>> 16); + destination[destOffset + 1] = (byte) (outBuff >>> 8); + return 2; + } else { + // Example: DkLE + int outBuff = + ((decodabet[source[srcOffset]] << 24) >>> 6) + | ((decodabet[source[srcOffset + 1]] << 24) >>> 12) + | ((decodabet[source[srcOffset + 2]] << 24) >>> 18) + | ((decodabet[source[srcOffset + 3]] << 24) >>> 24); + + destination[destOffset] = (byte) (outBuff >> 16); + destination[destOffset + 1] = (byte) (outBuff >> 8); + destination[destOffset + 2] = (byte) (outBuff); + return 3; + } + } // end decodeToBytes + + + /** + * Decodes data from Base64 notation. + * + * @param s the string to decode (decoded in default encoding) + * @return the decoded data + * @since 1.4 + */ + public static byte[] decode(String s) throws Base64DecoderException { + byte[] bytes = s.getBytes(); + return decode(bytes, 0, bytes.length); + } + + /** + * Decodes data from web safe Base64 notation. + * Web safe encoding uses '-' instead of '+', '_' instead of '/' + * + * @param s the string to decode (decoded in default encoding) + * @return the decoded data + */ + public static byte[] decodeWebSafe(String s) throws Base64DecoderException { + byte[] bytes = s.getBytes(); + return decodeWebSafe(bytes, 0, bytes.length); + } + + /** + * Decodes Base64 content in byte array format and returns + * the decoded byte array. + * + * @param source The Base64 encoded data + * @return decoded data + * @since 1.3 + * @throws Base64DecoderException + */ + public static byte[] decode(byte[] source) throws Base64DecoderException { + return decode(source, 0, source.length); + } + + /** + * Decodes web safe Base64 content in byte array format and returns + * the decoded data. + * Web safe encoding uses '-' instead of '+', '_' instead of '/' + * + * @param source the string to decode (decoded in default encoding) + * @return the decoded data + */ + public static byte[] decodeWebSafe(byte[] source) + throws Base64DecoderException { + return decodeWebSafe(source, 0, source.length); + } + + /** + * Decodes Base64 content in byte array format and returns + * the decoded byte array. + * + * @param source The Base64 encoded data + * @param off The offset of where to begin decoding + * @param len The length of characters to decode + * @return decoded data + * @since 1.3 + * @throws Base64DecoderException + */ + public static byte[] decode(byte[] source, int off, int len) + throws Base64DecoderException { + return decode(source, off, len, DECODABET); + } + + /** + * Decodes web safe Base64 content in byte array format and returns + * the decoded byte array. + * Web safe encoding uses '-' instead of '+', '_' instead of '/' + * + * @param source The Base64 encoded data + * @param off The offset of where to begin decoding + * @param len The length of characters to decode + * @return decoded data + */ + public static byte[] decodeWebSafe(byte[] source, int off, int len) + throws Base64DecoderException { + return decode(source, off, len, WEBSAFE_DECODABET); + } + + /** + * Decodes Base64 content using the supplied decodabet and returns + * the decoded byte array. + * + * @param source The Base64 encoded data + * @param off The offset of where to begin decoding + * @param len The length of characters to decode + * @param decodabet the decodabet for decoding Base64 content + * @return decoded data + */ + public static byte[] decode(byte[] source, int off, int len, byte[] decodabet) + throws Base64DecoderException { + int len34 = len * 3 / 4; + byte[] outBuff = new byte[2 + len34]; // Upper limit on size of output + int outBuffPosn = 0; + + byte[] b4 = new byte[4]; + int b4Posn = 0; + int i = 0; + byte sbiCrop = 0; + byte sbiDecode = 0; + for (i = 0; i < len; i++) { + sbiCrop = (byte) (source[i + off] & 0x7f); // Only the low seven bits + sbiDecode = decodabet[sbiCrop]; + + if (sbiDecode >= WHITE_SPACE_ENC) { // White space Equals sign or better + if (sbiDecode >= EQUALS_SIGN_ENC) { + // An equals sign (for padding) must not occur at position 0 or 1 + // and must be the last byte[s] in the encoded value + if (sbiCrop == EQUALS_SIGN) { + int bytesLeft = len - i; + byte lastByte = (byte) (source[len - 1 + off] & 0x7f); + if (b4Posn == 0 || b4Posn == 1) { + throw new Base64DecoderException( + "invalid padding byte '=' at byte offset " + i); + } else if ((b4Posn == 3 && bytesLeft > 2) + || (b4Posn == 4 && bytesLeft > 1)) { + throw new Base64DecoderException( + "padding byte '=' falsely signals end of encoded value " + + "at offset " + i); + } else if (lastByte != EQUALS_SIGN && lastByte != NEW_LINE) { + throw new Base64DecoderException( + "encoded value has invalid trailing byte"); + } + break; + } + + b4[b4Posn++] = sbiCrop; + if (b4Posn == 4) { + outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet); + b4Posn = 0; + } + } + } else { + throw new Base64DecoderException("Bad Base64 input character at " + i + + ": " + source[i + off] + "(decimal)"); + } + } + + // Because web safe encoding allows non padding base64 encodes, we + // need to pad the rest of the b4 buffer with equal signs when + // b4Posn != 0. There can be at most 2 equal signs at the end of + // four characters, so the b4 buffer must have two or three + // characters. This also catches the case where the input is + // padded with EQUALS_SIGN + if (b4Posn != 0) { + if (b4Posn == 1) { + throw new Base64DecoderException("single trailing character at offset " + + (len - 1)); + } + b4[b4Posn++] = EQUALS_SIGN; + outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet); + } + + byte[] out = new byte[outBuffPosn]; + System.arraycopy(outBuff, 0, out, 0, outBuffPosn); + return out; + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/util/Base64DecoderException.java b/app/src/main/java/moe/yuuta/gplicense/util/Base64DecoderException.java new file mode 100644 index 0000000..619985e --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/util/Base64DecoderException.java @@ -0,0 +1,18 @@ +package moe.yuuta.gplicense.util; + +/** + * Exception thrown when encountering an invalid Base64 input character. + * + * @author nelson + */ +public class Base64DecoderException extends Exception { + public Base64DecoderException() { + super(); + } + + public Base64DecoderException(String s) { + super(s); + } + + private static final long serialVersionUID = 1L; +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/gplicense/util/URIQueryDecoder.java b/app/src/main/java/moe/yuuta/gplicense/util/URIQueryDecoder.java new file mode 100644 index 0000000..273e85f --- /dev/null +++ b/app/src/main/java/moe/yuuta/gplicense/util/URIQueryDecoder.java @@ -0,0 +1,42 @@ +package moe.yuuta.gplicense.util; + +import com.elvishew.xlog.XLog; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLDecoder; +import java.util.Map; +import java.util.Scanner; + +public class URIQueryDecoder { + /** + * Decodes the query portion of the passed-in URI. + * + * @param encodedURI the URI containing the query to decode + * @param results a map containing all query parameters. Query parameters that do not have a + * value will map to a null string + */ + static public void DecodeQuery(URI encodedURI, Map results) { + Scanner scanner = new Scanner(encodedURI.getRawQuery()); + scanner.useDelimiter("&"); + try { + while (scanner.hasNext()) { + String param = scanner.next(); + String[] valuePair = param.split("="); + String name, value; + if (valuePair.length == 1) { + value = null; + } else if (valuePair.length == 2) { + value = URLDecoder.decode(valuePair[1], "UTF-8"); + } else { + throw new IllegalArgumentException("query parameter invalid"); + } + name = URLDecoder.decode(valuePair[0], "UTF-8"); + results.put(name, value); + } + } catch (UnsupportedEncodingException e) { + // This should never happen. + XLog.e("UQD", "UTF-8 Not Recognized as a charset. Device configuration Error."); + } + } +} diff --git a/app/src/main/java/moe/yuuta/workmode/MainActivity.kt b/app/src/main/java/moe/yuuta/workmode/MainActivity.kt index f18370f..b09e38e 100644 --- a/app/src/main/java/moe/yuuta/workmode/MainActivity.kt +++ b/app/src/main/java/moe/yuuta/workmode/MainActivity.kt @@ -25,8 +25,11 @@ import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout +import moe.yuuta.gplicense.LicenseCheckerCallback +import moe.yuuta.gplicense.Policy import moe.yuuta.workmode.access.AccessorStarter import moe.yuuta.workmode.async.* +import moe.yuuta.workmode.gpl.GPLicenser import moe.yuuta.workmode.suspend.AsyncSuspender import moe.yuuta.workmode.suspend.SuspendTile import moe.yuuta.workmode.suspend.data.ListMode @@ -37,7 +40,7 @@ import moe.yuuta.workmode.update.UpdateChecker import moe.yuuta.workmode.utils.Utils import java.util.stream.Collectors -class MainActivity : AppCompatActivity(), SwitchBar.OnSwitchChangeListener, View.OnClickListener { +class MainActivity : AppCompatActivity(), SwitchBar.OnSwitchChangeListener, View.OnClickListener, LicenseCheckerCallback { private val logger: Logger = XLog.tag("MainActivity").build() companion object { @@ -75,6 +78,7 @@ class MainActivity : AppCompatActivity(), SwitchBar.OnSwitchChangeListener, View filter.addAction(AccessorStarter.ACTION_UPDATE_UI_PROGRESS) registerReceiver(mUIUpdateReceiver, filter) scheduleUpdateChecking() + lifecycle.addObserver(GPLicenser(this, lifecycle, this)) setProgressUI(false) } @@ -336,6 +340,31 @@ class MainActivity : AppCompatActivity(), SwitchBar.OnSwitchChangeListener, View } } } + + override fun allow(reason: Int) = proceedLicensing(reason) + + override fun dontAllow(reason: Int) = proceedLicensing(reason) + + override fun applicationError(errorCode: Int) { + logger.e("StatusException: $errorCode") + SuspendedStorage(this).reportCrack("a_e", "co: $errorCode") + } + + private fun proceedLicensing(reason: Int) { + logger.d("Status: ${ + when (reason) { + Policy.LICENSED -> "OK" + Policy.NOT_LICENSED -> "Fail" + Policy.RETRY -> "Unknown" + else -> "? $reason" + } + }") + when (reason) { + Policy.LICENSED -> SuspendedStorage(this).removeCrack("g_p_l") + Policy.NOT_LICENSED -> SuspendedStorage(this).reportCrack("g_p_l", "n_p") + Policy.RETRY -> SuspendedStorage(this).reportCrack("g_p_l", "rt") + } + } } private class Adapter : RecyclerView.Adapter() { diff --git a/app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt b/app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt index d31e242..f23146a 100644 --- a/app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt +++ b/app/src/main/java/moe/yuuta/workmode/access/AccessorStarter.kt @@ -4,15 +4,11 @@ import android.content.Context import android.os.Bundle import android.os.Parcel import android.os.PersistableBundle -import com.crashlytics.android.Crashlytics -import com.crashlytics.android.answers.Answers -import com.crashlytics.android.answers.CustomEvent import com.elvishew.xlog.Logger import com.elvishew.xlog.XLog import eu.chainfire.librootjava.RootJava import eu.chainfire.libsuperuser.Shell import moe.yuuta.workmode.BuildConfig -import moe.yuuta.workmode.Setup import moe.yuuta.workmode.suspend.data.ListMode import moe.yuuta.workmode.suspend.data.Status import moe.yuuta.workmode.suspend.data.SuspendedStorage @@ -165,23 +161,8 @@ open class AccessorStarter(private val mContext: Context, private val mLogPath: 2.toByte() -> { // The ID is used to prevent from multiple reporting. val id = result.readString() - val reportTimes = SuspendedStorage(mContext).getStorage().getInt("c_$id", 0) - SuspendedStorage(mContext).getStorage().edit() - .putInt("c_$id", reportTimes + 1) - .apply() - if (reportTimes <= 0) { - val reason = result.readString() - Runnable { - Runnable { - if (Setup.FABRIC_ENABLE) - Runnable { - Answers.getInstance().logCustom(CustomEvent("St.rf.pa.") - .putCustomAttribute("rn", reason)) - Crashlytics.log("Sf. $reason") - }.run() - }.run() - }.run() - } + val reason = result.readString() + SuspendedStorage(mContext).reportCrack(id ?: "nd", reason ?: "nr") } } return result diff --git a/app/src/main/java/moe/yuuta/workmode/gpl/GPLicenser.kt b/app/src/main/java/moe/yuuta/workmode/gpl/GPLicenser.kt new file mode 100644 index 0000000..fdcad9a --- /dev/null +++ b/app/src/main/java/moe/yuuta/workmode/gpl/GPLicenser.kt @@ -0,0 +1,45 @@ +package moe.yuuta.workmode.gpl + +import android.annotation.SuppressLint +import android.content.Context +import android.provider.Settings +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import moe.yuuta.gplicense.AESObfuscator +import moe.yuuta.gplicense.LicenseChecker +import moe.yuuta.gplicense.LicenseCheckerCallback +import moe.yuuta.gplicense.ServerManagedPolicy +import moe.yuuta.workmode.BuildConfig + +class GPLicenser( + private val context: Context, + private val lifecycle: Lifecycle, + private val callback: LicenseCheckerCallback +) : LifecycleObserver { + private val SALT = byteArrayOf( + -90, 83, 80, -91, -37, -57, 74, -69, 52, 89, + -9, -5, -77, -71, -36, -79, -11, 37, -69, 88 + ) + + private lateinit var checker: LicenseChecker + + @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) + fun start() { + @SuppressLint("HardwareIds") val android_id = Settings.Secure.getString(context.contentResolver, + Settings.Secure.ANDROID_ID) + checker = LicenseChecker( + context, + ServerManagedPolicy(context, AESObfuscator(SALT, BuildConfig.APPLICATION_ID, android_id)), + BuildConfig.GOOGLE_PLAY_LICENSING_KEY + ) + checker.checkAccess(callback) + } + + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + fun destroy() { + if (::checker.isInitialized) { + checker.onDestroy() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt b/app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt index a6c6cdd..d8cfee7 100644 --- a/app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt +++ b/app/src/main/java/moe/yuuta/workmode/suspend/data/SuspendedStorage.kt @@ -2,7 +2,11 @@ package moe.yuuta.workmode.suspend.data import android.content.Context import android.content.SharedPreferences +import com.crashlytics.android.Crashlytics +import com.crashlytics.android.answers.Answers +import com.crashlytics.android.answers.CustomEvent import com.elvishew.xlog.XLog +import moe.yuuta.workmode.Setup import moe.yuuta.workmode.utils.Utils import java.util.stream.Collectors @@ -65,4 +69,38 @@ class SuspendedStorage(private val mContext: Context) { } .collect(Collectors.toSet())) } + + // #Anti-Crack + fun reportCrack(id: String, reason: String) { + val reportTimes = getStorage().getInt("c_$id", 0) + getStorage().edit() + .putInt("c_$id", reportTimes + 1) + .apply() + if (reportTimes <= 0) { + Runnable { + Runnable { + if (Setup.FABRIC_ENABLE) + Runnable { + Answers.getInstance().logCustom(CustomEvent("St.rf.pa.") + .putCustomAttribute("rn", reason) + .putCustomAttribute("ic", id)) + Crashlytics.log("Sf. $reason $id") + }.run() + }.run() + }.run() + } + } + + // #Anti-Crack + fun removeCrack(id: String) { + getStorage().edit() + .remove("c_$id") + .apply() + if (Setup.FABRIC_ENABLE) + Runnable { + Answers.getInstance().logCustom(CustomEvent("St.rf.rm.") + .putCustomAttribute("ic", id)) + Crashlytics.log("Sf.rm. $id") + }.run() + } } \ No newline at end of file -- cgit v1.2.3