From d5a4289c483228b17a1fab749b184fe3cd919d35 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 11:03:11 +0100 Subject: [PATCH 1/8] Android SDK --- README.md | 167 ++++++++ atlantis-android/.gitignore | 52 +++ atlantis-android/PUBLISHING.md | 356 +++++++++++++++++ atlantis-android/README.md | 129 ++++++ atlantis-android/atlantis/build.gradle.kts | 115 ++++++ atlantis-android/atlantis/consumer-rules.pro | 10 + atlantis-android/atlantis/proguard-rules.pro | 23 ++ .../atlantis/src/main/AndroidManifest.xml | 14 + .../kotlin/com/proxyman/atlantis/Atlantis.kt | 214 ++++++++++ .../proxyman/atlantis/AtlantisInterceptor.kt | 202 ++++++++++ .../com/proxyman/atlantis/Base64Utils.kt | 24 ++ .../com/proxyman/atlantis/Configuration.kt | 54 +++ .../com/proxyman/atlantis/GzipCompression.kt | 62 +++ .../kotlin/com/proxyman/atlantis/Message.kt | 105 +++++ .../proxyman/atlantis/NsdServiceDiscovery.kt | 202 ++++++++++ .../kotlin/com/proxyman/atlantis/Packages.kt | 345 ++++++++++++++++ .../com/proxyman/atlantis/Transporter.kt | 375 ++++++++++++++++++ .../atlantis/AtlantisInterceptorTest.kt | 219 ++++++++++ .../proxyman/atlantis/GzipCompressionTest.kt | 120 ++++++ .../com/proxyman/atlantis/MessageTest.kt | 61 +++ .../com/proxyman/atlantis/PackagesTest.kt | 240 +++++++++++ atlantis-android/build.gradle.kts | 11 + atlantis-android/gradle.properties | 48 +++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43739 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + atlantis-android/gradlew | 251 ++++++++++++ atlantis-android/gradlew.bat | 94 +++++ atlantis-android/sample/build.gradle.kts | 70 ++++ atlantis-android/sample/proguard-rules.pro | 1 + .../sample/src/main/AndroidManifest.xml | 32 ++ .../proxyman/atlantis/sample/MainActivity.kt | 226 +++++++++++ .../atlantis/sample/SampleApplication.kt | 23 ++ .../src/main/res/layout/activity_main.xml | 101 +++++ .../src/main/res/mipmap-hdpi/ic_launcher.xml | 5 + .../res/mipmap-hdpi/ic_launcher_round.xml | 5 + .../sample/src/main/res/values/colors.xml | 10 + .../sample/src/main/res/values/strings.xml | 4 + .../sample/src/main/res/values/themes.xml | 12 + .../main/res/xml/network_security_config.xml | 10 + atlantis-android/settings.gradle.kts | 19 + 40 files changed, 4018 insertions(+) create mode 100644 atlantis-android/.gitignore create mode 100644 atlantis-android/PUBLISHING.md create mode 100644 atlantis-android/README.md create mode 100644 atlantis-android/atlantis/build.gradle.kts create mode 100644 atlantis-android/atlantis/consumer-rules.pro create mode 100644 atlantis-android/atlantis/proguard-rules.pro create mode 100644 atlantis-android/atlantis/src/main/AndroidManifest.xml create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Base64Utils.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Configuration.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/NsdServiceDiscovery.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt create mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt create mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/GzipCompressionTest.kt create mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt create mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt create mode 100644 atlantis-android/build.gradle.kts create mode 100644 atlantis-android/gradle.properties create mode 100644 atlantis-android/gradle/wrapper/gradle-wrapper.jar create mode 100644 atlantis-android/gradle/wrapper/gradle-wrapper.properties create mode 100755 atlantis-android/gradlew create mode 100644 atlantis-android/gradlew.bat create mode 100644 atlantis-android/sample/build.gradle.kts create mode 100644 atlantis-android/sample/proguard-rules.pro create mode 100644 atlantis-android/sample/src/main/AndroidManifest.xml create mode 100644 atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt create mode 100644 atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt create mode 100644 atlantis-android/sample/src/main/res/layout/activity_main.xml create mode 100644 atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher_round.xml create mode 100644 atlantis-android/sample/src/main/res/values/colors.xml create mode 100644 atlantis-android/sample/src/main/res/values/strings.xml create mode 100644 atlantis-android/sample/src/main/res/values/themes.xml create mode 100644 atlantis-android/sample/src/main/res/xml/network_security_config.xml create mode 100644 atlantis-android/settings.gradle.kts diff --git a/README.md b/README.md index c636adb..b551123 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - [x] ✅ Capture WS/WSS Traffic from URLSessionWebSocketTask - [x] Capture gRPC traffic (Advanced) - [x] Support iOS Physical Devices and Simulators, including iPhone, iPad, Apple Watch, Apple TV +- [x] **NEW:** Support Android with OkHttp, Retrofit, and Apollo - [x] Review traffic log from macOS [Proxyman](https://proxyman.com) app ([Github](https://github.com/ProxymanApp/Proxyman)) - [x] Categorize the log by project and devices. - [x] Ready for Production @@ -29,11 +30,23 @@ - If you want to use debugging tools, please use normal Proxy. ## Requirement + +### iOS - macOS Proxyman app - iOS 16.0+ / macOS 11+ / Mac Catalyst 13.0+ / tvOS 13.0+ / watchOS 10.0+ - Xcode 14+ - Swift 5.0+ +### Android +- macOS Proxyman app +- Android API 26+ (Android 8.0 Oreo) +- OkHttp 4.x or 5.x +- Kotlin 1.9+ + +--- + +# iOS Integration + ## 👉 How to use ### 1. Install Atlantis framework ### Swift Packages Manager (Recommended) @@ -472,6 +485,160 @@ Atlantis.start() +--- + +# Android Integration + +Atlantis for Android captures HTTP/HTTPS traffic from OkHttp (including Retrofit and Apollo) and sends it to Proxyman for debugging. + +## 1. Install Atlantis Android + +### Gradle (Kotlin DSL) + +Add to your app's `build.gradle.kts`: + +```kotlin +dependencies { + debugImplementation("com.proxyman:atlantis-android:1.0.0") + + // You must include OkHttp in your project + implementation("com.squareup.okhttp3:okhttp:4.12.0") +} +``` + +### Gradle (Groovy) + +```groovy +dependencies { + debugImplementation 'com.proxyman:atlantis-android:1.0.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' +} +``` + +### JitPack (Alternative) + +Add JitPack repository to your `settings.gradle.kts`: + +```kotlin +dependencyResolutionManagement { + repositories { + maven { url = uri("https://jitpack.io") } + } +} +``` + +Then add the dependency: + +```kotlin +debugImplementation("com.github.ProxymanApp:atlantis:1.0.0") +``` + +## 2. Initialize Atlantis + +### In your Application class + +```kotlin +import android.app.Application +import com.proxyman.atlantis.Atlantis + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Only enable in debug builds + if (BuildConfig.DEBUG) { + // Simple start - discovers all Proxyman apps on network + Atlantis.start(this) + + // Or with specific hostname (find it in Proxyman -> Certificate menu) + // Atlantis.start(this, "MacBook-Pro.local") + } + } +} +``` + +## 3. Add Interceptor to OkHttpClient + +```kotlin +import com.proxyman.atlantis.Atlantis +import okhttp3.OkHttpClient + +// Create OkHttpClient with Atlantis interceptor +val okHttpClient = OkHttpClient.Builder() + .addInterceptor(Atlantis.getInterceptor()) + .build() +``` + +### With Retrofit + +```kotlin +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +val retrofit = Retrofit.Builder() + .baseUrl("https://api.example.com/") + .client(okHttpClient) // Use the OkHttpClient with Atlantis + .addConverterFactory(GsonConverterFactory.create()) + .build() +``` + +### With Apollo Kotlin + +```kotlin +import com.apollographql.apollo3.ApolloClient + +val apolloClient = ApolloClient.Builder() + .serverUrl("https://api.example.com/graphql") + .okHttpClient(okHttpClient) // Use the OkHttpClient with Atlantis + .build() +``` + +## 4. Required Permissions + +Atlantis requires these permissions (automatically added by the library): + +```xml + + + + +``` + +## 5. Start Debugging + +1. Open **Proxyman** on your Mac +2. Make sure your Android device/emulator and Mac are on the **same Wi-Fi network** + - For emulators: Atlantis automatically connects to `10.0.2.2:10909` + - For physical devices: Uses Network Service Discovery (NSD/mDNS) +3. Run your Android app +4. All HTTP/HTTPS traffic will appear in Proxyman! + +## Android Sample App + +A sample Android app is included in `atlantis-android/sample/`. To run it: + +1. Open `atlantis-android/` in Android Studio +2. Run the `sample` module +3. Tap the buttons to make network requests +4. View the traffic in Proxyman + +## Android Troubleshooting + +### Traffic not appearing in Proxyman? + +1. **Emulator**: Make sure Proxyman is running on your Mac. Atlantis connects to `10.0.2.2:10909`. + +2. **Physical device**: + - Ensure both devices are on the same Wi-Fi network + - Try specifying the hostname: `Atlantis.start(this, "Your-Mac.local")` + +3. **Check logs**: Look for `[Atlantis]` logs in Logcat for connection status. + +### OkHttp version compatibility + +Atlantis supports OkHttp 4.x and 5.x. If you're using an older version, please upgrade. + +--- ## ❓ FAQ #### 1. How does Atlantis work? diff --git a/atlantis-android/.gitignore b/atlantis-android/.gitignore new file mode 100644 index 0000000..f3acf65 --- /dev/null +++ b/atlantis-android/.gitignore @@ -0,0 +1,52 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# Keystore files +*.jks +*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Version control +vcs.xml + +# Misc +*.log +*.tmp +*.bak +*.swp +*~ diff --git a/atlantis-android/PUBLISHING.md b/atlantis-android/PUBLISHING.md new file mode 100644 index 0000000..06fe2dc --- /dev/null +++ b/atlantis-android/PUBLISHING.md @@ -0,0 +1,356 @@ +# Publishing Atlantis Android + +This guide explains how to publish the Atlantis Android library to Maven Central and JitPack. + +## Prerequisites + +- JDK 17+ +- Gradle 8.x +- GPG key for signing (Maven Central only) +- Sonatype OSSRH account (Maven Central only) + +--- + +## Option 1: JitPack (Recommended for Quick Setup) + +JitPack automatically builds and publishes your library from GitHub releases. No account setup required. + +### Steps + +1. **Create a GitHub Release** + + ```bash + # Tag the release + git tag -a v1.0.0 -m "Release version 1.0.0" + git push origin v1.0.0 + ``` + +2. **Create Release on GitHub** + - Go to your repository on GitHub + - Click "Releases" → "Create a new release" + - Select the tag `v1.0.0` + - Add release notes + - Publish the release + +3. **Wait for JitPack Build** + - Visit `https://jitpack.io/#ProxymanApp/atlantis` + - JitPack will automatically build when someone requests the dependency + - First build may take a few minutes + +4. **Users can now add the dependency:** + + ```kotlin + // settings.gradle.kts + dependencyResolutionManagement { + repositories { + maven { url = uri("https://jitpack.io") } + } + } + + // build.gradle.kts + dependencies { + implementation("com.github.ProxymanApp:atlantis:v1.0.0") + } + ``` + +### JitPack Configuration + +JitPack uses `jitpack.yml` for custom build configuration (optional): + +```yaml +# jitpack.yml (place in atlantis-android/ folder) +jdk: + - openjdk17 +install: + - cd atlantis-android && ./gradlew :atlantis:publishToMavenLocal +``` + +--- + +## Option 2: Maven Central + +Publishing to Maven Central requires more setup but provides better discoverability and CDN distribution. + +### 1. Create Sonatype OSSRH Account + +1. Create a Sonatype JIRA account at https://issues.sonatype.org +2. Create a "New Project" ticket requesting access to your group ID +3. Wait for approval (usually 1-2 business days) + +### 2. Configure GPG Signing + +```bash +# Generate GPG key +gpg --full-generate-key + +# List keys to get key ID +gpg --list-keys --keyid-format LONG + +# Export public key to keyserver +gpg --keyserver keyserver.ubuntu.com --send-keys YOUR_KEY_ID + +# Export private key for CI (store securely) +gpg --export-secret-keys YOUR_KEY_ID | base64 > private-key.gpg.b64 +``` + +### 3. Configure `gradle.properties` + +Create/update `~/.gradle/gradle.properties` (NOT in version control): + +```properties +# Sonatype credentials +ossrhUsername=your-sonatype-username +ossrhPassword=your-sonatype-password + +# GPG signing +signing.keyId=YOUR_KEY_ID_LAST_8_CHARS +signing.password=your-gpg-passphrase +signing.secretKeyRingFile=/path/to/secring.gpg +``` + +### 4. Update `build.gradle.kts` + +Add Maven Central publishing configuration to `atlantis/build.gradle.kts`: + +```kotlin +plugins { + // ... existing plugins + id("signing") +} + +// Add to afterEvaluate block +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + + groupId = "com.proxyman" + artifactId = "atlantis-android" + version = project.findProperty("VERSION_NAME") as String? ?: "1.0.0" + + pom { + name.set("Atlantis Android") + description.set("Capture HTTP/HTTPS traffic from Android apps and send to Proxyman for debugging") + url.set("https://github.com/ProxymanApp/atlantis") + + licenses { + license { + name.set("Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set("nicksantamaria") + name.set("Nghia Tran") + email.set("nicksantamaria@proxyman.io") + } + } + + scm { + url.set("https://github.com/ProxymanApp/atlantis") + connection.set("scm:git:git://github.com/ProxymanApp/atlantis.git") + developerConnection.set("scm:git:ssh://git@github.com/ProxymanApp/atlantis.git") + } + } + } + } + + repositories { + maven { + name = "sonatype" + val releasesRepoUrl = uri("https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/") + val snapshotsRepoUrl = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") + url = if (version.toString().endsWith("SNAPSHOT")) snapshotsRepoUrl else releasesRepoUrl + + credentials { + username = findProperty("ossrhUsername") as String? ?: "" + password = findProperty("ossrhPassword") as String? ?: "" + } + } + } + } + + signing { + sign(publishing.publications["release"]) + } +} +``` + +### 5. Publish to Maven Central + +```bash +cd atlantis-android + +# Publish to staging repository +./gradlew :atlantis:publishReleasePublicationToSonatypeRepository + +# Or publish all publications +./gradlew :atlantis:publishAllPublicationsToSonatypeRepository +``` + +### 6. Release from Staging + +1. Log in to https://s01.oss.sonatype.org +2. Go to "Staging Repositories" +3. Find your repository (named `comproxyman-XXXX`) +4. Click "Close" and wait for validation +5. If validation passes, click "Release" +6. Wait 10-30 minutes for sync to Maven Central + +--- + +## CI/CD with GitHub Actions + +### JitPack (Automatic) + +JitPack works automatically with GitHub releases - no CI configuration needed. + +### Maven Central with GitHub Actions + +Create `.github/workflows/publish.yml`: + +```yaml +name: Publish to Maven Central + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Decode GPG Key + run: | + echo "${{ secrets.GPG_PRIVATE_KEY }}" | base64 --decode > private-key.gpg + gpg --import private-key.gpg + + - name: Publish to Maven Central + working-directory: atlantis-android + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + run: | + ./gradlew :atlantis:publishReleasePublicationToSonatypeRepository \ + -PossrhUsername=$OSSRH_USERNAME \ + -PossrhPassword=$OSSRH_PASSWORD \ + -Psigning.keyId=$SIGNING_KEY_ID \ + -Psigning.password=$SIGNING_PASSWORD \ + -Psigning.secretKeyRingFile=$HOME/.gnupg/secring.gpg +``` + +### Required GitHub Secrets + +Add these secrets to your repository settings: + +- `GPG_PRIVATE_KEY`: Base64-encoded GPG private key +- `OSSRH_USERNAME`: Sonatype username +- `OSSRH_PASSWORD`: Sonatype password +- `SIGNING_KEY_ID`: Last 8 characters of GPG key ID +- `SIGNING_PASSWORD`: GPG key passphrase + +--- + +## Version Management + +### Updating Version + +Update `gradle.properties`: + +```properties +VERSION_NAME=1.1.0 +VERSION_CODE=2 +``` + +### Version Naming Convention + +- `1.0.0` - Initial release +- `1.0.1` - Bug fixes +- `1.1.0` - New features (backward compatible) +- `2.0.0` - Breaking changes + +### Snapshot Releases + +For development versions, use `-SNAPSHOT` suffix: + +```properties +VERSION_NAME=1.1.0-SNAPSHOT +``` + +Publish to snapshot repository: + +```bash +./gradlew :atlantis:publishReleasePublicationToSonatypeRepository +``` + +--- + +## Verification + +### Check Maven Central + +After publishing, verify your artifact is available: + +```bash +# Check Maven Central +curl -s "https://repo1.maven.org/maven2/com/proxyman/atlantis-android/maven-metadata.xml" + +# Or search on search.maven.org +# https://search.maven.org/search?q=g:com.proxyman%20AND%20a:atlantis-android +``` + +### Check JitPack + +Visit: `https://jitpack.io/#ProxymanApp/atlantis` + +--- + +## Troubleshooting + +### "Could not find artifact" on JitPack + +1. Check build logs at `https://jitpack.io/#ProxymanApp/atlantis` +2. Ensure `build.gradle.kts` is in the correct location +3. Try rebuilding by clicking "Get it" again + +### GPG Signing Errors + +1. Ensure GPG key is not expired +2. Check that the key is uploaded to keyserver +3. Verify key ID and passphrase are correct + +### Sonatype Validation Failures + +Common issues: +- Missing POM information (name, description, URL, SCM) +- Missing Javadoc JAR +- Missing Sources JAR +- Invalid signature + +Check the staging repository "Activity" tab for specific errors. + +--- + +## Support + +For publishing issues, contact: +- JitPack: https://github.com/jitpack/jitpack.io/issues +- Sonatype: https://central.sonatype.org/support/ +- Atlantis: https://github.com/ProxymanApp/atlantis/issues diff --git a/atlantis-android/README.md b/atlantis-android/README.md new file mode 100644 index 0000000..63d66be --- /dev/null +++ b/atlantis-android/README.md @@ -0,0 +1,129 @@ +# Atlantis Android + +Capture HTTP/HTTPS traffic from Android apps and send to Proxyman for debugging. + +## Overview + +Atlantis Android is a companion library to [Proxyman](https://proxyman.io) that allows you to capture and inspect network traffic from your Android applications without configuring a proxy or installing certificates. + +## Features + +- Automatic OkHttp traffic interception +- Works with Retrofit 2.9+ and Apollo Kotlin 3.x/4.x +- Network Service Discovery (NSD) for automatic Proxyman detection +- Direct connection support for emulators +- GZIP compression for efficient data transfer +- Minimal configuration required + +## Requirements + +- Android API 26+ (Android 8.0 Oreo) +- OkHttp 4.x or 5.x +- Kotlin 1.9+ + +## Installation + +### Gradle (Kotlin DSL) + +```kotlin +dependencies { + debugImplementation("com.proxyman:atlantis-android:1.0.0") +} +``` + +### Gradle (Groovy) + +```groovy +dependencies { + debugImplementation 'com.proxyman:atlantis-android:1.0.0' +} +``` + +## Quick Start + +### 1. Initialize in Application + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) { + Atlantis.start(this) + } + } +} +``` + +### 2. Add Interceptor to OkHttpClient + +```kotlin +val okHttpClient = OkHttpClient.Builder() + .addInterceptor(Atlantis.getInterceptor()) + .build() +``` + +### 3. Run Your App + +Open Proxyman on your Mac, run your Android app, and watch the traffic appear! + +## Project Structure + +``` +atlantis-android/ +├── atlantis/ # Library module +│ └── src/main/kotlin/ +│ └── com/proxyman/atlantis/ +│ ├── Atlantis.kt # Main entry point +│ ├── AtlantisInterceptor.kt # OkHttp interceptor +│ ├── Configuration.kt # Config model +│ ├── Message.kt # Message types +│ ├── Packages.kt # Data models +│ ├── Transporter.kt # TCP connection +│ ├── NsdServiceDiscovery.kt # mDNS discovery +│ └── GzipCompression.kt # Compression +├── sample/ # Sample app +└── PUBLISHING.md # Publishing guide +``` + +## Setup + +### Option 1: Open in Android Studio (Recommended) + +Simply open the `atlantis-android` folder in Android Studio. It will automatically download the Gradle wrapper and sync the project. + +### Option 2: Generate Gradle Wrapper Manually + +If you have Gradle installed locally: + +```bash +cd atlantis-android +gradle wrapper --gradle-version 8.4 +``` + +## Building + +```bash +# Build the library +./gradlew :atlantis:build + +# Run tests +./gradlew :atlantis:test + +# Build sample app +./gradlew :sample:assembleDebug +``` + +## Testing + +```bash +./gradlew :atlantis:test +``` + +## Publishing + +See [PUBLISHING.md](PUBLISHING.md) for instructions on publishing to Maven Central or JitPack. + +## License + +Apache License 2.0 - see [LICENSE](../LICENSE) diff --git a/atlantis-android/atlantis/build.gradle.kts b/atlantis-android/atlantis/build.gradle.kts new file mode 100644 index 0000000..41aa29b --- /dev/null +++ b/atlantis-android/atlantis/build.gradle.kts @@ -0,0 +1,115 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("maven-publish") +} + +android { + namespace = "com.proxyman.atlantis" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + + buildConfigField("String", "VERSION_NAME", "\"${project.findProperty("VERSION_NAME") ?: "1.0.0"}\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + buildConfig = true + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +dependencies { + // OkHttp - compileOnly so users provide their own version + compileOnly("com.squareup.okhttp3:okhttp:4.12.0") + + // Gson for JSON serialization + implementation("com.google.code.gson:gson:2.10.1") + + // AndroidX Core + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.annotation:annotation:1.7.1") + + // Coroutines for async operations + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Testing + testImplementation("junit:junit:4.13.2") + testImplementation("org.mockito:mockito-core:5.8.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") + testImplementation("com.squareup.okhttp3:okhttp:4.12.0") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} + +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + + groupId = project.findProperty("GROUP") as String? ?: "com.proxyman" + artifactId = project.findProperty("POM_ARTIFACT_ID") as String? ?: "atlantis-android" + version = project.findProperty("VERSION_NAME") as String? ?: "1.0.0" + + pom { + name.set(project.findProperty("POM_NAME") as String? ?: "Atlantis Android") + description.set(project.findProperty("POM_DESCRIPTION") as String? ?: "") + url.set(project.findProperty("POM_URL") as String? ?: "") + + licenses { + license { + name.set(project.findProperty("POM_LICENCE_NAME") as String? ?: "") + url.set(project.findProperty("POM_LICENCE_URL") as String? ?: "") + } + } + + developers { + developer { + id.set(project.findProperty("POM_DEVELOPER_ID") as String? ?: "") + name.set(project.findProperty("POM_DEVELOPER_NAME") as String? ?: "") + } + } + + scm { + url.set(project.findProperty("POM_SCM_URL") as String? ?: "") + connection.set(project.findProperty("POM_SCM_CONNECTION") as String? ?: "") + developerConnection.set(project.findProperty("POM_SCM_DEV_CONNECTION") as String? ?: "") + } + } + } + } + } +} diff --git a/atlantis-android/atlantis/consumer-rules.pro b/atlantis-android/atlantis/consumer-rules.pro new file mode 100644 index 0000000..88528ed --- /dev/null +++ b/atlantis-android/atlantis/consumer-rules.pro @@ -0,0 +1,10 @@ +# Atlantis consumer ProGuard rules +# Keep all public APIs +-keep class com.proxyman.atlantis.Atlantis { *; } +-keep class com.proxyman.atlantis.AtlantisInterceptor { *; } +-keep class com.proxyman.atlantis.AtlantisDelegate { *; } +-keep class com.proxyman.atlantis.TrafficPackage { *; } + +# Keep data classes for Gson serialization +-keep class com.proxyman.atlantis.** { *; } +-keepclassmembers class com.proxyman.atlantis.** { *; } diff --git a/atlantis-android/atlantis/proguard-rules.pro b/atlantis-android/atlantis/proguard-rules.pro new file mode 100644 index 0000000..f85333d --- /dev/null +++ b/atlantis-android/atlantis/proguard-rules.pro @@ -0,0 +1,23 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle.kts. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Keep Atlantis classes +-keep class com.proxyman.atlantis.** { *; } + +# Gson uses generic type information stored in a class file when working with fields. +# Proguard removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** + +# Keep OkHttp classes (they're provided by the app) +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/atlantis-android/atlantis/src/main/AndroidManifest.xml b/atlantis-android/atlantis/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f70bc8e --- /dev/null +++ b/atlantis-android/atlantis/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt new file mode 100644 index 0000000..5f4632a --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt @@ -0,0 +1,214 @@ +package com.proxyman.atlantis + +import android.content.Context +import android.util.Log +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Atlantis - Capture HTTP/HTTPS traffic from Android apps and send to Proxyman for debugging + * + * Atlantis is an Android library that captures all HTTP/HTTPS traffic from OkHttp + * (including Retrofit and Apollo) and sends it to Proxyman macOS app for inspection. + * + * ## Quick Start + * + * 1. Initialize Atlantis in your Application class: + * ```kotlin + * class MyApplication : Application() { + * override fun onCreate() { + * super.onCreate() + * if (BuildConfig.DEBUG) { + * Atlantis.start(this) + * } + * } + * } + * ``` + * + * 2. Add the interceptor to your OkHttpClient: + * ```kotlin + * val client = OkHttpClient.Builder() + * .addInterceptor(Atlantis.getInterceptor()) + * .build() + * ``` + * + * ## Features + * - Automatic OkHttp traffic interception + * - Works with Retrofit and Apollo + * - Network Service Discovery to find Proxyman + * - Direct connection support for emulators + * + * @see Proxyman + * @see GitHub Repository + */ +object Atlantis { + + private const val TAG = "Atlantis" + + /** + * Build version of Atlantis Android + * Must match Proxyman's expected version for compatibility + */ + const val BUILD_VERSION = "1.0.0" + + // MARK: - Private Properties + + private var contextRef: WeakReference? = null + private var transporter: Transporter? = null + private var configuration: Configuration? = null + private var delegate: WeakReference? = null + + private val isEnabled = AtomicBoolean(false) + private val interceptor = AtlantisInterceptor() + + // MARK: - Public API + + /** + * Start Atlantis and begin looking for Proxyman app + * + * This will: + * 1. Initialize the transporter + * 2. Start NSD discovery (for real devices) or direct connection (for emulators) + * 3. Begin sending captured traffic to Proxyman + * + * @param context Application context + * @param hostName Optional hostname to connect to a specific Proxyman instance. + * If null, will connect to any Proxyman found on the network. + * You can find your Mac's hostname in Proxyman -> Certificate menu -> + * Install Certificate for iOS -> With Atlantis + */ + @JvmStatic + @JvmOverloads + fun start(context: Context, hostName: String? = null) { + if (isEnabled.getAndSet(true)) { + Log.d(TAG, "Atlantis is already running") + return + } + + val appContext = context.applicationContext + contextRef = WeakReference(appContext) + + // Create configuration + configuration = Configuration.default(appContext, hostName) + + // Start transporter + transporter = Transporter(appContext).also { + it.start(configuration!!) + } + + printStartupMessage(hostName) + } + + /** + * Stop Atlantis + * + * This will: + * 1. Stop NSD discovery + * 2. Close all connections to Proxyman + * 3. Clear any pending packages + */ + @JvmStatic + fun stop() { + if (!isEnabled.getAndSet(false)) { + Log.d(TAG, "Atlantis is not running") + return + } + + transporter?.stop() + transporter = null + configuration = null + contextRef = null + + Log.d(TAG, "Atlantis stopped") + } + + /** + * Get the OkHttp interceptor to add to your OkHttpClient + * + * Usage: + * ```kotlin + * val client = OkHttpClient.Builder() + * .addInterceptor(Atlantis.getInterceptor()) + * .build() + * ``` + * + * Note: The interceptor will only capture traffic when Atlantis is started. + */ + @JvmStatic + fun getInterceptor(): AtlantisInterceptor { + return interceptor + } + + /** + * Check if Atlantis is currently running + */ + @JvmStatic + fun isRunning(): Boolean { + return isEnabled.get() + } + + /** + * Set a delegate to receive traffic packages + * + * This allows you to observe captured traffic in your app, + * in addition to sending it to Proxyman. + */ + @JvmStatic + fun setDelegate(delegate: AtlantisDelegate?) { + this.delegate = delegate?.let { WeakReference(it) } + } + + /** + * Set a connection listener to monitor Proxyman connection status + */ + @JvmStatic + fun setConnectionListener(listener: Transporter.ConnectionListener?) { + transporter?.connectionListener = listener + } + + // MARK: - Internal API (used by AtlantisInterceptor) + + /** + * Send a traffic package to Proxyman + * Called internally by AtlantisInterceptor + */ + internal fun sendPackage(trafficPackage: TrafficPackage) { + if (!isEnabled.get()) { + return + } + + // Notify delegate + delegate?.get()?.onTrafficCaptured(trafficPackage) + + // Build and send message + val configuration = configuration ?: return + val message = Message.buildTrafficMessage(configuration.id, trafficPackage) + + transporter?.send(message) + } + + // MARK: - Private Methods + + private fun printStartupMessage(hostName: String?) { + Log.i(TAG, "---------------------------------------------------------------------------------") + Log.i(TAG, "---------- \uD83E\uDDCA Atlantis Android is running (version $BUILD_VERSION)") + Log.i(TAG, "---------- GitHub: https://github.com/nicksantamaria/atlantis") + if (hostName != null) { + Log.i(TAG, "---------- Looking for Proxyman with hostname: $hostName") + } else { + Log.i(TAG, "---------- Looking for any Proxyman app on the network...") + } + Log.i(TAG, "---------------------------------------------------------------------------------") + } +} + +/** + * Delegate interface for observing captured traffic + */ +interface AtlantisDelegate { + /** + * Called when a new traffic package is captured + * This is called on a background thread + */ + fun onTrafficCaptured(trafficPackage: TrafficPackage) +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt new file mode 100644 index 0000000..e41c214 --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt @@ -0,0 +1,202 @@ +package com.proxyman.atlantis + +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.RequestBody +import okhttp3.ResponseBody +import okio.Buffer +import okio.GzipSource +import java.io.IOException +import java.nio.charset.Charset +import java.util.UUID + +/** + * OkHttp Interceptor that captures HTTP/HTTPS traffic and sends it to Proxyman + * + * This interceptor should be added to your OkHttpClient: + * ``` + * val client = OkHttpClient.Builder() + * .addInterceptor(Atlantis.getInterceptor()) + * .build() + * ``` + * + * Works automatically with Retrofit, Apollo, and any library that uses OkHttp. + */ +class AtlantisInterceptor internal constructor() : Interceptor { + + companion object { + private const val MAX_BODY_SIZE = 52428800L // 50MB + private val UTF8 = Charset.forName("UTF-8") + } + + @Throws(IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestId = UUID.randomUUID().toString() + val startTime = System.currentTimeMillis() / 1000.0 + + // Capture request + val atlantisRequest = captureRequest(request) + val trafficPackage = TrafficPackage( + id = requestId, + startAt = startTime, + request = atlantisRequest + ) + + // Execute the request + val response: Response + try { + response = chain.proceed(request) + } catch (e: IOException) { + // Capture error + trafficPackage.endAt = System.currentTimeMillis() / 1000.0 + trafficPackage.error = CustomError.fromException(e) + + // Send to Proxyman + Atlantis.sendPackage(trafficPackage) + + throw e + } + + // Capture response + val (atlantisResponse, responseBodyData) = captureResponse(response) + trafficPackage.response = atlantisResponse + trafficPackage.responseBodyData = responseBodyData + trafficPackage.endAt = System.currentTimeMillis() / 1000.0 + + // Send to Proxyman + Atlantis.sendPackage(trafficPackage) + + return response + } + + /** + * Capture request details + */ + private fun captureRequest(request: Request): com.proxyman.atlantis.Request { + val url = request.url.toString() + val method = request.method + + // Capture headers + val headers = mutableMapOf() + for (i in 0 until request.headers.size) { + val name = request.headers.name(i) + val value = request.headers.value(i) + headers[name] = value + } + + // Capture body + val body = captureRequestBody(request) + + return com.proxyman.atlantis.Request.fromOkHttp( + url = url, + method = method, + headers = headers, + body = body + ) + } + + /** + * Capture request body as byte array + */ + private fun captureRequestBody(request: Request): ByteArray? { + val requestBody = request.body ?: return null + + // Skip if body is too large + val contentLength = requestBody.contentLength() + if (contentLength > MAX_BODY_SIZE) { + return null + } + + return try { + val buffer = Buffer() + requestBody.writeTo(buffer) + + // Check content encoding + val contentEncoding = request.header("Content-Encoding") + if (contentEncoding.equals("gzip", ignoreCase = true)) { + // Decompress for readability + val gzipSource = GzipSource(buffer) + val decompressedBuffer = Buffer() + decompressedBuffer.writeAll(gzipSource) + decompressedBuffer.readByteArray() + } else { + buffer.readByteArray() + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Capture response details and body + * Returns a Pair of (Response, Base64EncodedBody) + */ + private fun captureResponse(response: Response): Pair { + val statusCode = response.code + + // Capture headers + val headers = mutableMapOf() + for (i in 0 until response.headers.size) { + val name = response.headers.name(i) + val value = response.headers.value(i) + headers[name] = value + } + + val atlantisResponse = com.proxyman.atlantis.Response.fromOkHttp( + statusCode = statusCode, + headers = headers + ) + + // Capture body + val bodyData = captureResponseBody(response) + val bodyBase64 = if (bodyData != null && bodyData.isNotEmpty()) { + Base64Utils.encode(bodyData) + } else { + "" + } + + return Pair(atlantisResponse, bodyBase64) + } + + /** + * Capture response body without consuming the original response + * OkHttp allows the body to be consumed only once, so we need to + * create a new response with the same body. + */ + private fun captureResponseBody(response: Response): ByteArray? { + val responseBody = response.body ?: return null + + // Skip if body is too large + val contentLength = responseBody.contentLength() + if (contentLength > MAX_BODY_SIZE) { + return "".toByteArray() + } + + return try { + // Peek the body without consuming it + val source = responseBody.source() + source.request(Long.MAX_VALUE) // Buffer the entire body + var buffer = source.buffer.clone() + + // Check if response is gzip compressed + val contentEncoding = response.header("Content-Encoding") + if (contentEncoding.equals("gzip", ignoreCase = true)) { + // Decompress for readability + val gzipSource = GzipSource(buffer) + val decompressedBuffer = Buffer() + decompressedBuffer.writeAll(gzipSource) + buffer = decompressedBuffer + } + + // Limit body size for safety + val size = minOf(buffer.size, MAX_BODY_SIZE) + buffer.readByteArray(size) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Base64Utils.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Base64Utils.kt new file mode 100644 index 0000000..65130bb --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Base64Utils.kt @@ -0,0 +1,24 @@ +package com.proxyman.atlantis + +/** + * Utility for Base64 encoding that works in both Android runtime and JUnit tests. + * + * Android's android.util.Base64 is not available in unit tests (only instrumented tests), + * so we use java.util.Base64 which is available everywhere since API 26. + */ +internal object Base64Utils { + + /** + * Encode bytes to Base64 string without line wrapping + */ + fun encode(data: ByteArray): String { + return java.util.Base64.getEncoder().encodeToString(data) + } + + /** + * Decode Base64 string to bytes + */ + fun decode(encoded: String): ByteArray { + return java.util.Base64.getDecoder().decode(encoded) + } +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Configuration.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Configuration.kt new file mode 100644 index 0000000..411d950 --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Configuration.kt @@ -0,0 +1,54 @@ +package com.proxyman.atlantis + +import android.content.Context +import android.content.pm.PackageManager + +/** + * Configuration for Atlantis + * Matches iOS Configuration.swift structure + */ +data class Configuration( + val projectName: String, + val deviceName: String, + val packageName: String, + val id: String, + val hostName: String?, + val appIcon: String? +) { + companion object { + /** + * Create default configuration from Android context + */ + fun default(context: Context, hostName: String? = null): Configuration { + val packageName = context.packageName + val projectName = getAppName(context) + val deviceName = android.os.Build.MODEL + val appIcon = AppIconHelper.getAppIconBase64(context) + + // Create unique ID similar to iOS: bundleIdentifier-deviceModel + val id = "$packageName-${android.os.Build.MANUFACTURER}_${android.os.Build.MODEL}" + + return Configuration( + projectName = projectName, + deviceName = deviceName, + packageName = packageName, + id = id, + hostName = hostName, + appIcon = appIcon + ) + } + + /** + * Get application name from context + */ + private fun getAppName(context: Context): String { + return try { + val packageManager = context.packageManager + val applicationInfo = context.applicationInfo + packageManager.getApplicationLabel(applicationInfo).toString() + } catch (e: Exception) { + context.packageName + } + } + } +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt new file mode 100644 index 0000000..7775c82 --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt @@ -0,0 +1,62 @@ +package com.proxyman.atlantis + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * GZIP compression utilities + * Matches iOS DataCompression.swift functionality + */ +object GzipCompression { + + /** + * Compress data using GZIP + * @param data The raw data to compress + * @return Compressed data or null if compression fails + */ + fun compress(data: ByteArray): ByteArray? { + if (data.isEmpty()) return data + + return try { + val outputStream = ByteArrayOutputStream() + GZIPOutputStream(outputStream).use { gzipStream -> + gzipStream.write(data) + } + outputStream.toByteArray() + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Decompress GZIP data + * @param data The compressed data + * @return Decompressed data or null if decompression fails + */ + fun decompress(data: ByteArray): ByteArray? { + if (data.isEmpty()) return data + + return try { + val inputStream = ByteArrayInputStream(data) + GZIPInputStream(inputStream).use { gzipStream -> + gzipStream.readBytes() + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + /** + * Check if data is GZIP compressed + * GZIP magic number: 0x1f 0x8b + */ + fun isGzipped(data: ByteArray): Boolean { + return data.size >= 2 && + data[0] == 0x1f.toByte() && + data[1] == 0x8b.toByte() + } +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt new file mode 100644 index 0000000..0c161e6 --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt @@ -0,0 +1,105 @@ +package com.proxyman.atlantis + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName + +/** + * Message wrapper for all data sent to Proxyman + * Matches iOS Message.swift structure exactly + */ +data class Message( + @SerializedName("id") + private val id: String, + + @SerializedName("messageType") + private val messageType: MessageType, + + @SerializedName("content") + private val content: String?, // Base64 encoded JSON of the actual content + + @SerializedName("buildVersion") + private val buildVersion: String? +) : Serializable { + + /** + * Message types matching iOS implementation + */ + enum class MessageType { + @SerializedName("connection") + CONNECTION, // First message, contains: Project, Device metadata + + @SerializedName("traffic") + TRAFFIC, // Request/Response log + + @SerializedName("websocket") + WEBSOCKET // For websocket send/receive/close + } + + override fun toData(): ByteArray? { + return try { + Gson().toJson(this).toByteArray(Charsets.UTF_8) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + companion object { + /** + * Build a connection message (first message sent to Proxyman) + */ + fun buildConnectionMessage(id: String, item: Serializable): Message { + val contentData = item.toData() + val contentString = contentData?.toString(Charsets.UTF_8) + return Message( + id = id, + messageType = MessageType.CONNECTION, + content = contentString, + buildVersion = Atlantis.BUILD_VERSION + ) + } + + /** + * Build a traffic message (HTTP request/response) + */ + fun buildTrafficMessage(id: String, item: Serializable): Message { + val contentData = item.toData() + val contentString = contentData?.toString(Charsets.UTF_8) + return Message( + id = id, + messageType = MessageType.TRAFFIC, + content = contentString, + buildVersion = Atlantis.BUILD_VERSION + ) + } + + /** + * Build a WebSocket message + */ + fun buildWebSocketMessage(id: String, item: Serializable): Message { + val contentData = item.toData() + val contentString = contentData?.toString(Charsets.UTF_8) + return Message( + id = id, + messageType = MessageType.WEBSOCKET, + content = contentString, + buildVersion = Atlantis.BUILD_VERSION + ) + } + } +} + +/** + * Interface for objects that can be serialized to JSON data + */ +interface Serializable { + fun toData(): ByteArray? + + /** + * Compress data using GZIP + */ + fun toCompressedData(): ByteArray? { + val rawData = toData() ?: return null + return GzipCompression.compress(rawData) ?: rawData + } +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/NsdServiceDiscovery.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/NsdServiceDiscovery.kt new file mode 100644 index 0000000..2dfa892 --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/NsdServiceDiscovery.kt @@ -0,0 +1,202 @@ +package com.proxyman.atlantis + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log +import java.net.InetAddress + +/** + * Network Service Discovery (NSD) for finding Proxyman app on local network + * This is Android's equivalent of iOS Bonjour + */ +class NsdServiceDiscovery( + private val context: Context, + private val listener: NsdListener +) { + + companion object { + private const val TAG = "AtlantisNSD" + + // Service type must match iOS: _Proxyman._tcp + const val SERVICE_TYPE = "_Proxyman._tcp." + + // Direct connection port for emulator + const val DIRECT_CONNECTION_PORT = 10909 + } + + interface NsdListener { + fun onServiceFound(host: InetAddress, port: Int, serviceName: String) + fun onServiceLost(serviceName: String) + fun onDiscoveryStarted() + fun onDiscoveryStopped() + fun onError(errorCode: Int, message: String) + } + + private var nsdManager: NsdManager? = null + private var discoveryListener: NsdManager.DiscoveryListener? = null + private var isDiscovering = false + private var targetHostName: String? = null + + /** + * Start discovering Proxyman services on the network + * @param hostName Optional hostname to filter services (like iOS hostName parameter) + */ + fun startDiscovery(hostName: String? = null) { + if (isDiscovering) { + Log.d(TAG, "Discovery already in progress") + return + } + + targetHostName = hostName + + try { + nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + + discoveryListener = createDiscoveryListener() + nsdManager?.discoverServices( + SERVICE_TYPE, + NsdManager.PROTOCOL_DNS_SD, + discoveryListener + ) + + Log.d(TAG, "Starting NSD discovery for Proxyman services...") + if (hostName != null) { + Log.d(TAG, "Looking for specific host: $hostName") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to start NSD discovery", e) + listener.onError(-1, "Failed to start discovery: ${e.message}") + } + } + + /** + * Stop discovering services + */ + fun stopDiscovery() { + if (!isDiscovering) { + return + } + + try { + discoveryListener?.let { listener -> + nsdManager?.stopServiceDiscovery(listener) + } + } catch (e: Exception) { + Log.e(TAG, "Error stopping NSD discovery", e) + } finally { + isDiscovering = false + discoveryListener = null + } + } + + /** + * Create the discovery listener + */ + private fun createDiscoveryListener(): NsdManager.DiscoveryListener { + return object : NsdManager.DiscoveryListener { + + override fun onDiscoveryStarted(serviceType: String) { + Log.d(TAG, "NSD discovery started for: $serviceType") + isDiscovering = true + listener.onDiscoveryStarted() + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.d(TAG, "NSD discovery stopped for: $serviceType") + isDiscovering = false + listener.onDiscoveryStopped() + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + Log.d(TAG, "Service found: ${serviceInfo.serviceName}") + + // Check if we should connect to this service based on hostname + if (shouldConnectToService(serviceInfo.serviceName)) { + resolveService(serviceInfo) + } else { + Log.d(TAG, "Skipping service: ${serviceInfo.serviceName} (hostname filter active)") + } + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + Log.d(TAG, "Service lost: ${serviceInfo.serviceName}") + listener.onServiceLost(serviceInfo.serviceName) + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery start failed: $errorCode") + isDiscovering = false + listener.onError(errorCode, "Discovery start failed") + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery stop failed: $errorCode") + listener.onError(errorCode, "Discovery stop failed") + } + } + } + + /** + * Resolve a discovered service to get its host and port + */ + private fun resolveService(serviceInfo: NsdServiceInfo) { + val resolveListener = object : NsdManager.ResolveListener { + + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "Resolve failed for ${serviceInfo.serviceName}: $errorCode") + } + + override fun onServiceResolved(resolvedInfo: NsdServiceInfo) { + Log.d(TAG, "Service resolved: ${resolvedInfo.serviceName}") + Log.d(TAG, " Host: ${resolvedInfo.host}") + Log.d(TAG, " Port: ${resolvedInfo.port}") + + resolvedInfo.host?.let { host -> + listener.onServiceFound( + host = host, + port = resolvedInfo.port, + serviceName = resolvedInfo.serviceName + ) + } + } + } + + try { + nsdManager?.resolveService(serviceInfo, resolveListener) + } catch (e: Exception) { + Log.e(TAG, "Error resolving service", e) + } + } + + /** + * Check if we should connect to this service based on hostname filter + * Mirrors iOS shouldConnectToEndpoint logic + */ + private fun shouldConnectToService(serviceName: String): Boolean { + val requiredHost = targetHostName ?: return true + + val lowercasedRequiredHost = requiredHost.lowercase().removeSuffix(".") + val lowercasedServiceName = serviceName.lowercase() + + // Allow connection if the service name contains the required host + // This handles cases like required="mac-mini.local" and service="Proxyman-mac-mini.local" + return lowercasedServiceName.contains(lowercasedRequiredHost) + } + + /** + * Check if running on an emulator + */ + fun isEmulator(): Boolean { + return (android.os.Build.FINGERPRINT.startsWith("google/sdk_gphone") || + android.os.Build.FINGERPRINT.startsWith("generic") || + android.os.Build.MODEL.contains("Emulator") || + android.os.Build.MODEL.contains("Android SDK built for") || + android.os.Build.MANUFACTURER.contains("Genymotion") || + android.os.Build.BRAND.startsWith("generic") || + android.os.Build.DEVICE.startsWith("generic") || + "google_sdk" == android.os.Build.PRODUCT || + android.os.Build.HARDWARE.contains("ranchu") || + android.os.Build.HARDWARE.contains("goldfish")) + } +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt new file mode 100644 index 0000000..c292249 --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt @@ -0,0 +1,345 @@ +package com.proxyman.atlantis + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import java.io.ByteArrayOutputStream +import java.util.UUID + +/** + * Connection package sent as the first message to Proxyman + * Contains device and project metadata + */ +data class ConnectionPackage( + @SerializedName("device") + val device: Device, + + @SerializedName("project") + val project: Project, + + @SerializedName("icon") + val icon: String? // Base64 encoded PNG +) : Serializable { + + constructor(config: Configuration) : this( + device = Device.current(config.deviceName), + project = Project.current(config.projectName, config.packageName), + icon = config.appIcon + ) + + override fun toData(): ByteArray? { + return try { + Gson().toJson(this).toByteArray(Charsets.UTF_8) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} + +/** + * Traffic package containing HTTP request/response data + */ +data class TrafficPackage( + @SerializedName("id") + val id: String, + + @SerializedName("startAt") + var startAt: Double, + + @SerializedName("request") + val request: Request, + + @SerializedName("response") + var response: Response? = null, + + @SerializedName("error") + var error: CustomError? = null, + + @SerializedName("responseBodyData") + var responseBodyData: String = "", // Base64 encoded + + @SerializedName("endAt") + var endAt: Double? = null, + + @SerializedName("packageType") + val packageType: PackageType = PackageType.HTTP +) : Serializable { + + enum class PackageType { + @SerializedName("http") + HTTP, + + @SerializedName("websocket") + WEBSOCKET + } + + override fun toData(): ByteArray? { + return try { + Gson().toJson(this).toByteArray(Charsets.UTF_8) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + companion object { + private const val MAX_BODY_SIZE = 52428800 // 50MB + + /** + * Create a new TrafficPackage with a unique ID + */ + fun create(request: Request): TrafficPackage { + return TrafficPackage( + id = UUID.randomUUID().toString(), + startAt = System.currentTimeMillis() / 1000.0, + request = request + ) + } + } +} + +/** + * Device information + */ +data class Device( + @SerializedName("name") + val name: String, + + @SerializedName("model") + val model: String +) { + companion object { + fun current(customName: String? = null): Device { + val deviceName = customName ?: Build.MODEL ?: "Unknown Device" + val manufacturer = Build.MANUFACTURER ?: "Unknown" + val model = Build.MODEL ?: "Unknown" + val release = Build.VERSION.RELEASE ?: "Unknown" + val fullModel = "$manufacturer $model (Android $release)" + return Device(name = deviceName, model = fullModel) + } + } +} + +/** + * Project/App information + */ +data class Project( + @SerializedName("name") + val name: String, + + @SerializedName("bundleIdentifier") + val bundleIdentifier: String +) { + companion object { + fun current(customName: String? = null, packageName: String): Project { + return Project( + name = customName ?: packageName, + bundleIdentifier = packageName + ) + } + } +} + +/** + * HTTP Header + */ +data class Header( + @SerializedName("key") + val key: String, + + @SerializedName("value") + val value: String +) + +/** + * HTTP Request + */ +data class Request( + @SerializedName("url") + val url: String, + + @SerializedName("method") + val method: String, + + @SerializedName("headers") + val headers: List
, + + @SerializedName("body") + var body: String? = null // Base64 encoded +) { + companion object { + private const val MAX_BODY_SIZE = 52428800 // 50MB + + /** + * Create from OkHttp request components + */ + fun fromOkHttp( + url: String, + method: String, + headers: Map, + body: ByteArray? + ): Request { + val headerList = headers.map { Header(it.key, it.value) } + val bodyString = if (body != null && body.size <= MAX_BODY_SIZE) { + Base64Utils.encode(body) + } else { + null + } + return Request( + url = url, + method = method, + headers = headerList, + body = bodyString + ) + } + } +} + +/** + * HTTP Response + */ +data class Response( + @SerializedName("statusCode") + val statusCode: Int, + + @SerializedName("headers") + val headers: List
+) { + companion object { + /** + * Create from OkHttp response components + */ + fun fromOkHttp(statusCode: Int, headers: Map): Response { + val headerList = headers.map { Header(it.key, it.value) } + return Response(statusCode = statusCode, headers = headerList) + } + } +} + +/** + * Custom error for failed requests + */ +data class CustomError( + @SerializedName("code") + val code: Int, + + @SerializedName("message") + val message: String +) { + companion object { + fun fromException(e: Exception): CustomError { + return CustomError( + code = -1, + message = e.message ?: "Unknown error" + ) + } + } +} + +/** + * WebSocket message package + */ +data class WebsocketMessagePackage( + @SerializedName("id") + private val id: String, + + @SerializedName("createdAt") + private val createdAt: Double, + + @SerializedName("messageType") + private val messageType: MessageType, + + @SerializedName("stringValue") + private val stringValue: String?, + + @SerializedName("dataValue") + private val dataValue: String? // Base64 encoded +) : Serializable { + + enum class MessageType { + @SerializedName("pingPong") + PING_PONG, + + @SerializedName("send") + SEND, + + @SerializedName("receive") + RECEIVE, + + @SerializedName("sendCloseMessage") + SEND_CLOSE_MESSAGE + } + + override fun toData(): ByteArray? { + return try { + Gson().toJson(this).toByteArray(Charsets.UTF_8) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + companion object { + fun createStringMessage(id: String, message: String, type: MessageType): WebsocketMessagePackage { + return WebsocketMessagePackage( + id = id, + createdAt = System.currentTimeMillis() / 1000.0, + messageType = type, + stringValue = message, + dataValue = null + ) + } + + fun createDataMessage(id: String, data: ByteArray, type: MessageType): WebsocketMessagePackage { + return WebsocketMessagePackage( + id = id, + createdAt = System.currentTimeMillis() / 1000.0, + messageType = type, + stringValue = null, + dataValue = Base64Utils.encode(data) + ) + } + + fun createCloseMessage(id: String, closeCode: Int, reason: String?): WebsocketMessagePackage { + return WebsocketMessagePackage( + id = id, + createdAt = System.currentTimeMillis() / 1000.0, + messageType = MessageType.SEND_CLOSE_MESSAGE, + stringValue = closeCode.toString(), + dataValue = reason?.let { Base64Utils.encode(it.toByteArray()) } + ) + } + } +} + +/** + * Helper to get app icon as Base64 PNG + */ +internal object AppIconHelper { + fun getAppIconBase64(context: Context): String? { + return try { + val packageManager = context.packageManager + val applicationInfo = context.applicationInfo + val drawable = packageManager.getApplicationIcon(applicationInfo) + + if (drawable is BitmapDrawable) { + val bitmap = drawable.bitmap + val scaledBitmap = Bitmap.createScaledBitmap(bitmap, 64, 64, true) + val stream = ByteArrayOutputStream() + scaledBitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + val byteArray = stream.toByteArray() + Base64Utils.encode(byteArray) + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt new file mode 100644 index 0000000..b7a39fa --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt @@ -0,0 +1,375 @@ +package com.proxyman.atlantis + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.* +import java.io.DataOutputStream +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Transporter manages TCP connections to Proxyman macOS app + * Handles service discovery, connection management, and message sending + * + * Mirrors iOS Transporter.swift functionality + */ +class Transporter( + private val context: Context +) : NsdServiceDiscovery.NsdListener { + + companion object { + private const val TAG = "AtlantisTransporter" + + // Maximum size for a single package (50MB) + const val MAX_PACKAGE_SIZE = 52428800 + + // Maximum pending items to prevent memory issues + private const val MAX_PENDING_ITEMS = 50 + + // Connection timeout in milliseconds + private const val CONNECTION_TIMEOUT = 10000 + + // Retry settings for emulator + private const val MAX_EMULATOR_RETRIES = 5 + private const val EMULATOR_RETRY_DELAY_MS = 15000L + } + + private var nsdServiceDiscovery: NsdServiceDiscovery? = null + private var config: Configuration? = null + private var socket: Socket? = null + private var outputStream: DataOutputStream? = null + + private val pendingPackages = ConcurrentLinkedQueue() + private val isConnected = AtomicBoolean(false) + private val isStarted = AtomicBoolean(false) + + private var transporterScope: CoroutineScope? = null + private var emulatorRetryCount = 0 + + // Listener for connection status changes + var connectionListener: ConnectionListener? = null + + interface ConnectionListener { + fun onConnected(host: String, port: Int) + fun onDisconnected() + fun onConnectionFailed(error: String) + } + + /** + * Start the transporter + */ + fun start(configuration: Configuration) { + if (isStarted.getAndSet(true)) { + Log.d(TAG, "Transporter already started") + return + } + + config = configuration + transporterScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Check if running on emulator + val isEmulator = isEmulator() + + if (isEmulator) { + // Emulator: Direct connection to localhost:10909 + Log.d(TAG, "Running on emulator, attempting direct connection to host machine") + connectToEmulatorHost() + } else { + // Real device: Use NSD to discover Proxyman + Log.d(TAG, "Running on real device, starting NSD discovery") + startNsdDiscovery(configuration.hostName) + } + } + + /** + * Stop the transporter + */ + fun stop() { + if (!isStarted.getAndSet(false)) { + return + } + + Log.d(TAG, "Stopping transporter") + + // Stop NSD discovery + nsdServiceDiscovery?.stopDiscovery() + nsdServiceDiscovery = null + + // Close socket + closeConnection() + + // Clear pending packages + pendingPackages.clear() + + // Cancel coroutine scope + transporterScope?.cancel() + transporterScope = null + + emulatorRetryCount = 0 + } + + /** + * Send a package to Proxyman + */ + fun send(package_: Serializable) { + if (!isStarted.get()) { + return + } + + if (!isConnected.get()) { + // Queue the package if not connected + appendToPendingList(package_) + return + } + + // Send immediately + transporterScope?.launch { + sendPackage(package_) + } + } + + // MARK: - Private Methods + + /** + * Connect directly to host machine for emulator + * Android emulator uses 10.0.2.2 to reach host's localhost + */ + private fun connectToEmulatorHost() { + transporterScope?.launch { + try { + // 10.0.2.2 is the special alias to host loopback interface + val host = "10.0.2.2" + val port = NsdServiceDiscovery.DIRECT_CONNECTION_PORT + + Log.d(TAG, "Connecting to emulator host at $host:$port") + connectToHost(host, port) + + } catch (e: Exception) { + Log.e(TAG, "Failed to connect to emulator host", e) + handleEmulatorConnectionFailure() + } + } + } + + /** + * Handle emulator connection failure with retry + */ + private fun handleEmulatorConnectionFailure() { + if (emulatorRetryCount < MAX_EMULATOR_RETRIES) { + emulatorRetryCount++ + Log.d(TAG, "Retrying emulator connection ($emulatorRetryCount/$MAX_EMULATOR_RETRIES) in ${EMULATOR_RETRY_DELAY_MS/1000}s...") + + transporterScope?.launch { + delay(EMULATOR_RETRY_DELAY_MS) + if (isStarted.get()) { + connectToEmulatorHost() + } + } + } else { + Log.e(TAG, "Maximum emulator retry limit reached. Make sure Proxyman is running on your Mac.") + connectionListener?.onConnectionFailed("Could not connect to Proxyman. Make sure it's running on your Mac.") + } + } + + /** + * Start NSD discovery + */ + private fun startNsdDiscovery(hostName: String?) { + nsdServiceDiscovery = NsdServiceDiscovery(context, this) + nsdServiceDiscovery?.startDiscovery(hostName) + + if (hostName != null) { + Log.d(TAG, "Looking for Proxyman with hostname: $hostName") + } else { + Log.d(TAG, "Looking for any Proxyman app on the network") + } + } + + /** + * Connect to a specific host and port + */ + private suspend fun connectToHost(host: String, port: Int) { + withContext(Dispatchers.IO) { + try { + // Close existing connection if any + closeConnection() + + // Create new socket + val newSocket = Socket() + newSocket.connect(InetSocketAddress(host, port), CONNECTION_TIMEOUT) + newSocket.tcpNoDelay = true + + socket = newSocket + outputStream = DataOutputStream(newSocket.getOutputStream()) + + isConnected.set(true) + emulatorRetryCount = 0 + + Log.d(TAG, "Connected to Proxyman at $host:$port") + connectionListener?.onConnected(host, port) + + // Send connection package + sendConnectionPackage() + + // Flush pending packages + flushPendingPackages() + + } catch (e: Exception) { + Log.e(TAG, "Connection failed to $host:$port", e) + isConnected.set(false) + + if (isEmulator()) { + handleEmulatorConnectionFailure() + } else { + connectionListener?.onConnectionFailed("Connection failed: ${e.message}") + } + } + } + } + + /** + * Close the current connection + */ + private fun closeConnection() { + try { + outputStream?.close() + socket?.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing connection", e) + } finally { + outputStream = null + socket = null + isConnected.set(false) + connectionListener?.onDisconnected() + } + } + + /** + * Send the initial connection package + */ + private suspend fun sendConnectionPackage() { + val configuration = config ?: return + + val connectionPackage = ConnectionPackage(configuration) + val message = Message.buildConnectionMessage(configuration.id, connectionPackage) + + sendPackage(message) + Log.d(TAG, "Sent connection package") + } + + /** + * Send a package over the socket + * Message format: [8-byte length header][GZIP compressed data] + */ + private suspend fun sendPackage(package_: Serializable) { + withContext(Dispatchers.IO) { + val stream = outputStream + if (stream == null || !isConnected.get()) { + appendToPendingList(package_) + return@withContext + } + + try { + // Compress the data + val compressedData = package_.toCompressedData() ?: return@withContext + + // Create length header (8 bytes, UInt64) + val lengthBuffer = ByteBuffer.allocate(8) + lengthBuffer.putLong(compressedData.size.toLong()) + val headerData = lengthBuffer.array() + + // Send header + stream.write(headerData) + + // Send compressed data + stream.write(compressedData) + stream.flush() + + } catch (e: IOException) { + Log.e(TAG, "Error sending package", e) + isConnected.set(false) + appendToPendingList(package_) + + // Try to reconnect if this was a connection error + if (isEmulator()) { + handleEmulatorConnectionFailure() + } + } + } + } + + /** + * Add package to pending list + */ + private fun appendToPendingList(package_: Serializable) { + // Remove oldest items if limit exceeded (FIFO) + while (pendingPackages.size >= MAX_PENDING_ITEMS) { + pendingPackages.poll() + } + pendingPackages.offer(package_) + } + + /** + * Flush all pending packages + */ + private suspend fun flushPendingPackages() { + if (pendingPackages.isEmpty()) return + + Log.d(TAG, "Flushing ${pendingPackages.size} pending packages") + + while (pendingPackages.isNotEmpty() && isConnected.get()) { + val package_ = pendingPackages.poll() ?: break + sendPackage(package_) + } + } + + /** + * Check if running on emulator + */ + private fun isEmulator(): Boolean { + return (android.os.Build.FINGERPRINT.startsWith("google/sdk_gphone") || + android.os.Build.FINGERPRINT.startsWith("generic") || + android.os.Build.MODEL.contains("Emulator") || + android.os.Build.MODEL.contains("Android SDK built for") || + android.os.Build.MANUFACTURER.contains("Genymotion") || + android.os.Build.BRAND.startsWith("generic") || + android.os.Build.DEVICE.startsWith("generic") || + "google_sdk" == android.os.Build.PRODUCT || + android.os.Build.HARDWARE.contains("ranchu") || + android.os.Build.HARDWARE.contains("goldfish")) + } + + // MARK: - NsdServiceDiscovery.NsdListener + + override fun onServiceFound(host: InetAddress, port: Int, serviceName: String) { + Log.d(TAG, "Proxyman service found: $serviceName at ${host.hostAddress}:$port") + + transporterScope?.launch { + connectToHost(host.hostAddress ?: return@launch, port) + } + } + + override fun onServiceLost(serviceName: String) { + Log.d(TAG, "Proxyman service lost: $serviceName") + // Keep the connection if we're still connected + // The socket will detect connection issues when sending + } + + override fun onDiscoveryStarted() { + Log.d(TAG, "NSD discovery started") + } + + override fun onDiscoveryStopped() { + Log.d(TAG, "NSD discovery stopped") + } + + override fun onError(errorCode: Int, message: String) { + Log.e(TAG, "NSD error ($errorCode): $message") + connectionListener?.onConnectionFailed("NSD error: $message") + } +} diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt new file mode 100644 index 0000000..623de89 --- /dev/null +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt @@ -0,0 +1,219 @@ +package com.proxyman.atlantis + +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import java.util.concurrent.TimeUnit + +class AtlantisInterceptorTest { + + private lateinit var mockWebServer: MockWebServer + private lateinit var client: OkHttpClient + private lateinit var interceptor: AtlantisInterceptor + + @Before + fun setup() { + mockWebServer = MockWebServer() + mockWebServer.start() + + interceptor = AtlantisInterceptor() + client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .build() + } + + @After + fun teardown() { + mockWebServer.shutdown() + } + + @Test + fun `test interceptor captures GET request`() { + // Enqueue a mock response + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody("{\"message\":\"success\"}") + .addHeader("Content-Type", "application/json")) + + // Make request + val request = Request.Builder() + .url(mockWebServer.url("/api/test")) + .get() + .build() + + val response = client.newCall(request).execute() + + // Verify response was not affected + assertEquals(200, response.code) + assertNotNull(response.body) + } + + @Test + fun `test interceptor captures POST request with body`() { + mockWebServer.enqueue(MockResponse() + .setResponseCode(201) + .setBody("{\"id\":123}") + .addHeader("Content-Type", "application/json")) + + val requestBody = "{\"name\":\"test\"}".toRequestBody() + + val request = Request.Builder() + .url(mockWebServer.url("/api/users")) + .post(requestBody) + .addHeader("Content-Type", "application/json") + .build() + + val response = client.newCall(request).execute() + + assertEquals(201, response.code) + } + + @Test + fun `test interceptor handles error response`() { + mockWebServer.enqueue(MockResponse() + .setResponseCode(404) + .setBody("{\"error\":\"Not found\"}")) + + val request = Request.Builder() + .url(mockWebServer.url("/api/notfound")) + .get() + .build() + + val response = client.newCall(request).execute() + + assertEquals(404, response.code) + } + + @Test + fun `test interceptor handles empty response body`() { + mockWebServer.enqueue(MockResponse() + .setResponseCode(204)) + + val request = Request.Builder() + .url(mockWebServer.url("/api/delete")) + .delete() + .build() + + val response = client.newCall(request).execute() + + assertEquals(204, response.code) + } + + @Test + fun `test interceptor preserves response body for consumer`() { + val expectedBody = "{\"data\":\"test content\"}" + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody(expectedBody) + .addHeader("Content-Type", "application/json")) + + val request = Request.Builder() + .url(mockWebServer.url("/api/data")) + .get() + .build() + + val response = client.newCall(request).execute() + val actualBody = response.body?.string() + + // The interceptor should not consume the body + assertEquals(expectedBody, actualBody) + } + + @Test + fun `test interceptor captures headers`() { + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody("OK") + .addHeader("X-Custom-Header", "custom-value") + .addHeader("X-Request-Id", "12345")) + + val request = Request.Builder() + .url(mockWebServer.url("/api/headers")) + .get() + .addHeader("Authorization", "Bearer token123") + .addHeader("Accept", "application/json") + .build() + + val response = client.newCall(request).execute() + + assertEquals(200, response.code) + assertEquals("custom-value", response.header("X-Custom-Header")) + } + + @Test + fun `test interceptor handles large response`() { + // Create a large response body + val largeBody = "X".repeat(100000) + + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody(largeBody)) + + val request = Request.Builder() + .url(mockWebServer.url("/api/large")) + .get() + .build() + + val response = client.newCall(request).execute() + val body = response.body?.string() + + assertEquals(200, response.code) + assertEquals(largeBody.length, body?.length) + } + + @Test + fun `test interceptor handles redirect`() { + // First response: redirect + mockWebServer.enqueue(MockResponse() + .setResponseCode(302) + .addHeader("Location", mockWebServer.url("/api/final").toString())) + + // Second response: final destination + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody("{\"redirected\":true}")) + + val request = Request.Builder() + .url(mockWebServer.url("/api/redirect")) + .get() + .build() + + val response = client.newCall(request).execute() + + assertEquals(200, response.code) + } + + @Test + fun `test multiple concurrent requests`() { + // Enqueue multiple responses + repeat(5) { i -> + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody("{\"index\":$i}")) + } + + // Make concurrent requests + val threads = (0 until 5).map { i -> + Thread { + val request = Request.Builder() + .url(mockWebServer.url("/api/concurrent/$i")) + .get() + .build() + + val response = client.newCall(request).execute() + assertEquals(200, response.code) + } + } + + threads.forEach { it.start() } + threads.forEach { it.join() } + } +} diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/GzipCompressionTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/GzipCompressionTest.kt new file mode 100644 index 0000000..d0d799e --- /dev/null +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/GzipCompressionTest.kt @@ -0,0 +1,120 @@ +package com.proxyman.atlantis + +import org.junit.Assert.* +import org.junit.Test + +class GzipCompressionTest { + + @Test + fun `test compress and decompress`() { + val original = "Hello, World! This is a test message for compression." + val originalBytes = original.toByteArray(Charsets.UTF_8) + + // Compress + val compressed = GzipCompression.compress(originalBytes) + assertNotNull(compressed) + + // Verify it's actually compressed (should start with gzip magic bytes) + assertTrue(GzipCompression.isGzipped(compressed!!)) + + // Decompress + val decompressed = GzipCompression.decompress(compressed) + assertNotNull(decompressed) + + // Verify content matches + assertEquals(original, decompressed!!.toString(Charsets.UTF_8)) + } + + @Test + fun `test compress empty data`() { + val empty = ByteArray(0) + val result = GzipCompression.compress(empty) + + assertNotNull(result) + assertTrue(result!!.isEmpty()) + } + + @Test + fun `test decompress empty data`() { + val empty = ByteArray(0) + val result = GzipCompression.decompress(empty) + + assertNotNull(result) + assertTrue(result!!.isEmpty()) + } + + @Test + fun `test isGzipped with valid gzip data`() { + val data = "Test data".toByteArray() + val compressed = GzipCompression.compress(data) + + assertTrue(GzipCompression.isGzipped(compressed!!)) + } + + @Test + fun `test isGzipped with non-gzip data`() { + val data = "Not compressed".toByteArray() + + assertFalse(GzipCompression.isGzipped(data)) + } + + @Test + fun `test isGzipped with short data`() { + val shortData = byteArrayOf(0x1f) // Only 1 byte + + assertFalse(GzipCompression.isGzipped(shortData)) + } + + @Test + fun `test compression reduces size for large data`() { + // Create a large repetitive string (compresses well) + val largeData = "A".repeat(10000).toByteArray() + val compressed = GzipCompression.compress(largeData) + + assertNotNull(compressed) + assertTrue("Compressed size should be smaller", compressed!!.size < largeData.size) + } + + @Test + fun `test decompress invalid data returns null`() { + val invalidData = "This is not valid gzip data".toByteArray() + + // Mark as "gzip" by adding magic bytes but with invalid content + val fakeGzip = byteArrayOf(0x1f, 0x8b.toByte()) + invalidData + + // Should return null for invalid gzip + val result = GzipCompression.decompress(fakeGzip) + assertNull(result) + } + + @Test + fun `test roundtrip with JSON data`() { + val jsonData = """ + { + "id": "test-123", + "name": "Test Package", + "data": { + "nested": true, + "values": [1, 2, 3, 4, 5] + } + } + """.trimIndent() + + val originalBytes = jsonData.toByteArray(Charsets.UTF_8) + val compressed = GzipCompression.compress(originalBytes) + val decompressed = GzipCompression.decompress(compressed!!) + + assertEquals(jsonData, decompressed!!.toString(Charsets.UTF_8)) + } + + @Test + fun `test roundtrip with binary data`() { + // Create some binary data + val binaryData = ByteArray(256) { it.toByte() } + + val compressed = GzipCompression.compress(binaryData) + val decompressed = GzipCompression.decompress(compressed!!) + + assertArrayEquals(binaryData, decompressed) + } +} diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt new file mode 100644 index 0000000..2746cbc --- /dev/null +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt @@ -0,0 +1,61 @@ +package com.proxyman.atlantis + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class MessageTest { + + private val gson = Gson() + + @Test + fun `test MessageType serialization`() { + assertEquals("\"connection\"", gson.toJson(Message.MessageType.CONNECTION)) + assertEquals("\"traffic\"", gson.toJson(Message.MessageType.TRAFFIC)) + assertEquals("\"websocket\"", gson.toJson(Message.MessageType.WEBSOCKET)) + } + + @Test + fun `test build connection message`() { + val testPackage = TestSerializable("test content") + val message = Message.buildConnectionMessage("test-id", testPackage) + + val json = message.toData()?.toString(Charsets.UTF_8) + assertNotNull(json) + + assertTrue(json!!.contains("\"messageType\":\"connection\"")) + assertTrue(json.contains("\"id\":\"test-id\"")) + assertTrue(json.contains("\"buildVersion\"")) + } + + @Test + fun `test build traffic message`() { + val testPackage = TestSerializable("test traffic") + val message = Message.buildTrafficMessage("traffic-id", testPackage) + + val json = message.toData()?.toString(Charsets.UTF_8) + assertNotNull(json) + + assertTrue(json!!.contains("\"messageType\":\"traffic\"")) + assertTrue(json.contains("\"id\":\"traffic-id\"")) + } + + @Test + fun `test build websocket message`() { + val testPackage = TestSerializable("ws message") + val message = Message.buildWebSocketMessage("ws-id", testPackage) + + val json = message.toData()?.toString(Charsets.UTF_8) + assertNotNull(json) + + assertTrue(json!!.contains("\"messageType\":\"websocket\"")) + assertTrue(json.contains("\"id\":\"ws-id\"")) + } + + // Helper test class + private class TestSerializable(val content: String) : Serializable { + override fun toData(): ByteArray? { + return Gson().toJson(this).toByteArray(Charsets.UTF_8) + } + } +} diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt new file mode 100644 index 0000000..1a0cf04 --- /dev/null +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt @@ -0,0 +1,240 @@ +package com.proxyman.atlantis + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +class PackagesTest { + + private val gson = Gson() + + @Test + fun `test Header creation`() { + val header = Header("Content-Type", "application/json") + + assertEquals("Content-Type", header.key) + assertEquals("application/json", header.value) + } + + @Test + fun `test Header serialization`() { + val header = Header("X-Custom", "test-value") + val json = gson.toJson(header) + + assertTrue(json.contains("\"key\":\"X-Custom\"")) + assertTrue(json.contains("\"value\":\"test-value\"")) + } + + @Test + fun `test Request creation from OkHttp`() { + val headers = mapOf( + "Content-Type" to "application/json", + "Authorization" to "Bearer token" + ) + val body = "{\"name\":\"test\"}".toByteArray() + + val request = Request.fromOkHttp( + url = "https://api.example.com/users", + method = "POST", + headers = headers, + body = body + ) + + assertEquals("https://api.example.com/users", request.url) + assertEquals("POST", request.method) + assertEquals(2, request.headers.size) + assertNotNull(request.body) + } + + @Test + fun `test Request body is Base64 encoded`() { + val body = "Hello World".toByteArray() + val request = Request.fromOkHttp( + url = "https://example.com", + method = "POST", + headers = emptyMap(), + body = body + ) + + // Body should be Base64 encoded + val expectedBase64 = Base64Utils.encode(body) + assertEquals(expectedBase64, request.body) + } + + @Test + fun `test Request with null body`() { + val request = Request.fromOkHttp( + url = "https://example.com", + method = "GET", + headers = emptyMap(), + body = null + ) + + assertNull(request.body) + } + + @Test + fun `test Response creation from OkHttp`() { + val headers = mapOf( + "Content-Type" to "application/json", + "Content-Length" to "1234" + ) + + val response = Response.fromOkHttp( + statusCode = 200, + headers = headers + ) + + assertEquals(200, response.statusCode) + assertEquals(2, response.headers.size) + } + + @Test + fun `test Response serialization`() { + val response = Response.fromOkHttp( + statusCode = 404, + headers = mapOf("X-Error" to "Not Found") + ) + + val json = gson.toJson(response) + assertTrue(json.contains("\"statusCode\":404")) + assertTrue(json.contains("\"key\":\"X-Error\"")) + } + + @Test + fun `test CustomError from Exception`() { + val exception = RuntimeException("Network error") + val error = CustomError.fromException(exception) + + assertEquals(-1, error.code) + assertEquals("Network error", error.message) + } + + @Test + fun `test TrafficPackage creation`() { + val request = Request.fromOkHttp( + url = "https://api.example.com/data", + method = "GET", + headers = emptyMap(), + body = null + ) + + val trafficPackage = TrafficPackage.create(request) + + assertNotNull(trafficPackage.id) + assertTrue(trafficPackage.startAt > 0) + assertEquals(request, trafficPackage.request) + assertNull(trafficPackage.response) + assertNull(trafficPackage.error) + assertEquals(TrafficPackage.PackageType.HTTP, trafficPackage.packageType) + } + + @Test + fun `test TrafficPackage serialization`() { + val request = Request.fromOkHttp( + url = "https://api.example.com", + method = "GET", + headers = mapOf("Accept" to "application/json"), + body = null + ) + + val trafficPackage = TrafficPackage.create(request) + trafficPackage.response = Response.fromOkHttp(200, mapOf("Content-Type" to "application/json")) + trafficPackage.endAt = System.currentTimeMillis() / 1000.0 + + val data = trafficPackage.toData() + assertNotNull(data) + + val json = data!!.toString(Charsets.UTF_8) + assertTrue(json.contains("\"url\":\"https://api.example.com\"")) + assertTrue(json.contains("\"method\":\"GET\"")) + assertTrue(json.contains("\"statusCode\":200")) + assertTrue(json.contains("\"packageType\":\"http\"")) + } + + @Test + fun `test Device current`() { + val device = Device.current() + + assertNotNull(device.name) + assertNotNull(device.model) + // In JUnit tests, Build.MODEL is null so it falls back to "Unknown Device" + // and model will contain "Unknown Unknown (Android Unknown)" + assertTrue(device.name.isNotEmpty()) + assertTrue(device.model.isNotEmpty()) + } + + @Test + fun `test Device with custom name`() { + val device = Device.current("My Test Device") + + assertEquals("My Test Device", device.name) + } + + @Test + fun `test Project current`() { + val project = Project.current(null, "com.example.app") + + assertEquals("com.example.app", project.name) + assertEquals("com.example.app", project.bundleIdentifier) + } + + @Test + fun `test Project with custom name`() { + val project = Project.current("My App", "com.example.app") + + assertEquals("My App", project.name) + assertEquals("com.example.app", project.bundleIdentifier) + } + + @Test + fun `test WebsocketMessagePackage string message`() { + val wsPackage = WebsocketMessagePackage.createStringMessage( + id = "ws-123", + message = "Hello WebSocket", + type = WebsocketMessagePackage.MessageType.SEND + ) + + val data = wsPackage.toData() + assertNotNull(data) + + val json = data!!.toString(Charsets.UTF_8) + assertTrue(json.contains("\"id\":\"ws-123\"")) + assertTrue(json.contains("\"messageType\":\"send\"")) + assertTrue(json.contains("\"stringValue\":\"Hello WebSocket\"")) + } + + @Test + fun `test WebsocketMessagePackage data message`() { + val payload = "Binary data".toByteArray() + val wsPackage = WebsocketMessagePackage.createDataMessage( + id = "ws-456", + data = payload, + type = WebsocketMessagePackage.MessageType.RECEIVE + ) + + val data = wsPackage.toData() + assertNotNull(data) + + val json = data!!.toString(Charsets.UTF_8) + assertTrue(json.contains("\"id\":\"ws-456\"")) + assertTrue(json.contains("\"messageType\":\"receive\"")) + assertTrue(json.contains("\"dataValue\"")) + } + + @Test + fun `test WebsocketMessagePackage close message`() { + val wsPackage = WebsocketMessagePackage.createCloseMessage( + id = "ws-close", + closeCode = 1000, + reason = "Normal closure" + ) + + val data = wsPackage.toData() + assertNotNull(data) + + val json = data!!.toString(Charsets.UTF_8) + assertTrue(json.contains("\"messageType\":\"sendCloseMessage\"")) + assertTrue(json.contains("\"stringValue\":\"1000\"")) + } +} diff --git a/atlantis-android/build.gradle.kts b/atlantis-android/build.gradle.kts new file mode 100644 index 0000000..481f5f8 --- /dev/null +++ b/atlantis-android/build.gradle.kts @@ -0,0 +1,11 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id("com.android.application") version "8.5.2" apply false + id("com.android.library") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false + id("maven-publish") +} + +tasks.register("clean", Delete::class) { + delete(rootProject.layout.buildDirectory) +} diff --git a/atlantis-android/gradle.properties b/atlantis-android/gradle.properties new file mode 100644 index 0000000..77ea632 --- /dev/null +++ b/atlantis-android/gradle.properties @@ -0,0 +1,48 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true + +# Library version +VERSION_NAME=1.0.0 +VERSION_CODE=1 +GROUP=com.proxyman +POM_ARTIFACT_ID=atlantis-android + +# Maven publishing +POM_NAME=Atlantis Android +POM_DESCRIPTION=Capture HTTP/HTTPS traffic from Android apps and send to Proxyman for debugging +POM_URL=https://github.com/nicksantamaria/atlantis +POM_SCM_URL=https://github.com/nicksantamaria/atlantis +POM_SCM_CONNECTION=scm:git:git://github.com/nicksantamaria/atlantis.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/nicksantamaria/atlantis.git +POM_LICENCE_NAME=Apache License, Version 2.0 +POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo +POM_DEVELOPER_ID=nicksantamaria +POM_DEVELOPER_NAME=Nghia Tran diff --git a/atlantis-android/gradle/wrapper/gradle-wrapper.jar b/atlantis-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..980502d167d3610f88fa03b2f717935189d9fbcf GIT binary patch literal 43739 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 z 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/atlantis-android/gradlew.bat b/atlantis-android/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/atlantis-android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/atlantis-android/sample/build.gradle.kts b/atlantis-android/sample/build.gradle.kts new file mode 100644 index 0000000..51bcad9 --- /dev/null +++ b/atlantis-android/sample/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.proxyman.atlantis.sample" + compileSdk = 34 + + defaultConfig { + applicationId = "com.proxyman.atlantis.sample" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = 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 + buildConfig = true + } +} + +dependencies { + // Atlantis library + implementation(project(":atlantis")) + + // OkHttp + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Retrofit + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") + + // AndroidX + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Testing + 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/atlantis-android/sample/proguard-rules.pro b/atlantis-android/sample/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/atlantis-android/sample/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/atlantis-android/sample/src/main/AndroidManifest.xml b/atlantis-android/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7c6ba8b --- /dev/null +++ b/atlantis-android/sample/src/main/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt new file mode 100644 index 0000000..dbc6737 --- /dev/null +++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt @@ -0,0 +1,226 @@ +package com.proxyman.atlantis.sample + +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.proxyman.atlantis.Atlantis +import com.proxyman.atlantis.sample.databinding.ActivityMainBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.GET +import retrofit2.http.Path + +/** + * Main Activity demonstrating Atlantis with OkHttp and Retrofit + */ +class MainActivity : AppCompatActivity() { + + companion object { + private const val TAG = "AtlantisSample" + } + + private lateinit var binding: ActivityMainBinding + + // OkHttpClient with Atlantis interceptor + private val okHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(Atlantis.getInterceptor()) + .build() + } + + // Retrofit instance using the OkHttpClient + private val retrofit by lazy { + Retrofit.Builder() + .baseUrl("https://httpbin.org/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + private val httpBinApi by lazy { + retrofit.create(HttpBinApi::class.java) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setupUI() + } + + private fun setupUI() { + binding.btnGetRequest.setOnClickListener { + makeGetRequest() + } + + binding.btnPostRequest.setOnClickListener { + makePostRequest() + } + + binding.btnRetrofitRequest.setOnClickListener { + makeRetrofitRequest() + } + + binding.btnJsonRequest.setOnClickListener { + makeJsonRequest() + } + + binding.btnErrorRequest.setOnClickListener { + makeErrorRequest() + } + + updateStatus() + } + + private fun updateStatus() { + val status = if (Atlantis.isRunning()) { + "Atlantis is running.\nLooking for Proxyman..." + } else { + "Atlantis is not running" + } + binding.tvStatus.text = status + } + + private fun makeGetRequest() { + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("https://httpbin.org/get") + .build() + + okHttpClient.newCall(request).execute().use { response -> + response.body?.string() ?: "Empty response" + } + } + showResult("GET Request", result) + } catch (e: Exception) { + showError("GET Request failed", e) + } + } + } + + private fun makePostRequest() { + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + val jsonBody = """{"name": "Atlantis", "platform": "Android"}""" + val body = jsonBody.toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("https://httpbin.org/post") + .post(body) + .build() + + okHttpClient.newCall(request).execute().use { response -> + response.body?.string() ?: "Empty response" + } + } + showResult("POST Request", result) + } catch (e: Exception) { + showError("POST Request failed", e) + } + } + } + + private fun makeRetrofitRequest() { + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + httpBinApi.getIp() + } + showResult("Retrofit Request", "Origin IP: ${result.origin}") + } catch (e: Exception) { + showError("Retrofit Request failed", e) + } + } + } + + private fun makeJsonRequest() { + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + httpBinApi.getJson() + } + showResult("JSON Request", "Slideshow title: ${result.slideshow?.title}") + } catch (e: Exception) { + showError("JSON Request failed", e) + } + } + } + + private fun makeErrorRequest() { + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("https://httpbin.org/status/404") + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("HTTP ${response.code}: ${response.message}") + } + } + } + } catch (e: Exception) { + showError("Error Request (expected)", e) + } + } + } + + private fun showResult(title: String, result: String) { + Log.d(TAG, "$title: $result") + runOnUiThread { + binding.tvResult.text = "$title:\n\n${result.take(500)}..." + Toast.makeText(this, "$title completed!", Toast.LENGTH_SHORT).show() + } + } + + private fun showError(title: String, e: Exception) { + Log.e(TAG, title, e) + runOnUiThread { + binding.tvResult.text = "$title:\n\nError: ${e.message}" + Toast.makeText(this, "$title: ${e.message}", Toast.LENGTH_SHORT).show() + } + } +} + +/** + * Retrofit API interface for httpbin.org + */ +interface HttpBinApi { + + @GET("ip") + suspend fun getIp(): IpResponse + + @GET("json") + suspend fun getJson(): JsonResponse + + @GET("status/{code}") + suspend fun getStatus(@Path("code") code: Int): Any +} + +data class IpResponse( + val origin: String? +) + +data class JsonResponse( + val slideshow: Slideshow? +) + +data class Slideshow( + val author: String?, + val date: String?, + val title: String? +) diff --git a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt new file mode 100644 index 0000000..d2e4b27 --- /dev/null +++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt @@ -0,0 +1,23 @@ +package com.proxyman.atlantis.sample + +import android.app.Application +import com.proxyman.atlantis.Atlantis + +/** + * Sample Application demonstrating Atlantis integration + */ +class SampleApplication : Application() { + + override fun onCreate() { + super.onCreate() + + // Initialize Atlantis in debug builds only + if (BuildConfig.DEBUG) { + // Simple start - discovers all Proxyman apps on the network + Atlantis.start(this) + + // Or with specific hostname: + // Atlantis.start(this, "MacBook-Pro.local") + } + } +} diff --git a/atlantis-android/sample/src/main/res/layout/activity_main.xml b/atlantis-android/sample/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..c1b7eb3 --- /dev/null +++ b/atlantis-android/sample/src/main/res/layout/activity_main.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher.xml b/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..5ca770d --- /dev/null +++ b/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher_round.xml b/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher_round.xml new file mode 100644 index 0000000..5ca770d --- /dev/null +++ b/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/atlantis-android/sample/src/main/res/values/colors.xml b/atlantis-android/sample/src/main/res/values/colors.xml new file mode 100644 index 0000000..ca1931b --- /dev/null +++ b/atlantis-android/sample/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/atlantis-android/sample/src/main/res/values/strings.xml b/atlantis-android/sample/src/main/res/values/strings.xml new file mode 100644 index 0000000..4f12c92 --- /dev/null +++ b/atlantis-android/sample/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Atlantis Sample + diff --git a/atlantis-android/sample/src/main/res/values/themes.xml b/atlantis-android/sample/src/main/res/values/themes.xml new file mode 100644 index 0000000..1eae6fc --- /dev/null +++ b/atlantis-android/sample/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + diff --git a/atlantis-android/sample/src/main/res/xml/network_security_config.xml b/atlantis-android/sample/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..df4353f --- /dev/null +++ b/atlantis-android/sample/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/atlantis-android/settings.gradle.kts b/atlantis-android/settings.gradle.kts new file mode 100644 index 0000000..a839f68 --- /dev/null +++ b/atlantis-android/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "atlantis-android" +include(":atlantis") +include(":sample") From e379fdd23ae500c23efe506d8cec5fdb58a3ca60 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 11:18:25 +0100 Subject: [PATCH 2/8] Run Android Unit Tests --- .github/workflows/main.yml | 44 ++++++++++++++++++- .../com/proxyman/atlantis/GzipCompression.kt | 2 - 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1afbdd8..bdd0a44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,7 +9,8 @@ on: - main jobs: - build-and-test: + ios-test: + name: iOS Tests runs-on: macos-15 strategy: fail-fast: false @@ -130,4 +131,43 @@ jobs: -destination "id=${SIMULATOR_ID}" \ -skipPackagePluginValidation \ -skipMacroValidation \ - | xcpretty --color \ No newline at end of file + | xcpretty --color + + android-test: + name: Android Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + working-directory: atlantis-android + run: chmod +x gradlew + + - name: Run Android Unit Tests + working-directory: atlantis-android + run: ./gradlew :atlantis:test --no-daemon + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-test-results + path: atlantis-android/atlantis/build/reports/tests/ \ No newline at end of file diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt index 7775c82..83d1513 100644 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt @@ -26,7 +26,6 @@ object GzipCompression { } outputStream.toByteArray() } catch (e: Exception) { - e.printStackTrace() null } } @@ -45,7 +44,6 @@ object GzipCompression { gzipStream.readBytes() } } catch (e: Exception) { - e.printStackTrace() null } } From 78948fa662f4d1307ea53b12fe512358cc3c1364 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 11:41:00 +0100 Subject: [PATCH 3/8] Fix sample app --- .../proxyman/atlantis/AtlantisInterceptor.kt | 191 ++++++++++++------ .../proxyman/atlantis/sample/MainActivity.kt | 2 +- 2 files changed, 134 insertions(+), 59 deletions(-) diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt index e41c214..92c27b5 100644 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt @@ -1,11 +1,12 @@ package com.proxyman.atlantis import okhttp3.Interceptor +import okhttp3.MediaType import okhttp3.Request -import okhttp3.Response import okhttp3.RequestBody -import okhttp3.ResponseBody +import okhttp3.Response import okio.Buffer +import okio.BufferedSink import okio.GzipSource import java.io.IOException import java.nio.charset.Charset @@ -14,6 +15,9 @@ import java.util.UUID /** * OkHttp Interceptor that captures HTTP/HTTPS traffic and sends it to Proxyman * + * This interceptor is designed to be completely transparent - it will NEVER + * interfere with normal HTTP requests, even if Proxyman is not running. + * * This interceptor should be added to your OkHttpClient: * ``` * val client = OkHttpClient.Builder() @@ -26,55 +30,100 @@ import java.util.UUID class AtlantisInterceptor internal constructor() : Interceptor { companion object { + private const val TAG = "AtlantisInterceptor" private const val MAX_BODY_SIZE = 52428800L // 50MB private val UTF8 = Charset.forName("UTF-8") } @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() + val originalRequest = chain.request() val requestId = UUID.randomUUID().toString() val startTime = System.currentTimeMillis() / 1000.0 - // Capture request - val atlantisRequest = captureRequest(request) - val trafficPackage = TrafficPackage( - id = requestId, - startAt = startTime, - request = atlantisRequest - ) + // Wrap the request body to capture it as it's written (non-destructive) + var capturedRequestBody: ByteArray? = null + val requestToSend = if (originalRequest.body != null && canCaptureRequestBody(originalRequest.body!!)) { + val wrappedBody = CapturingRequestBody(originalRequest.body!!) { data -> + capturedRequestBody = data + } + originalRequest.newBuilder().method(originalRequest.method, wrappedBody).build() + } else { + originalRequest + } - // Execute the request + // Execute the request FIRST - this is the priority + // Atlantis should NEVER block or fail the actual HTTP request val response: Response + try { - response = chain.proceed(request) + response = chain.proceed(requestToSend) } catch (e: IOException) { - // Capture error - trafficPackage.endAt = System.currentTimeMillis() / 1000.0 - trafficPackage.error = CustomError.fromException(e) - - // Send to Proxyman - Atlantis.sendPackage(trafficPackage) + // Request failed, but we still want to log it + // Create and send error package (best effort, ignore capture failures) + try { + val trafficPackage = TrafficPackage( + id = requestId, + startAt = startTime, + request = captureRequestMetadata(originalRequest, capturedRequestBody), + endAt = System.currentTimeMillis() / 1000.0, + error = CustomError.fromException(e) + ) + Atlantis.sendPackage(trafficPackage) + } catch (captureError: Exception) { + // Silently ignore capture errors - never affect the app + } throw e } - // Capture response - val (atlantisResponse, responseBodyData) = captureResponse(response) - trafficPackage.response = atlantisResponse - trafficPackage.responseBodyData = responseBodyData - trafficPackage.endAt = System.currentTimeMillis() / 1000.0 - - // Send to Proxyman - Atlantis.sendPackage(trafficPackage) + // Request succeeded, now capture the response (best effort) + try { + val (atlantisResponse, responseBodyData) = captureResponse(response) + val trafficPackage = TrafficPackage( + id = requestId, + startAt = startTime, + request = captureRequestMetadata(originalRequest, capturedRequestBody), + response = atlantisResponse, + responseBodyData = responseBodyData, + endAt = System.currentTimeMillis() / 1000.0 + ) + Atlantis.sendPackage(trafficPackage) + } catch (captureError: Exception) { + // Silently ignore capture errors - never affect the app + } return response } /** - * Capture request details + * Check if we can safely capture the request body + * Some body types can only be written once (one-shot) or are streaming (duplex) */ - private fun captureRequest(request: Request): com.proxyman.atlantis.Request { + private fun canCaptureRequestBody(body: RequestBody): Boolean { + // Skip one-shot bodies - they can only be written once + if (body.isOneShot()) { + return false + } + + // Skip duplex bodies - they're for bidirectional streaming + if (body.isDuplex()) { + return false + } + + // Skip very large bodies + val contentLength = body.contentLength() + if (contentLength > MAX_BODY_SIZE) { + return false + } + + return true + } + + /** + * Capture request metadata (URL, method, headers) and optionally the body + */ + private fun captureRequestMetadata(request: Request, capturedBody: ByteArray?): com.proxyman.atlantis.Request { val url = request.url.toString() val method = request.method @@ -86,48 +135,37 @@ class AtlantisInterceptor internal constructor() : Interceptor { headers[name] = value } - // Capture body - val body = captureRequestBody(request) + // Process captured body (decompress if needed) + val processedBody = if (capturedBody != null) { + processRequestBody(capturedBody, request.header("Content-Encoding")) + } else { + null + } return com.proxyman.atlantis.Request.fromOkHttp( url = url, method = method, headers = headers, - body = body + body = processedBody ) } /** - * Capture request body as byte array + * Process captured request body (e.g., decompress gzip) */ - private fun captureRequestBody(request: Request): ByteArray? { - val requestBody = request.body ?: return null - - // Skip if body is too large - val contentLength = requestBody.contentLength() - if (contentLength > MAX_BODY_SIZE) { - return null - } - - return try { - val buffer = Buffer() - requestBody.writeTo(buffer) - - // Check content encoding - val contentEncoding = request.header("Content-Encoding") - if (contentEncoding.equals("gzip", ignoreCase = true)) { - // Decompress for readability + private fun processRequestBody(data: ByteArray, contentEncoding: String?): ByteArray { + if (contentEncoding.equals("gzip", ignoreCase = true)) { + return try { + val buffer = Buffer().write(data) val gzipSource = GzipSource(buffer) val decompressedBuffer = Buffer() decompressedBuffer.writeAll(gzipSource) decompressedBuffer.readByteArray() - } else { - buffer.readByteArray() + } catch (e: Exception) { + data // Return original if decompression fails } - } catch (e: Exception) { - e.printStackTrace() - null } + return data } /** @@ -150,7 +188,7 @@ class AtlantisInterceptor internal constructor() : Interceptor { headers = headers ) - // Capture body + // Capture body (best effort) val bodyData = captureResponseBody(response) val bodyBase64 = if (bodyData != null && bodyData.isNotEmpty()) { Base64Utils.encode(bodyData) @@ -163,8 +201,7 @@ class AtlantisInterceptor internal constructor() : Interceptor { /** * Capture response body without consuming the original response - * OkHttp allows the body to be consumed only once, so we need to - * create a new response with the same body. + * Uses OkHttp's peekBody-like approach to safely read without affecting the caller */ private fun captureResponseBody(response: Response): ByteArray? { val responseBody = response.body ?: return null @@ -177,6 +214,7 @@ class AtlantisInterceptor internal constructor() : Interceptor { return try { // Peek the body without consuming it + // This is safe because OkHttp buffers the response for us val source = responseBody.source() source.request(Long.MAX_VALUE) // Buffer the entire body var buffer = source.buffer.clone() @@ -195,8 +233,45 @@ class AtlantisInterceptor internal constructor() : Interceptor { val size = minOf(buffer.size, MAX_BODY_SIZE) buffer.readByteArray(size) } catch (e: Exception) { - e.printStackTrace() + // Return null on any error - don't break the response null } } + + /** + * A RequestBody wrapper that captures the body data as it's being written + * This is non-destructive - the original body is written to the network normally + */ + private class CapturingRequestBody( + private val delegate: RequestBody, + private val onCapture: (ByteArray) -> Unit + ) : RequestBody() { + + override fun contentType(): MediaType? = delegate.contentType() + + override fun contentLength(): Long = delegate.contentLength() + + override fun isOneShot(): Boolean = delegate.isOneShot() + + override fun isDuplex(): Boolean = delegate.isDuplex() + + override fun writeTo(sink: BufferedSink) { + // Create a buffer to capture the data + val captureBuffer = Buffer() + + // Write to the capture buffer first + delegate.writeTo(captureBuffer) + + // Capture the data + val capturedData = captureBuffer.clone().readByteArray() + try { + onCapture(capturedData) + } catch (e: Exception) { + // Silently ignore capture callback errors + } + + // Write the captured data to the actual sink + sink.writeAll(captureBuffer) + } + } } diff --git a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt index dbc6737..921482a 100644 --- a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt +++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt @@ -40,7 +40,7 @@ class MainActivity : AppCompatActivity() { // Retrofit instance using the OkHttpClient private val retrofit by lazy { Retrofit.Builder() - .baseUrl("https://httpbin.org/") + .baseUrl("https://httpbin.proxyman.app/") .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() From c933a6ea38b64a303deb573bb934978acb3bdfaa Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 14:08:20 +0100 Subject: [PATCH 4/8] Bonjour service is working --- .../kotlin/com/proxyman/atlantis/Message.kt | 6 ++-- .../com/proxyman/atlantis/Transporter.kt | 3 +- .../com/proxyman/atlantis/MessageTest.kt | 23 +++++++++++++ .../proxyman/atlantis/sample/MainActivity.kt | 32 +++++++++++++++++-- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt index 0c161e6..036ff13 100644 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt @@ -50,7 +50,7 @@ data class Message( */ fun buildConnectionMessage(id: String, item: Serializable): Message { val contentData = item.toData() - val contentString = contentData?.toString(Charsets.UTF_8) + val contentString = contentData?.let { Base64Utils.encode(it) } return Message( id = id, messageType = MessageType.CONNECTION, @@ -64,7 +64,7 @@ data class Message( */ fun buildTrafficMessage(id: String, item: Serializable): Message { val contentData = item.toData() - val contentString = contentData?.toString(Charsets.UTF_8) + val contentString = contentData?.let { Base64Utils.encode(it) } return Message( id = id, messageType = MessageType.TRAFFIC, @@ -78,7 +78,7 @@ data class Message( */ fun buildWebSocketMessage(id: String, item: Serializable): Message { val contentData = item.toData() - val contentString = contentData?.toString(Charsets.UTF_8) + val contentString = contentData?.let { Base64Utils.encode(it) } return Message( id = id, messageType = MessageType.WEBSOCKET, diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt index b7a39fa..a9155a7 100644 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt @@ -9,6 +9,7 @@ import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.nio.ByteBuffer +import java.nio.ByteOrder import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.atomic.AtomicBoolean @@ -279,7 +280,7 @@ class Transporter( val compressedData = package_.toCompressedData() ?: return@withContext // Create length header (8 bytes, UInt64) - val lengthBuffer = ByteBuffer.allocate(8) + val lengthBuffer = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN) lengthBuffer.putLong(compressedData.size.toLong()) val headerData = lengthBuffer.array() diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt index 2746cbc..2e6c7a9 100644 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt @@ -7,6 +7,11 @@ import org.junit.Test class MessageTest { private val gson = Gson() + + private fun extractContent(json: String): String? { + val map = gson.fromJson(json, Map::class.java) + return map["content"] as? String + } @Test fun `test MessageType serialization`() { @@ -26,6 +31,12 @@ class MessageTest { assertTrue(json!!.contains("\"messageType\":\"connection\"")) assertTrue(json.contains("\"id\":\"test-id\"")) assertTrue(json.contains("\"buildVersion\"")) + + val content = extractContent(json) + assertNotNull(content) + val decoded = Base64Utils.decode(content!!).toString(Charsets.UTF_8) + val expectedPayload = gson.toJson(testPackage) + assertEquals(expectedPayload, decoded) } @Test @@ -38,6 +49,12 @@ class MessageTest { assertTrue(json!!.contains("\"messageType\":\"traffic\"")) assertTrue(json.contains("\"id\":\"traffic-id\"")) + + val content = extractContent(json) + assertNotNull(content) + val decoded = Base64Utils.decode(content!!).toString(Charsets.UTF_8) + val expectedPayload = gson.toJson(testPackage) + assertEquals(expectedPayload, decoded) } @Test @@ -50,6 +67,12 @@ class MessageTest { assertTrue(json!!.contains("\"messageType\":\"websocket\"")) assertTrue(json.contains("\"id\":\"ws-id\"")) + + val content = extractContent(json) + assertNotNull(content) + val decoded = Base64Utils.decode(content!!).toString(Charsets.UTF_8) + val expectedPayload = gson.toJson(testPackage) + assertEquals(expectedPayload, decoded) } // Helper test class diff --git a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt index 921482a..69cc666 100644 --- a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt +++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt @@ -6,6 +6,7 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.proxyman.atlantis.Atlantis +import com.proxyman.atlantis.Transporter import com.proxyman.atlantis.sample.databinding.ActivityMainBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -29,6 +30,24 @@ class MainActivity : AppCompatActivity() { } private lateinit var binding: ActivityMainBinding + private var connectionState: String? = null + + private val connectionListener = object : Transporter.ConnectionListener { + override fun onConnected(host: String, port: Int) { + connectionState = "Connected to Proxyman at $host:$port" + runOnUiThread { updateStatus() } + } + + override fun onDisconnected() { + connectionState = "Disconnected. Looking for Proxyman..." + runOnUiThread { updateStatus() } + } + + override fun onConnectionFailed(error: String) { + connectionState = "Connection failed: $error" + runOnUiThread { updateStatus() } + } + } // OkHttpClient with Atlantis interceptor private val okHttpClient by lazy { @@ -55,8 +74,14 @@ class MainActivity : AppCompatActivity() { binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) + Atlantis.setConnectionListener(connectionListener) setupUI() } + + override fun onDestroy() { + Atlantis.setConnectionListener(null) + super.onDestroy() + } private fun setupUI() { binding.btnGetRequest.setOnClickListener { @@ -83,10 +108,11 @@ class MainActivity : AppCompatActivity() { } private fun updateStatus() { - val status = if (Atlantis.isRunning()) { - "Atlantis is running.\nLooking for Proxyman..." - } else { + val status = if (!Atlantis.isRunning()) { "Atlantis is not running" + } else { + val detail = connectionState ?: "Looking for Proxyman..." + "Atlantis is running.\n$detail" } binding.tvStatus.text = status } From b1ba2657b4164602af96eebc0ba164b4de1f50ed Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 14:22:10 +0100 Subject: [PATCH 5/8] Update main.yml --- .github/workflows/main.yml | 127 +++++-------------------------------- 1 file changed, 17 insertions(+), 110 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bdd0a44..de64f68 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -9,129 +9,36 @@ on: - main jobs: - ios-test: - name: iOS Tests + swiftpm-test: + name: SwiftPM Tests runs-on: macos-15 - strategy: - fail-fast: false - matrix: - include: - - xcode: "16.2" - ios: "18" steps: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode ${{ matrix.xcode }} - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer + - name: Select latest Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable - name: Show Xcode and Swift version run: | xcodebuild -version swift --version - - name: List available simulators - run: xcrun simctl list devices available - - - name: List SwiftPM schemes - run: xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -list - - - name: Install xcpretty - run: sudo gem install xcpretty - - - name: Select iOS simulator for ${{ matrix.ios }} - env: - IOS_VERSION: ${{ matrix.ios }} - run: | - set -euo pipefail - RUNTIME_JSON=$(xcrun simctl list runtimes --json 2>/dev/null || true) - if [ -z "$RUNTIME_JSON" ]; then - echo "Failed to read simctl runtimes JSON" - xcrun simctl list runtimes || true - exit 1 - fi - export RUNTIME_JSON - RUNTIME_ID=$(python3 - <<'PY' - import json, os, sys - data = json.loads(os.environ["RUNTIME_JSON"]) - target = os.environ["IOS_VERSION"] - runtimes = [ - r for r in data.get("runtimes", []) - if r.get("platform") == "iOS" - and r.get("isAvailable") - and ( - r.get("version", "") == target - or r.get("version", "").startswith(target + ".") - ) - ] - if not runtimes: - print(f"Missing iOS runtime for {target}", file=sys.stderr) - sys.exit(1) - print(runtimes[0]["identifier"]) - PY - ) - export RUNTIME_ID - DEVICE_JSON=$(xcrun simctl list devices --json 2>/dev/null || true) - if [ -z "$DEVICE_JSON" ]; then - echo "Failed to read simctl devices JSON" - xcrun simctl list devices || true - exit 1 - fi - export DEVICE_JSON - DEVICE_ID=$(python3 - <<'PY' - import json, os, sys - data = json.loads(os.environ["DEVICE_JSON"]) - runtime = os.environ["RUNTIME_ID"] - devices = data.get("devices", {}).get(runtime, []) - for device in devices: - if device.get("isAvailable") and "iPhone 16" in device.get("name", ""): - print(device["udid"]) - sys.exit(0) - for device in devices: - if device.get("isAvailable") and "iPhone" in device.get("name", ""): - print(device["udid"]) - sys.exit(0) - print("") - PY - ) - if [ -z "$DEVICE_ID" ]; then - DEVICE_TYPES_JSON=$(xcrun simctl list devicetypes --json 2>/dev/null || true) - if [ -z "$DEVICE_TYPES_JSON" ]; then - echo "Failed to read simctl device types JSON" - xcrun simctl list devicetypes || true - exit 1 - fi - export DEVICE_TYPES_JSON - DEVICE_TYPE=$(python3 - <<'PY' - import json, sys, os - data = json.loads(os.environ["DEVICE_TYPES_JSON"]) - devicetypes = [d for d in data.get("devicetypes", []) if d.get("name", "").startswith("iPhone")] - for device in devicetypes: - if device.get("name") == "iPhone 16": - print(device["identifier"]) - sys.exit(0) - if devicetypes: - print(devicetypes[0]["identifier"]) - sys.exit(0) - print("", end="") - sys.exit(1) - PY - ) - DEVICE_ID=$(xcrun simctl create "CI-iPhone-${IOS_VERSION}" "$DEVICE_TYPE" "$RUNTIME_ID") - fi - echo "SIMULATOR_ID=$DEVICE_ID" >> "$GITHUB_ENV" + - name: Cache SwiftPM + uses: actions/cache@v4 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: ${{ runner.os }}-swiftpm-${{ hashFiles('Package.swift', 'Package.resolved') }} + restore-keys: | + ${{ runner.os }}-swiftpm- - - name: Build and Test on iOS ${{ matrix.ios }} - run: | - set -o pipefail - xcodebuild test \ - -workspace .swiftpm/xcode/package.xcworkspace \ - -scheme Atlantis \ - -destination "id=${SIMULATOR_ID}" \ - -skipPackagePluginValidation \ - -skipMacroValidation \ - | xcpretty --color + - name: Run SwiftPM Tests + run: swift test --test-product AtlantisTests -v android-test: name: Android Tests From b03d3ed4487ea8a62ca624d16e4de1f9803651ce Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Thu, 5 Feb 2026 15:51:35 +0100 Subject: [PATCH 6/8] Update main.yml --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index de64f68..f000b4d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,7 +38,7 @@ jobs: ${{ runner.os }}-swiftpm- - name: Run SwiftPM Tests - run: swift test --test-product AtlantisTests -v + run: swift test android-test: name: Android Tests From 8f18df321128a316f92d65a8eff0b9bbcd2dc6b3 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Fri, 6 Feb 2026 11:23:49 +0100 Subject: [PATCH 7/8] Websocket for OKHTTP --- .../kotlin/com/proxyman/atlantis/Atlantis.kt | 239 +++++++++++++++ .../proxyman/atlantis/AtlantisInterceptor.kt | 6 + .../atlantis/AtlantisWebSocketListener.kt | 132 ++++++++ .../kotlin/com/proxyman/atlantis/Packages.kt | 20 +- .../atlantis/AtlantisInterceptorTest.kt | 40 +++ .../atlantis/AtlantisWebSocketTest.kt | 285 ++++++++++++++++++ .../com/proxyman/atlantis/MessageTest.kt | 30 ++ .../com/proxyman/atlantis/PackagesTest.kt | 43 +++ .../proxyman/atlantis/sample/MainActivity.kt | 51 +++- .../atlantis/sample/SampleApplication.kt | 9 + .../sample/WebSocketTestController.kt | 155 ++++++++++ .../src/main/res/layout/activity_main.xml | 11 +- 12 files changed, 1010 insertions(+), 11 deletions(-) create mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisWebSocketListener.kt create mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisWebSocketTest.kt create mode 100644 atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/WebSocketTestController.kt diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt index 5f4632a..bdb37c9 100644 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt @@ -2,7 +2,12 @@ package com.proxyman.atlantis import android.content.Context import android.util.Log +import okhttp3.Headers +import okhttp3.Request as OkHttpRequest +import okhttp3.Response as OkHttpResponse +import okhttp3.WebSocketListener import java.lang.ref.WeakReference +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean /** @@ -60,6 +65,12 @@ object Atlantis { private val isEnabled = AtomicBoolean(false) private val interceptor = AtlantisInterceptor() + + // MARK: - WebSocket caches (mirrors iOS Atlantis.swift) + + private val webSocketPackages = ConcurrentHashMap() + private val waitingWebsocketPackages = ConcurrentHashMap>() + private val wsLock = Any() // MARK: - Public API @@ -118,6 +129,11 @@ object Atlantis { transporter = null configuration = null contextRef = null + + synchronized(wsLock) { + webSocketPackages.clear() + waitingWebsocketPackages.clear() + } Log.d(TAG, "Atlantis stopped") } @@ -165,6 +181,20 @@ object Atlantis { fun setConnectionListener(listener: Transporter.ConnectionListener?) { transporter?.connectionListener = listener } + + /** + * Wrap an OkHttp WebSocketListener to capture WebSocket messages and send them to Proxyman. + * + * Usage: + * ```kotlin + * val listener = Atlantis.wrapWebSocketListener(object : WebSocketListener() { ... }) + * client.newWebSocket(request, listener) + * ``` + */ + @JvmStatic + fun wrapWebSocketListener(listener: WebSocketListener): AtlantisWebSocketListener { + return AtlantisWebSocketListener(listener) + } // MARK: - Internal API (used by AtlantisInterceptor) @@ -186,6 +216,205 @@ object Atlantis { transporter?.send(message) } + + // MARK: - Internal API (used by AtlantisWebSocketListener) + + internal fun onWebSocketOpen(id: String, request: OkHttpRequest, response: OkHttpResponse) { + if (!isEnabled.get()) return + + val configuration = configuration ?: return + val transporter = transporter ?: return + + val atlantisRequest = Request.fromOkHttp( + url = request.url.toString(), + method = request.method, + headers = headersToSingleValueMap(request.headers), + body = null + ) + + val atlantisResponse = Response.fromOkHttp( + statusCode = response.code, + headers = headersToSingleValueMap(response.headers) + ) + + val now = System.currentTimeMillis() / 1000.0 + + val basePackage: TrafficPackage + synchronized(wsLock) { + basePackage = TrafficPackage( + id = id, + startAt = now, + request = atlantisRequest, + response = atlantisResponse, + responseBodyData = "", + endAt = now, + packageType = TrafficPackage.PackageType.WEBSOCKET + ) + webSocketPackages[id] = basePackage + } + + // Send the initial traffic message to register the WebSocket connection in Proxyman. + // This mirrors iOS: handleDidFinish sends a traffic-type message for the HTTP upgrade. + val trafficMessage = Message.buildTrafficMessage(configuration.id, basePackage) + transporter.send(trafficMessage) + + // Flush any queued messages that happened before onOpen + attemptSendingAllWaitingWSPackages(id) + } + + internal fun onWebSocketSendText(id: String, text: String) { + sendWebSocketMessage( + id = id + ) { WebsocketMessagePackage.createStringMessage(id = id, message = text, type = WebsocketMessagePackage.MessageType.SEND) } + } + + internal fun onWebSocketSendBinary(id: String, bytes: ByteArray) { + sendWebSocketMessage( + id = id + ) { WebsocketMessagePackage.createDataMessage(id = id, data = bytes, type = WebsocketMessagePackage.MessageType.SEND) } + } + + internal fun onWebSocketReceiveText(id: String, text: String) { + sendWebSocketMessage( + id = id + ) { WebsocketMessagePackage.createStringMessage(id = id, message = text, type = WebsocketMessagePackage.MessageType.RECEIVE) } + } + + internal fun onWebSocketReceiveBinary(id: String, bytes: ByteArray) { + sendWebSocketMessage( + id = id + ) { WebsocketMessagePackage.createDataMessage(id = id, data = bytes, type = WebsocketMessagePackage.MessageType.RECEIVE) } + } + + internal fun onWebSocketClosing(id: String, code: Int, reason: String?) { + if (!isEnabled.get()) return + val configuration = configuration ?: return + val transporter = transporter ?: return + + // Atomically remove the base package so only the FIRST close call sends a message. + // Subsequent calls (proxy close, onClosing callback, onClosed callback) will find + // nothing in the cache and return early. + val basePackage = synchronized(wsLock) { + val pkg = webSocketPackages.remove(id) ?: return + waitingWebsocketPackages.remove(id) + pkg + } + + val wsPackage = WebsocketMessagePackage.createCloseMessage(id = id, closeCode = code, reason = reason) + val messageTrafficPackage = basePackage.copy(websocketMessagePackage = wsPackage) + + val delegate = delegate?.get() + if (delegate is AtlantisWebSocketDelegate) { + delegate.onWebSocketMessageCaptured(messageTrafficPackage) + } + + val message = Message.buildWebSocketMessage(configuration.id, messageTrafficPackage) + transporter.send(message) + } + + internal fun onWebSocketClosed(id: String, code: Int, reason: String?) { + // Ensure close message is sent (idempotent: onWebSocketClosing no-ops if already removed) + onWebSocketClosing(id, code, reason) + } + + internal fun onWebSocketFailure(id: String, t: Throwable, response: OkHttpResponse?) { + if (!isEnabled.get()) return + val responseInfo = response?.let { " HTTP ${it.code}" } ?: "" + Log.e(TAG, "WebSocket failure (id=$id)$responseInfo: ${t.message ?: t.javaClass.simpleName}", t) + // Best effort: clean up local caches. Transporter will handle reconnect/pending queue. + synchronized(wsLock) { + webSocketPackages.remove(id) + waitingWebsocketPackages.remove(id) + } + } + + private fun sendWebSocketMessage( + id: String, + wsPackageBuilder: () -> WebsocketMessagePackage + ) { + if (!isEnabled.get()) return + + val configuration = configuration ?: return + val transporter = transporter ?: return + + val basePackage = synchronized(wsLock) { webSocketPackages[id] } ?: return + + val wsPackage = try { + wsPackageBuilder() + } catch (_: Exception) { + return + } + + // Create a snapshot package per message to avoid mutating the cached basePackage. + // This is critical because Transporter queues Serializable objects by reference. + val messageTrafficPackage = basePackage.copy(websocketMessagePackage = wsPackage) + + // Notify delegate + val delegate = delegate?.get() + if (delegate is AtlantisWebSocketDelegate) { + delegate.onWebSocketMessageCaptured(messageTrafficPackage) + } + + startSendingWebsocketMessage( + configurationId = configuration.id, + transporter = transporter, + package_ = messageTrafficPackage + ) + } + + private fun startSendingWebsocketMessage( + configurationId: String, + transporter: Transporter, + package_: TrafficPackage + ) { + val id = package_.id + + synchronized(wsLock) { + // If WS response isn't ready yet, queue it (mirrors iOS waitingWebsocketPackages) + if (package_.response == null) { + val waitingList = waitingWebsocketPackages[id] ?: mutableListOf() + waitingList.add(package_) + waitingWebsocketPackages[id] = waitingList + return + } + } + + // Send all waiting WS packages (if any) + attemptSendingAllWaitingWSPackages(id) + + val message = Message.buildWebSocketMessage(configurationId, package_) + transporter.send(message) + } + + private fun attemptSendingAllWaitingWSPackages(id: String) { + val transporter = transporter ?: return + val messagesToSend: List = synchronized(wsLock) { + val configurationId = configuration?.id ?: return + val waitingList = waitingWebsocketPackages.remove(id) ?: return + val baseResponse = webSocketPackages[id]?.response + + waitingList.map { item -> + val toSend = if (item.response == null && baseResponse != null) { + item.copy(response = baseResponse) + } else { + item + } + Message.buildWebSocketMessage(configurationId, toSend) + } + } + + messagesToSend.forEach { transporter.send(it) } + } + + private fun headersToSingleValueMap(headers: Headers): Map { + if (headers.size == 0) return emptyMap() + val map = LinkedHashMap(headers.size) + for (name in headers.names()) { + val values = headers.values(name) + map[name] = values.joinToString(",") + } + return map + } // MARK: - Private Methods @@ -212,3 +441,13 @@ interface AtlantisDelegate { */ fun onTrafficCaptured(trafficPackage: TrafficPackage) } + +/** + * Optional delegate for observing captured WebSocket traffic packages. + * + * This is separate from [AtlantisDelegate] to avoid breaking existing implementers + * (especially Java implementations) when adding new callbacks. + */ +interface AtlantisWebSocketDelegate { + fun onWebSocketMessageCaptured(trafficPackage: TrafficPackage) +} diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt index 92c27b5..ff955ea 100644 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt @@ -77,6 +77,12 @@ class AtlantisInterceptor internal constructor() : Interceptor { throw e } + // Skip WebSocket upgrade responses (101 Switching Protocols). + // WebSocket traffic is handled entirely by AtlantisWebSocketListener. + if (response.code == 101) { + return response + } + // Request succeeded, now capture the response (best effort) try { val (atlantisResponse, responseBodyData) = captureResponse(response) diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisWebSocketListener.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisWebSocketListener.kt new file mode 100644 index 0000000..9b9042e --- /dev/null +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisWebSocketListener.kt @@ -0,0 +1,132 @@ +package com.proxyman.atlantis + +import okhttp3.Response as OkHttpResponse +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import java.util.UUID + +/** + * OkHttp WebSocketListener wrapper that captures WebSocket traffic and forwards it to Proxyman. + * + * - Incoming messages are captured via WebSocketListener callbacks. + * - Outgoing messages are captured via a proxy WebSocket passed to the user's listener. + * + * Important: + * If the app sends messages using the WebSocket instance returned by OkHttpClient.newWebSocket(), + * those sends are NOT interceptable via OkHttp APIs. For outgoing capture, the app should send + * using the WebSocket instance received in onOpen/onMessage callbacks (the proxy). + */ +class AtlantisWebSocketListener internal constructor( + private val userListener: WebSocketListener +) : WebSocketListener() { + + internal val connectionId: String = UUID.randomUUID().toString() + + @Volatile + private var proxyWebSocket: WebSocket? = null + + private fun getOrCreateProxyWebSocket(webSocket: WebSocket): WebSocket { + val existing = proxyWebSocket + if (existing != null) return existing + return AtlantisProxyWebSocket(webSocket, connectionId).also { proxyWebSocket = it } + } + + override fun onOpen(webSocket: WebSocket, response: OkHttpResponse) { + val proxy = getOrCreateProxyWebSocket(webSocket) + try { + Atlantis.onWebSocketOpen( + id = connectionId, + request = webSocket.request(), + response = response + ) + } catch (_: Exception) { + // Best effort only + } + userListener.onOpen(proxy, response) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val proxy = getOrCreateProxyWebSocket(webSocket) + try { + Atlantis.onWebSocketReceiveText(id = connectionId, text = text) + } catch (_: Exception) { + } + userListener.onMessage(proxy, text) + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + val proxy = getOrCreateProxyWebSocket(webSocket) + try { + Atlantis.onWebSocketReceiveBinary(id = connectionId, bytes = bytes.toByteArray()) + } catch (_: Exception) { + } + userListener.onMessage(proxy, bytes) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + val proxy = getOrCreateProxyWebSocket(webSocket) + try { + Atlantis.onWebSocketClosing(id = connectionId, code = code, reason = reason) + } catch (_: Exception) { + } + userListener.onClosing(proxy, code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + val proxy = getOrCreateProxyWebSocket(webSocket) + try { + Atlantis.onWebSocketClosed(id = connectionId, code = code, reason = reason) + } catch (_: Exception) { + } + userListener.onClosed(proxy, code, reason) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: OkHttpResponse?) { + val proxy = getOrCreateProxyWebSocket(webSocket) + try { + Atlantis.onWebSocketFailure(id = connectionId, t = t, response = response) + } catch (_: Exception) { + } + userListener.onFailure(proxy, t, response) + } + + private class AtlantisProxyWebSocket( + private val delegate: WebSocket, + private val id: String + ) : WebSocket { + + override fun request(): okhttp3.Request = delegate.request() + + override fun queueSize(): Long = delegate.queueSize() + + override fun send(text: String): Boolean { + try { + Atlantis.onWebSocketSendText(id = id, text = text) + } catch (_: Exception) { + } + return delegate.send(text) + } + + override fun send(bytes: ByteString): Boolean { + try { + Atlantis.onWebSocketSendBinary(id = id, bytes = bytes.toByteArray()) + } catch (_: Exception) { + } + return delegate.send(bytes) + } + + override fun close(code: Int, reason: String?): Boolean { + try { + Atlantis.onWebSocketClosing(id = id, code = code, reason = reason) + } catch (_: Exception) { + } + return delegate.close(code, reason) + } + + override fun cancel() { + delegate.cancel() + } + } +} + diff --git a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt index c292249..727e35a 100644 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt +++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt @@ -67,7 +67,10 @@ data class TrafficPackage( var endAt: Double? = null, @SerializedName("packageType") - val packageType: PackageType = PackageType.HTTP + val packageType: PackageType = PackageType.HTTP, + + @SerializedName("websocketMessagePackage") + var websocketMessagePackage: WebsocketMessagePackage? = null ) : Serializable { enum class PackageType { @@ -97,7 +100,20 @@ data class TrafficPackage( return TrafficPackage( id = UUID.randomUUID().toString(), startAt = System.currentTimeMillis() / 1000.0, - request = request + request = request, + packageType = PackageType.HTTP + ) + } + + /** + * Create a new WebSocket TrafficPackage with a unique ID + */ + fun createWebSocket(request: Request): TrafficPackage { + return TrafficPackage( + id = UUID.randomUUID().toString(), + startAt = System.currentTimeMillis() / 1000.0, + request = request, + packageType = PackageType.WEBSOCKET ) } } diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt index 623de89..7113e82 100644 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt @@ -191,6 +191,46 @@ class AtlantisInterceptorTest { assertEquals(200, response.code) } + @Test + fun `test interceptor skips WebSocket upgrade 101 response`() { + // Return a 101 Switching Protocols response (WebSocket upgrade) + mockWebServer.enqueue(MockResponse() + .setResponseCode(101) + .addHeader("Upgrade", "websocket") + .addHeader("Connection", "Upgrade")) + + val request = Request.Builder() + .url(mockWebServer.url("/ws")) + .get() + .addHeader("Connection", "Upgrade") + .addHeader("Upgrade", "websocket") + .build() + + val response = client.newCall(request).execute() + + // Verify the interceptor does not interfere with the 101 response + assertEquals(101, response.code) + assertEquals("websocket", response.header("Upgrade")) + } + + @Test + fun `test interceptor still captures non-101 responses`() { + // A normal 200 response must still be captured (not skipped) + mockWebServer.enqueue(MockResponse() + .setResponseCode(200) + .setBody("{\"ok\":true}")) + + val request = Request.Builder() + .url(mockWebServer.url("/api/health")) + .get() + .build() + + val response = client.newCall(request).execute() + + assertEquals(200, response.code) + assertEquals("{\"ok\":true}", response.body?.string()) + } + @Test fun `test multiple concurrent requests`() { // Enqueue multiple responses diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisWebSocketTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisWebSocketTest.kt new file mode 100644 index 0000000..176dcaf --- /dev/null +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisWebSocketTest.kt @@ -0,0 +1,285 @@ +package com.proxyman.atlantis + +import com.google.gson.Gson +import org.junit.Assert.* +import org.junit.Test + +/** + * Tests for WebSocket-specific bugfixes: + * + * 1. Interceptor skips 101 WebSocket upgrades (no duplicate HTTP capture) + * 2. onWebSocketClosing deduplication (only 1 close message, not 3) + * 3. WebSocket lifecycle produces the correct message types and shares one ID + */ +class AtlantisWebSocketTest { + + private val gson = Gson() + + // ----------------------------------------------------------------------- + // Helper: decode Message JSON -> extract inner TrafficPackage JSON + // ----------------------------------------------------------------------- + + /** Extract the base64-encoded "content" field from a Message JSON string. */ + private fun extractDecodedContent(messageJson: String): String { + val map = gson.fromJson(messageJson, Map::class.java) + val base64 = map["content"] as String + return Base64Utils.decode(base64).toString(Charsets.UTF_8) + } + + /** Extract "messageType" from top-level Message JSON. */ + private fun extractMessageType(messageJson: String): String { + val map = gson.fromJson(messageJson, Map::class.java) + return map["messageType"] as String + } + + // ----------------------------------------------------------------------- + // 1. Initial traffic message has messageType=traffic, packageType=websocket + // ----------------------------------------------------------------------- + + @Test + fun `test initial WS traffic message uses traffic type with websocket packageType`() { + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = mapOf("Sec-WebSocket-Key" to "abc"), + body = null + ) + val response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")) + + val basePackage = TrafficPackage( + id = "ws-conn-1", + startAt = 1.0, + request = request, + response = response, + responseBodyData = "", + endAt = 1.0, + packageType = TrafficPackage.PackageType.WEBSOCKET + ) + + // The initial message must use buildTrafficMessage (type=traffic) + val trafficMsg = Message.buildTrafficMessage("config-1", basePackage) + val trafficJson = trafficMsg.toData()!!.toString(Charsets.UTF_8) + assertEquals("traffic", extractMessageType(trafficJson)) + + val innerJson = extractDecodedContent(trafficJson) + assertTrue("Inner packageType must be websocket", innerJson.contains("\"packageType\":\"websocket\"")) + assertFalse("No websocketMessagePackage in initial traffic", innerJson.contains("\"websocketMessagePackage\":{")) + } + + // ----------------------------------------------------------------------- + // 2. WS frame messages use messageType=websocket + // ----------------------------------------------------------------------- + + @Test + fun `test WS frame message uses websocket message type`() { + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = emptyMap(), + body = null + ) + val response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")) + + val basePackage = TrafficPackage( + id = "ws-conn-2", + startAt = 1.0, + request = request, + response = response, + responseBodyData = "", + endAt = 1.0, + packageType = TrafficPackage.PackageType.WEBSOCKET + ) + + val wsPackage = WebsocketMessagePackage.createStringMessage( + id = "ws-conn-2", + message = "hello", + type = WebsocketMessagePackage.MessageType.SEND + ) + val framePackage = basePackage.copy(websocketMessagePackage = wsPackage) + + val wsMsg = Message.buildWebSocketMessage("config-1", framePackage) + val wsJson = wsMsg.toData()!!.toString(Charsets.UTF_8) + + assertEquals("websocket", extractMessageType(wsJson)) + + val innerJson = extractDecodedContent(wsJson) + assertTrue(innerJson.contains("\"packageType\":\"websocket\"")) + assertTrue(innerJson.contains("\"messageType\":\"send\"")) + assertTrue(innerJson.contains("\"stringValue\":\"hello\"")) + } + + // ----------------------------------------------------------------------- + // 3. TrafficPackage.id is preserved across copy() (all WS messages share one ID) + // ----------------------------------------------------------------------- + + @Test + fun `test all WS frame copies share the same TrafficPackage id`() { + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = emptyMap(), + body = null + ) + val response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")) + + val basePackage = TrafficPackage( + id = "ws-conn-shared", + startAt = 1.0, + request = request, + response = response, + responseBodyData = "", + endAt = 1.0, + packageType = TrafficPackage.PackageType.WEBSOCKET + ) + + val sendFrame = basePackage.copy( + websocketMessagePackage = WebsocketMessagePackage.createStringMessage( + id = "ws-conn-shared", message = "a", type = WebsocketMessagePackage.MessageType.SEND + ) + ) + val receiveFrame = basePackage.copy( + websocketMessagePackage = WebsocketMessagePackage.createStringMessage( + id = "ws-conn-shared", message = "b", type = WebsocketMessagePackage.MessageType.RECEIVE + ) + ) + val closeFrame = basePackage.copy( + websocketMessagePackage = WebsocketMessagePackage.createCloseMessage( + id = "ws-conn-shared", closeCode = 1000, reason = "done" + ) + ) + + // All copies must share the same id as the base package + assertEquals("ws-conn-shared", sendFrame.id) + assertEquals("ws-conn-shared", receiveFrame.id) + assertEquals("ws-conn-shared", closeFrame.id) + + // But websocketMessagePackage should be different per frame + assertEquals("send", getWsMessageType(sendFrame)) + assertEquals("receive", getWsMessageType(receiveFrame)) + assertEquals("sendCloseMessage", getWsMessageType(closeFrame)) + } + + // ----------------------------------------------------------------------- + // 4. Close deduplication: only first close produces a package; subsequent + // calls to onWebSocketClosing with same id find no base package. + // ----------------------------------------------------------------------- + + @Test + fun `test close dedup - base package removed after first close copy`() { + // Simulate what Atlantis.onWebSocketClosing does: remove from map, build close package. + val packages = java.util.concurrent.ConcurrentHashMap() + + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = emptyMap(), + body = null + ) + val basePackage = TrafficPackage( + id = "ws-dedup", + startAt = 1.0, + request = request, + response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")), + responseBodyData = "", + endAt = 1.0, + packageType = TrafficPackage.PackageType.WEBSOCKET + ) + packages["ws-dedup"] = basePackage + + // First close: remove succeeds + val first = packages.remove("ws-dedup") + assertNotNull("First close should find the package", first) + + // Second close: remove returns null (already removed) + val second = packages.remove("ws-dedup") + assertNull("Second close must NOT find the package (dedup)", second) + + // Third close: same + val third = packages.remove("ws-dedup") + assertNull("Third close must NOT find the package (dedup)", third) + } + + // ----------------------------------------------------------------------- + // 5. Copy preserves all fields but allows different websocketMessagePackage + // ----------------------------------------------------------------------- + + @Test + fun `test copy does not mutate base package`() { + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = emptyMap(), + body = null + ) + val response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")) + + val base = TrafficPackage( + id = "ws-immut", + startAt = 1.0, + request = request, + response = response, + responseBodyData = "", + endAt = 1.0, + packageType = TrafficPackage.PackageType.WEBSOCKET + ) + + assertNull("Base has no websocketMessagePackage initially", base.websocketMessagePackage) + + val withMsg = base.copy( + websocketMessagePackage = WebsocketMessagePackage.createStringMessage( + id = "ws-immut", message = "hi", type = WebsocketMessagePackage.MessageType.SEND + ) + ) + + // Base must remain untouched + assertNull("Base still has no websocketMessagePackage after copy", base.websocketMessagePackage) + assertNotNull("Copy has websocketMessagePackage", withMsg.websocketMessagePackage) + } + + // ----------------------------------------------------------------------- + // 6. Interceptor skip: verify a 101 response is NOT captured by sendPackage + // (We test the data-model side: a TrafficPackage with statusCode 101 + // should never be created by the interceptor path.) + // ----------------------------------------------------------------------- + + @Test + fun `test TrafficPackage with 101 response is valid but should not appear from interceptor`() { + // This test documents the invariant: interceptor skips 101. + // We verify that a manually-created 101 TrafficPackage serializes correctly + // (it can exist from the WebSocket path), but with packageType=websocket. + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = emptyMap(), + body = null + ) + val response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")) + + val pkg = TrafficPackage( + id = "ws-101", + startAt = 1.0, + request = request, + response = response, + responseBodyData = "", + endAt = 1.0, + packageType = TrafficPackage.PackageType.WEBSOCKET + ) + + val json = pkg.toData()!!.toString(Charsets.UTF_8) + assertTrue(json.contains("\"statusCode\":101")) + assertTrue(json.contains("\"packageType\":\"websocket\"")) + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private fun getWsMessageType(pkg: TrafficPackage): String { + val json = pkg.toData()!!.toString(Charsets.UTF_8) + // Extract messageType from the nested websocketMessagePackage + val parsed = gson.fromJson(json, Map::class.java) + @Suppress("UNCHECKED_CAST") + val wsPkg = parsed["websocketMessagePackage"] as? Map ?: error("no websocketMessagePackage") + return wsPkg["messageType"] as String + } +} diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt index 2e6c7a9..ddb8605 100644 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt @@ -74,6 +74,36 @@ class MessageTest { val expectedPayload = gson.toJson(testPackage) assertEquals(expectedPayload, decoded) } + + @Test + fun `test build websocket message with TrafficPackage payload`() { + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = emptyMap(), + body = null + ) + + val trafficPackage = TrafficPackage.createWebSocket(request).apply { + response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")) + websocketMessagePackage = WebsocketMessagePackage.createStringMessage( + id = id, + message = "hello", + type = WebsocketMessagePackage.MessageType.RECEIVE + ) + } + + val message = Message.buildWebSocketMessage("config-id", trafficPackage) + val json = message.toData()!!.toString(Charsets.UTF_8) + val content = extractContent(json)!! + val decoded = Base64Utils.decode(content).toString(Charsets.UTF_8) + + assertTrue(json.contains("\"messageType\":\"websocket\"")) + assertTrue(decoded.contains("\"packageType\":\"websocket\"")) + assertTrue(decoded.contains("\"websocketMessagePackage\"")) + assertTrue(decoded.contains("\"messageType\":\"receive\"")) + assertTrue(decoded.contains("\"stringValue\":\"hello\"")) + } // Helper test class private class TestSerializable(val content: String) : Serializable { diff --git a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt index 1a0cf04..4134068 100644 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt +++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt @@ -128,6 +128,49 @@ class PackagesTest { assertNull(trafficPackage.error) assertEquals(TrafficPackage.PackageType.HTTP, trafficPackage.packageType) } + + @Test + fun `test TrafficPackage WebSocket creation`() { + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = mapOf("Sec-WebSocket-Protocol" to "chat"), + body = null + ) + + val trafficPackage = TrafficPackage.createWebSocket(request) + + assertNotNull(trafficPackage.id) + assertTrue(trafficPackage.startAt > 0) + assertEquals(request, trafficPackage.request) + assertEquals(TrafficPackage.PackageType.WEBSOCKET, trafficPackage.packageType) + assertNull(trafficPackage.websocketMessagePackage) + } + + @Test + fun `test TrafficPackage WebSocket serialization with websocketMessagePackage`() { + val request = Request.fromOkHttp( + url = "wss://echo.websocket.org/", + method = "GET", + headers = emptyMap(), + body = null + ) + + val trafficPackage = TrafficPackage.createWebSocket(request) + trafficPackage.response = Response.fromOkHttp(101, mapOf("Upgrade" to "websocket")) + trafficPackage.websocketMessagePackage = + WebsocketMessagePackage.createStringMessage( + id = trafficPackage.id, + message = "hello", + type = WebsocketMessagePackage.MessageType.SEND + ) + + val json = trafficPackage.toData()!!.toString(Charsets.UTF_8) + assertTrue(json.contains("\"packageType\":\"websocket\"")) + assertTrue(json.contains("\"websocketMessagePackage\"")) + assertTrue(json.contains("\"messageType\":\"send\"")) + assertTrue(json.contains("\"stringValue\":\"hello\"")) + } @Test fun `test TrafficPackage serialization`() { diff --git a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt index 69cc666..402a0a3 100644 --- a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt +++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt @@ -31,6 +31,8 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private var connectionState: String? = null + private var httpLog: String = "" + private var wsLog: String = "" private val connectionListener = object : Transporter.ConnectionListener { override fun onConnected(host: String, port: Int) { @@ -49,11 +51,9 @@ class MainActivity : AppCompatActivity() { } } - // OkHttpClient with Atlantis interceptor - private val okHttpClient by lazy { - OkHttpClient.Builder() - .addInterceptor(Atlantis.getInterceptor()) - .build() + // OkHttpClient shared from Application (also used by WebSocket test) + private val okHttpClient: OkHttpClient by lazy { + (application as SampleApplication).okHttpClient } // Retrofit instance using the OkHttpClient @@ -76,6 +76,8 @@ class MainActivity : AppCompatActivity() { Atlantis.setConnectionListener(connectionListener) setupUI() + + observeWebSocketLogs() } override fun onDestroy() { @@ -103,8 +105,13 @@ class MainActivity : AppCompatActivity() { binding.btnErrorRequest.setOnClickListener { makeErrorRequest() } + + binding.btnStartWebSocketTest.setOnClickListener { + WebSocketTestController.startAutoTest(okHttpClient) + } updateStatus() + updateLogView() } private fun updateStatus() { @@ -116,6 +123,34 @@ class MainActivity : AppCompatActivity() { } binding.tvStatus.text = status } + + private fun observeWebSocketLogs() { + lifecycleScope.launch { + WebSocketTestController.logText.collect { text -> + wsLog = text + updateLogView() + } + } + + lifecycleScope.launch { + WebSocketTestController.isTestRunning.collect { running -> + binding.btnStartWebSocketTest.isEnabled = !running + } + } + } + + private fun updateLogView() { + val combined = buildString { + if (httpLog.isNotBlank()) { + append("=== HTTP ===\n") + append(httpLog) + append("\n\n") + } + append("=== WebSocket (auto every 1s) ===\n") + append(if (wsLog.isNotBlank()) wsLog else "(no websocket logs yet)") + } + binding.tvResult.text = combined + } private fun makeGetRequest() { lifecycleScope.launch { @@ -208,7 +243,8 @@ class MainActivity : AppCompatActivity() { private fun showResult(title: String, result: String) { Log.d(TAG, "$title: $result") runOnUiThread { - binding.tvResult.text = "$title:\n\n${result.take(500)}..." + httpLog = "$title:\n\n${result.take(500)}" + updateLogView() Toast.makeText(this, "$title completed!", Toast.LENGTH_SHORT).show() } } @@ -216,7 +252,8 @@ class MainActivity : AppCompatActivity() { private fun showError(title: String, e: Exception) { Log.e(TAG, title, e) runOnUiThread { - binding.tvResult.text = "$title:\n\nError: ${e.message}" + httpLog = "$title:\n\nError: ${e.message}" + updateLogView() Toast.makeText(this, "$title: ${e.message}", Toast.LENGTH_SHORT).show() } } diff --git a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt index d2e4b27..e7247c6 100644 --- a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt +++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt @@ -2,11 +2,15 @@ package com.proxyman.atlantis.sample import android.app.Application import com.proxyman.atlantis.Atlantis +import okhttp3.OkHttpClient /** * Sample Application demonstrating Atlantis integration */ class SampleApplication : Application() { + + lateinit var okHttpClient: OkHttpClient + private set override fun onCreate() { super.onCreate() @@ -19,5 +23,10 @@ class SampleApplication : Application() { // Or with specific hostname: // Atlantis.start(this, "MacBook-Pro.local") } + + // Shared OkHttpClient for both HTTP + WebSocket testing + okHttpClient = OkHttpClient.Builder() + .addInterceptor(Atlantis.getInterceptor()) + .build() } } diff --git a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/WebSocketTestController.kt b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/WebSocketTestController.kt new file mode 100644 index 0000000..2a03c53 --- /dev/null +++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/WebSocketTestController.kt @@ -0,0 +1,155 @@ +package com.proxyman.atlantis.sample + +import com.proxyman.atlantis.Atlantis +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString.Companion.toByteString +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean + +object WebSocketTestController { + + private const val WS_URL = "wss://echo.websocket.org/" + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val isRunning = AtomicBoolean(false) + + private val _logText = MutableStateFlow("") + val logText: StateFlow = _logText.asStateFlow() + + private val _isTestRunning = MutableStateFlow(false) + val isTestRunning: StateFlow = _isTestRunning.asStateFlow() + + private var job: Job? = null + + fun startAutoTest(client: OkHttpClient) { + if (!isRunning.compareAndSet(false, true)) { + log("WebSocket test is already running") + return + } + + _isTestRunning.value = true + job = scope.launch { + try { + runTest(client) + } finally { + _isTestRunning.value = false + isRunning.set(false) + } + } + } + + fun stop() { + job?.cancel() + job = null + isRunning.set(false) + _isTestRunning.value = false + log("WebSocket test stopped") + } + + private suspend fun runTest(client: OkHttpClient) { + log("Connecting to $WS_URL") + + val wsOpen = CompletableDeferred() + val wsClosed = CompletableDeferred() + + val userListener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + log("onOpen: HTTP ${response.code}") + wsOpen.complete(webSocket) // this is the Atlantis proxy WebSocket + } + + override fun onMessage(webSocket: WebSocket, text: String) { + log("onMessage (text): $text") + } + + override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { + log("onMessage (binary): ${bytes.size} bytes") + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + log("onClosing: code=$code reason=$reason") + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + log("onClosed: code=$code reason=$reason") + wsClosed.complete(Unit) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + log("onFailure: ${t.message ?: t.javaClass.simpleName}") + wsClosed.complete(Unit) + } + } + + val request = Request.Builder() + .url(WS_URL) + .build() + + val atlantisListener = Atlantis.wrapWebSocketListener(userListener) + client.newWebSocket(request, atlantisListener) + + val ws = withTimeoutOrNull(10_000) { wsOpen.await() } + if (ws == null) { + log("Timeout: did not receive onOpen within 10s") + return + } + + delay(1000) + val text = "Hello from Atlantis Android!" + log("send (text): $text") + ws.send(text) + + delay(1000) + val json = """{"type":"test","timestamp":${System.currentTimeMillis()},"data":{"key":"value"}}""" + log("send (json): $json") + ws.send(json) + + delay(1000) + val binaryPayload = byteArrayOf(0x00, 0x01, 0x02, 0x7F, 0x10, 0x11, 0x12) + log("send (binary): ${binaryPayload.size} bytes") + ws.send(binaryPayload.toByteString()) + + delay(1000) + log("close: code=1000 reason=done") + ws.close(1000, "done") + + withTimeoutOrNull(10_000) { wsClosed.await() } + log("Test finished") + } + + private fun log(message: String) { + val ts = timestamp() + val line = "[$ts] $message" + _logText.value = buildString { + val current = _logText.value + if (current.isNotBlank()) { + append(current) + append("\n") + } + append(line) + } + } + + private fun timestamp(): String { + val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + return fmt.format(Date()) + } +} + diff --git a/atlantis-android/sample/src/main/res/layout/activity_main.xml b/atlantis-android/sample/src/main/res/layout/activity_main.xml index c1b7eb3..e340b96 100644 --- a/atlantis-android/sample/src/main/res/layout/activity_main.xml +++ b/atlantis-android/sample/src/main/res/layout/activity_main.xml @@ -67,6 +67,13 @@ android:layout_height="wrap_content" android:text="Error Request (404)" /> + + @@ -93,7 +100,7 @@ android:background="@android:color/white" android:fontFamily="monospace" android:padding="8dp" - android:text="Press a button to make a request.\nWatch Proxyman for captured traffic!" + android:text="Press a button to make a request or start WebSocket test.\nWatch Proxyman for captured traffic!" android:textSize="12sp" /> From a46c864a197118c22227e844b602c68557f95e82 Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Fri, 6 Feb 2026 13:41:37 +0100 Subject: [PATCH 8/8] Create publish.sh --- atlantis-android/publish.sh | 277 ++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100755 atlantis-android/publish.sh diff --git a/atlantis-android/publish.sh b/atlantis-android/publish.sh new file mode 100755 index 0000000..c0516b6 --- /dev/null +++ b/atlantis-android/publish.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# publish.sh — Publish Atlantis Android to JitPack and/or Maven Central. +# +# Usage: +# ./publish.sh --version 1.2.0 # publish to both +# ./publish.sh --target jitpack --version 1.2.0 +# ./publish.sh --target maven-central --version 1.2.0 +# ./publish.sh --version 1.2.0 --dry-run +# +# Flags: +# --target jitpack | maven-central | both (optional, defaults to both) +# --version Semver string (required, e.g. 1.2.0 or 1.2.0-SNAPSHOT) +# --dry-run Skip destructive actions (optional) + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Colors +# --------------------------------------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +step_num=0 + +step() { + step_num=$((step_num + 1)) + echo -e "\n${CYAN}${BOLD}[Step ${step_num}]${NC} ${BOLD}$1${NC}" +} + +info() { + echo -e " ${GREEN}✓${NC} $1" +} + +warn() { + echo -e " ${YELLOW}⚠${NC} $1" +} + +fail() { + echo -e " ${RED}✗ ERROR:${NC} $1" >&2 + exit 1 +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +TARGET="" +VERSION="" +DRY_RUN=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + TARGET="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + -h|--help) + echo "Usage: $0 [--target ] --version [--dry-run]" + exit 0 + ;; + *) + fail "Unknown argument: $1" + ;; + esac +done + +# Default to "both" when --target is omitted +if [[ -z "$TARGET" ]]; then + TARGET="both" +fi + +if [[ "$TARGET" != "jitpack" && "$TARGET" != "maven-central" && "$TARGET" != "both" ]]; then + fail "--target must be 'jitpack', 'maven-central', or 'both', got '$TARGET'" +fi + +if [[ -z "$VERSION" ]]; then + fail "--version is required (e.g. 1.2.0)" +fi + +if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-SNAPSHOT)?$'; then + fail "Invalid version format '$VERSION'. Expected semver like 1.2.0 or 1.2.0-SNAPSHOT" +fi + +# --------------------------------------------------------------------------- +# Resolve paths — script must run from atlantis-android/ +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +GRADLE_PROPS="gradle.properties" + +if [[ ! -f "$GRADLE_PROPS" ]]; then + fail "Cannot find $GRADLE_PROPS. Are you in the atlantis-android directory?" +fi + +if [[ ! -f "gradlew" ]]; then + fail "Cannot find gradlew. Are you in the atlantis-android directory?" +fi + +# --------------------------------------------------------------------------- +# Banner +# --------------------------------------------------------------------------- +echo -e "${BOLD}========================================${NC}" +echo -e "${BOLD} Atlantis Android — Publish${NC}" +echo -e "${BOLD}========================================${NC}" +echo -e " Target: ${CYAN}${TARGET}${NC}" +echo -e " Version: ${CYAN}${VERSION}${NC}" +echo -e " Dry run: ${CYAN}${DRY_RUN}${NC}" +echo "" + +# --------------------------------------------------------------------------- +# Step 1: Validate prerequisites +# --------------------------------------------------------------------------- +step "Validating prerequisites" + +command -v java >/dev/null 2>&1 || fail "'java' not found. Install JDK 17+." +info "java found: $(java -version 2>&1 | head -1)" + +if [[ "$TARGET" == "jitpack" || "$TARGET" == "both" ]]; then + command -v gh >/dev/null 2>&1 || fail "'gh' (GitHub CLI) not found. Install via: brew install gh" + info "gh found: $(gh --version | head -1)" +fi + +if [[ "$TARGET" == "maven-central" || "$TARGET" == "both" ]]; then + command -v gpg >/dev/null 2>&1 || fail "'gpg' not found. Install via: brew install gnupg" + info "gpg found: $(gpg --version | head -1)" +fi + +# --------------------------------------------------------------------------- +# Step 2: Update version in gradle.properties +# --------------------------------------------------------------------------- +step "Updating version in $GRADLE_PROPS" + +# Read current VERSION_CODE and increment +CURRENT_CODE=$(grep '^VERSION_CODE=' "$GRADLE_PROPS" | cut -d'=' -f2) +NEW_CODE=$((CURRENT_CODE + 1)) + +# Replace VERSION_NAME +sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=${VERSION}/" "$GRADLE_PROPS" +# Replace VERSION_CODE +sed -i '' "s/^VERSION_CODE=.*/VERSION_CODE=${NEW_CODE}/" "$GRADLE_PROPS" + +info "VERSION_NAME → ${VERSION}" +info "VERSION_CODE → ${NEW_CODE} (was ${CURRENT_CODE})" + +# --------------------------------------------------------------------------- +# Step 3: Run unit tests +# --------------------------------------------------------------------------- +step "Running unit tests" + +./gradlew :atlantis:test --no-daemon +info "All tests passed" + +# --------------------------------------------------------------------------- +# Step 4: Build release AAR +# --------------------------------------------------------------------------- +step "Building release AAR" + +./gradlew :atlantis:assembleRelease --no-daemon +info "Release AAR built successfully" + +# --------------------------------------------------------------------------- +# Step 5: Publish to Maven Local (smoke test) +# --------------------------------------------------------------------------- +step "Publishing to Maven Local (smoke test)" + +./gradlew :atlantis:publishToMavenLocal --no-daemon +info "Published to Maven Local (~/.m2/repository/com/proxyman/atlantis-android/${VERSION}/)" + +# --------------------------------------------------------------------------- +# Target-specific steps +# --------------------------------------------------------------------------- + +# --- Maven Central: check creds + publish to Sonatype (before tagging) ----- +if [[ "$TARGET" == "maven-central" || "$TARGET" == "both" ]]; then + step "Checking Maven Central credentials" + + GRADLE_HOME_PROPS="$HOME/.gradle/gradle.properties" + if [[ ! -f "$GRADLE_HOME_PROPS" ]]; then + fail "~/.gradle/gradle.properties not found. See PUBLISHING.md for setup instructions." + fi + + for key in ossrhUsername ossrhPassword signing.keyId signing.password signing.secretKeyRingFile; do + if ! grep -q "^${key}=" "$GRADLE_HOME_PROPS" 2>/dev/null; then + fail "Missing '${key}' in ~/.gradle/gradle.properties" + fi + done + info "All required credentials found in ~/.gradle/gradle.properties" + + step "Publishing to Sonatype staging repository" + + if [[ "$DRY_RUN" == true ]]; then + warn "[dry-run] Would publish to Sonatype staging" + else + ./gradlew :atlantis:publishReleasePublicationToSonatypeRepository --no-daemon + info "Published to Sonatype staging repository" + fi +fi + +# --- Git: commit version bump, tag, push (shared, runs once) --------------- +step "Committing version bump" + +if [[ "$DRY_RUN" == true ]]; then + warn "[dry-run] Would commit gradle.properties changes" +else + git add "$GRADLE_PROPS" + git commit -m "chore: bump version to ${VERSION}" + info "Committed version bump" +fi + +step "Creating git tag v${VERSION}" + +if [[ "$DRY_RUN" == true ]]; then + warn "[dry-run] Would create and push tag v${VERSION}" +else + git tag -a "v${VERSION}" -m "Release version ${VERSION}" + git push origin HEAD + git push origin "v${VERSION}" + info "Tag v${VERSION} pushed to origin" +fi + +# --- JitPack: create GitHub release ----------------------------------------- +if [[ "$TARGET" == "jitpack" || "$TARGET" == "both" ]]; then + step "Creating GitHub release" + + if [[ "$DRY_RUN" == true ]]; then + warn "[dry-run] Would create GitHub release v${VERSION}" + else + gh release create "v${VERSION}" \ + --title "v${VERSION}" \ + --generate-notes + info "GitHub release v${VERSION} created" + fi +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" + +if [[ "$TARGET" == "maven-central" || "$TARGET" == "both" ]]; then + echo -e "${GREEN}${BOLD}Published to Sonatype staging!${NC} Complete the release manually:" + echo -e " 1. Log in to ${CYAN}https://s01.oss.sonatype.org${NC}" + echo -e " 2. Go to ${BOLD}Staging Repositories${NC}" + echo -e " 3. Find your repository (${BOLD}comproxyman-XXXX${NC})" + echo -e " 4. Click ${BOLD}Close${NC} → wait for validation → click ${BOLD}Release${NC}" + echo -e " 5. Artifacts sync to Maven Central in ~10-30 minutes" + echo "" + echo -e " Verify: ${CYAN}https://repo1.maven.org/maven2/com/proxyman/atlantis-android/${VERSION}/${NC}" + echo "" +fi + +if [[ "$TARGET" == "jitpack" || "$TARGET" == "both" ]]; then + echo -e "${GREEN}${BOLD}JitPack ready!${NC} Builds automatically when the dependency is first requested." + echo -e " JitPack status: ${CYAN}https://jitpack.io/#ProxymanApp/atlantis${NC}" + echo "" + echo -e " Users can add the dependency:" + echo -e " ${BOLD}implementation(\"com.github.ProxymanApp:atlantis:v${VERSION}\")${NC}" + echo "" +fi + +echo "" +echo -e "${GREEN}${BOLD}All done.${NC}"