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