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 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 980502d..0000000 Binary files a/atlantis-android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/atlantis-android/gradle/wrapper/gradle-wrapper.properties b/atlantis-android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index b82aa23..0000000 --- a/atlantis-android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/atlantis-android/gradlew b/atlantis-android/gradlew deleted file mode 100755 index faf9300..0000000 --- a/atlantis-android/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/atlantis-android/gradlew.bat b/atlantis-android/gradlew.bat 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