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