Android SDK (#183)

* Android SDK

* Run Android Unit Tests

* Fix sample app

* Bonjour service is working

* Update main.yml

* Update main.yml

* Websocket for OKHTTP

* Create publish.sh
This commit is contained in:
Noah Tran
2026-02-06 13:45:55 +01:00
committed by GitHub
parent ca081924f7
commit 4e7c12deb7
45 changed files with 5470 additions and 106 deletions
+53 -106
View File
@@ -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
- 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/
+167
View File
@@ -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()
</details>
---
# 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
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
```
## 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?
+52
View File
@@ -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
*~
+356
View File
@@ -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<MavenPublication>("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
+129
View File
@@ -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)
+115
View File
@@ -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<MavenPublication>("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? ?: "")
}
}
}
}
}
}
@@ -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.** { *; }
+23
View File
@@ -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.**
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for network communication -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required for checking network state -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Required for NSD (Network Service Discovery) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
</manifest>
@@ -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 <a href="https://proxyman.io">Proxyman</a>
* @see <a href="https://github.com/nicksantamaria/atlantis">GitHub Repository</a>
*/
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<Context>? = null
private var transporter: Transporter? = null
private var configuration: Configuration? = null
private var delegate: WeakReference<AtlantisDelegate>? = null
private val isEnabled = AtomicBoolean(false)
private val interceptor = AtlantisInterceptor()
// MARK: - WebSocket caches (mirrors iOS Atlantis.swift)
private val webSocketPackages = ConcurrentHashMap<String, TrafficPackage>()
private val waitingWebsocketPackages = ConcurrentHashMap<String, MutableList<TrafficPackage>>()
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<Message> = 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<String, String> {
if (headers.size == 0) return emptyMap()
val map = LinkedHashMap<String, String>(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)
}
@@ -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<String, String>()
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<com.proxyman.atlantis.Response, String> {
val statusCode = response.code
// Capture headers
val headers = mutableMapOf<String, String>()
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 "<Body too large>".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)
}
}
}
@@ -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()
}
}
}
@@ -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)
}
}
@@ -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
}
}
}
}
@@ -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()
}
}
@@ -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
}
}
@@ -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"))
}
}
@@ -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<Header>,
@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<String, String>,
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<Header>
) {
companion object {
/**
* Create from OkHttp response components
*/
fun fromOkHttp(statusCode: Int, headers: Map<String, String>): 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
}
}
}
@@ -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<Serializable>()
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")
}
}
@@ -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() }
}
}
@@ -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<String, TrafficPackage>()
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<String, Any> ?: error("no websocketMessagePackage")
return wsPkg["messageType"] as String
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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\""))
}
}
+11
View File
@@ -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)
}
+48
View File
@@ -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
Binary file not shown.
@@ -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
Vendored Executable
+251
View File
@@ -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" "$@"
+94
View File
@@ -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
+277
View File
@@ -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 <jitpack|maven-central|both>] --version <SEMVER> [--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}"
+70
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# Add project specific ProGuard rules here.
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<application
android:name=".SampleApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AtlantisSample"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.AtlantisSample">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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?
)
@@ -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()
}
}
@@ -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<String> = _logText.asStateFlow()
private val _isTestRunning = MutableStateFlow(false)
val isTestRunning: StateFlow<Boolean> = _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<WebSocket>()
val wsClosed = CompletableDeferred<Unit>()
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())
}
}
@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Atlantis Sample App"
android:textSize="24sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Atlantis Status"
android:textColor="@android:color/darker_gray"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvTitle" />
<LinearLayout
android:id="@+id/buttonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/tvStatus">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnGetRequest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="GET Request (OkHttp)" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPostRequest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="POST Request (OkHttp)" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnRetrofitRequest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Retrofit Request" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnJsonRequest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="JSON Request (Retrofit)" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnErrorRequest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Error Request (404)" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnStartWebSocketTest"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Start WebSocket Test (Auto every 1s)" />
</LinearLayout>
<TextView
android:id="@+id/tvResultLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Log:"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/buttonContainer" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvResultLabel">
<TextView
android:id="@+id/tvResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white"
android:fontFamily="monospace"
android:padding="8dp"
android:text="Press a button to make a request or start WebSocket test.\nWatch Proxyman for captured traffic!"
android:textSize="12sp" />
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/purple_500"/>
<foreground android:drawable="@color/white"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/purple_500"/>
<foreground android:drawable="@color/white"/>
</adaptive-icon>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Atlantis Sample</string>
</resources>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AtlantisSample" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@android:color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@android:color/black</item>
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext traffic for debugging purposes -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
+19
View File
@@ -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")