diff --git a/app/build.gradle b/app/build.gradle
index 94088353..757f5019 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/Billing.java b/app/src/main/java/com/emanuelef/remote_capture/Billing.java
index 8657aac1..93f28f16 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/Billing.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/Billing.java
@@ -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) {
diff --git a/app/src/main/java/com/emanuelef/remote_capture/Utils.java b/app/src/main/java/com/emanuelef/remote_capture/Utils.java
index 11a539f0..b74a369b 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/Utils.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/Utils.java
@@ -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;
}
diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/AboutActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/AboutActivity.java
index e3d3f02d..fc3c7e66 100644
--- a/app/src/main/java/com/emanuelef/remote_capture/activities/AboutActivity.java
+++ b/app/src/main/java/com/emanuelef/remote_capture/activities/AboutActivity.java
@@ -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();
}
}
diff --git a/app/src/main/res/layout/license_dialog.xml b/app/src/main/res/layout/license_dialog.xml
index 1849b34e..6e9a9ba7 100644
--- a/app/src/main/res/layout/license_dialog.xml
+++ b/app/src/main/res/layout/license_dialog.xml
@@ -1,70 +1,131 @@
-
-
-
+ android:orientation="vertical">
+ android:padding="10dp">
+ android:layout_gravity="center_horizontal"
+ android:textStyle="bold"
+ android:text="@string/installation_id" />
-
+
+
+
+
+
+
+
+ android:text="@string/activate_via_qr_code"
+ android:layout_gravity="center"
+ style="?attr/materialButtonOutlinedStyle" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 65f49b8c..e5d3fdf6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -26,6 +26,7 @@
- FlagKit: MIT\n\n
- IP Geolocation by DB-IP\n\n
- AppIntro: Apache-2.0\n\n
+ - QrGenerator: MIT\n\n
- Font Awesome: Licenses\n\n
- App icon by Freepik from flaticon\n\n
- SourceCodePro font: OFL-1.1\n\n
@@ -461,4 +462,15 @@
Username
Password
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?
+ Do you want to generate a license for the \"%1$s\" device using the following unlock token?
+ Invalid license
+ Connection error: %1$s
+ Activate via QR code
+ QR code expired. Generate a new QR code and retry
+ Install PCAPdroid from Google Play and scan this QR code
+ Purchase an unlock token to proceed with the QR code activation
+ You have reached the licenses limit for this unlock token. Buy a new token to generate more licenses
+ License generation error [%1$d]: %2$s
+ Requesting a license code, please wait
+ License activation completed