diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..6734619 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -0,0 +1,87 @@ +name: CloudflareWarpSpeedTest Android CI/CD + +on: + push: + branches: [ "*", "*/*" ] + tags: + - 'v*' + pull_request: + branches: [ "*", "*/*" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x android/gradlew + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + sdk-platform: '34' + build-tools: '34.0.0' + + - name: Build with Gradle + working-directory: ./android + run: ./gradlew build + + - name: Run Tests + working-directory: ./android + run: ./gradlew test + + - name: Run Lint + working-directory: ./android + run: ./gradlew lint + + - name: Upload Build Reports + if: always() + uses: actions/upload-artifact@v3 + with: + name: build-reports + path: android/app/build/reports + + - name: Build Release APK + if: startsWith(github.ref, 'refs/tags/v') + working-directory: ./android + run: ./gradlew assembleRelease + + - name: Sign Release APK + if: startsWith(github.ref, 'refs/tags/v') + uses: r0adkll/sign-android-release@v1 + with: + releaseDirectory: android/app/build/outputs/apk/release + signingKeyBase64: ${{ secrets.SIGNING_KEY }} + alias: ${{ secrets.KEY_ALIAS }} + keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: "34.0.0" + + - name: Create Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ env.SIGNED_RELEASE_FILE }} + draft: true + prerelease: true + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 888ed8b..086ca67 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,9 +5,9 @@ name: Go Build and Test on: push: - branches: [ "*" ] + branches: [ "*", "*/*" ] pull_request: - branches: [ "*" ] + branches: [ "*", "*/*" ] jobs: build-and-test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 290ad5b..9117a62 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,9 @@ name: Release for multiple platforms on: release: types: [created] + push: + tags: + - 'v*' permissions: contents: write diff --git a/.gitignore b/.gitignore index 13221d2..03c3ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,43 @@ dist Releases -CloudflareWarpSpeedTest +# CloudflareWarpSpeedTest *.exe *.csv .idea .vscode + +# Go build artifacts +*.o +*.a +*.so + +# Android build artifacts +*.aar +*.apk +*.aab +*.dex +*.class +android/app/build/ +android/.gradle/ +android/local.properties +android/.idea/ +android/app/release/ +android/app/debug/ +android/captures/ +android/app/libs/ +android/build/ + +# Keep Gradle Wrapper +!android/gradle/wrapper/gradle-wrapper.jar +!android/gradle/wrapper/gradle-wrapper.properties +!android/gradlew +!android/gradlew.bat + +# macOS system files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes + +# IDE files diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..6666a80 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.peanut996.cloudflarewarpspeedtest' + compileSdk 34 + + defaultConfig { + applicationId "com.peanut996.cloudflarewarpspeedtest" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + viewBinding true + } + buildToolsVersion '34.0.0' +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.aar']) + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.10.0' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3064058 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/MainActivity.kt b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/MainActivity.kt new file mode 100644 index 0000000..b10b38c --- /dev/null +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/MainActivity.kt @@ -0,0 +1,173 @@ +package com.peanut996.cloudflarewarpspeedtest + +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 com.peanut996.cloudflarewarpspeedtest.models.SpeedTestResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +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 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + speedTest = SpeedTest() + setupViews() + 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) + + // Initially disable stop button and hide progress + stopButton.isEnabled = false + progressBar.visibility = View.GONE + progressTextView.visibility = View.GONE + } + + private fun setupClickListeners() { + startButton.setOnClickListener { + startSpeedTest() + } + + stopButton.setOnClickListener { + stopSpeedTest() + } + } + + private fun configureSpeedTest() { + val config = SpeedTestConfig() + try { + speedTest.configure(config) + } catch (e: Exception) { + Log.e(TAG, "Failed to configure speed test: ${e.message}") + resultTextView.text = "Configuration error: ${e.message}" + } + } + + private fun startSpeedTest() { + lifecycleScope.launch { + try { + startButton.isEnabled = false + stopButton.isEnabled = true + progressBar.visibility = View.VISIBLE + progressTextView.visibility = View.VISIBLE + resultTextView.text = "Starting speed test..." + + speedTest.start() + + // Poll for results while the test is running + while (speedTest.isRunning()) { + val result = speedTest.getResults() + if (result != null) { + withContext(Dispatchers.Main) { + updateProgress(result) + } + } + delay(500) // Update every 500ms + } + + // Process any remaining results in the channel + var finalResult: Any? + do { + finalResult = speedTest.getResults() + if (finalResult != null) { + withContext(Dispatchers.Main) { + updateProgress(finalResult) + } + } + } 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 + } + } finally { + withContext(Dispatchers.Main) { + startButton.isEnabled = true + stopButton.isEnabled = false + } + } + } + } + + private fun stopSpeedTest() { + speedTest.stop() + startButton.isEnabled = true + stopButton.isEnabled = false + progressBar.visibility = View.GONE + progressTextView.visibility = View.GONE + resultTextView.text = "Speed test stopped" + } + + private fun updateProgress(result: Any) { + when (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 + } + } + } + } + + 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 new file mode 100644 index 0000000..3c12e3f --- /dev/null +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/SpeedTest.kt @@ -0,0 +1,236 @@ +package com.peanut996.cloudflarewarpspeedtest + +import android.util.Log +import android.widget.TextView +import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestConfig +import com.peanut996.cloudflarewarpspeedtest.models.SpeedTestResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.log + +class SpeedTest { + private val TAG = "SpeedTest" + private val isRunning = AtomicBoolean(false) + private var job: Job? = null + private val coroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val resultQueue = Channel(Channel.UNLIMITED) + private var config = SpeedTestConfig() + + companion object { + private val warpHandshakePacket: ByteArray by lazy { + val hexString = "013cbdafb4135cac96a29484d7a0175ab152dd3e59be35049beadf758b8d48af14ca65f25a168934746fe8bc8867b1c17113d71c0fac5c141ef9f35783ffa5357c9871f4a006662b83ad71245a862495376a5fe3b4f2e1f06974d748416670e5f9b086297f652e6dfbf742fbfc63c3d8aeb175a3e9b7582fbc67c77577e4c0b32b05f92900000000000000000000000000000000" + hexString.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private val ports = listOf(500, 854, 859, 864, 878, 880, 890, 891, 894, 903, 908, 928, 934, 939, 942, 943, 945, 946, 955, 968, 987, 988, 1002, 1010, 1014, 1018, 1070, 1074, 1180, 1387, 1701, 2408, 4500, 5050, 5242, 6515, 7103, 7152, 7156, 7281, 7559, 8319, 8742, 8854, 8886) + } + + fun configure(config: SpeedTestConfig) { + this.config = config + } + + fun isRunning(): Boolean = isRunning.get() + + suspend fun getResults(): Any? = resultQueue.tryReceive().getOrNull() + + fun stop() { + job?.cancel() + isRunning.set(false) + } + + private fun loadWarpIPRanges(): List { + val ipRanges = mutableListOf() + + // Add IPv4 CIDR ranges + val ipv4Ranges = if (!config.ipv6Mode) { + listOf( + "162.159.192.0/24", + "162.159.193.0/24", + "162.159.195.0/24", + "162.159.204.0/24", + "188.114.96.0/24", + "188.114.97.0/24", + "188.114.98.0/24", + "188.114.99.0/24" + ) + } else emptyList() + + // Process IPv4 ranges + for (cidr in ipv4Ranges) { + try { + val (baseIP, mask) = cidr.split("/") + val baseIPParts = baseIP.split(".").map { it.toInt() } + val maskBits = mask.toInt() + + if (maskBits < 0 || maskBits > 32) continue + + val hostBits = 32 - maskBits + val numHosts = 1 shl hostBits + + // Only generate IPs within the subnet + for (i in 0 until numHosts) { + val lastOctet = baseIPParts[3] + i + if (lastOctet > 255) break + + 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}") + continue + } + } + + // Add IPv6 CIDR ranges if enabled + val ipv6Ranges = if (config.ipv6Mode) { + listOf( + "2606:4700:d0::/48", + "2606:4700:d1::/48" + ) + } else emptyList() + + // TODO: Add IPv6 support + return ipRanges.take(config.maxScanCount) + } + + fun start() { + if (isRunning.get()) { + Log.w(TAG, "Speed test is already running") + return + } + + isRunning.set(true) + job = coroutineScope.launch { + try { + val ipRanges = loadWarpIPRanges() + val results = mutableListOf() + var testedCount = 0 + val endpoints = ipRanges.flatMap { ip -> + ports.map { port -> Pair(ip, port) } + }.take(config.maxScanCount) + val totalCount = endpoints.size + Log.d(TAG, "total count: $totalCount") + + withContext(Dispatchers.IO) { + val jobs = endpoints.map { (ip, port) -> + async { + val result = testEndpoint(ip, port) { + testedCount++ + // 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()) { + resultQueue.send("No available endpoints found after testing ${endpoints.size} combinations") + } + } + } + } + result + } + } + jobs.awaitAll().filterNotNull().forEach { results.add(it) } + } + + // Sort results by delay and filter based on config + val filteredResults = results + .filter { it.delay >= config.minDelay && it.delay <= config.maxDelay } + .filter { it.lossRate <= config.maxLossRate } + .sortedBy { it.delay } + .take(config.resultDisplayCount) + + // Send final results or "No available endpoints" message + if (filteredResults.isEmpty()) { + resultQueue.send("No available endpoints found after testing ${endpoints.size} combinations") + } else { + resultQueue.send(filteredResults) + } + } catch (e: Exception) { + Log.e(TAG, "Error during speed test: ${e.message}") + resultQueue.send("Error during speed test: ${e.message}") + } finally { + isRunning.set(false) + } + } + } + + private suspend fun testEndpoint(ipAddr: String, port: Int, onTested: () -> Unit): SpeedTestResult? = withContext(Dispatchers.IO) { + Log.d(TAG, "Testing endpoint: $ipAddr:$port") + var socket: DatagramSocket? = null + + try { + socket = DatagramSocket() + socket.soTimeout = 2000 // 2 second timeout + + val address = InetAddress.getByName(ipAddr) + var successfulPings = 0 + var totalDelay = 0L + var lastError: String? = null + + repeat(config.pingTimes) { + val startTime = System.currentTimeMillis() + val packet = DatagramPacket(warpHandshakePacket, warpHandshakePacket.size, address, port) + + try { + socket.send(packet) + + val receiveData = ByteArray(92) // WireGuard handshake response size + val receivePacket = DatagramPacket(receiveData, receiveData.size) + socket.receive(receivePacket) + val delay = System.currentTimeMillis() - startTime + if (delay <= config.maxDelay) { // Only count responses within maxDelay + totalDelay += delay + successfulPings++ + // Send immediate success notification + if (successfulPings == 1) { // Only send on first success to avoid spam + resultQueue.send("Found working endpoint - IP: $ipAddr, Port: $port, Latency: ${delay}ms") + } + } + } catch (e: Exception) { + lastError = e.message + Log.d(TAG, "Ping attempt ${it + 1}/${config.pingTimes} failed for $ipAddr:$port: ${e.message}") + } + } + + if (successfulPings > 0) { + val avgDelay = totalDelay / successfulPings + val lossRate = 1.0 - (successfulPings.toDouble() / config.pingTimes) + + if (avgDelay <= config.maxDelay && lossRate <= config.maxLossRate) { + Log.d(TAG, "Ping successful for $ipAddr:$port") + return@withContext SpeedTestResult( + ip = ipAddr, + port = port, + delay = avgDelay.toInt(), + lossRate = lossRate + ) + } else { + Log.d(TAG, "Endpoint $ipAddr:$port excluded: delay=$avgDelay, lossRate=$lossRate") + } + } else { + Log.d(TAG, "All pings failed for $ipAddr:$port. Last error: $lastError") + } + } catch (e: Exception) { + Log.e(TAG, "Error testing endpoint $ipAddr:$port: ${e.message}") + } finally { + socket?.close() + onTested() // Ensure we count the attempt + } + null + } +} 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 new file mode 100644 index 0000000..dcbceb3 --- /dev/null +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestConfig.kt @@ -0,0 +1,13 @@ +package com.peanut996.cloudflarewarpspeedtest.models + +data class SpeedTestConfig( + val threadCount: Int = 200, + val pingTimes: Int = 1, + val maxScanCount: Int = 100, + val maxDelay: Int = 300, + val minDelay: Int = 0, + val maxLossRate: Double = 1.0, + val testAllCombos: Boolean = false, + val ipv6Mode: Boolean = false, + val resultDisplayCount: Int = 10 +) diff --git a/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestResult.kt b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestResult.kt new file mode 100644 index 0000000..46dc48a --- /dev/null +++ b/android/app/src/main/kotlin/com/peanut996/cloudflarewarpspeedtest/models/SpeedTestResult.kt @@ -0,0 +1,8 @@ +package com.peanut996.cloudflarewarpspeedtest.models + +data class SpeedTestResult( + val ip: String, + val port: Int, + val delay: Int, + val lossRate: Double +) diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..319627c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..3ae4da7 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,69 @@ + + + +