diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 1afbdd8..f000b4d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -9,125 +9,72 @@ on:
- main
jobs:
- build-and-test:
+ 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: 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: List SwiftPM schemes
- run: xcodebuild -workspace .swiftpm/xcode/package.xcworkspace -list
+ - name: Run SwiftPM Tests
+ run: swift test
- - name: Install xcpretty
- run: sudo gem install xcpretty
+ android-test:
+ name: Android Tests
+ runs-on: ubuntu-latest
- - 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"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
- - 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
\ No newline at end of file
+ - 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/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..bdb37c9
--- /dev/null
+++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt
@@ -0,0 +1,453 @@
+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
+
+/**
+ * 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: - WebSocket caches (mirrors iOS Atlantis.swift)
+
+ private val webSocketPackages = ConcurrentHashMap()
+ private val waitingWebsocketPackages = ConcurrentHashMap>()
+ private val wsLock = Any()
+
+ // 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
+
+ synchronized(wsLock) {
+ webSocketPackages.clear()
+ waitingWebsocketPackages.clear()
+ }
+
+ 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
+ }
+
+ /**
+ * 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)
+
+ /**
+ * 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: - 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
+
+ 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)
+}
+
+/**
+ * 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
new file mode 100644
index 0000000..ff955ea
--- /dev/null
+++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt
@@ -0,0 +1,283 @@
+package com.proxyman.atlantis
+
+import okhttp3.Interceptor
+import okhttp3.MediaType
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.Response
+import okio.Buffer
+import okio.BufferedSink
+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 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()
+ * .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 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 originalRequest = chain.request()
+ val requestId = UUID.randomUUID().toString()
+ val startTime = System.currentTimeMillis() / 1000.0
+
+ // 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 FIRST - this is the priority
+ // Atlantis should NEVER block or fail the actual HTTP request
+ val response: Response
+
+ try {
+ response = chain.proceed(requestToSend)
+ } catch (e: IOException) {
+ // 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
+ }
+
+ // 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)
+ 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
+ }
+
+ /**
+ * 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 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
+
+ // 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
+ }
+
+ // 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 = processedBody
+ )
+ }
+
+ /**
+ * Process captured request body (e.g., decompress gzip)
+ */
+ 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()
+ } catch (e: Exception) {
+ data // Return original if decompression fails
+ }
+ }
+ return data
+ }
+
+ /**
+ * 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 (best effort)
+ 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
+ * 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
+
+ // 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
+ // 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()
+
+ // 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) {
+ // 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/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/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..83d1513
--- /dev/null
+++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt
@@ -0,0 +1,60 @@
+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) {
+ 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) {
+ 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..036ff13
--- /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?.let { Base64Utils.encode(it) }
+ 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?.let { Base64Utils.encode(it) }
+ 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?.let { Base64Utils.encode(it) }
+ 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..727e35a
--- /dev/null
+++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt
@@ -0,0 +1,361 @@
+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,
+
+ @SerializedName("websocketMessagePackage")
+ var websocketMessagePackage: WebsocketMessagePackage? = null
+) : 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,
+ 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
+ )
+ }
+ }
+}
+
+/**
+ * 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..a9155a7
--- /dev/null
+++ b/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt
@@ -0,0 +1,376 @@
+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.nio.ByteOrder
+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).order(ByteOrder.LITTLE_ENDIAN)
+ 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..7113e82
--- /dev/null
+++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt
@@ -0,0 +1,259 @@
+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 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
+ 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/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/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..ddb8605
--- /dev/null
+++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt
@@ -0,0 +1,114 @@
+package com.proxyman.atlantis
+
+import com.google.gson.Gson
+import org.junit.Assert.*
+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`() {
+ 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\""))
+
+ val content = extractContent(json)
+ assertNotNull(content)
+ val decoded = Base64Utils.decode(content!!).toString(Charsets.UTF_8)
+ val expectedPayload = gson.toJson(testPackage)
+ assertEquals(expectedPayload, decoded)
+ }
+
+ @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\""))
+
+ val content = extractContent(json)
+ assertNotNull(content)
+ val decoded = Base64Utils.decode(content!!).toString(Charsets.UTF_8)
+ val expectedPayload = gson.toJson(testPackage)
+ assertEquals(expectedPayload, decoded)
+ }
+
+ @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\""))
+
+ val content = extractContent(json)
+ assertNotNull(content)
+ val decoded = Base64Utils.decode(content!!).toString(Charsets.UTF_8)
+ 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 {
+ 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..4134068
--- /dev/null
+++ b/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt
@@ -0,0 +1,283 @@
+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 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`() {
+ 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 0000000..980502d
Binary files /dev/null and b/atlantis-android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/atlantis-android/gradle/wrapper/gradle-wrapper.properties b/atlantis-android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..b82aa23
--- /dev/null
+++ b/atlantis-android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/atlantis-android/gradlew b/atlantis-android/gradlew
new file mode 100755
index 0000000..faf9300
--- /dev/null
+++ b/atlantis-android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright Β© 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β»,
+# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»;
+# * compound commands having a testable exit status, especially Β«caseΒ»;
+# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ 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/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}"
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..402a0a3
--- /dev/null
+++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt
@@ -0,0 +1,289 @@
+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.Transporter
+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
+ 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) {
+ 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 shared from Application (also used by WebSocket test)
+ private val okHttpClient: OkHttpClient by lazy {
+ (application as SampleApplication).okHttpClient
+ }
+
+ // Retrofit instance using the OkHttpClient
+ private val retrofit by lazy {
+ Retrofit.Builder()
+ .baseUrl("https://httpbin.proxyman.app/")
+ .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)
+
+ Atlantis.setConnectionListener(connectionListener)
+ setupUI()
+
+ observeWebSocketLogs()
+ }
+
+ override fun onDestroy() {
+ Atlantis.setConnectionListener(null)
+ super.onDestroy()
+ }
+
+ private fun setupUI() {
+ binding.btnGetRequest.setOnClickListener {
+ makeGetRequest()
+ }
+
+ binding.btnPostRequest.setOnClickListener {
+ makePostRequest()
+ }
+
+ binding.btnRetrofitRequest.setOnClickListener {
+ makeRetrofitRequest()
+ }
+
+ binding.btnJsonRequest.setOnClickListener {
+ makeJsonRequest()
+ }
+
+ binding.btnErrorRequest.setOnClickListener {
+ makeErrorRequest()
+ }
+
+ binding.btnStartWebSocketTest.setOnClickListener {
+ WebSocketTestController.startAutoTest(okHttpClient)
+ }
+
+ updateStatus()
+ updateLogView()
+ }
+
+ private fun updateStatus() {
+ 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
+ }
+
+ 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 {
+ 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 {
+ httpLog = "$title:\n\n${result.take(500)}"
+ updateLogView()
+ Toast.makeText(this, "$title completed!", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun showError(title: String, e: Exception) {
+ Log.e(TAG, title, e)
+ runOnUiThread {
+ httpLog = "$title:\n\nError: ${e.message}"
+ updateLogView()
+ 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..e7247c6
--- /dev/null
+++ b/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt
@@ -0,0 +1,32 @@
+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()
+
+ // 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")
+ }
+
+ // 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
new file mode 100644
index 0000000..e340b96
--- /dev/null
+++ b/atlantis-android/sample/src/main/res/layout/activity_main.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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")