From 1623cdc468ac14d5663192de0885f15b68e3689b Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Fri, 6 Feb 2026 21:14:58 +0100 Subject: [PATCH 1/2] Remove android, move to new repo --- README.md | 6 - Sources/Atlantis.swift | 2 +- atlantis-android/.gitignore | 52 -- atlantis-android/PUBLISHING.md | 356 -------------- atlantis-android/README.md | 129 ----- atlantis-android/atlantis/build.gradle.kts | 115 ----- atlantis-android/atlantis/consumer-rules.pro | 10 - atlantis-android/atlantis/proguard-rules.pro | 23 - .../atlantis/src/main/AndroidManifest.xml | 14 - .../kotlin/com/proxyman/atlantis/Atlantis.kt | 453 ------------------ .../proxyman/atlantis/AtlantisInterceptor.kt | 283 ----------- .../atlantis/AtlantisWebSocketListener.kt | 132 ----- .../com/proxyman/atlantis/Base64Utils.kt | 24 - .../com/proxyman/atlantis/Configuration.kt | 54 --- .../com/proxyman/atlantis/GzipCompression.kt | 60 --- .../kotlin/com/proxyman/atlantis/Message.kt | 105 ---- .../proxyman/atlantis/NsdServiceDiscovery.kt | 202 -------- .../kotlin/com/proxyman/atlantis/Packages.kt | 361 -------------- .../com/proxyman/atlantis/Transporter.kt | 376 --------------- .../atlantis/AtlantisInterceptorTest.kt | 259 ---------- .../atlantis/AtlantisWebSocketTest.kt | 285 ----------- .../proxyman/atlantis/GzipCompressionTest.kt | 120 ----- .../com/proxyman/atlantis/MessageTest.kt | 114 ----- .../com/proxyman/atlantis/PackagesTest.kt | 283 ----------- atlantis-android/build.gradle.kts | 11 - atlantis-android/gradle.properties | 48 -- .../gradle/wrapper/gradle-wrapper.jar | Bin 43739 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - atlantis-android/gradlew | 251 ---------- atlantis-android/gradlew.bat | 94 ---- atlantis-android/publish.sh | 277 ----------- atlantis-android/sample/build.gradle.kts | 70 --- atlantis-android/sample/proguard-rules.pro | 1 - .../sample/src/main/AndroidManifest.xml | 32 -- .../proxyman/atlantis/sample/MainActivity.kt | 289 ----------- .../atlantis/sample/SampleApplication.kt | 32 -- .../sample/WebSocketTestController.kt | 155 ------ .../src/main/res/layout/activity_main.xml | 108 ----- .../src/main/res/mipmap-hdpi/ic_launcher.xml | 5 - .../res/mipmap-hdpi/ic_launcher_round.xml | 5 - .../sample/src/main/res/values/colors.xml | 10 - .../sample/src/main/res/values/strings.xml | 4 - .../sample/src/main/res/values/themes.xml | 12 - .../main/res/xml/network_security_config.xml | 10 - atlantis-android/settings.gradle.kts | 19 - atlantis-proxyman.podspec | 31 -- 46 files changed, 1 insertion(+), 5288 deletions(-) delete mode 100644 atlantis-android/.gitignore delete mode 100644 atlantis-android/PUBLISHING.md delete mode 100644 atlantis-android/README.md delete mode 100644 atlantis-android/atlantis/build.gradle.kts delete mode 100644 atlantis-android/atlantis/consumer-rules.pro delete mode 100644 atlantis-android/atlantis/proguard-rules.pro delete mode 100644 atlantis-android/atlantis/src/main/AndroidManifest.xml delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisWebSocketListener.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Base64Utils.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Configuration.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/NsdServiceDiscovery.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt delete mode 100644 atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt delete mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt delete mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisWebSocketTest.kt delete mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/GzipCompressionTest.kt delete mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt delete mode 100644 atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt delete mode 100644 atlantis-android/build.gradle.kts delete mode 100644 atlantis-android/gradle.properties delete mode 100644 atlantis-android/gradle/wrapper/gradle-wrapper.jar delete mode 100644 atlantis-android/gradle/wrapper/gradle-wrapper.properties delete mode 100755 atlantis-android/gradlew delete mode 100644 atlantis-android/gradlew.bat delete mode 100755 atlantis-android/publish.sh delete mode 100644 atlantis-android/sample/build.gradle.kts delete mode 100644 atlantis-android/sample/proguard-rules.pro delete mode 100644 atlantis-android/sample/src/main/AndroidManifest.xml delete mode 100644 atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt delete mode 100644 atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt delete mode 100644 atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/WebSocketTestController.kt delete mode 100644 atlantis-android/sample/src/main/res/layout/activity_main.xml delete mode 100644 atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher.xml delete mode 100644 atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher_round.xml delete mode 100644 atlantis-android/sample/src/main/res/values/colors.xml delete mode 100644 atlantis-android/sample/src/main/res/values/strings.xml delete mode 100644 atlantis-android/sample/src/main/res/values/themes.xml delete mode 100644 atlantis-android/sample/src/main/res/xml/network_security_config.xml delete mode 100644 atlantis-android/settings.gradle.kts delete mode 100644 atlantis-proxyman.podspec diff --git a/README.md b/README.md index b551123..ada5e59 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,6 @@ ### Swift Packages Manager (Recommended) - Add `https://github.com/ProxymanApp/atlantis` to your project -### CocoaPod (Deprecated) -- Add the following line to your Podfile -```bash -pod 'atlantis-proxyman' -``` - ### 2. Add Required settings to `Info.plist` 1. Open your iOS Project -> Open the `Info.plist` file and add the following keys and values: diff --git a/Sources/Atlantis.swift b/Sources/Atlantis.swift index cc96bc9..174feda 100644 --- a/Sources/Atlantis.swift +++ b/Sources/Atlantis.swift @@ -81,7 +81,7 @@ public final class Atlantis: NSObject { /// Build version of Atlantis /// It's essential for Proxyman to known if it's compatible with this version /// Instead of receving the number from the info.plist, we should hardcode here because the info file doesn't exist in SPM - public static let buildVersion: String = "1.33.0" + public static let buildVersion: String = "1.34.0" /// Start Swizzle all network functions and monitoring the traffic /// It also starts looking Bonjour network from Proxyman app. diff --git a/atlantis-android/.gitignore b/atlantis-android/.gitignore deleted file mode 100644 index f3acf65..0000000 --- a/atlantis-android/.gitignore +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index 06fe2dc..0000000 --- a/atlantis-android/PUBLISHING.md +++ /dev/null @@ -1,356 +0,0 @@ -# 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 deleted file mode 100644 index 63d66be..0000000 --- a/atlantis-android/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# 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 deleted file mode 100644 index 41aa29b..0000000 --- a/atlantis-android/atlantis/build.gradle.kts +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 88528ed..0000000 --- a/atlantis-android/atlantis/consumer-rules.pro +++ /dev/null @@ -1,10 +0,0 @@ -# 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 deleted file mode 100644 index f85333d..0000000 --- a/atlantis-android/atlantis/proguard-rules.pro +++ /dev/null @@ -1,23 +0,0 @@ -# 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 deleted file mode 100644 index f70bc8e..0000000 --- a/atlantis-android/atlantis/src/main/AndroidManifest.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - 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 deleted file mode 100644 index bdb37c9..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Atlantis.kt +++ /dev/null @@ -1,453 +0,0 @@ -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 deleted file mode 100644 index ff955ea..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisInterceptor.kt +++ /dev/null @@ -1,283 +0,0 @@ -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 deleted file mode 100644 index 9b9042e..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/AtlantisWebSocketListener.kt +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index 65130bb..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Base64Utils.kt +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 411d950..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Configuration.kt +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 83d1513..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/GzipCompression.kt +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 036ff13..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Message.kt +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 2dfa892..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/NsdServiceDiscovery.kt +++ /dev/null @@ -1,202 +0,0 @@ -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 deleted file mode 100644 index 727e35a..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Packages.kt +++ /dev/null @@ -1,361 +0,0 @@ -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 deleted file mode 100644 index a9155a7..0000000 --- a/atlantis-android/atlantis/src/main/kotlin/com/proxyman/atlantis/Transporter.kt +++ /dev/null @@ -1,376 +0,0 @@ -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 deleted file mode 100644 index 7113e82..0000000 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisInterceptorTest.kt +++ /dev/null @@ -1,259 +0,0 @@ -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 deleted file mode 100644 index 176dcaf..0000000 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/AtlantisWebSocketTest.kt +++ /dev/null @@ -1,285 +0,0 @@ -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 deleted file mode 100644 index d0d799e..0000000 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/GzipCompressionTest.kt +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index ddb8605..0000000 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/MessageTest.kt +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100644 index 4134068..0000000 --- a/atlantis-android/atlantis/src/test/kotlin/com/proxyman/atlantis/PackagesTest.kt +++ /dev/null @@ -1,283 +0,0 @@ -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 deleted file mode 100644 index 481f5f8..0000000 --- a/atlantis-android/build.gradle.kts +++ /dev/null @@ -1,11 +0,0 @@ -// 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 deleted file mode 100644 index 1cc0827..0000000 --- a/atlantis-android/gradle.properties +++ /dev/null @@ -1,48 +0,0 @@ -# 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.33.0 -VERSION_CODE=13300 -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/ProxymanApp/atlantis -POM_SCM_URL=https://github.com/ProxymanApp/atlantis -POM_SCM_CONNECTION=scm:git:git://github.com/ProxymanApp/atlantis.git -POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/ProxymanApp/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=proxymanllc -POM_DEVELOPER_NAME=Proxyman LLC diff --git a/atlantis-android/gradle/wrapper/gradle-wrapper.jar b/atlantis-android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 980502d167d3610f88fa03b2f717935189d9fbcf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43739 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 z 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 '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/atlantis-android/gradlew.bat b/atlantis-android/gradlew.bat deleted file mode 100644 index 9d21a21..0000000 --- a/atlantis-android/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@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 deleted file mode 100755 index c0516b6..0000000 --- a/atlantis-android/publish.sh +++ /dev/null @@ -1,277 +0,0 @@ -#!/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 deleted file mode 100644 index 51bcad9..0000000 --- a/atlantis-android/sample/build.gradle.kts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index fb164d6..0000000 --- a/atlantis-android/sample/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ -# Add project specific ProGuard rules here. diff --git a/atlantis-android/sample/src/main/AndroidManifest.xml b/atlantis-android/sample/src/main/AndroidManifest.xml deleted file mode 100644 index 7c6ba8b..0000000 --- a/atlantis-android/sample/src/main/AndroidManifest.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - - - - - - - - - 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 deleted file mode 100644 index 402a0a3..0000000 --- a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/MainActivity.kt +++ /dev/null @@ -1,289 +0,0 @@ -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 deleted file mode 100644 index e7247c6..0000000 --- a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/SampleApplication.kt +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 2a03c53..0000000 --- a/atlantis-android/sample/src/main/kotlin/com/proxyman/atlantis/sample/WebSocketTestController.kt +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index e340b96..0000000 --- a/atlantis-android/sample/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 deleted file mode 100644 index 5ca770d..0000000 --- a/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - 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 deleted file mode 100644 index 5ca770d..0000000 --- a/atlantis-android/sample/src/main/res/mipmap-hdpi/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/atlantis-android/sample/src/main/res/values/colors.xml b/atlantis-android/sample/src/main/res/values/colors.xml deleted file mode 100644 index ca1931b..0000000 --- a/atlantis-android/sample/src/main/res/values/colors.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - #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 deleted file mode 100644 index 4f12c92..0000000 --- a/atlantis-android/sample/src/main/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - Atlantis Sample - diff --git a/atlantis-android/sample/src/main/res/values/themes.xml b/atlantis-android/sample/src/main/res/values/themes.xml deleted file mode 100644 index 1eae6fc..0000000 --- a/atlantis-android/sample/src/main/res/values/themes.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - 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 deleted file mode 100644 index df4353f..0000000 --- a/atlantis-android/sample/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/atlantis-android/settings.gradle.kts b/atlantis-android/settings.gradle.kts deleted file mode 100644 index a839f68..0000000 --- a/atlantis-android/settings.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "atlantis-android" -include(":atlantis") -include(":sample") diff --git a/atlantis-proxyman.podspec b/atlantis-proxyman.podspec deleted file mode 100644 index 731ee08..0000000 --- a/atlantis-proxyman.podspec +++ /dev/null @@ -1,31 +0,0 @@ -Pod::Spec.new do |spec| - spec.name = "atlantis-proxyman" - spec.version = "1.31.0" - spec.summary = "A iOS framework for intercepting HTTP/HTTPS Traffic without Proxy and Certificate config" - spec.description = <<-DESC - ✅ A iOS framework (Developed and Maintained by Proxyman Team) for intercepting HTTP/HTTPS Traffic from your app. No more messing around with proxy, certificate config. - ✅ Automatically intercept all HTTP/HTTPS Traffic from your app - ✅ No need to config HTTP Proxy, Install or Trust any Certificate - Review Request/Response from Proxyman macOS - Categorize the log by project and devices. - DESC - - spec.homepage = "https://proxyman.com/" - spec.documentation_url = 'https://github.com/ProxymanApp/atlantis' - spec.screenshots = "https://raw.githubusercontent.com/ProxymanApp/atlantis/refs/heads/main/images/Atlantis_Dashboard_1.jpg" - spec.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } - - spec.author = { "Proxyman LLC" => "nghia@proxyman.com" } - spec.social_media_url = "https://x.com/proxyman_app" - - spec.ios.deployment_target = "13.0" - spec.osx.deployment_target = "10.15" - spec.tvos.deployment_target = '13.0' - spec.watchos.deployment_target = '10.0' - spec.visionos.deployment_target = '1.0' - spec.module_name = "Atlantis" - - spec.source = { :git => "https://github.com/ProxymanApp/atlantis.git", :tag => "#{spec.version}" } - spec.source_files = 'Sources/*.swift' - spec.swift_versions = ['5.0', '5.1', '5.2', '5.3'] -end From 71be88429edfc5f45d6ee53581cadaac655eec3c Mon Sep 17 00:00:00 2001 From: Nghia Tran Date: Fri, 6 Feb 2026 21:16:13 +0100 Subject: [PATCH 2/2] Fix ci --- .github/workflows/main.yml | 41 +------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f000b4d..3acb6af 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,43 +38,4 @@ jobs: ${{ runner.os }}-swiftpm- - name: Run SwiftPM Tests - run: swift test - - android-test: - name: Android Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: 'temurin' - - - name: Cache Gradle packages - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - working-directory: atlantis-android - run: chmod +x gradlew - - - name: Run Android Unit Tests - working-directory: atlantis-android - run: ./gradlew :atlantis:test --no-daemon - - - name: Upload Test Results - if: always() - uses: actions/upload-artifact@v4 - with: - name: android-test-results - path: atlantis-android/atlantis/build/reports/tests/ \ No newline at end of file + run: swift test \ No newline at end of file