diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 6734619..b8b86a1 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -76,12 +76,18 @@ jobs: env: BUILD_TOOLS_VERSION: "34.0.0" + - name: Rename APK with Git SHA + if: startsWith(github.ref, 'refs/tags/v') + run: | + GIT_SHA=$(git rev-parse --short HEAD) + mv ${{ env.SIGNED_RELEASE_FILE }} android/app/build/outputs/apk/release/WARPTest-${GIT_SHA}.apk + echo "RELEASE_APK=android/app/build/outputs/apk/release/WARPTest-${GIT_SHA}.apk" >> $GITHUB_ENV + - name: Create Release if: startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v2 with: files: | - ${{ env.SIGNED_RELEASE_FILE }} + ${{ env.RELEASE_APK }} draft: true prerelease: true - diff --git a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/MainActivity.kt b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/MainActivity.kt index b10b38c..dac3913 100644 --- a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/MainActivity.kt +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/MainActivity.kt @@ -4,11 +4,12 @@ import android.os.Bundle import android.util.Log import android.view.View import android.widget.Button -import android.widget.ProgressBar import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope -import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestConfig +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestConfigBuilder import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -18,12 +19,15 @@ import kotlinx.coroutines.withContext class MainActivity : AppCompatActivity() { private val TAG = "MainActivity" private lateinit var speedTest: SpeedTest - private lateinit var resultTextView: TextView private lateinit var progressTextView: TextView - private lateinit var progressBar: ProgressBar private lateinit var startButton: Button private lateinit var stopButton: Button private lateinit var ipPortTextView: TextView + private lateinit var settingsButton: View + private lateinit var resultRecyclerView: RecyclerView + private lateinit var resultHeader: View + private lateinit var resultAdapter: SpeedTestResultAdapter + private var currentConfig = SpeedTestConfigBuilder.createDefault() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -31,22 +35,34 @@ class MainActivity : AppCompatActivity() { speedTest = SpeedTest() setupViews() + setupRecyclerView() setupClickListeners() configureSpeedTest() } private fun setupViews() { - resultTextView = findViewById(R.id.resultText) progressTextView = findViewById(R.id.progressText) - progressBar = findViewById(R.id.progressBar) startButton = findViewById(R.id.startButton) stopButton = findViewById(R.id.stopButton) ipPortTextView = findViewById(R.id.ipPortText) + settingsButton = findViewById(R.id.settingsButton) + resultRecyclerView = findViewById(R.id.resultRecyclerView) + resultHeader = findViewById(R.id.resultHeader) // Initially disable stop button and hide progress stopButton.isEnabled = false - progressBar.visibility = View.GONE progressTextView.visibility = View.GONE + resultHeader.visibility = View.GONE + resultRecyclerView.visibility = View.GONE + } + + private fun setupRecyclerView() { + resultAdapter = SpeedTestResultAdapter() + resultRecyclerView.apply { + layoutManager = LinearLayoutManager(context) + adapter = resultAdapter + setHasFixedSize(true) + } } private fun setupClickListeners() { @@ -57,15 +73,27 @@ class MainActivity : AppCompatActivity() { stopButton.setOnClickListener { stopSpeedTest() } + + settingsButton.setOnClickListener { + showConfigDialog() + } + } + + private fun showConfigDialog() { + SpeedTestConfigDialog.newInstance(currentConfig).apply { + setOnConfigUpdatedListener { config -> + currentConfig = config + configureSpeedTest() + } + }.show(supportFragmentManager, "config_dialog") } private fun configureSpeedTest() { - val config = SpeedTestConfig() try { - speedTest.configure(config) + speedTest.configure(currentConfig) } catch (e: Exception) { Log.e(TAG, "Failed to configure speed test: ${e.message}") - resultTextView.text = "Configuration error: ${e.message}" + progressTextView.text = "Configuration error: ${e.message}" } } @@ -74,9 +102,11 @@ class MainActivity : AppCompatActivity() { try { startButton.isEnabled = false stopButton.isEnabled = true - progressBar.visibility = View.VISIBLE progressTextView.visibility = View.VISIBLE - resultTextView.text = "Starting speed test..." + resultAdapter.clearResults() + resultHeader.visibility = View.GONE + resultRecyclerView.visibility = View.GONE + ipPortTextView.text = "" speedTest.start() @@ -101,12 +131,11 @@ class MainActivity : AppCompatActivity() { } } } while (finalResult != null) + } catch (e: Exception) { Log.e(TAG, "Error during speed test: ${e.message}") withContext(Dispatchers.Main) { - resultTextView.text = "Error: ${e.message}" - progressBar.visibility = View.GONE - progressTextView.visibility = View.GONE + progressTextView.text = "Error: ${e.message}" } } finally { withContext(Dispatchers.Main) { @@ -119,55 +148,28 @@ class MainActivity : AppCompatActivity() { private fun stopSpeedTest() { speedTest.stop() - startButton.isEnabled = true stopButton.isEnabled = false - progressBar.visibility = View.GONE progressTextView.visibility = View.GONE - resultTextView.text = "Speed test stopped" + resultHeader.visibility = View.GONE + resultRecyclerView.visibility = View.GONE + progressTextView.text = "Speed test stopped" } private fun updateProgress(result: Any) { when (result) { + is String -> { + progressTextView.text = result + } is List<*> -> { @Suppress("UNCHECKED_CAST") - val results = result as List - resultTextView.text = formatResults(results) - progressBar.visibility = View.GONE - progressTextView.visibility = View.GONE - } - is String -> { - val progressMatch = Regex("Progress: (\\d+)/(\\d+) \\((\\d+)%\\)").find(result) - if (progressMatch != null) { - val (current, total, percentage) = progressMatch.destructured - progressBar.progress = percentage.toInt() - progressTextView.text = "Testing endpoints: $current/$total ($percentage%)" - } else if (result.startsWith("Found working endpoint")) { - // Append the new endpoint to existing text while keeping progress - val currentText = resultTextView.text.toString() - resultTextView.text = if (currentText.startsWith("Starting")) { - result + "\n" - } else { - currentText + result + "\n" - } - } else { - resultTextView.text = result + val speedTestResults = result as List + resultAdapter.updateResults(speedTestResults) + if (speedTestResults.isNotEmpty()) { + progressTextView.visibility = View.GONE + resultHeader.visibility = View.VISIBLE + resultRecyclerView.visibility = View.VISIBLE } } } } - - private fun formatResults(results: List): String { - if (results.isEmpty()) { - return "No results yet" - } - - return buildString { - appendLine("Test Results:") - results.forEachIndexed { index, result -> - appendLine("${index + 1}. IP: ${result.ip}:${result.port}") - appendLine(" Delay: ${result.delay}ms") - appendLine(" Loss Rate: ${String.format("%.2f", result.lossRate * 100)}%") - } - } - } } diff --git a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTest.kt b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTest.kt index 3c12e3f..398b23b 100644 --- a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTest.kt +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTest.kt @@ -85,8 +85,6 @@ class SpeedTest { val ip = "${baseIPParts[0]}.${baseIPParts[1]}.${baseIPParts[2]}.$lastOctet" ipRanges.add(ip) - - if (ipRanges.size >= config.maxScanCount) break } } catch (e: Exception) { Log.e(TAG, "Error processing CIDR $cidr: ${e.message}") @@ -103,7 +101,7 @@ class SpeedTest { } else emptyList() // TODO: Add IPv6 support - return ipRanges.take(config.maxScanCount) + return ipRanges } fun start() { @@ -118,9 +116,11 @@ class SpeedTest { val ipRanges = loadWarpIPRanges() val results = mutableListOf() var testedCount = 0 - val endpoints = ipRanges.flatMap { ip -> + val allEndpoints = ipRanges.flatMap { ip -> ports.map { port -> Pair(ip, port) } - }.take(config.maxScanCount) + } + // Randomly select maxScanCount endpoints + val endpoints = allEndpoints.shuffled().take(config.maxScanCount) val totalCount = endpoints.size Log.d(TAG, "total count: $totalCount") @@ -132,7 +132,6 @@ class SpeedTest { // Calculate percentage and update progress val percentage = (testedCount.toFloat() / totalCount.toFloat() * 100).toInt() launch { - resultQueue.send("Progress: $testedCount/$totalCount ($percentage%)") // Send intermediate result if all tests are complete if (testedCount == totalCount) { if (results.isEmpty()) { diff --git a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTestConfigDialog.kt b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTestConfigDialog.kt new file mode 100644 index 0000000..715b35b --- /dev/null +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTestConfigDialog.kt @@ -0,0 +1,132 @@ +package com.peanut996.cloudflarewarpspeedtest + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.RadioButton +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestConfig +import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestConfigBuilder + +class SpeedTestConfigDialog : DialogFragment() { + private var currentConfig = SpeedTestConfigBuilder.createDefault() + private var onConfigUpdated: ((SpeedTestConfig) -> Unit)? = null + + private lateinit var threadCountInput: TextInputEditText + private lateinit var pingTimesInput: TextInputEditText + private lateinit var maxScanCountInput: TextInputEditText + private lateinit var maxDelayInput: TextInputEditText + private lateinit var minDelayInput: TextInputEditText + private lateinit var maxLossRateInput: TextInputEditText + private lateinit var resultDisplayCountInput: TextInputEditText + private lateinit var testAllCombosCheckbox: CheckBox + private lateinit var ipv6ModeCheckbox: CheckBox + private lateinit var defaultConfigRadio: RadioButton + private lateinit var fastConfigRadio: RadioButton + private lateinit var accurateConfigRadio: RadioButton + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val view = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_speed_test_config, null) + + initializeViews(view) + setupPresetConfigs() + loadCurrentConfig() + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle("Speed Test Configuration") + .setView(view) + .setPositiveButton("Apply") { _, _ -> applyConfig() } + .setNegativeButton("Cancel", null) + .create() + } + + private fun initializeViews(view: View) { + threadCountInput = view.findViewById(R.id.threadCountInput) + pingTimesInput = view.findViewById(R.id.pingTimesInput) + maxScanCountInput = view.findViewById(R.id.maxScanCountInput) + maxDelayInput = view.findViewById(R.id.maxDelayInput) + minDelayInput = view.findViewById(R.id.minDelayInput) + maxLossRateInput = view.findViewById(R.id.maxLossRateInput) + resultDisplayCountInput = view.findViewById(R.id.resultDisplayCountInput) + testAllCombosCheckbox = view.findViewById(R.id.testAllCombosCheckbox) + ipv6ModeCheckbox = view.findViewById(R.id.ipv6ModeCheckbox) + defaultConfigRadio = view.findViewById(R.id.defaultConfigRadio) + fastConfigRadio = view.findViewById(R.id.fastConfigRadio) + accurateConfigRadio = view.findViewById(R.id.accurateConfigRadio) + } + + private fun setupPresetConfigs() { + defaultConfigRadio.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) updateInputs(SpeedTestConfigBuilder.createDefault()) + } + fastConfigRadio.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) updateInputs(SpeedTestConfigBuilder.createFastConfig()) + } + accurateConfigRadio.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) updateInputs(SpeedTestConfigBuilder.createAccurateConfig()) + } + } + + private fun loadCurrentConfig() { + updateInputs(currentConfig) + } + + private fun updateInputs(config: SpeedTestConfig) { + threadCountInput.setText(config.threadCount.toString()) + pingTimesInput.setText(config.pingTimes.toString()) + maxScanCountInput.setText(config.maxScanCount.toString()) + maxDelayInput.setText(config.maxDelay.toString()) + minDelayInput.setText(config.minDelay.toString()) + maxLossRateInput.setText(config.maxLossRate.toString()) + resultDisplayCountInput.setText(config.resultDisplayCount.toString()) + testAllCombosCheckbox.isChecked = config.testAllCombos + ipv6ModeCheckbox.isChecked = config.ipv6Mode + } + + private fun applyConfig() { + try { + val builder = SpeedTestConfigBuilder() + .setThreadCount(threadCountInput.text.toString().toInt()) + .setPingTimes(pingTimesInput.text.toString().toInt()) + .setMaxScanCount(maxScanCountInput.text.toString().toInt()) + .setMaxDelay(maxDelayInput.text.toString().toInt()) + .setMinDelay(minDelayInput.text.toString().toInt()) + .setMaxLossRate(maxLossRateInput.text.toString().toDouble()) + .setResultDisplayCount(resultDisplayCountInput.text.toString().toInt()) + .setTestAllCombos(testAllCombosCheckbox.isChecked) + .setIpv6Mode(ipv6ModeCheckbox.isChecked) + + val newConfig = builder.build() + currentConfig = newConfig + onConfigUpdated?.invoke(newConfig) + } catch (e: Exception) { + Toast.makeText(context, "Invalid input: ${e.message}", Toast.LENGTH_LONG).show() + } + } + + fun setConfig(config: SpeedTestConfig) { + currentConfig = config + if (this::threadCountInput.isInitialized) { + loadCurrentConfig() + } + } + + fun setOnConfigUpdatedListener(listener: (SpeedTestConfig) -> Unit) { + onConfigUpdated = listener + } + + companion object { + fun newInstance(config: SpeedTestConfig): SpeedTestConfigDialog { + return SpeedTestConfigDialog().apply { + currentConfig = config + } + } + } +} diff --git a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTestResultAdapter.kt b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTestResultAdapter.kt new file mode 100644 index 0000000..e433451 --- /dev/null +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTestResultAdapter.kt @@ -0,0 +1,55 @@ +package com.peanut996.cloudflarewarpspeedtest + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import android.widget.Toast +import androidx.recyclerview.widget.RecyclerView +import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestResult + +class SpeedTestResultAdapter : RecyclerView.Adapter() { + private val results = mutableListOf() + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val ipPortText: TextView = view.findViewById(R.id.ipPortText) + val detailsText: TextView = view.findViewById(R.id.detailsText) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_speed_test_result, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val result = results[position] + val ipPort = "${result.ip}:${result.port}" + holder.ipPortText.text = ipPort + holder.detailsText.text = "Delay: ${result.delay}ms, Loss Rate: ${String.format("%.1f", result.lossRate * 100)}%" + + holder.itemView.setOnClickListener { + val context = holder.itemView.context + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("IP:Port", ipPort) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Copied: $ipPort", Toast.LENGTH_SHORT).show() + } + } + + override fun getItemCount() = results.size + + fun updateResults(newResults: List) { + results.clear() + results.addAll(newResults) + notifyDataSetChanged() + } + + fun clearResults() { + results.clear() + notifyDataSetChanged() + } +} diff --git a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestConfig.kt b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestConfig.kt index dcbceb3..510684d 100644 --- a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestConfig.kt +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestConfig.kt @@ -1,5 +1,18 @@ package com.peanut996.cloudflarewarpspeedtest.models +/** + * Configuration for speed test + * + * @property threadCount Number of concurrent threads for testing (1-1000) + * @property pingTimes Number of ping attempts per endpoint (1-10) + * @property maxScanCount Maximum number of endpoints to scan (1-10000) + * @property maxDelay Maximum acceptable delay in milliseconds (50-2000) + * @property minDelay Minimum acceptable delay in milliseconds (0-1000) + * @property maxLossRate Maximum acceptable packet loss rate (0.0-1.0) + * @property testAllCombos Whether to test all IP:Port combinations + * @property ipv6Mode Whether to use IPv6 addresses + * @property resultDisplayCount Number of results to display (1-100) + */ data class SpeedTestConfig( val threadCount: Int = 200, val pingTimes: Int = 1, @@ -11,3 +24,140 @@ data class SpeedTestConfig( val ipv6Mode: Boolean = false, val resultDisplayCount: Int = 10 ) + +/** + * Builder class for SpeedTestConfig with validation + */ +class SpeedTestConfigBuilder { + private var threadCount: Int = 200 + private var pingTimes: Int = 1 + private var maxScanCount: Int = 100 + private var maxDelay: Int = 300 + private var minDelay: Int = 0 + private var maxLossRate: Double = 1.0 + private var testAllCombos: Boolean = false + private var ipv6Mode: Boolean = false + private var resultDisplayCount: Int = 10 + + /** + * Set number of concurrent threads (1-1000) + */ + fun setThreadCount(value: Int) = apply { + require(value in 1..1000) { "Thread count must be between 1 and 1000" } + threadCount = value + } + + /** + * Set number of ping attempts per endpoint (1-10) + */ + fun setPingTimes(value: Int) = apply { + require(value in 1..10) { "Ping times must be between 1 and 10" } + pingTimes = value + } + + /** + * Set maximum number of endpoints to scan (1-10000) + */ + fun setMaxScanCount(value: Int) = apply { + require(value in 1..10000) { "Max scan count must be between 1 and 10000" } + maxScanCount = value + } + + /** + * Set maximum acceptable delay in milliseconds (50-2000) + */ + fun setMaxDelay(value: Int) = apply { + require(value in 50..2000) { "Max delay must be between 50 and 2000 ms" } + maxDelay = value + } + + /** + * Set minimum acceptable delay in milliseconds (0-1000) + */ + fun setMinDelay(value: Int) = apply { + require(value in 0..1000) { "Min delay must be between 0 and 1000 ms" } + minDelay = value + } + + /** + * Set maximum acceptable packet loss rate (0.0-1.0) + */ + fun setMaxLossRate(value: Double) = apply { + require(value in 0.0..1.0) { "Max loss rate must be between 0.0 and 1.0" } + maxLossRate = value + } + + /** + * Set whether to test all IP:Port combinations + */ + fun setTestAllCombos(value: Boolean) = apply { + testAllCombos = value + } + + /** + * Set whether to use IPv6 addresses + */ + fun setIpv6Mode(value: Boolean) = apply { + ipv6Mode = value + } + + /** + * Set number of results to display (1-100) + */ + fun setResultDisplayCount(value: Int) = apply { + require(value in 1..100) { "Result display count must be between 1 and 100" } + resultDisplayCount = value + } + + /** + * Build SpeedTestConfig with validation + */ + fun build(): SpeedTestConfig { + require(minDelay < maxDelay) { "Min delay must be less than max delay" } + + return SpeedTestConfig( + threadCount = threadCount, + pingTimes = pingTimes, + maxScanCount = maxScanCount, + maxDelay = maxDelay, + minDelay = minDelay, + maxLossRate = maxLossRate, + testAllCombos = testAllCombos, + ipv6Mode = ipv6Mode, + resultDisplayCount = resultDisplayCount + ) + } + + companion object { + /** + * Create default config + */ + fun createDefault() = SpeedTestConfig() + + /** + * Create config optimized for speed (less accurate) + */ + fun createFastConfig() = SpeedTestConfigBuilder() + .setThreadCount(400) + .setPingTimes(1) + .setMaxScanCount(50) + .setMaxDelay(500) + .setMinDelay(0) + .setMaxLossRate(1.0) + .setResultDisplayCount(5) + .build() + + /** + * Create config optimized for accuracy (slower) + */ + fun createAccurateConfig() = SpeedTestConfigBuilder() + .setThreadCount(100) + .setPingTimes(3) + .setMaxScanCount(200) + .setMaxDelay(1000) + .setMinDelay(0) + .setMaxLossRate(0.5) + .setResultDisplayCount(20) + .build() + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..11d33ce --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index 319627c..fd4bb93 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -4,9 +4,30 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> + + + android:fillColor="#F6821F" + android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/> + + + + + + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 3ae4da7..6795b0f 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -5,65 +5,87 @@ android:layout_height="match_parent" android:padding="16dp"> -