diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index 772d447..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,85 +0,0 @@ -plugins { - id 'com.android.application' - id 'kotlin-android' - id 'kotlin-parcelize' -} - -android { - compileSdkVersion 35 - - namespace "tech.httptoolkit.android" - - defaultConfig { - applicationId "tech.httptoolkit.android.v1" - minSdkVersion 21 - targetSdkVersion 34 - versionCode 34 - versionName "1.5.0" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - - manifestPlaceholders = [ - sentryEnabled: "false", - sentryDsn: "null" - ] - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - - manifestPlaceholders = [ - sentryEnabled: "true", - sentryDsn: "https://6943ce7476d54485a5998ad45289a9bc@sentry.io/1809979" - ] - } - } - - lintOptions { - lintConfig file("./lint.xml") - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = '11' - } - - buildFeatures { - viewBinding true - buildConfig true - } -} - -repositories { - mavenCentral() - google() -} - -dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4" - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.core:core-ktx:1.15.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.0' - implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' - implementation 'me.dm7.barcodescanner:zxing:1.9.8' - implementation 'com.beust:klaxon:5.5' - implementation 'com.squareup.okhttp3:okhttp:4.3.0' - implementation 'com.google.android.material:material:1.12.0' - implementation 'net.swiftzer.semver:semver:1.1.1' - implementation 'io.sentry:sentry-android:7.18.0' - implementation 'org.slf4j:slf4j-nop:1.7.25' - implementation 'com.google.android.gms:play-services-base:18.5.0' - implementation 'com.android.installreferrer:installreferrer:2.2' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.6.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' -} diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..a95d966 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + id("kotlin-parcelize") +} + +android { + namespace = "tech.httptoolkit.android" + compileSdk = 35 + + defaultConfig { + applicationId = "tech.httptoolkit.android.v1" + minSdk = 21 + targetSdk = 34 + versionCode = 34 + versionName = "1.5.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + manifestPlaceholders["sentryEnabled"] = "false" + manifestPlaceholders["sentryDsn"] = "null" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + + manifestPlaceholders["sentryEnabled"] = "true" + manifestPlaceholders["sentryDsn"] = "https://6943ce7476d54485a5998ad45289a9bc@sentry.io/1809979" + } + } + + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + buildFeatures { + viewBinding = true + buildConfig = true + } + lint { + lintConfig = file("./lint.xml") + } +} + +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) + implementation(libs.kotlin.stdlib.jdk7) + implementation(libs.kotlin.reflect) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.appcompat) + implementation(libs.core.ktx) + implementation(libs.constraintlayout) + implementation(libs.localbroadcastmanager) + implementation(libs.zxing.android.embedded) { isTransitive = false } + implementation(libs.core) + implementation(libs.klaxon) + implementation(libs.okhttp) + implementation(libs.material) + implementation(libs.semver) + implementation(libs.sentry.android) + implementation(libs.slf4j.nop) + implementation(libs.play.services.base) + implementation(libs.installreferrer) + implementation(libs.swiperefreshlayout) + testImplementation(libs.junit) + androidTestImplementation(libs.runner) + androidTestImplementation(libs.espresso.core) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b6ad75..c75f1b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ - + diff --git a/app/src/main/java/tech/httptoolkit/android/ApplicationListActivity.kt b/app/src/main/java/tech/httptoolkit/android/ApplicationListActivity.kt index f5cf49a..842df1c 100644 --- a/app/src/main/java/tech/httptoolkit/android/ApplicationListActivity.kt +++ b/app/src/main/java/tech/httptoolkit/android/ApplicationListActivity.kt @@ -16,10 +16,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import kotlinx.coroutines.* import tech.httptoolkit.android.databinding.AppsListBinding import java.util.* -import kotlin.collections.ArrayList - -// Used to both to send and return the current list of selected apps -const val UNSELECTED_APPS_EXTRA = "tech.httptoolkit.android.UNSELECTED_APPS_EXTRA" class ApplicationListActivity : AppCompatActivity(), SwipeRefreshLayout.OnRefreshListener, CoroutineScope by MainScope(), PopupMenu.OnMenuItemClickListener, View.OnClickListener { @@ -38,8 +34,7 @@ class ApplicationListActivity : AppCompatActivity(), SwipeRefreshLayout.OnRefres override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - blockedPackages = intent.getStringArrayExtra(UNSELECTED_APPS_EXTRA)!!.toHashSet() - + blockedPackages = intent.getStringArrayExtra(IntentExtras.UNSELECTED_APPS_EXTRA)!!.toHashSet() binding = AppsListBinding.inflate(layoutInflater) setContentView(binding.root) @@ -60,7 +55,7 @@ class ApplicationListActivity : AppCompatActivity(), SwipeRefreshLayout.OnRefres onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { setResult(RESULT_OK, Intent().putExtra( - UNSELECTED_APPS_EXTRA, + IntentExtras.UNSELECTED_APPS_EXTRA, blockedPackages.toTypedArray() )) finish() diff --git a/app/src/main/java/tech/httptoolkit/android/Constants.kt b/app/src/main/java/tech/httptoolkit/android/Constants.kt new file mode 100644 index 0000000..eeb9202 --- /dev/null +++ b/app/src/main/java/tech/httptoolkit/android/Constants.kt @@ -0,0 +1,10 @@ +package tech.httptoolkit.android + +object IntentExtras { + const val SCANNED_URL_EXTRA = "tech.httptoolkit.android.SCANNED_URL" + const val SELECTED_PORTS_EXTRA = "tech.httptoolkit.android.SELECTED_PORTS_EXTRA" + const val UNSELECTED_APPS_EXTRA = "tech.httptoolkit.android.UNSELECTED_APPS_EXTRA" + const val PROXY_CONFIG_EXTRA = "tech.httptoolkit.android.PROXY_CONFIG" + const val UNINTERCEPTED_APPS_EXTRA = "tech.httptoolkit.android.UNINTERCEPTED_APPS" + const val INTERCEPTED_PORTS_EXTRA = "tech.httptoolkit.android.INTERCEPTED_PORTS" +} \ No newline at end of file diff --git a/app/src/main/java/tech/httptoolkit/android/MainActivity.kt b/app/src/main/java/tech/httptoolkit/android/MainActivity.kt index 1613de8..3f240ff 100644 --- a/app/src/main/java/tech/httptoolkit/android/MainActivity.kt +++ b/app/src/main/java/tech/httptoolkit/android/MainActivity.kt @@ -1,7 +1,6 @@ package tech.httptoolkit.android import android.Manifest -import android.app.Activity import android.app.NotificationManager import android.content.* import android.content.pm.PackageManager @@ -44,9 +43,6 @@ import java.security.cert.X509Certificate const val START_VPN_REQUEST = 123 const val INSTALL_CERT_REQUEST = 456 -const val SCAN_REQUEST = 789 -const val PICK_APPS_REQUEST = 499 -const val PICK_PORTS_REQUEST = 443 const val ENABLE_NOTIFICATIONS_REQUEST = 101 enum class MainState { @@ -71,7 +67,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == VPN_STARTED_BROADCAST) { mainState = MainState.CONNECTED - currentProxyConfig = intent.getParcelableExtra(PROXY_CONFIG_EXTRA) + currentProxyConfig = intent.getParcelableExtra(IntentExtras.PROXY_CONFIG_EXTRA) updateUi() } else if (intent.action == VPN_STOPPED_BROADCAST) { mainState = MainState.DISCONNECTED @@ -88,7 +84,76 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { // Used to track extremely fast VPN setup failures, indicating setup issues (rather than // manual user cancellation). Doesn't matter that it's not properly persistent. - private var lastPauseTime = -1L; + private var lastPauseTime = -1L + + val pickAppsContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + Log.i(TAG, "Pick apps result: OK") + val unselectedApps = result.data!!.getStringArrayExtra(IntentExtras.UNSELECTED_APPS_EXTRA)!!.toSet() + if (unselectedApps != app.uninterceptedApps) { + app.uninterceptedApps = unselectedApps + if (isVpnActive()) startVpn() + } + } else { + Log.i(TAG, "Pick apps result: ${result.resultCode}") + } + } + + val pickPortsContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + Log.i(TAG, "Pick ports result: OK") + val selectedPorts = result.data!!.getIntArrayExtra(IntentExtras.SELECTED_PORTS_EXTRA)!!.toSet() + if (selectedPorts != app.interceptedPorts) { + app.interceptedPorts = selectedPorts + if (isVpnActive()) startVpn() + } + } else { + Log.i(TAG, "Pick ports result: ${result.resultCode}") + } + } + + private val barcodeLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val url = result.data!!.getStringExtra(IntentExtras.SCANNED_URL_EXTRA)!! + launch { connectToVpnFromUrl(url) } + } + } + + private val cameraPermissionsFromSettings = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + checkCameraPermission() + } + + private val cameraPermissionHandler = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + Log.i(TAG, "Camera permissions granted") + scanQRCode() + } else { + Log.i(TAG, "Camera permissions rejected") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) { + MaterialAlertDialogBuilder(this) + .setTitle("Camera permission required") + .setMessage("To scan QR codes, you need to allow camera access.") + .setPositiveButton(getString(R.string.proceed)) { _, _ -> checkCameraPermission() } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> } + .show() + } else { + MaterialAlertDialogBuilder(this) + .setTitle("Camera permission required") + .setMessage("To scan QR codes, you need to allow camera access in your device settings.") + .setPositiveButton(getString(R.string.open_settings)) { _, _ -> + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", packageName, null) + intent.data = uri + cameraPermissionsFromSettings.launch(intent) + } + .setNegativeButton(getString(R.string.cancel)) { _, _ -> } + .show() + } + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -184,8 +249,8 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { .setIcon(R.drawable.ic_exclamation_triangle) .setMessage( "Do you want to share all this device's HTTP traffic with HTTP Toolkit?" + - "\n\n" + - "Only accept this if you trust the source." + "\n\n" + + "Only accept this if you trust the source." ) .setPositiveButton("Enable") { _, _ -> Log.i(TAG, "Prompt confirmed") @@ -206,6 +271,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { isRCIntent && intent.action == ACTIVATE_INTENT -> { launch { connectToVpnFromUrl(intent.data!!) } } + isRCIntent && intent.action == DEACTIVATE_INTENT -> { disconnect() } @@ -214,13 +280,15 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { // The app is being opened - nothing to do here } - else -> Log.w(TAG, "Ignoring unknown intent. Action ${ - intent.action - }, data: ${ - intent.data - }${ - if (isRCIntent) " (RC)" else "" - }") + else -> Log.w(TAG, + "Ignoring unknown intent. Action ${ + intent.action + }, data: ${ + intent.data + }${ + if (isRCIntent) " (RC)" else "" + }" + ) } } @@ -244,7 +312,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { if (hasCamera) { detailContainer.addView(detailText(R.string.disconnected_details)) - val scanQrButton = primaryButton(R.string.scan_button, ::scanCode) + val scanQrButton = primaryButton(R.string.scan_button, ::checkCameraPermission) buttonContainer.addView(scanQrButton) } else { detailContainer.addView(detailText(R.string.disconnected_no_camera_details)) @@ -257,10 +325,12 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { }) } } + MainState.CONNECTING -> { statusText.setText(R.string.connecting_status) buttonContainer.visibility = View.GONE } + MainState.CONNECTED -> { val proxyConfig = this.currentProxyConfig!! val totalAppCount = packageManager.getInstalledPackages(PackageManager.GET_META_DATA) @@ -287,10 +357,12 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { buttonContainer.addView(primaryButton(R.string.disconnect_button, ::disconnect)) buttonContainer.addView(secondaryButton(R.string.test_button, ::testInterception)) } + MainState.DISCONNECTING -> { statusText.setText(R.string.disconnecting_status) buttonContainer.visibility = View.GONE } + MainState.FAILED -> { statusText.setText(R.string.failed_status) @@ -326,8 +398,17 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { return text } - private fun scanCode() { - startActivityForResult(Intent(this, ScanActivity::class.java), SCAN_REQUEST) + private fun checkCameraPermission() { + val canUseCamera = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + if (canUseCamera == PERMISSION_GRANTED) { + scanQRCode() + } else { + cameraPermissionHandler.launch(Manifest.permission.CAMERA) + } + } + + private fun scanQRCode() { + barcodeLauncher.launch(Intent(this, QRScanActivity::class.java)) } private suspend fun connectToVpn(config: ProxyConfig) { @@ -408,20 +489,18 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { } private fun chooseApps() { - startActivityForResult( + pickAppsContract.launch( Intent(this, ApplicationListActivity::class.java).apply { - putExtra(UNSELECTED_APPS_EXTRA, app.uninterceptedApps.toTypedArray()) - }, - PICK_APPS_REQUEST + putExtra(IntentExtras.UNSELECTED_APPS_EXTRA, app.uninterceptedApps.toTypedArray()) + } ) } private fun choosePorts() { - startActivityForResult( + pickPortsContract.launch( Intent(this, PortListActivity::class.java).apply { - putExtra(SELECTED_PORTS_EXTRA, app.interceptedPorts.toIntArray()) - }, - PICK_PORTS_REQUEST + putExtra(IntentExtras.SELECTED_PORTS_EXTRA, app.interceptedPorts.toIntArray()) + } ) } @@ -471,13 +550,11 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { (requestCode == INSTALL_CERT_REQUEST && whereIsCertTrusted(currentProxyConfig!!) != null) || (requestCode == ENABLE_NOTIFICATIONS_REQUEST && areNotificationsEnabled()) - Log.i(TAG, "onActivityResult: " + ( + Log.i(TAG, + "onActivityResult: " + ( when (requestCode) { START_VPN_REQUEST -> "start-vpn" INSTALL_CERT_REQUEST -> "install-cert" - SCAN_REQUEST -> "scan-request" - PICK_APPS_REQUEST -> "pick-apps" - PICK_PORTS_REQUEST -> "pick-ports" ENABLE_NOTIFICATIONS_REQUEST -> "enable-notifications" else -> requestCode.toString() } @@ -491,31 +568,16 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { Log.i(TAG, "Installing cert...") ensureCertificateTrusted(currentProxyConfig!!) } else if (requestCode == INSTALL_CERT_REQUEST) { - Log.i(TAG ,"Cert installed, checking notification perms...") + Log.i(TAG, "Cert installed, checking notification perms...") ensureNotificationsEnabled() } else if (requestCode == ENABLE_NOTIFICATIONS_REQUEST) { - Log.i(TAG ,"Notifications OK, starting VPN...") + Log.i(TAG, "Notifications OK, starting VPN...") startVpn() - } else if (requestCode == SCAN_REQUEST && data != null) { - val url = data.getStringExtra(SCANNED_URL_EXTRA)!! - launch { connectToVpnFromUrl(url) } - } else if (requestCode == PICK_APPS_REQUEST) { - val unselectedApps = data!!.getStringArrayExtra(UNSELECTED_APPS_EXTRA)!!.toSet() - if (unselectedApps != app.uninterceptedApps) { - app.uninterceptedApps = unselectedApps - if (isVpnActive()) startVpn() - } - } else if (requestCode == PICK_PORTS_REQUEST) { - val selectedPorts = data!!.getIntArrayExtra(SELECTED_PORTS_EXTRA)!!.toSet() - if (selectedPorts != app.interceptedPorts) { - app.interceptedPorts = selectedPorts - if (isVpnActive()) startVpn() - } } } else if ( requestCode == START_VPN_REQUEST && System.currentTimeMillis() - lastPauseTime < 200 && // On Pixel 4a it takes < 50ms - resultCode == Activity.RESULT_CANCELED + resultCode == RESULT_CANCELED ) { // If another always-on VPN is active, VPN start requests fail instantly as cancelled. // We can't check that the VPN is always-on, but given an instant failure that's @@ -562,9 +624,9 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { startService(Intent(this, ProxyVpnService::class.java).apply { action = START_VPN_ACTION - putExtra(PROXY_CONFIG_EXTRA, currentProxyConfig) - putExtra(UNINTERCEPTED_APPS_EXTRA, app.uninterceptedApps.toTypedArray()) - putExtra(INTERCEPTED_PORTS_EXTRA, app.interceptedPorts.toIntArray()) + putExtra(IntentExtras.PROXY_CONFIG_EXTRA, currentProxyConfig) + putExtra(IntentExtras.UNINTERCEPTED_APPS_EXTRA, app.uninterceptedApps.toTypedArray()) + putExtra(IntentExtras.INTERCEPTED_PORTS_EXTRA, app.interceptedPorts.toIntArray()) }) } @@ -699,7 +761,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { .setIcon(R.drawable.ic_exclamation_triangle) .setMessage( Html.fromHtml( - """ + """

${ if (PROMPTED_CERT_SETUP_SUPPORTED) @@ -712,23 +774,24 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { To allow HTTP Toolkit to intercept HTTPS traffic:

    - ${if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) // Android 12+ - """ -
  •   Open "${ - // Slightly different UI for Android 12 and 13: - if (Build.VERSION.SDK_INT < 33) "Advanced Settings" else "More security settings" - }" in your security settings
  • -
  •   Open "Encryption & Credentials"
  • - """ - else - """ -
  •   Open "Encryption & Credentials" in your security settings
  • - """ + ${ + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) // Android 12+ + """ +
  •   Open "${ + // Slightly different UI for Android 12 and 13: + if (Build.VERSION.SDK_INT < 33) "Advanced Settings" else "More security settings" + }" in your security settings
  • +
  •   Open "Encryption & Credentials"
  • + """ + else + """ +
  •   Open "Encryption & Credentials" in your security settings
  • + """ }
  •   Select "Install a certificate", then "CA Certificate"
  •   Select the HTTP Toolkit certificate in your Downloads folder
- """,0) + """, 0) ) .setPositiveButton("Open security settings") { _, _ -> startActivityForResult(Intent(Settings.ACTION_SECURITY_SETTINGS), INSTALL_CERT_REQUEST) @@ -793,14 +856,14 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { // ShouldExplain means that we've asked before, but been rejected, but we are // still allowed to ask again. Be more insistent, and do so: showNotificationPermissionRequiredPrompt() { -> - Log.i(TAG ,"Asking for POST_NOTIFICATIONS after prompt") + Log.i(TAG, "Asking for POST_NOTIFICATIONS after prompt") notificationPermissionHandler.launch(Manifest.permission.POST_NOTIFICATIONS) } return } else if (!previouslyRejected) { // This means we're asking for the first time - no detailed rationale and no // fallbacks required, just ask for permission: - Log.i(TAG ,"Asking for POST_NOTIFICATIONS directly") + Log.i(TAG, "Asking for POST_NOTIFICATIONS directly") notificationPermissionHandler.launch(Manifest.permission.POST_NOTIFICATIONS) return } @@ -813,7 +876,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { // But if we have to send you to settings, we always want to show a prompt first: showNotificationPermissionRequiredPrompt { -> - Log.i(TAG ,"Sending to settings to fix notification permissions") + Log.i(TAG, "Sending to settings to fix notification permissions") val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null) @@ -823,7 +886,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { } private fun showNotificationPermissionRequiredPrompt(nextStep: () -> Unit) { - Log.i(TAG ,"Showing notifications-required prompt") + Log.i(TAG, "Showing notifications-required prompt") launch { withContext(Dispatchers.Main) { MaterialAlertDialogBuilder(this@MainActivity) @@ -843,15 +906,16 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() { } } - private val notificationPermissionHandler = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted && areNotificationsEnabled()) { // Note permission might be accepted but channels disabled - Log.i(TAG, "Notifications permission prompt accepted") - onActivityResult(ENABLE_NOTIFICATIONS_REQUEST, RESULT_OK, null) - } else { - Log.w(TAG, "Notifications permission prompt rejected") - requestNotificationPermission(true) + private val notificationPermissionHandler = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted && areNotificationsEnabled()) { // Note permission might be accepted but channels disabled + Log.i(TAG, "Notifications permission prompt accepted") + onActivityResult(ENABLE_NOTIFICATIONS_REQUEST, RESULT_OK, null) + } else { + Log.w(TAG, "Notifications permission prompt rejected") + requestNotificationPermission(true) + } } - } private suspend fun promptToUpdate() { withContext(Dispatchers.Main) { diff --git a/app/src/main/java/tech/httptoolkit/android/PortListActivity.kt b/app/src/main/java/tech/httptoolkit/android/PortListActivity.kt index 21b6da5..f346bd8 100644 --- a/app/src/main/java/tech/httptoolkit/android/PortListActivity.kt +++ b/app/src/main/java/tech/httptoolkit/android/PortListActivity.kt @@ -7,7 +7,8 @@ import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ContextThemeWrapper import androidx.core.widget.doAfterTextChanged -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope import tech.httptoolkit.android.databinding.PortsListBinding import java.util.* @@ -20,9 +21,6 @@ val DEFAULT_PORTS = setOf( const val MIN_PORT = 1 const val MAX_PORT = 65535 -// Used to both to send and return the current list of selected ports -const val SELECTED_PORTS_EXTRA = "tech.httptoolkit.android.SELECTED_PORTS_EXTRA" - class PortListActivity : AppCompatActivity(), CoroutineScope by MainScope() { private lateinit var ports: TreeSet // TreeSet = Mutable + Sorted @@ -35,7 +33,7 @@ class PortListActivity : AppCompatActivity(), CoroutineScope by MainScope() { binding = PortsListBinding.inflate(layoutInflater) setContentView(binding.root) - ports = intent.getIntArrayExtra(SELECTED_PORTS_EXTRA)!! + ports = intent.getIntArrayExtra(IntentExtras.SELECTED_PORTS_EXTRA)!! .toCollection(TreeSet()) binding.portsListRecyclerView.adapter = @@ -84,7 +82,7 @@ class PortListActivity : AppCompatActivity(), CoroutineScope by MainScope() { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { setResult(RESULT_OK, Intent().putExtra( - SELECTED_PORTS_EXTRA, + IntentExtras.SELECTED_PORTS_EXTRA, ports.toIntArray() )) finish() diff --git a/app/src/main/java/tech/httptoolkit/android/ProxyVpnService.kt b/app/src/main/java/tech/httptoolkit/android/ProxyVpnService.kt index d2f1aa7..4f1d952 100644 --- a/app/src/main/java/tech/httptoolkit/android/ProxyVpnService.kt +++ b/app/src/main/java/tech/httptoolkit/android/ProxyVpnService.kt @@ -1,20 +1,20 @@ package tech.httptoolkit.android -import android.net.VpnService -import android.content.Intent import android.app.* +import android.content.Intent import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.net.ProxyInfo +import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor import android.util.Log import androidx.core.app.NotificationCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager +import io.sentry.Sentry import tech.httptoolkit.android.vpn.socket.IProtectSocket import tech.httptoolkit.android.vpn.socket.SocketProtector -import io.sentry.Sentry -import java.io.* +import java.io.IOException private const val ALL_ROUTES = "0.0.0.0" private const val VPN_IP_ADDRESS = "169.254.61.43" // Random link-local IP, this will be the tunnel's IP @@ -28,10 +28,6 @@ const val STOP_VPN_ACTION = "tech.httptoolkit.android.STOP_VPN_ACTION" const val VPN_STARTED_BROADCAST = "tech.httptoolkit.android.VPN_STARTED_BROADCAST" const val VPN_STOPPED_BROADCAST = "tech.httptoolkit.android.VPN_STOPPED_BROADCAST" -const val PROXY_CONFIG_EXTRA = "tech.httptoolkit.android.PROXY_CONFIG" -const val UNINTERCEPTED_APPS_EXTRA = "tech.httptoolkit.android.UNINTERCEPTED_APPS" -const val INTERCEPTED_PORTS_EXTRA = "tech.httptoolkit.android.INTERCEPTED_PORTS" - private var currentService: ProxyVpnService? = null fun isVpnActive(): Boolean { return if (currentService == null) @@ -77,9 +73,9 @@ class ProxyVpnService : VpnService(), IProtectSocket { app = this.application as HttpToolkitApplication if (intent.action == START_VPN_ACTION) { - val proxyConfig = intent.getParcelableExtra(PROXY_CONFIG_EXTRA)!! - val uninterceptedApps = intent.getStringArrayExtra(UNINTERCEPTED_APPS_EXTRA)!!.toSet() - val interceptedPorts = intent.getIntArrayExtra(INTERCEPTED_PORTS_EXTRA)!!.toSet() + val proxyConfig = intent.getParcelableExtra(IntentExtras.PROXY_CONFIG_EXTRA)!! + val uninterceptedApps = intent.getStringArrayExtra(IntentExtras.UNINTERCEPTED_APPS_EXTRA)!!.toSet() + val interceptedPorts = intent.getIntArrayExtra(IntentExtras.INTERCEPTED_PORTS_EXTRA)!!.toSet() val vpnStarted = if (isActive()) restartVpn(proxyConfig, uninterceptedApps, interceptedPorts) @@ -241,7 +237,7 @@ class ProxyVpnService : VpnService(), IProtectSocket { showServiceNotification() localBroadcastManager!!.sendBroadcast( Intent(VPN_STARTED_BROADCAST).apply { - putExtra(PROXY_CONFIG_EXTRA, proxyConfig) + putExtra(IntentExtras.PROXY_CONFIG_EXTRA, proxyConfig) } ) diff --git a/app/src/main/java/tech/httptoolkit/android/QRScanActivity.kt b/app/src/main/java/tech/httptoolkit/android/QRScanActivity.kt new file mode 100644 index 0000000..ebfa993 --- /dev/null +++ b/app/src/main/java/tech/httptoolkit/android/QRScanActivity.kt @@ -0,0 +1,63 @@ +package tech.httptoolkit.android + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.KeyEvent +import com.google.zxing.BarcodeFormat +import com.google.zxing.ResultPoint +import com.journeyapps.barcodescanner.BarcodeCallback +import com.journeyapps.barcodescanner.BarcodeResult +import com.journeyapps.barcodescanner.DecoratedBarcodeView +import com.journeyapps.barcodescanner.DefaultDecoderFactory + +class QRScanActivity : Activity() { + private var barcodeView: DecoratedBarcodeView? = null + private var lastText: String? = null + + private val callback: BarcodeCallback = object : BarcodeCallback { + override fun barcodeResult(result: BarcodeResult) { + val resultText = result.text + if (resultText == null || resultText == lastText) { + // Prevent duplicate scans + return + } + + lastText = resultText + Log.i("QRScanActivity", "Scanned: $resultText") + + if (lastText!!.startsWith("https://android.httptoolkit.tech/connect/")) { + setResult(RESULT_OK, Intent().putExtra(IntentExtras.SCANNED_URL_EXTRA, lastText)) + finish() + } + } + + override fun possibleResultPoints(resultPoints: MutableList?) { + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.qr_scan_activity) + barcodeView = findViewById(R.id.barcode_scanner) + barcodeView!!.barcodeView.decoderFactory = DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE)) + barcodeView!!.initializeFromIntent(intent) + barcodeView!!.decodeContinuous(callback) + barcodeView!!.setStatusText("Scan HTTPToolkit QR code to connect") + } + + override fun onResume() { + super.onResume() + barcodeView!!.resume() + } + + override fun onPause() { + super.onPause() + barcodeView!!.pause() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + return barcodeView!!.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) + } +} \ No newline at end of file diff --git a/app/src/main/java/tech/httptoolkit/android/ScanActivity.kt b/app/src/main/java/tech/httptoolkit/android/ScanActivity.kt deleted file mode 100644 index c69af79..0000000 --- a/app/src/main/java/tech/httptoolkit/android/ScanActivity.kt +++ /dev/null @@ -1,83 +0,0 @@ -package tech.httptoolkit.android - -import android.Manifest -import android.app.Activity -import android.content.Intent -import android.content.pm.PackageManager.PERMISSION_GRANTED -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle -import android.util.Log -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import com.google.zxing.BarcodeFormat -import com.google.zxing.Result -import me.dm7.barcodescanner.zxing.ZXingScannerView - -const val SCANNED_URL_EXTRA = "tech.httptoolkit.android.SCANNED_URL" - -class ScanActivity : AppCompatActivity(), ZXingScannerView.ResultHandler { - - private var app: HttpToolkitApplication? = null - - private var scannerView: ZXingScannerView? = null - - public override fun onCreate(state: Bundle?) { - super.onCreate(state) - - val canUseCamera = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) - if (canUseCamera != PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 0) - // Until confirmed, the activity will show as empty, switching to the - // camera as soon as permission is accepted. - } - scannerView = ZXingScannerView(this) - scannerView!!.setFormats(listOf(BarcodeFormat.QR_CODE)) - setContentView(scannerView) - app = this.application as HttpToolkitApplication - } - - public override fun onResume() { - super.onResume() - - scannerView!!.setResultHandler(this) - scannerView!!.startCamera() - } - - public override fun onPause() { - super.onPause() - - scannerView!!.stopCamera() - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (grantResults.isNotEmpty() && grantResults[0] == PERMISSION_GRANTED) { - Log.i(TAG, "Camera permissions granted") - } else { - Log.i(TAG, "Camera permissions rejected") - setResult(Activity.RESULT_CANCELED) - finish() - } - } - - override fun handleResult(rawResult: Result) { - val url = rawResult.text - - if (!url.startsWith("https://android.httptoolkit.tech/connect/")) { - Log.v(TAG, "Scanned unrecognized QR code: $url") - scannerView?.resumeCameraPreview(this) - return - } - - Log.v(TAG, "Scanned $url") - - setResult(RESULT_OK, Intent().let { intent -> - intent.putExtra(SCANNED_URL_EXTRA, url) - }) - finish() - } -} \ No newline at end of file diff --git a/app/src/main/res/layout/qr_scan_activity.xml b/app/src/main/res/layout/qr_scan_activity.xml new file mode 100644 index 0000000..958f1df --- /dev/null +++ b/app/src/main/res/layout/qr_scan_activity.xml @@ -0,0 +1,8 @@ + + + + \ 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 34c384b..b90327c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,4 +54,7 @@ Add another port Reset to default ports HTTP Toolkit sets the device\'s HTTP proxy configuration, to capture traffic from all apps on any port, but some apps may ignore this.\n\nYou can add ports here to forcibly redirect all outgoing traffic to that port, but be careful, as this will interfere with any non-HTTP traffic on these ports. + Cancel + Proceed + Open Settings diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 72863e1..0000000 --- a/build.gradle +++ /dev/null @@ -1,27 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. - -buildscript { - ext.kotlin_version = '1.9.25' - repositories { - google() - jcenter() - } - dependencies { - classpath 'com.android.tools.build:gradle:8.7.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.google.gms:google-services:4.4.2' - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - jcenter() - } -} - -tasks.register('clean', Delete) { - delete rootProject.buildDir -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b8e06aa --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.google.services) apply false +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..f923912 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,52 @@ +[versions] +kotlin = "1.9.25" +androidGradlePlugin = "8.7.3" +appcompat = "1.7.0" +constraintlayout = "2.2.0" +core = "3.4.1" +coreKtx = "1.15.0" +espressoCore = "3.6.1" +googleServices = "4.4.2" +installreferrer = "2.2" +junit = "4.13.2" +klaxon = "5.5" +kotlinxCoroutinesCore = "1.8.1" +localbroadcastmanager = "1.1.0" +material = "1.12.0" +okhttp = "4.11.0" +playServicesBase = "18.5.0" +runner = "1.6.2" +semver = "1.1.1" +sentryAndroid = "8.0.0" +slf4jNop = "1.7.25" +zxingAndroidEmbedded = "4.3.0" +swiperefreshlayout = "1.1.0" + +[libraries] +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } +core = { module = "com.google.zxing:core", version.ref = "core" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } +espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } +installreferrer = { module = "com.android.installreferrer:installreferrer", version.ref = "installreferrer" } +junit = { module = "junit:junit", version.ref = "junit" } +klaxon = { module = "com.beust:klaxon", version.ref = "klaxon" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlin-stdlib-jdk7 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +localbroadcastmanager = { module = "androidx.localbroadcastmanager:localbroadcastmanager", version.ref = "localbroadcastmanager" } +material = { module = "com.google.android.material:material", version.ref = "material" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "playServicesBase" } +runner = { module = "androidx.test:runner", version.ref = "runner" } +semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } +sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentryAndroid" } +slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4jNop" } +swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" } +zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } \ No newline at end of file diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index e7b4def..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -include ':app' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..34fca13 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +include(":app")