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