Implement license activation via QR code

It's now possible to use a QR code to activate licenses on
non-Play builds of the app. This comes handy on TV devices
This commit is contained in:
emanuele-f 2023-04-16 19:42:39 +02:00
parent 2b57c40dfe
commit c4d8a09e1d
6 changed files with 396 additions and 65 deletions

View File

@ -87,4 +87,5 @@ dependencies {
implementation 'cat.ereza:customactivityoncrash:2.3.0'
implementation 'com.github.KaKaVip:Android-Flag-Kit:v0.1'
implementation 'com.github.AppIntro:AppIntro:6.2.0'
implementation 'com.github.androidmads:QRGenerator:1.0.1'
}

View File

@ -115,13 +115,18 @@ public class Billing {
public void connectBilling() {}
public void disconnectBilling() {}
public void setLicense(String license) {
if(!isValidLicense(license))
public boolean setLicense(String license) {
boolean valid = true;
if(!isValidLicense(license)) {
license = "";
valid = false;
}
mPrefs.edit()
.putString("license", license)
.apply();
return valid;
}
public boolean isValidLicense(String license) {

View File

@ -60,6 +60,7 @@ import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.provider.OpenableColumns;
import android.provider.Settings;
import android.text.SpannableString;
import android.text.SpannedString;
import android.text.TextUtils;
@ -139,6 +140,7 @@ import javax.net.ssl.HttpsURLConnection;
public class Utils {
static final String TAG = "Utils";
public static final String INTERACT_ACROSS_USERS = "android.permission.INTERACT_ACROSS_USERS";
public static final String PCAPDROID_WEBSITE = "https://pcapdroid.org";
public static final int PER_USER_RANGE = 100000;
public static final int UID_UNKNOWN = -1;
public static final int UID_NO_FILTER = -2;
@ -1410,6 +1412,10 @@ public class Utils {
"OS version: " + getOsVersion() + "\n";
}
public static String getDeviceName(Context ctx) {
return Settings.Secure.getString(ctx.getContentResolver(), "bluetooth_name");
}
public static String getAppVersionString() {
return "PCAPdroid v" + BuildConfig.VERSION_NAME;
}

View File

@ -20,30 +20,66 @@
package com.emanuelef.remote_capture.activities;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Point;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.text.method.LinkMovementMethod;
import android.util.TypedValue;
import android.view.Display;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.WindowManager;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.core.text.HtmlCompat;
import androidx.core.view.MenuProvider;
import com.emanuelef.remote_capture.Billing;
import com.emanuelef.remote_capture.Log;
import com.emanuelef.remote_capture.R;
import com.emanuelef.remote_capture.Utils;
import com.emanuelef.remote_capture.model.Prefs;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.net.ssl.HttpsURLConnection;
import androidmads.library.qrgenearator.QRGContents;
import androidmads.library.qrgenearator.QRGEncoder;
public class AboutActivity extends BaseActivity implements MenuProvider {
private static final String TAG = "AboutActivity";
private ExecutorService mQrReqExecutor;
private HttpsURLConnection mQrCon;
private boolean mDialogClosing = false;
private long mQrStartTime = 0;
private long mQrDeadline = 0;
private Handler mHandler;
private AlertDialog mLicenseDialog;
@Override
protected void onCreate(Bundle savedInstanceState) {
@ -52,6 +88,7 @@ public class AboutActivity extends BaseActivity implements MenuProvider {
setContentView(R.layout.about_activity);
addMenuProvider(this);
mHandler = new Handler(Looper.getMainLooper());
TextView appVersion = findViewById(R.id.app_version);
appVersion.setText("PCAPdroid " + Utils.getAppVersion(this));
@ -64,6 +101,25 @@ public class AboutActivity extends BaseActivity implements MenuProvider {
sourceLink.setMovementMethod(LinkMovementMethod.getInstance());
}
@Override
protected void onStop() {
stopQrExecutor();
super.onStop();
}
private void stopQrExecutor() {
// necessary to interrupt the executor thread
if(mQrCon != null)
mQrCon.disconnect();
mQrCon = null;
if(mQrReqExecutor != null)
mQrReqExecutor.shutdownNow();
mQrReqExecutor = null;
mHandler.removeCallbacksAndMessages(null);
}
@Override
public void onCreateMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.about_menu, menu);
@ -107,13 +163,20 @@ public class AboutActivity extends BaseActivity implements MenuProvider {
private void showLicenseDialog() {
Billing billing = Billing.newInstance(this);
LayoutInflater inflater = getLayoutInflater();
View content = inflater.inflate(R.layout.license_dialog, null);
final View content = inflater.inflate(R.layout.license_dialog, null);
String instId = billing.getInstallationId();
TextView instIdText = content.findViewById(R.id.installation_id);
instIdText.setText(instId);
if(Utils.isTv(this))
mDialogClosing = false;
final View showQr = content.findViewById(R.id.show_qr_code);
showQr.setOnClickListener(v -> showQrCode(content, instId));
if(Utils.isTv(this) && !billing.isPurchased(Billing.SUPPORTER_SKU)) {
instIdText.setOnClickListener(v -> Utils.shareText(this, getString(R.string.installation_id), instId));
showQrCode(content, instId);
}
TextView validationRc = content.findViewById(R.id.validation_rc);
EditText licenseCode = content.findViewById(R.id.license_code);
@ -122,25 +185,208 @@ public class AboutActivity extends BaseActivity implements MenuProvider {
content.findViewById(R.id.copy_id).setOnClickListener(v -> Utils.copyToClipboard(this, instId));
boolean was_valid = billing.isPurchased(Billing.SUPPORTER_SKU);
AlertDialog myDialog = new AlertDialog.Builder(this)
mLicenseDialog = new AlertDialog.Builder(this)
.setView(content)
.setPositiveButton(R.string.ok, (dialog, whichButton) -> {
boolean was_valid = billing.isPurchased(Billing.SUPPORTER_SKU);
billing.setLicense(licenseCode.getText().toString());
if(!was_valid && billing.isPurchased(Billing.SUPPORTER_SKU))
Utils.showToastLong(this, R.string.paid_features_unlocked);
})
.setOnDismissListener(dialog -> {
mDialogClosing = true;
mLicenseDialog = null;
stopQrExecutor();
})
.setNeutralButton(R.string.validate, (dialog, which) -> {}) // see below
.create();
myDialog.show();
myDialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> {
mLicenseDialog.show();
mLicenseDialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> {
boolean valid = billing.isValidLicense(licenseCode.getText().toString());
validationRc.setText(valid ? R.string.valid : R.string.invalid);
validationRc.setTextColor(ContextCompat.getColor(this, valid ? R.color.ok : R.color.danger));
});
myDialog.getWindow().setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
mLicenseDialog.getWindow().setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
}
private void showQrCode(View dialog, String instId) {
View qrBox = dialog.findViewById(R.id.qr_box);
View qrLoading = dialog.findViewById(R.id.qr_code_loading);
View showQr = dialog.findViewById(R.id.show_qr_code);
View qrInfo = dialog.findViewById(R.id.qr_info_text);
showQr.setVisibility(View.GONE);
qrLoading.setVisibility(View.VISIBLE);
qrBox.setVisibility(View.GONE);
qrInfo.setVisibility(View.GONE);
mQrReqExecutor = Executors.newSingleThreadExecutor();
Handler handler = new Handler(Looper.getMainLooper());
// start activation
mQrReqExecutor.execute(() -> {
try {
URL url = new URL(Utils.PCAPDROID_WEBSITE + "/getlicense/qr_activation");
HttpsURLConnection con = (HttpsURLConnection) url.openConnection();
mQrCon = con;
try {
con.setRequestProperty("User-Agent", Utils.getAppVersionString());
con.setRequestMethod("POST");
con.setUseCaches(false);
con.setAllowUserInteraction(false);
con.setDoInput(true);
con.setDoOutput(true);
con.setConnectTimeout(5000);
// Send POST request
try (BufferedOutputStream os = new BufferedOutputStream(con.getOutputStream())) {
os.write(("installation_id=" + instId).getBytes());
}
int rc = con.getResponseCode();
Log.d(TAG, "QR HTTP response: " + rc);
if (rc != 200) {
handler.post(() ->
hideQrCode(dialog, "QR request failed with code " + rc));
return;
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(con.getInputStream()))) {
// Step 1: get QR request ID
String timeout_s = parseSseLine(reader.readLine());
String qr_req_id = parseSseLine(reader.readLine());
if ((qr_req_id == null) || (timeout_s == null)) {
handler.post(() ->
hideQrCode(dialog, "Invalid QR request ID"));
return;
}
int timeout_ms = Integer.parseInt(timeout_s) * 1000;
long deadline = SystemClock.uptimeMillis() + timeout_ms;
Log.d(TAG, "QR request_id=" + qr_req_id + ", timeout=" + timeout_ms + " ms");
// Step 2: generate QR code
Bitmap qrBitmap = genQrCode(instId, qr_req_id);
handler.post(() -> onQrRequestReady(dialog, qrBitmap, deadline));
// Step 3: wait license
String license = parseSseLine(reader.readLine());
if(license == null) {
handler.post(() ->
hideQrCode(dialog, getString(R.string.qr_code_expired)));
return;
}
handler.post(() -> onQrLicenseReceived(dialog, license));
}
} finally {
con.disconnect();
}
} catch (IOException | NumberFormatException e) {
e.printStackTrace();
handler.post(() -> {
if(e instanceof EOFException)
hideQrCode(dialog, getString(R.string.qr_code_expired));
else
hideQrCode(dialog, getString(R.string.connection_error, e.getMessage()));
});
}
});
}
private String parseSseLine(String line) {
if(line == null)
return null;
if(line.startsWith("data: "))
line = line.substring(6);
return line;
}
private Bitmap genQrCode(String instId, String qrReqId) {
float maxDp = 180f;
int maxPx = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
maxDp,
getResources().getDisplayMetrics()
);
WindowManager manager = (WindowManager) getSystemService(WINDOW_SERVICE);
Display display = manager.getDefaultDisplay();
Point point = new Point();
display.getSize(point);
int smallerDimension = Math.min(Math.min(point.x, point.y) / 2, maxPx);
String qrData = "pcapdroid://get_license?installation_id="+ instId +"&qr_request_id=" + qrReqId + "&device=" + Uri.encode(Utils.getDeviceName(this));
Log.d(TAG, "QR activation URI: " + qrData);
QRGEncoder qrgEncoder = new QRGEncoder(qrData, null, QRGContents.Type.TEXT, smallerDimension);
return qrgEncoder.getBitmap(0);
}
private void onQrRequestReady(View dialog, Bitmap qrcode, long deadline) {
View qrBox = dialog.findViewById(R.id.qr_box);
ImageView qrImage = dialog.findViewById(R.id.qr_code);
View qrLoading = dialog.findViewById(R.id.qr_code_loading);
View qrInfo = dialog.findViewById(R.id.qr_info_text);
mQrStartTime = SystemClock.uptimeMillis();
mQrDeadline = deadline;
updateQrProgress(dialog);
qrImage.setImageBitmap(qrcode);
qrBox.setVisibility(View.VISIBLE);
qrInfo.setVisibility(View.VISIBLE);
qrLoading.setVisibility(View.GONE);
}
private void updateQrProgress(View dialog) {
ProgressBar qrProgress = dialog.findViewById(R.id.qr_remaining_time);
if(qrProgress == null)
return;
long interval = mQrDeadline - mQrStartTime;
int progress = Math.min((int)((SystemClock.uptimeMillis() - mQrStartTime) * 100 / interval), 100);
qrProgress.setProgress(100 - progress);
mHandler.postDelayed(() -> updateQrProgress(dialog), 1000);
}
private void onQrLicenseReceived(View dialog, String license) {
EditText licenseCode = dialog.findViewById(R.id.license_code);
Billing billing = Billing.newInstance(this);
boolean was_valid = billing.isPurchased(Billing.SUPPORTER_SKU);
if(billing.setLicense(license)) {
licenseCode.setText(license);
Utils.showToast(this, R.string.license_activation_ok);
if(!was_valid)
Utils.showToastLong(this, R.string.paid_features_unlocked);
hideQrCode(dialog, null);
if(mLicenseDialog != null)
mLicenseDialog.dismiss();
} else
hideQrCode(dialog, getString(R.string.invalid_license));
}
private void hideQrCode(View dialog, @Nullable String error_msg) {
View showQr = dialog.findViewById(R.id.show_qr_code);
View qrLoading = dialog.findViewById(R.id.qr_code_loading);
View qrBox = dialog.findViewById(R.id.qr_box);
View qrInfo = dialog.findViewById(R.id.qr_info_text);
qrBox.setVisibility(View.GONE);
qrInfo.setVisibility(View.GONE);
qrLoading.setVisibility(View.GONE);
showQr.setVisibility(View.VISIBLE);
if((error_msg != null) && !mDialogClosing)
Toast.makeText(this, error_msg, Toast.LENGTH_LONG).show();
stopQrExecutor();
}
}

View File

@ -1,70 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textStyle="bold"
android:text="@string/installation_id" />
android:orientation="vertical">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
android:padding="10dp">
<TextView
android:id="@+id/installation_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:focusable="true"
tools:text="A127ADC1245" />
android:layout_gravity="center_horizontal"
android:textStyle="bold"
android:text="@string/installation_id" />
<ImageView
android:id="@+id/copy_id"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<TextView
android:id="@+id/installation_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:focusable="true"
tools:text="A127ADC1245" />
<ImageView
android:id="@+id/copy_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:src="@drawable/ic_content_copy" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/show_qr_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:src="@drawable/ic_content_copy" />
android:text="@string/activate_via_qr_code"
android:layout_gravity="center"
style="?attr/materialButtonOutlinedStyle" />
<TextView
android:id="@+id/qr_code_loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
android:text="@string/loading" />
<TextView
android:id="@+id/qr_info_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAlignment="center"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginHorizontal="40dp"
android:layout_marginTop="10dp"
android:text="@string/qr_info_text" />
<LinearLayout
android:id="@+id/qr_box"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:visibility="visible"
android:layout_gravity="center"
android:orientation="vertical">
<ImageView
android:id="@+id/qr_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="20dp"
tools:layout_width="200dp"
tools:layout_height="200dp"/>
<ProgressBar
android:id="@+id/qr_remaining_time"
style="@android:style/Widget.Material.ProgressBar.Horizontal"
tools:progress="25"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
<TextView
android:id="@+id/paid_features_msg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="10dp"
android:text="@string/access_paid_features_msg" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textStyle="bold"
android:text="@string/license_code" />
<EditText
android:id="@+id/license_code"
android:fontFamily="monospace"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:gravity="top"
android:lines="4" />
<TextView
android:id="@+id/validation_rc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/ok"
android:textAllCaps="true"
android:textSize="13sp"
tools:text="@string/valid" />
</LinearLayout>
<TextView
android:id="@+id/paid_features_msg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/access_paid_features_msg" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textStyle="bold"
android:text="@string/license_code" />
<EditText
android:id="@+id/license_code"
android:fontFamily="monospace"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:gravity="top"
android:lines="4" />
<TextView
android:id="@+id/validation_rc"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/ok"
android:textAllCaps="true"
android:textSize="13sp"
tools:text="@string/valid" />
</LinearLayout>
</ScrollView>

View File

@ -26,6 +26,7 @@
- FlagKit: <a href='https://github.com/madebybowtie/FlagKit/blob/master/LICENSE'>MIT</a>\n\n
- IP Geolocation by <a href='https://db-ip.com'>DB-IP</a>\n\n
- AppIntro: <a href='https://github.com/AppIntro/AppIntro/blob/main/LICENSE'>Apache-2.0</a>\n\n
- QrGenerator: <a href='https://github.com/androidmads/QRGenerator/blob/master/LICENSE.md'>MIT</a>\n\n
- Font Awesome: <a href='https://fontawesome.com/license/free'>Licenses</a>\n\n
- App icon by <a href="https://www.freepik.com" title="Freepik">Freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">flaticon</a>\n\n
- SourceCodePro font: <a href='https://github.com/adobe-fonts/source-code-pro/blob/release/LICENSE.md'>OFL-1.1</a>\n\n
@ -461,4 +462,15 @@
<string name="username">Username</string>
<string name="password">Password</string>
<string name="mitm_addon_autostart_workaround">Connection to the mitm addon failed. As a workaround, you can try to open the mitm addon app and then go back to PCAPdroid without closing it. Do you want to open it now?</string>
<string name="qr_license_confirm">Do you want to generate a license for the \"%1$s\" device using the following unlock token?</string>
<string name="invalid_license">Invalid license</string>
<string name="connection_error">Connection error: %1$s</string>
<string name="activate_via_qr_code">Activate via QR code</string>
<string name="qr_code_expired">QR code expired. Generate a new QR code and retry</string>
<string name="qr_info_text">Install PCAPdroid from Google Play and scan this QR code</string>
<string name="qr_purchase_required">Purchase an unlock token to proceed with the QR code activation</string>
<string name="license_limit_reached">You have reached the licenses limit for this unlock token. Buy a new token to generate more licenses</string>
<string name="license_error">License generation error [%1$d]: %2$s</string>
<string name="requesting_license">Requesting a license code, please wait</string>
<string name="license_activation_ok">License activation completed</string>
</resources>