diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7fb4a39
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,234 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*.*.*' # Trigger on version tags like v1.0.0
+ workflow_dispatch: # Allow manual triggering
+ inputs:
+ version:
+ description: 'Version number (e.g., 1.0.0)'
+ required: true
+ default: '1.0.0'
+
+jobs:
+ build-and-release:
+ runs-on: macos-14
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Set up Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
+
+ - name: Install Android SDK
+ run: |
+ brew install android-sdk
+ echo "ANDROID_HOME=/usr/local/share/android-sdk" >> $GITHUB_ENV
+ echo "/usr/local/share/android-sdk/platform-tools" >> $GITHUB_PATH
+
+ - name: Extract version number
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
+ VERSION="${{ github.event.inputs.version }}"
+ else
+ VERSION="${GITHUB_REF#refs/tags/v}"
+ fi
+ echo "VERSION=$VERSION" >> $GITHUB_ENV
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
+
+ - name: Build unsigned release package
+ run: |
+ cd Scripts
+ chmod +x package-release.sh
+ ./package-release.sh "${{ env.VERSION }}"
+
+ - name: Build signed release package
+ if: ${{ secrets.SIGNING_IDENTITY != '' }}
+ env:
+ SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+ run: |
+ cd Scripts
+ ./package-release.sh "${{ env.VERSION }}"
+
+ - name: Calculate checksums
+ id: checksums
+ run: |
+ cd Build/Distribution
+
+ # Check which files were created
+ UNSIGNED_FILE="Xamrock-Studio-${{ env.VERSION }}.zip"
+ SIGNED_FILE="Xamrock-Studio-${{ env.VERSION }}-Signed.zip"
+
+ if [ -f "$UNSIGNED_FILE" ]; then
+ UNSIGNED_SHA256=$(shasum -a 256 "$UNSIGNED_FILE" | cut -d ' ' -f 1)
+ echo "unsigned_sha256=$UNSIGNED_SHA256" >> $GITHUB_OUTPUT
+ echo "has_unsigned=true" >> $GITHUB_OUTPUT
+ fi
+
+ if [ -f "$SIGNED_FILE" ]; then
+ SIGNED_SHA256=$(shasum -a 256 "$SIGNED_FILE" | cut -d ' ' -f 1)
+ echo "signed_sha256=$SIGNED_SHA256" >> $GITHUB_OUTPUT
+ echo "has_signed=true" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create Release Notes
+ id: release_notes
+ run: |
+ cat > release_notes.md << 'EOF'
+ ## Xamrock Studio v${{ env.VERSION }}
+
+ ### 📥 Downloads
+
+ EOF
+
+ # Add signed version if available
+ if [ "${{ steps.checksums.outputs.has_signed }}" = "true" ]; then
+ cat >> release_notes.md << 'EOF'
+ **Recommended: Signed & Notarized** (Best for non-technical users)
+ - Download: `Xamrock-Studio-${{ env.VERSION }}-Signed.zip`
+ - ✅ **No security warnings** - Double-click to install
+ - ✅ Verified by Apple
+ - 📦 Includes iOS & Android test frameworks
+
+ EOF
+ fi
+
+ # Add unsigned version
+ if [ "${{ steps.checksums.outputs.has_unsigned }}" = "true" ]; then
+ cat >> release_notes.md << 'EOF'
+ **Open Source Build** (For developers & contributors)
+ - Download: `Xamrock-Studio-${{ env.VERSION }}.zip`
+ - ⚠️ Requires manual security approval (see instructions below)
+ - 📦 Includes iOS & Android test frameworks
+
+ EOF
+ fi
+
+ cat >> release_notes.md << 'EOF'
+
+ ### 🚀 Installation
+
+ EOF
+
+ # Signed version instructions
+ if [ "${{ steps.checksums.outputs.has_signed }}" = "true" ]; then
+ cat >> release_notes.md << 'EOF'
+ **Signed Version:**
+ 1. Download `Xamrock-Studio-${{ env.VERSION }}-Signed.zip`
+ 2. Double-click to extract
+ 3. Drag **Xamrock Studio.app** to Applications folder
+ 4. Double-click to open - **no additional steps needed!** ✨
+
+ EOF
+ fi
+
+ # Unsigned version instructions
+ if [ "${{ steps.checksums.outputs.has_unsigned }}" = "true" ]; then
+ cat >> release_notes.md << 'EOF'
+ **Unsigned Version:**
+ 1. Download `Xamrock-Studio-${{ env.VERSION }}.zip`
+ 2. Double-click to extract
+ 3. Drag **Xamrock Studio.app** to Applications folder
+ 4. **Right-click** the app → Select **"Open"** → Click **"Open"** in the dialog
+ - This is required only on first launch
+ - macOS will remember your choice for future launches
+
+
+ Why do I need to right-click?
+
+ The unsigned version uses ad-hoc signing for open-source distribution. macOS Gatekeeper requires this one-time approval. This is normal for free/open-source Mac apps.
+
+ If you prefer a smoother experience, download the signed version above.
+
+
+ EOF
+ fi
+
+ cat >> release_notes.md << 'EOF'
+
+ ### ✅ Requirements
+
+ **macOS**
+ - macOS 14.6 or later
+
+ **iOS Testing**
+ - Xcode 14.0 or later
+
+ **Android Testing**
+ - Android SDK Platform Tools (`adb`)
+ - Java JDK 17 or later
+
+ ### 🔒 Checksums
+
+ Verify your download with SHA256:
+
+ EOF
+
+ if [ "${{ steps.checksums.outputs.has_signed }}" = "true" ]; then
+ cat >> release_notes.md << 'EOF'
+ **Signed version:**
+ ```
+ ${{ steps.checksums.outputs.signed_sha256 }} Xamrock-Studio-${{ env.VERSION }}-Signed.zip
+ ```
+
+ EOF
+ fi
+
+ if [ "${{ steps.checksums.outputs.has_unsigned }}" = "true" ]; then
+ cat >> release_notes.md << 'EOF'
+ **Unsigned version:**
+ ```
+ ${{ steps.checksums.outputs.unsigned_sha256 }} Xamrock-Studio-${{ env.VERSION }}.zip
+ ```
+
+ EOF
+ fi
+
+ cat >> release_notes.md << 'EOF'
+
+ Verify:
+ ```bash
+ shasum -a 256 Xamrock-Studio-*.zip
+ ```
+ EOF
+
+ # Expand variables in the markdown
+ eval "echo \"$(cat release_notes.md)\"" > release_notes_final.md
+
+ echo "RELEASE_NOTES<> $GITHUB_ENV
+ cat release_notes_final.md >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: v${{ env.VERSION }}
+ name: Xamrock Studio v${{ env.VERSION }}
+ body: ${{ env.RELEASE_NOTES }}
+ draft: false
+ prerelease: false
+ files: |
+ Build/Distribution/Xamrock-Studio-${{ env.VERSION }}*.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload build artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: xamrock-studio-${{ env.VERSION }}
+ path: Build/Distribution/*.zip
+ retention-days: 90
diff --git a/AndroidTestHost/.gradle/9.2.1/checksums/checksums.lock b/AndroidTestHost/.gradle/9.2.1/checksums/checksums.lock
new file mode 100644
index 0000000..3c97c35
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/checksums/checksums.lock differ
diff --git a/AndroidTestHost/.gradle/9.2.1/checksums/md5-checksums.bin b/AndroidTestHost/.gradle/9.2.1/checksums/md5-checksums.bin
new file mode 100644
index 0000000..50e90be
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/checksums/md5-checksums.bin differ
diff --git a/AndroidTestHost/.gradle/9.2.1/checksums/sha1-checksums.bin b/AndroidTestHost/.gradle/9.2.1/checksums/sha1-checksums.bin
new file mode 100644
index 0000000..c038d5c
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/checksums/sha1-checksums.bin differ
diff --git a/AndroidTestHost/.gradle/9.2.1/executionHistory/executionHistory.bin b/AndroidTestHost/.gradle/9.2.1/executionHistory/executionHistory.bin
new file mode 100644
index 0000000..6e9bb1d
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/executionHistory/executionHistory.bin differ
diff --git a/AndroidTestHost/.gradle/9.2.1/executionHistory/executionHistory.lock b/AndroidTestHost/.gradle/9.2.1/executionHistory/executionHistory.lock
new file mode 100644
index 0000000..8157e99
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/executionHistory/executionHistory.lock differ
diff --git a/AndroidTestHost/.gradle/9.2.1/fileChanges/last-build.bin b/AndroidTestHost/.gradle/9.2.1/fileChanges/last-build.bin
new file mode 100644
index 0000000..f76dd23
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/fileChanges/last-build.bin differ
diff --git a/AndroidTestHost/.gradle/9.2.1/fileHashes/fileHashes.bin b/AndroidTestHost/.gradle/9.2.1/fileHashes/fileHashes.bin
new file mode 100644
index 0000000..825058f
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/fileHashes/fileHashes.bin differ
diff --git a/AndroidTestHost/.gradle/9.2.1/fileHashes/fileHashes.lock b/AndroidTestHost/.gradle/9.2.1/fileHashes/fileHashes.lock
new file mode 100644
index 0000000..229c55b
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/fileHashes/fileHashes.lock differ
diff --git a/AndroidTestHost/.gradle/9.2.1/fileHashes/resourceHashesCache.bin b/AndroidTestHost/.gradle/9.2.1/fileHashes/resourceHashesCache.bin
new file mode 100644
index 0000000..48e74a7
Binary files /dev/null and b/AndroidTestHost/.gradle/9.2.1/fileHashes/resourceHashesCache.bin differ
diff --git a/AndroidTestHost/.gradle/9.2.1/gc.properties b/AndroidTestHost/.gradle/9.2.1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/AndroidTestHost/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/AndroidTestHost/.gradle/buildOutputCleanup/buildOutputCleanup.lock
new file mode 100644
index 0000000..7384d3b
Binary files /dev/null and b/AndroidTestHost/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/AndroidTestHost/.gradle/buildOutputCleanup/cache.properties b/AndroidTestHost/.gradle/buildOutputCleanup/cache.properties
new file mode 100644
index 0000000..3de0351
--- /dev/null
+++ b/AndroidTestHost/.gradle/buildOutputCleanup/cache.properties
@@ -0,0 +1,2 @@
+#Sun Jan 04 22:58:59 PST 2026
+gradle.version=9.2.1
diff --git a/AndroidTestHost/.gradle/buildOutputCleanup/outputFiles.bin b/AndroidTestHost/.gradle/buildOutputCleanup/outputFiles.bin
new file mode 100644
index 0000000..8de2514
Binary files /dev/null and b/AndroidTestHost/.gradle/buildOutputCleanup/outputFiles.bin differ
diff --git a/AndroidTestHost/.gradle/file-system.probe b/AndroidTestHost/.gradle/file-system.probe
new file mode 100644
index 0000000..997a25d
Binary files /dev/null and b/AndroidTestHost/.gradle/file-system.probe differ
diff --git a/AndroidTestHost/.gradle/vcs-1/gc.properties b/AndroidTestHost/.gradle/vcs-1/gc.properties
new file mode 100644
index 0000000..e69de29
diff --git a/AndroidTestHost/BUILD_INSTRUCTIONS.md b/AndroidTestHost/BUILD_INSTRUCTIONS.md
new file mode 100644
index 0000000..1739417
--- /dev/null
+++ b/AndroidTestHost/BUILD_INSTRUCTIONS.md
@@ -0,0 +1,57 @@
+# Building Android Test APKs
+
+## Option 1: Using Android Studio (Recommended)
+
+1. **Open the project:**
+ - Android Studio should have opened the `AndroidTestHost` folder
+ - Wait for Gradle sync to complete
+
+2. **Build the APKs:**
+ - Open the Terminal in Android Studio (View → Tool Windows → Terminal)
+ - Run: `./gradlew assembleDebug assembleDebugAndroidTest`
+
+ OR
+
+ - Build → Make Project (Cmd+F9)
+ - Then in Terminal: `./gradlew assembleDebugAndroidTest`
+
+3. **Find the built APKs:**
+ - `app/build/outputs/apk/debug/app-debug.apk`
+ - `app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk`
+
+4. **Copy APKs to Xamrock Studio bundle:**
+ ```bash
+ mkdir -p "/Users/kiloloco/Library/Developer/Xcode/DerivedData/Studio-dgcncnmufbhmpxfrqnyqamtlrojx/Build/Products/Debug/Xamrock Studio.app/Contents/Resources/AndroidTestHost"
+
+ cp app/build/outputs/apk/debug/app-debug.apk "/Users/kiloloco/Library/Developer/Xcode/DerivedData/Studio-dgcncnmufbhmpxfrqnyqamtlrojx/Build/Products/Debug/Xamrock Studio.app/Contents/Resources/AndroidTestHost/"
+
+ cp app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk "/Users/kiloloco/Library/Developer/Xcode/DerivedData/Studio-dgcncnmufbhmpxfrqnyqamtlrojx/Build/Products/Debug/Xamrock Studio.app/Contents/Resources/AndroidTestHost/"
+ ```
+
+## Option 2: Using Homebrew Gradle
+
+If Android Studio doesn't work:
+
+```bash
+# Install Gradle
+brew install gradle
+
+# Build APKs
+cd AndroidTestHost
+gradle assembleDebug assembleDebugAndroidTest
+
+# Copy APKs (same as above)
+```
+
+## Verification
+
+After copying, check that the APKs exist:
+```bash
+ls -la "/Users/kiloloco/Library/Developer/Xcode/DerivedData/Studio-dgcncnmufbhmpxfrqnyqamtlrojx/Build/Products/Debug/Xamrock Studio.app/Contents/Resources/AndroidTestHost/"
+```
+
+You should see:
+- `app-debug.apk` (~2-5 MB)
+- `app-debug-androidTest.apk` (~2-5 MB)
+
+Now restart Xamrock Studio and try recording on Android!
diff --git a/AndroidTestHost/app/build.gradle b/AndroidTestHost/app/build.gradle
new file mode 100644
index 0000000..02a89b3
--- /dev/null
+++ b/AndroidTestHost/app/build.gradle
@@ -0,0 +1,59 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+}
+
+android {
+ namespace 'com.xamrock.testhost'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.xamrock.testhost"
+ minSdk 24
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_17
+ targetCompatibility JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+
+ packagingOptions {
+ resources {
+ excludes += ['META-INF/LICENSE.md', 'META-INF/LICENSE-notice.md']
+ }
+ }
+}
+
+dependencies {
+ implementation 'androidx.core:core-ktx:1.12.0'
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'com.google.android.material:material:1.11.0'
+
+ // Testing dependencies
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test:runner:1.5.2'
+ androidTestImplementation 'androidx.test:rules:1.5.0'
+ androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
+
+ // NanoHTTPD for embedded HTTP server
+ androidTestImplementation 'org.nanohttpd:nanohttpd:2.3.1'
+
+ // JSON parsing
+ androidTestImplementation 'com.google.code.gson:gson:2.10.1'
+}
diff --git a/AndroidTestHost/app/proguard-rules.pro b/AndroidTestHost/app/proguard-rules.pro
new file mode 100644
index 0000000..4bb70e6
--- /dev/null
+++ b/AndroidTestHost/app/proguard-rules.pro
@@ -0,0 +1,11 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Keep test-related classes
+-keep class com.xamrock.testhost.** { *; }
+-keep class androidx.test.** { *; }
+-keep class org.junit.** { *; }
diff --git a/AndroidTestHost/app/src/androidTest/java/com/xamrock/testhost/StudioRecorderInstrumentationTest.kt b/AndroidTestHost/app/src/androidTest/java/com/xamrock/testhost/StudioRecorderInstrumentationTest.kt
new file mode 100644
index 0000000..4a5e8d1
--- /dev/null
+++ b/AndroidTestHost/app/src/androidTest/java/com/xamrock/testhost/StudioRecorderInstrumentationTest.kt
@@ -0,0 +1,456 @@
+package com.xamrock.testhost
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.util.Base64
+import android.util.Log
+import android.view.accessibility.AccessibilityNodeInfo
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiObject2
+import androidx.test.uiautomator.Until
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import fi.iki.elonen.NanoHTTPD
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.ByteArrayOutputStream
+import java.text.SimpleDateFormat
+import java.util.*
+
+@RunWith(AndroidJUnit4::class)
+class StudioRecorderInstrumentationTest {
+
+ private lateinit var device: UiDevice
+ private lateinit var httpServer: StudioHTTPServer
+ private lateinit var targetPackage: String
+ private var skipAppLaunch: Boolean = false
+ private val gson = Gson()
+
+ companion object {
+ private const val TAG = "StudioRecorder"
+ private const val PORT = 8080
+ }
+
+ @Before
+ fun setUp() {
+ // Get instrumentation arguments
+ val arguments = InstrumentationRegistry.getArguments()
+ targetPackage = arguments.getString("targetPackage") ?: "com.google.android.apps.maps"
+ skipAppLaunch = arguments.getString("skipAppLaunch") == "true"
+
+ Log.d(TAG, "Target package: $targetPackage, Skip launch: $skipAppLaunch")
+
+ // Initialize UiDevice
+ device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ // Start the HTTP server
+ httpServer = StudioHTTPServer(PORT, device, targetPackage)
+ httpServer.start()
+
+ Log.d(TAG, "HTTP server started on port $PORT")
+ }
+
+ @Test
+ fun testRecordingSession() {
+ // Launch target app if not skipping
+ if (!skipAppLaunch) {
+ launchTargetApp()
+ }
+
+ // Keep test running until stop command received
+ while (!httpServer.shouldStop) {
+ Thread.sleep(100)
+ }
+
+ Log.d(TAG, "Recording session ended")
+ }
+
+ @After
+ fun tearDown() {
+ httpServer.stop()
+ }
+
+ private fun launchTargetApp() {
+ val context = ApplicationProvider.getApplicationContext()
+ val intent = context.packageManager.getLaunchIntentForPackage(targetPackage)
+
+ if (intent != null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ context.startActivity(intent)
+ device.wait(Until.hasObject(By.pkg(targetPackage).depth(0)), 5000)
+ Log.d(TAG, "Launched target app: $targetPackage")
+ } else {
+ Log.e(TAG, "Could not find launch intent for package: $targetPackage")
+ }
+ }
+
+ /**
+ * HTTP Server implementation using NanoHTTPD
+ */
+ inner class StudioHTTPServer(
+ port: Int,
+ private val device: UiDevice,
+ private val targetPackage: String
+ ) : NanoHTTPD(port) {
+
+ var shouldStop = false
+
+ override fun serve(session: IHTTPSession): Response {
+ val uri = session.uri
+ val method = session.method
+
+ Log.d(TAG, "Received request: $method $uri")
+
+ return when {
+ method == Method.GET && uri == "/health" -> {
+ newFixedLengthResponse(Response.Status.OK, "text/plain", "OK")
+ }
+
+ method == Method.POST && uri == "/capture" -> {
+ try {
+ val snapshot = captureHierarchySnapshot()
+ val json = gson.toJson(snapshot)
+ newFixedLengthResponse(Response.Status.OK, "application/json", json)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error capturing snapshot", e)
+ newFixedLengthResponse(
+ Response.Status.INTERNAL_ERROR,
+ "text/plain",
+ "Error: ${e.message}"
+ )
+ }
+ }
+
+ method == Method.POST && uri == "/interact" -> {
+ try {
+ // Parse request body
+ val files = HashMap()
+ session.parseBody(files)
+ val body = files["postData"] ?: ""
+
+ val command = gson.fromJson(body, InteractionCommand::class.java)
+
+ // Execute interaction
+ executeInteraction(command)
+
+ // Wait for UI to settle
+ Thread.sleep(1000)
+
+ // Capture new snapshot
+ val snapshot = captureHierarchySnapshot()
+ val json = gson.toJson(snapshot)
+ newFixedLengthResponse(Response.Status.OK, "application/json", json)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error executing interaction", e)
+ newFixedLengthResponse(
+ Response.Status.INTERNAL_ERROR,
+ "text/plain",
+ "Error: ${e.message}"
+ )
+ }
+ }
+
+ method == Method.POST && uri == "/stop" -> {
+ shouldStop = true
+ newFixedLengthResponse(Response.Status.OK, "text/plain", "Stopping")
+ }
+
+ else -> {
+ newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not Found")
+ }
+ }
+ }
+
+ private fun captureHierarchySnapshot(): HierarchySnapshot {
+ // Capture screenshot
+ val screenshotFile = java.io.File.createTempFile("screenshot", ".png")
+ device.takeScreenshot(screenshotFile)
+ val screenshot = android.graphics.BitmapFactory.decodeFile(screenshotFile.absolutePath)
+ screenshotFile.delete()
+ val screenshotBase64 = bitmapToBase64(screenshot)
+
+ // Get root accessibility node
+ val rootNode = InstrumentationRegistry.getInstrumentation()
+ .uiAutomation.rootInActiveWindow
+
+ // Serialize hierarchy
+ val elements = if (rootNode != null) {
+ listOf(serializeNode(rootNode))
+ } else {
+ emptyList()
+ }
+
+ // Get display metrics
+ val displayMetrics = ApplicationProvider.getApplicationContext()
+ .resources.displayMetrics
+
+ // Create snapshot
+ return HierarchySnapshot(
+ timestamp = getCurrentTimestamp(),
+ elements = elements,
+ screenshot = screenshotBase64,
+ appFrame = null,
+ screenBounds = SizeData(
+ width = displayMetrics.widthPixels.toDouble(),
+ height = displayMetrics.heightPixels.toDouble()
+ ),
+ // For Android, displayScale should be 1.0 since element bounds are already in pixels
+ // (matching the screenshot coordinate system)
+ displayScale = 1.0,
+ platform = "android"
+ )
+ }
+
+ private fun serializeNode(node: AccessibilityNodeInfo): SnapshotElement {
+ val bounds = android.graphics.Rect()
+ node.getBoundsInScreen(bounds)
+
+ // Get children
+ val children = mutableListOf()
+ for (i in 0 until node.childCount) {
+ val child = node.getChild(i)
+ if (child != null) {
+ children.add(serializeNode(child))
+ child.recycle()
+ }
+ }
+
+ // Map Android elementType to iOS UIElementType (for UI compatibility)
+ val className = node.className?.toString() ?: ""
+ val elementType = mapAndroidClassToElementType(className)
+
+ // Create platform metadata to preserve Android-specific data
+ val platformMetadata = PlatformMetadata(
+ androidClassName = className,
+ isClickable = node.isClickable,
+ isLongClickable = node.isLongClickable,
+ isScrollable = node.isScrollable,
+ isFocusable = node.isFocusable,
+ isCheckable = node.isCheckable,
+ isPassword = node.isPassword
+ )
+
+ return SnapshotElement(
+ elementType = elementType,
+ label = node.text?.toString() ?: "",
+ title = node.contentDescription?.toString() ?: "",
+ value = "", // Android doesn't have a direct equivalent
+ placeholderValue = "", // Android doesn't have a direct equivalent
+ isEnabled = node.isEnabled,
+ isSelected = node.isSelected,
+ frame = FrameData(
+ x = bounds.left.toDouble(),
+ y = bounds.top.toDouble(),
+ width = (bounds.right - bounds.left).toDouble(),
+ height = (bounds.bottom - bounds.top).toDouble()
+ ),
+ identifier = node.viewIdResourceName ?: "",
+ children = children,
+ platformMetadata = platformMetadata
+ )
+ }
+
+ private fun mapAndroidClassToElementType(className: String): Int {
+ // Map Android View classes to iOS UIElementType equivalents
+ // https://developer.apple.com/documentation/xctest/xcuielementtype
+ return when {
+ className.contains("Button") -> 9 // Button
+ className.contains("EditText") || className.contains("TextField") -> 49 // TextField
+ className.contains("TextView") -> 52 // TextView (static text)
+ className.contains("CheckBox") -> 12 // CheckBox
+ className.contains("Switch") -> 40 // Switch
+ className.contains("ImageView") -> 34 // Image
+ className.contains("ScrollView") -> 53 // ScrollView
+ className.contains("RecyclerView") || className.contains("ListView") -> 57 // Table
+ className.contains("ViewGroup") -> 24 // Group/Other
+ else -> 1 // Other (generic element)
+ }
+ }
+
+ private fun executeInteraction(command: InteractionCommand) {
+ Log.d(TAG, "Executing interaction: ${command.type}")
+
+ when (command.type) {
+ "tap" -> {
+ val query = command.query ?: throw IllegalArgumentException("Missing query")
+ val element = findElement(query)
+ element.click()
+ }
+
+ "doubleTap" -> {
+ val query = command.query ?: throw IllegalArgumentException("Missing query")
+ val element = findElement(query)
+ // Double tap by clicking twice
+ element.click()
+ Thread.sleep(100)
+ element.click()
+ }
+
+ "longPress" -> {
+ val query = command.query ?: throw IllegalArgumentException("Missing query")
+ val duration = (command.duration ?: 1.0) * 1000
+ val element = findElement(query)
+ element.click(duration.toLong())
+ }
+
+ "swipe" -> {
+ val direction = command.direction ?: throw IllegalArgumentException("Missing direction")
+ if (command.query != null) {
+ val element = findElement(command.query)
+ val bounds = element.visibleBounds
+ val centerX = (bounds.left + bounds.right) / 2
+ val centerY = (bounds.top + bounds.bottom) / 2
+ when (direction) {
+ "up" -> device.swipe(centerX, centerY, centerX, bounds.top, 10)
+ "down" -> device.swipe(centerX, centerY, centerX, bounds.bottom, 10)
+ "left" -> device.swipe(centerX, centerY, bounds.left, centerY, 10)
+ "right" -> device.swipe(centerX, centerY, bounds.right, centerY, 10)
+ }
+ } else {
+ // Swipe on entire screen
+ val displayWidth = device.displayWidth
+ val displayHeight = device.displayHeight
+ when (direction) {
+ "up" -> device.swipe(displayWidth / 2, displayHeight * 3 / 4, displayWidth / 2, displayHeight / 4, 10)
+ "down" -> device.swipe(displayWidth / 2, displayHeight / 4, displayWidth / 2, displayHeight * 3 / 4, 10)
+ "left" -> device.swipe(displayWidth * 3 / 4, displayHeight / 2, displayWidth / 4, displayHeight / 2, 10)
+ "right" -> device.swipe(displayWidth / 4, displayHeight / 2, displayWidth * 3 / 4, displayHeight / 2, 10)
+ }
+ }
+ }
+
+ "typeText" -> {
+ val text = command.text ?: throw IllegalArgumentException("Missing text")
+ if (command.query != null) {
+ val element = findElement(command.query)
+ element.click()
+ Thread.sleep(200)
+ }
+ device.findObject(By.focused(true))?.text = text
+ }
+
+ "tapCoordinate" -> {
+ val x = command.x ?: throw IllegalArgumentException("Missing x coordinate")
+ val y = command.y ?: throw IllegalArgumentException("Missing y coordinate")
+ device.click(x.toInt(), y.toInt())
+ }
+
+ "swipeCoordinate" -> {
+ val x = command.x ?: throw IllegalArgumentException("Missing x coordinate")
+ val y = command.y ?: throw IllegalArgumentException("Missing y coordinate")
+ val direction = command.direction ?: throw IllegalArgumentException("Missing direction")
+
+ when (direction) {
+ "up" -> device.swipe(x.toInt(), y.toInt(), x.toInt(), (y - 100).toInt(), 10)
+ "down" -> device.swipe(x.toInt(), y.toInt(), x.toInt(), (y + 100).toInt(), 10)
+ "left" -> device.swipe(x.toInt(), y.toInt(), (x - 100).toInt(), y.toInt(), 10)
+ "right" -> device.swipe(x.toInt(), y.toInt(), (x + 100).toInt(), y.toInt(), 10)
+ }
+ }
+
+ else -> throw IllegalArgumentException("Unknown command type: ${command.type}")
+ }
+ }
+
+ private fun findElement(query: ElementQuery): UiObject2 {
+ var selector = By.pkg(targetPackage)
+
+ // Build selector based on query parameters
+ query.identifier?.let { if (it.isNotEmpty()) selector = By.res(it) }
+ query.label?.let { if (it.isNotEmpty()) selector = By.text(it) }
+ query.title?.let { if (it.isNotEmpty()) selector = By.desc(it) }
+
+ val element = device.findObject(selector)
+ ?: throw IllegalArgumentException("Element not found with query: $query")
+
+ return element
+ }
+
+ private fun bitmapToBase64(bitmap: Bitmap): String {
+ val outputStream = ByteArrayOutputStream()
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
+ val bytes = outputStream.toByteArray()
+ return Base64.encodeToString(bytes, Base64.NO_WRAP)
+ }
+
+ private fun getCurrentTimestamp(): String {
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
+ dateFormat.timeZone = TimeZone.getTimeZone("UTC")
+ return dateFormat.format(Date())
+ }
+ }
+
+ // Data classes for JSON serialization (matching Swift HierarchySnapshot structure)
+ data class HierarchySnapshot(
+ val timestamp: String,
+ val elements: List,
+ val screenshot: String?,
+ val appFrame: FrameData? = null,
+ val screenBounds: SizeData?,
+ val displayScale: Double?,
+ val platform: String?
+ )
+
+ data class SnapshotElement(
+ val elementType: Int,
+ val label: String,
+ val title: String,
+ val value: String,
+ val placeholderValue: String,
+ val isEnabled: Boolean,
+ val isSelected: Boolean,
+ val frame: FrameData,
+ val identifier: String,
+ val children: List,
+ val platformMetadata: PlatformMetadata?
+ )
+
+ data class PlatformMetadata(
+ val androidClassName: String? = null,
+ val isClickable: Boolean? = null,
+ val isLongClickable: Boolean? = null,
+ val isScrollable: Boolean? = null,
+ val isFocusable: Boolean? = null,
+ val isCheckable: Boolean? = null,
+ val isPassword: Boolean? = null,
+ val iosHittable: Boolean? = null
+ )
+
+ data class FrameData(
+ val x: Double,
+ val y: Double,
+ val width: Double,
+ val height: Double
+ )
+
+ data class SizeData(
+ val width: Double,
+ val height: Double
+ )
+
+ data class InteractionCommand(
+ val type: String,
+ val query: ElementQuery? = null,
+ val duration: Double? = null,
+ val direction: String? = null,
+ val text: String? = null,
+ val x: Double? = null,
+ val y: Double? = null
+ )
+
+ data class ElementQuery(
+ val identifier: String? = null,
+ val label: String? = null,
+ val title: String? = null,
+ val elementType: Int? = null,
+ val index: Int? = null
+ )
+}
diff --git a/AndroidTestHost/app/src/main/AndroidManifest.xml b/AndroidTestHost/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a9504cd
--- /dev/null
+++ b/AndroidTestHost/app/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AndroidTestHost/app/src/main/java/com/xamrock/testhost/MainActivity.kt b/AndroidTestHost/app/src/main/java/com/xamrock/testhost/MainActivity.kt
new file mode 100644
index 0000000..7a83651
--- /dev/null
+++ b/AndroidTestHost/app/src/main/java/com/xamrock/testhost/MainActivity.kt
@@ -0,0 +1,11 @@
+package com.xamrock.testhost
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+
+class MainActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // Empty test host app - just needs to exist for the instrumentation test
+ }
+}
diff --git a/AndroidTestHost/app/src/main/res/values/strings.xml b/AndroidTestHost/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..261251a
--- /dev/null
+++ b/AndroidTestHost/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Xamrock Test Host
+
diff --git a/AndroidTestHost/build.gradle b/AndroidTestHost/build.gradle
new file mode 100644
index 0000000..2e97eca
--- /dev/null
+++ b/AndroidTestHost/build.gradle
@@ -0,0 +1,9 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '8.7.3' apply false
+ id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
+}
+
+task clean(type: Delete) {
+ delete rootProject.layout.buildDirectory
+}
diff --git a/AndroidTestHost/gradle.properties b/AndroidTestHost/gradle.properties
new file mode 100644
index 0000000..757ed3d
--- /dev/null
+++ b/AndroidTestHost/gradle.properties
@@ -0,0 +1,6 @@
+# Project-wide Gradle settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.enableJetifier=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/AndroidTestHost/gradle.zip b/AndroidTestHost/gradle.zip
new file mode 100644
index 0000000..1936be7
--- /dev/null
+++ b/AndroidTestHost/gradle.zip
@@ -0,0 +1,7 @@
+
+307 Temporary Redirect
+
+307 Temporary Redirect
+
cloudflare
+
+
diff --git a/AndroidTestHost/gradlew b/AndroidTestHost/gradlew
new file mode 100644
index 0000000..a026f64
--- /dev/null
+++ b/AndroidTestHost/gradlew
@@ -0,0 +1,13 @@
+#!/bin/sh
+
+# Use Android Studio's Gradle if available
+ANDROID_STUDIO_GRADLE="/Applications/Android Studio.app/Contents/gradle/gradle-8.5/bin/gradle"
+
+if [ -f "$ANDROID_STUDIO_GRADLE" ]; then
+ exec "$ANDROID_STUDIO_GRADLE" "$@"
+else
+ echo "Please build using Android Studio:"
+ echo "1. Open AndroidTestHost folder in Android Studio"
+ echo "2. Run: ./gradlew assembleDebug assembleDebugAndroidTest"
+ exit 1
+fi
diff --git a/AndroidTestHost/local.properties b/AndroidTestHost/local.properties
new file mode 100644
index 0000000..6d3e879
--- /dev/null
+++ b/AndroidTestHost/local.properties
@@ -0,0 +1 @@
+sdk.dir=/Users/kiloloco/Library/Android/sdk
diff --git a/AndroidTestHost/settings.gradle b/AndroidTestHost/settings.gradle
new file mode 100644
index 0000000..a75f1ef
--- /dev/null
+++ b/AndroidTestHost/settings.gradle
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "XamrockTestHost"
+include ':app'
diff --git a/README.md b/README.md
index 542323c..c5ad0a0 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,17 @@
# Xamrock Studio
-Xamrock Studio is a macOS application that streamlines iOS UI test automation by turning manual app interactions into executable test code. Record your app flows visually on iOS simulators or physical devices, and instantly generate test scripts for XCUITest, Maestro, or Appium; eliminating the tedious process of writing repetitive UI test code by hand.
+Xamrock Studio is a macOS application that streamlines mobile UI test automation by turning manual app interactions into executable test code. Record your app flows visually on iOS simulators, physical devices, Android emulators, and physical devices, and instantly generate test scripts for XCUITest, Espresso, Maestro, or Appium; eliminating the tedious process of writing repetitive UI test code by hand.
## Requirements
+### iOS
- Xcode
- macOS +14.6
+### Android
+- Android SDK Platform Tools (adb)
+- Java Development Kit (JDK) 17 or later
+
## License
MIT
diff --git a/Scripts/build-android-test-host.sh b/Scripts/build-android-test-host.sh
new file mode 100755
index 0000000..0d1747d
--- /dev/null
+++ b/Scripts/build-android-test-host.sh
@@ -0,0 +1,113 @@
+#!/bin/bash
+
+# Build script for Android Test Host APKs
+# This script builds the Android instrumentation test APKs and copies them to the Resources folder
+
+set -e # Exit on any error
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Get the script's directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+ANDROID_DIR="$PROJECT_ROOT/AndroidTestHost"
+RESOURCES_DIR="$PROJECT_ROOT/Studio/Resources/AndroidTestHost"
+
+echo -e "${BLUE}========================================${NC}"
+echo -e "${BLUE}Xamrock Studio - Android Test Host Build${NC}"
+echo -e "${BLUE}========================================${NC}"
+echo ""
+
+# Check if Android project exists
+if [ ! -d "$ANDROID_DIR" ]; then
+ echo -e "${RED}Error: AndroidTestHost directory not found at $ANDROID_DIR${NC}"
+ exit 1
+fi
+
+# Check if gradlew exists
+if [ ! -f "$ANDROID_DIR/gradlew" ]; then
+ echo -e "${RED}Error: gradlew not found in $ANDROID_DIR${NC}"
+ echo -e "${YELLOW}Tip: Ensure you have the Android project set up correctly${NC}"
+ exit 1
+fi
+
+# Check for Java
+if ! command -v java &> /dev/null; then
+ echo -e "${RED}Error: Java (JDK) is required but not found${NC}"
+ echo -e "${YELLOW}Please install JDK 17 or later:${NC}"
+ echo -e " brew install openjdk@17"
+ exit 1
+fi
+
+echo -e "${YELLOW}Java version:${NC}"
+java -version
+echo ""
+
+# Navigate to Android project
+cd "$ANDROID_DIR"
+
+echo -e "${BLUE}Step 1:${NC} Cleaning previous build..."
+./gradlew clean
+
+echo ""
+echo -e "${BLUE}Step 2:${NC} Building debug APK..."
+./gradlew assembleDebug
+
+echo ""
+echo -e "${BLUE}Step 3:${NC} Building instrumentation test APK..."
+./gradlew assembleDebugAndroidTest
+
+echo ""
+echo -e "${BLUE}Step 4:${NC} Verifying APKs..."
+
+APP_APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
+TEST_APK="$ANDROID_DIR/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"
+
+if [ ! -f "$APP_APK" ]; then
+ echo -e "${RED}Error: app-debug.apk not found${NC}"
+ exit 1
+fi
+
+if [ ! -f "$TEST_APK" ]; then
+ echo -e "${RED}Error: app-debug-androidTest.apk not found${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}✓ app-debug.apk found${NC}"
+echo -e "${GREEN}✓ app-debug-androidTest.apk found${NC}"
+
+echo ""
+echo -e "${BLUE}Step 5:${NC} Copying APKs to Resources folder..."
+
+# Create Resources directory if it doesn't exist
+mkdir -p "$RESOURCES_DIR"
+
+# Copy APKs
+cp "$APP_APK" "$RESOURCES_DIR/"
+cp "$TEST_APK" "$RESOURCES_DIR/"
+
+echo -e "${GREEN}✓ APKs copied to $RESOURCES_DIR${NC}"
+
+echo ""
+echo -e "${BLUE}Step 6:${NC} Displaying APK information..."
+
+APP_SIZE=$(du -h "$RESOURCES_DIR/app-debug.apk" | cut -f1)
+TEST_SIZE=$(du -h "$RESOURCES_DIR/app-debug-androidTest.apk" | cut -f1)
+
+echo -e " app-debug.apk: ${GREEN}$APP_SIZE${NC}"
+echo -e " app-debug-androidTest.apk: ${GREEN}$TEST_SIZE${NC}"
+
+echo ""
+echo -e "${GREEN}========================================${NC}"
+echo -e "${GREEN}✅ Build completed successfully!${NC}"
+echo -e "${GREEN}========================================${NC}"
+echo ""
+echo -e "${YELLOW}Next steps:${NC}"
+echo -e " 1. Rebuild Xamrock Studio in Xcode to bundle the new APKs"
+echo -e " 2. Run Xamrock Studio and test Android recording"
+echo ""
diff --git a/Scripts/package-release.sh b/Scripts/package-release.sh
index f4b846e..7811d0a 100755
--- a/Scripts/package-release.sh
+++ b/Scripts/package-release.sh
@@ -4,6 +4,12 @@ set -e
# Usage: ./package-release.sh [version]
# Example: ./package-release.sh 1.0.0
+#
+# Environment variables for code signing (optional):
+# SIGNING_IDENTITY - Developer ID Application certificate name
+# APPLE_ID - Apple ID for notarization
+# APPLE_ID_PASSWORD - App-specific password for notarization
+# APPLE_TEAM_ID - Team ID for notarization
VERSION=${1:-"1.0.0"}
CONFIGURATION="Release"
@@ -14,10 +20,24 @@ APP_NAME="Xamrock Studio.app"
ARCHIVE_PATH="${BUILD_DIR}/Studio.xcarchive"
APP_PATH="${BUILD_DIR}/${APP_NAME}"
DIST_DIR="${BUILD_DIR}/Distribution"
-ZIP_NAME="Xamrock-Studio-${VERSION}.zip"
+
+# Determine if this is a signed release
+if [ -n "$SIGNING_IDENTITY" ]; then
+ ZIP_NAME="Xamrock-Studio-${VERSION}-Signed.zip"
+ IS_SIGNED=true
+else
+ ZIP_NAME="Xamrock-Studio-${VERSION}.zip"
+ IS_SIGNED=false
+fi
echo "=========================================="
echo "Packaging Xamrock Studio v${VERSION}"
+if [ "$IS_SIGNED" = true ]; then
+ echo "Build type: Signed (Developer ID)"
+ echo "Identity: $SIGNING_IDENTITY"
+else
+ echo "Build type: Unsigned (ad-hoc signing)"
+fi
echo "=========================================="
echo ""
@@ -46,29 +66,104 @@ xcodebuild -exportArchive \
-exportPath "${BUILD_DIR}" \
-exportOptionsPlist "${SCRIPT_DIR}/ExportOptions.plist"
-# Step 3: Bundle test runners
+# Step 3: Build Android test host APKs
+echo ""
+echo "Step 3/6: Building Android test host..."
+if "${SCRIPT_DIR}/build-android-test-host.sh"; then
+ echo "✅ Android test host built successfully"
+
+ # Bundle Android APKs into app
+ ANDROID_RESOURCES_DIR="${PROJECT_DIR}/Studio/Resources/AndroidTestHost"
+ ANDROID_BUNDLE_DIR="${APP_PATH}/Contents/Resources/AndroidTestHost"
+
+ if [ -d "$ANDROID_RESOURCES_DIR" ]; then
+ echo "Bundling Android test host APKs..."
+ mkdir -p "${ANDROID_BUNDLE_DIR}"
+ cp -R "${ANDROID_RESOURCES_DIR}/"* "${ANDROID_BUNDLE_DIR}/"
+ echo "✅ Android APKs bundled"
+ else
+ echo "⚠️ Warning: Android resources not found, skipping Android bundle"
+ fi
+else
+ echo "⚠️ Warning: Android test host build failed, continuing without Android support"
+fi
+
+# Step 4: Bundle iOS test runners
echo ""
-echo "Step 3/5: Bundling iOS test runners..."
+echo "Step 4/6: Bundling iOS test runners..."
"${SCRIPT_DIR}/bundle-test-runner.sh" "${APP_PATH}"
-# Step 3.5: Re-sign the app after adding test runners
+# Step 5: Re-sign the app after adding test runners
echo ""
-echo "Step 3.5/5: Re-signing app bundle..."
-codesign --force --sign "-" --deep "${APP_PATH}"
+echo "Step 5/6: Re-signing app bundle..."
+if [ "$IS_SIGNED" = true ]; then
+ echo "Signing with Developer ID: $SIGNING_IDENTITY"
+ # Sign with hardened runtime and timestamp for notarization
+ codesign --force \
+ --sign "$SIGNING_IDENTITY" \
+ --deep \
+ --options runtime \
+ --timestamp \
+ "${APP_PATH}"
+
+ # Verify the signature
+ echo "Verifying signature..."
+ codesign --verify --verbose=2 "${APP_PATH}"
+ spctl --assess --verbose=2 "${APP_PATH}"
+else
+ echo "Using ad-hoc signing (unsigned for distribution)"
+ codesign --force --sign "-" --deep "${APP_PATH}"
+fi
-# Step 4: Prepare distribution directory
+# Step 6: Prepare distribution directory
echo ""
-echo "Step 4/5: Preparing distribution package..."
+echo "Step 6/6: Preparing distribution package..."
mkdir -p "${DIST_DIR}"
rm -rf "${DIST_DIR}/${APP_NAME}"
cp -R "${APP_PATH}" "${DIST_DIR}/"
-# Step 5: Create ZIP archive
+# Create ZIP archive
echo ""
-echo "Step 5/5: Creating ZIP archive..."
+echo "Creating ZIP archive..."
cd "${DIST_DIR}"
zip -qr -X "${ZIP_NAME}" "${APP_NAME}"
+# Notarize if this is a signed build
+if [ "$IS_SIGNED" = true ]; then
+ echo ""
+ echo "=========================================="
+ echo "Notarizing with Apple..."
+ echo "=========================================="
+
+ if [ -z "$APPLE_ID" ] || [ -z "$APPLE_ID_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then
+ echo "⚠️ Warning: Notarization credentials not provided"
+ echo "Set APPLE_ID, APPLE_ID_PASSWORD, and APPLE_TEAM_ID to notarize"
+ echo "Continuing without notarization..."
+ else
+ echo "Submitting ${ZIP_NAME} for notarization..."
+
+ # Submit for notarization
+ xcrun notarytool submit "${ZIP_NAME}" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_ID_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" \
+ --wait
+
+ echo "Notarization complete!"
+
+ # Unzip, staple, and re-zip
+ echo "Stapling notarization ticket..."
+ unzip -q "${ZIP_NAME}"
+ xcrun stapler staple "${APP_NAME}"
+
+ # Re-create the ZIP with stapled ticket
+ rm "${ZIP_NAME}"
+ zip -qr -X "${ZIP_NAME}" "${APP_NAME}"
+
+ echo "✅ Notarization ticket stapled successfully"
+ fi
+fi
+
# Calculate checksum
CHECKSUM=$(shasum -a 256 "${ZIP_NAME}" | cut -d ' ' -f 1)
@@ -79,6 +174,19 @@ echo "=========================================="
echo "Package: ${DIST_DIR}/${ZIP_NAME}"
echo "Size: $(du -h "${DIST_DIR}/${ZIP_NAME}" | cut -f1)"
echo "SHA256: ${CHECKSUM}"
+
+if [ "$IS_SIGNED" = true ]; then
+ echo ""
+ echo "Build Type: Developer ID Signed & Notarized"
+ echo "Distribution: Ready for immediate download and installation"
+ echo "User Experience: No security warnings - double-click to install"
+else
+ echo ""
+ echo "Build Type: Unsigned (ad-hoc signing)"
+ echo "Distribution: Free/open-source distribution"
+ echo "User Experience: Requires right-click > Open on first launch"
+fi
+
echo ""
echo "To distribute:"
echo "1. Upload ${ZIP_NAME} to GitHub releases"
diff --git a/Studio/CodeGeneration/EspressoGenerator.swift b/Studio/CodeGeneration/EspressoGenerator.swift
new file mode 100644
index 0000000..e570ecc
--- /dev/null
+++ b/Studio/CodeGeneration/EspressoGenerator.swift
@@ -0,0 +1,174 @@
+import Foundation
+
+class EspressoGenerator: CodeGenerationStrategy {
+ func generate(
+ flowGroup: FlowGroup,
+ screens: [CapturedScreen],
+ edges: [NavigationEdge],
+ bundleID: String
+ ) -> String {
+ var code = """
+ package com.example.tests
+
+ import androidx.test.ext.junit.rules.ActivityScenarioRule
+ import androidx.test.ext.junit.runners.AndroidJUnit4
+ import androidx.test.espresso.Espresso.onView
+ import androidx.test.espresso.action.ViewActions.*
+ import androidx.test.espresso.matcher.ViewMatchers.*
+ import org.hamcrest.Matchers.allOf
+ import org.junit.Rule
+ import org.junit.Test
+ import org.junit.runner.RunWith
+
+ /**
+ * Generated by Xamrock Studio
+ * Flow: \(flowGroup.name)
+ */
+ @RunWith(AndroidJUnit4::class)
+ class \(CodeGenerationUtilities.sanitizeClassName(flowGroup.name))Test {
+
+ @get:Rule
+ val activityRule = ActivityScenarioRule(MainActivity::class.java)
+
+ @Test
+ fun test\(CodeGenerationUtilities.sanitizeFunctionName(flowGroup.name))() {
+
+ """
+
+ let flowScreens = screens.filter { screen in
+ screen.flowGroupIds.contains(flowGroup.id)
+ }
+
+ let path = CodeGenerationUtilities.buildTestPath(screens: flowScreens, edges: edges)
+
+ for step in path {
+ code += generateStep(step, indentation: " ")
+ }
+
+ code += """
+ }
+ }
+
+ """
+
+ return code
+ }
+
+ private func generateStep(_ step: TestStep, indentation: String) -> String {
+ var code = "\n"
+ code += "\(indentation)// \(step.description)\n"
+
+ switch step.action {
+ case .tap(let identifier, let label):
+ if !identifier.isEmpty && identifier != "manual_edge" {
+ code += "\(indentation)onView(withId(R.id.\(sanitizeResourceID(identifier))))\n"
+ code += "\(indentation) .perform(click())\n"
+ } else if !label.isEmpty {
+ code += "\(indentation)onView(withText(\"\(escapeString(label))\"))\n"
+ code += "\(indentation) .perform(click())\n"
+ } else {
+ code += "\(indentation)// TODO: Tap element (identifier unknown)\n"
+ }
+
+ case .typeText(let identifier, let text):
+ if !identifier.isEmpty && identifier != "manual_edge" {
+ code += "\(indentation)onView(withId(R.id.\(sanitizeResourceID(identifier))))\n"
+ code += "\(indentation) .perform(click())\n"
+ code += "\(indentation)onView(withId(R.id.\(sanitizeResourceID(identifier))))\n"
+ code += "\(indentation) .perform(typeText(\"\(escapeString(text))\"))\n"
+ } else {
+ code += "\(indentation)// TODO: Type text '\(text)' (identifier unknown)\n"
+ }
+
+ case .wait(let seconds):
+ code += "\(indentation)Thread.sleep(\(Int(seconds * 1000)))\n"
+
+ case .verify(let condition):
+ code += "\(indentation)// TODO: Verify \(condition)\n"
+
+ case .swipe(let direction, let identifier, let label):
+ if !identifier.isEmpty && identifier != "manual_edge" {
+ code += "\(indentation)onView(withId(R.id.\(sanitizeResourceID(identifier))))\n"
+ code += "\(indentation) .perform(swipe\(direction.rawValue.capitalized)())\n"
+ } else if !label.isEmpty {
+ code += "\(indentation)onView(withText(\"\(escapeString(label))\"))\n"
+ code += "\(indentation) .perform(swipe\(direction.rawValue.capitalized)())\n"
+ } else {
+ code += "\(indentation)// Swipe \(direction.rawValue) on screen\n"
+ code += "\(indentation)onView(isRoot()).perform(swipe\(direction.rawValue.capitalized)())\n"
+ }
+
+ case .longPress(let identifier, let label, let duration):
+ if !identifier.isEmpty && identifier != "manual_edge" {
+ code += "\(indentation)onView(withId(R.id.\(sanitizeResourceID(identifier))))\n"
+ code += "\(indentation) .perform(longClick())\n"
+ } else if !label.isEmpty {
+ code += "\(indentation)onView(withText(\"\(escapeString(label))\"))\n"
+ code += "\(indentation) .perform(longClick())\n"
+ } else {
+ code += "\(indentation)// TODO: Long press element (identifier unknown)\n"
+ }
+
+ case .doubleTap(let identifier, let label):
+ if !identifier.isEmpty && identifier != "manual_edge" {
+ code += "\(indentation)onView(withId(R.id.\(sanitizeResourceID(identifier))))\n"
+ code += "\(indentation) .perform(doubleClick())\n"
+ } else if !label.isEmpty {
+ code += "\(indentation)onView(withText(\"\(escapeString(label))\"))\n"
+ code += "\(indentation) .perform(doubleClick())\n"
+ } else {
+ code += "\(indentation)// TODO: Double tap element (identifier unknown)\n"
+ }
+
+ case .tapCoordinate(let x, let y):
+ code += "\(indentation)// TODO: Tap at coordinate (\(Int(x)), \(Int(y)))\n"
+ code += "\(indentation)// Note: Coordinate-based taps require custom implementation in Espresso\n"
+
+ case .tapCell(let index, let identifier, let label):
+ if !identifier.isEmpty && identifier != "manual_edge" {
+ code += "\(indentation)onView(withId(R.id.\(sanitizeResourceID(identifier))))\n"
+ code += "\(indentation) .perform(scrollToPosition(\(index)))\n"
+ code += "\(indentation)onData(anything())\n"
+ code += "\(indentation) .atPosition(\(index))\n"
+ code += "\(indentation) .perform(click())\n"
+ } else if !label.isEmpty {
+ code += "\(indentation)onView(withText(\"\(escapeString(label))\"))\n"
+ code += "\(indentation) .perform(click())\n"
+ } else {
+ code += "\(indentation)// TODO: Tap cell at index \(index)\n"
+ }
+ }
+
+ return code
+ }
+
+ private func sanitizeResourceID(_ id: String) -> String {
+ // Remove package name prefix if present (e.g., "com.example:id/button" -> "button")
+ if let colonIndex = id.lastIndex(of: ":"),
+ let slashIndex = id.lastIndex(of: "/") {
+ let afterSlash = id.index(after: slashIndex)
+ return String(id[afterSlash...])
+ }
+
+ // Convert to valid Kotlin identifier
+ var sanitized = id.replacingOccurrences(of: "-", with: "_")
+ sanitized = sanitized.replacingOccurrences(of: ".", with: "_")
+ sanitized = sanitized.replacingOccurrences(of: " ", with: "_")
+
+ // Ensure it doesn't start with a number
+ if let firstChar = sanitized.first, firstChar.isNumber {
+ sanitized = "_" + sanitized
+ }
+
+ return sanitized
+ }
+
+ private func escapeString(_ str: String) -> String {
+ return str
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\r")
+ .replacingOccurrences(of: "\t", with: "\\t")
+ }
+}
diff --git a/Studio/Coordinators/DeviceManager.swift b/Studio/Coordinators/DeviceManager.swift
index c201b5a..9a00b72 100644
--- a/Studio/Coordinators/DeviceManager.swift
+++ b/Studio/Coordinators/DeviceManager.swift
@@ -8,10 +8,15 @@ class DeviceManager: ObservableObject, DeviceManaging {
@Published var isLoadingDevices = false
@Published var errorMessage: String?
- private let deviceService: DeviceService
+ private let iosDeviceService: DeviceService
+ private let androidDeviceService: DeviceService
- init(deviceService: DeviceService = IOSDeviceService()) {
- self.deviceService = deviceService
+ init(
+ iosDeviceService: DeviceService = IOSDeviceService(),
+ androidDeviceService: DeviceService = AndroidDeviceService()
+ ) {
+ self.iosDeviceService = iosDeviceService
+ self.androidDeviceService = androidDeviceService
}
func loadDevices() async throws -> [Device] {
@@ -20,22 +25,53 @@ class DeviceManager: ObservableObject, DeviceManaging {
defer { isLoadingDevices = false }
+ // Fetch devices from both platforms, handling errors individually
+ var allDevices: [Device] = []
+
+ // Try to fetch iOS devices
do {
- let allDevices = try await deviceService.fetchDevices()
- availableDevices = allDevices.filter { $0.isAvailable }
+ let iosDevices = try await iosDeviceService.fetchDevices()
+ allDevices.append(contentsOf: iosDevices)
+ } catch {
+ // Continue to try Android devices
+ }
+
+ // Try to fetch Android devices
+ do {
+ let androidDevices = try await androidDeviceService.fetchDevices()
+ allDevices.append(contentsOf: androidDevices)
+ } catch {
+ // Continue with whatever devices we have
+ }
- if selectedDevice == nil {
- selectedDevice = try await findDefaultDevice()
+ // Filter available devices
+ availableDevices = allDevices.filter { $0.isAvailable }
+
+ // Sort devices: physical first, then by platform (iOS, Android), then by name
+ availableDevices.sort { lhs, rhs in
+ if lhs.isPhysical != rhs.isPhysical {
+ return lhs.isPhysical
+ }
+ if lhs.platform != rhs.platform {
+ return lhs.platform == .ios
}
+ return lhs.name < rhs.name
+ }
- return availableDevices
- } catch {
- errorMessage = "Failed to load devices: \(error.localizedDescription)"
- throw error
+ if selectedDevice == nil {
+ selectedDevice = try await findDefaultDevice()
}
+
+ return availableDevices
}
func findDefaultDevice() async throws -> Device? {
- try await deviceService.findDefaultDevice()
+ // Prefer iOS physical device
+ if let iosDevice = try await iosDeviceService.findDefaultDevice() {
+ return iosDevice
+ }
+
+ // Fall back to Android device
+ return try await androidDeviceService.findDefaultDevice()
}
}
diff --git a/Studio/Coordinators/SessionCoordinator.swift b/Studio/Coordinators/SessionCoordinator.swift
index 419a4b9..011e41c 100644
--- a/Studio/Coordinators/SessionCoordinator.swift
+++ b/Studio/Coordinators/SessionCoordinator.swift
@@ -9,8 +9,10 @@ class SessionCoordinator: ObservableObject {
@Published var connectionStatus: String?
private var testRunner: TestRunnerService?
+ private var androidTestRunner: AndroidTestRunnerService?
private var communicationService: CommunicationService?
private(set) var interactionService: InteractionService?
+ private var currentPlatform: DevicePlatform?
func startSession(
bundleID: String,
@@ -18,92 +20,145 @@ class SessionCoordinator: ObservableObject {
skipAppLaunch: Bool
) async throws {
guard !bundleID.isEmpty else {
- errorMessage = "Please enter a bundle ID"
+ errorMessage = "Please enter a \(device.platform == .ios ? "bundle ID" : "package name")"
throw SessionError.invalidBundleID
}
errorMessage = nil
isRecording = true
+ currentPlatform = device.platform
do {
- let deviceIP = device.isPhysical ? "iPhone.local" : "localhost"
+ switch device.platform {
+ case .ios:
+ try await startIOSSession(bundleID: bundleID, device: device, skipAppLaunch: skipAppLaunch)
+ case .android:
+ try await startAndroidSession(packageName: bundleID, device: device, skipAppLaunch: skipAppLaunch)
+ }
+
+ isTestRunning = true
+ connectionStatus = nil
+ } catch {
+ errorMessage = "Failed to start session: \(error.localizedDescription)"
+ isRecording = false
+ isTestRunning = false
+ throw error
+ }
+ }
- let commService = try CommunicationService(deviceIP: deviceIP)
- communicationService = commService
- interactionService = InteractionService(communicationService: commService)
+ private func startIOSSession(
+ bundleID: String,
+ device: Device,
+ skipAppLaunch: Bool
+ ) async throws {
+ let deviceIP = device.isPhysical ? "iPhone.local" : "localhost"
- let runner = TestRunnerService(communicationService: commService)
- testRunner = runner
+ let commService = try CommunicationService(deviceIP: deviceIP)
+ communicationService = commService
+ interactionService = InteractionService(communicationService: commService)
- try await runner.startSession(
- bundleID: bundleID,
- deviceID: device.id,
- skipAppLaunch: skipAppLaunch
- )
+ let runner = TestRunnerService(communicationService: commService)
+ testRunner = runner
+
+ try await runner.startSession(
+ bundleID: bundleID,
+ deviceID: device.id,
+ skipAppLaunch: skipAppLaunch
+ )
+
+ // Wait for server to become ready with polling and exponential backoff
+ try await waitForServerHealth(deviceType: device.isPhysical ? "physical device" : "simulator")
+ }
- // Wait for server to become ready with polling and exponential backoff
- let maxAttempts = 30 // 30 attempts over ~60 seconds
- var attempt = 0
- var isHealthy = false
+ private func startAndroidSession(
+ packageName: String,
+ device: Device,
+ skipAppLaunch: Bool
+ ) async throws {
+ // Android always uses localhost with port forwarding
+ let commService = try CommunicationService(deviceIP: "localhost")
+ communicationService = commService
+ interactionService = InteractionService(communicationService: commService)
+
+ let runner = AndroidTestRunnerService(communicationService: commService)
+ androidTestRunner = runner
+
+ try await runner.startSession(
+ packageName: packageName,
+ deviceID: device.id,
+ skipAppLaunch: skipAppLaunch
+ )
+
+ // Wait for server to become ready
+ try await waitForServerHealth(deviceType: device.isPhysical ? "physical device" : "emulator")
+ }
- connectionStatus = "Waiting for test server to start..."
+ private func waitForServerHealth(deviceType: String) async throws {
+ let maxAttempts = 30 // 30 attempts over ~60 seconds
+ var attempt = 0
+ var isHealthy = false
- while attempt < maxAttempts && !isHealthy {
- attempt += 1
+ connectionStatus = "Waiting for test server to start..."
- // Calculate delay: start at 1s, cap at 3s
- let baseDelay = min(1.0 + Double(attempt - 1) * 0.1, 3.0)
- let delayNanoseconds = UInt64(baseDelay * 1_000_000_000)
+ while attempt < maxAttempts && !isHealthy {
+ attempt += 1
- try await Task.sleep(nanoseconds: delayNanoseconds)
+ // Calculate delay: start at 1s, cap at 3s
+ let baseDelay = min(1.0 + Double(attempt - 1) * 0.1, 3.0)
+ let delayNanoseconds = UInt64(baseDelay * 1_000_000_000)
- connectionStatus = "Attempting to connect... (\(attempt)/\(maxAttempts))"
+ try await Task.sleep(nanoseconds: delayNanoseconds)
- if let healthy = await communicationService?.checkHealth(), healthy {
- isHealthy = true
- connectionStatus = "Connected!"
- break
- }
+ connectionStatus = "Attempting to connect... (\(attempt)/\(maxAttempts))"
- try Task.checkCancellation()
+ if let healthy = await communicationService?.checkHealth(), healthy {
+ isHealthy = true
+ connectionStatus = "Connected!"
+ break
}
- guard isHealthy else {
- let deviceType = device.isPhysical ? "physical device" : "simulator"
- throw SessionError.connectionFailed(
- deviceType: deviceType,
- attempts: attempt
- )
- }
+ try Task.checkCancellation()
+ }
- isTestRunning = true
- connectionStatus = nil
- } catch {
- errorMessage = "Failed to start session: \(error.localizedDescription)"
- isRecording = false
- isTestRunning = false
- throw error
+ guard isHealthy else {
+ throw SessionError.connectionFailed(
+ deviceType: deviceType,
+ attempts: attempt
+ )
}
}
func stopSession() async {
- await testRunner?.stopSession()
- testRunner = nil
+ if let platform = currentPlatform {
+ switch platform {
+ case .ios:
+ await testRunner?.stopSession()
+ testRunner = nil
+ case .android:
+ await androidTestRunner?.stopSession()
+ androidTestRunner = nil
+ }
+ }
+
communicationService = nil
interactionService = nil
isRecording = false
isTestRunning = false
+ currentPlatform = nil
}
func captureSnapshot() async throws -> HierarchySnapshot {
- guard let testRunner = testRunner else {
+ if let testRunner = testRunner {
+ return try await testRunner.captureSnapshot()
+ } else if let androidTestRunner = androidTestRunner {
+ return try await androidTestRunner.captureSnapshot()
+ } else {
throw SessionError.sessionNotStarted
}
- return try await testRunner.captureSnapshot()
}
var hasActiveSession: Bool {
- testRunner != nil && isTestRunning
+ (testRunner != nil || androidTestRunner != nil) && isTestRunning
}
}
diff --git a/Studio/Models/Device.swift b/Studio/Models/Device.swift
index d4f96cb..e95096e 100644
--- a/Studio/Models/Device.swift
+++ b/Studio/Models/Device.swift
@@ -1,5 +1,10 @@
import Foundation
+enum DevicePlatform: String, Codable {
+ case ios = "ios"
+ case android = "android"
+}
+
struct Device: Identifiable, Hashable {
let id: String
let name: String
@@ -7,12 +12,15 @@ struct Device: Identifiable, Hashable {
let state: String
let isAvailable: Bool
let isPhysical: Bool
+ let platform: DevicePlatform
var displayName: String {
+ let platformPrefix = platform == .android ? "📱" : "🍎"
+
if isPhysical {
- return "\(name) (Physical Device)"
+ return "\(platformPrefix) \(name) (Physical Device)"
} else {
- return "\(name) (\(runtime))"
+ return "\(platformPrefix) \(name) (\(runtime))"
}
}
}
diff --git a/Studio/Models/HierarchySnapshot.swift b/Studio/Models/HierarchySnapshot.swift
index d4f345e..cf94abc 100644
--- a/Studio/Models/HierarchySnapshot.swift
+++ b/Studio/Models/HierarchySnapshot.swift
@@ -8,9 +8,10 @@ struct HierarchySnapshot: Codable {
let appFrame: FrameData?
let screenBounds: SizeData?
let displayScale: Double?
+ let platform: DevicePlatform?
enum CodingKeys: String, CodingKey {
- case timestamp, elements, screenshot, appFrame, screenBounds, displayScale
+ case timestamp, elements, screenshot, appFrame, screenBounds, displayScale, platform
}
init(from decoder: Decoder) throws {
@@ -21,6 +22,7 @@ struct HierarchySnapshot: Codable {
appFrame = try container.decodeIfPresent(FrameData.self, forKey: .appFrame)
screenBounds = try container.decodeIfPresent(SizeData.self, forKey: .screenBounds)
displayScale = try container.decodeIfPresent(Double.self, forKey: .displayScale)
+ platform = try container.decodeIfPresent(DevicePlatform.self, forKey: .platform)
}
}
@@ -36,10 +38,11 @@ struct SnapshotElement: Codable, Identifiable {
let frame: FrameData
let identifier: String
let children: [SnapshotElement]
+ let platformMetadata: PlatformMetadata?
enum CodingKeys: String, CodingKey {
case elementType, label, title, value, placeholderValue
- case isEnabled, isSelected, frame, identifier, children
+ case isEnabled, isSelected, frame, identifier, children, platformMetadata
}
init(from decoder: Decoder) throws {
@@ -55,6 +58,7 @@ struct SnapshotElement: Codable, Identifiable {
self.frame = try container.decode(FrameData.self, forKey: .frame)
self.identifier = try container.decode(String.self, forKey: .identifier)
self.children = try container.decode([SnapshotElement].self, forKey: .children)
+ self.platformMetadata = try container.decodeIfPresent(PlatformMetadata.self, forKey: .platformMetadata)
}
func encode(to encoder: Encoder) throws {
@@ -69,6 +73,7 @@ struct SnapshotElement: Codable, Identifiable {
try container.encode(frame, forKey: .frame)
try container.encode(identifier, forKey: .identifier)
try container.encode(children, forKey: .children)
+ try container.encodeIfPresent(platformMetadata, forKey: .platformMetadata)
}
var cgRect: CGRect {
@@ -78,6 +83,13 @@ struct SnapshotElement: Codable, Identifiable {
var isInteractive: Bool {
guard isEnabled else { return false }
+ // For Android elements, check platform metadata for clickability
+ if let metadata = platformMetadata {
+ if metadata.isClickable == true || metadata.isLongClickable == true {
+ return true
+ }
+ }
+
let definitelyInteractiveTypes: Set = [
9, // Button
10, // RadioButton
@@ -252,6 +264,40 @@ enum InteractionType: String, Codable {
case cellInteraction
}
+struct PlatformMetadata: Codable {
+ // Android-specific properties
+ let androidClassName: String?
+ let isClickable: Bool?
+ let isLongClickable: Bool?
+ let isScrollable: Bool?
+ let isFocusable: Bool?
+ let isCheckable: Bool?
+ let isPassword: Bool?
+
+ // iOS-specific properties (for future use)
+ let iosHittable: Bool?
+
+ init(
+ androidClassName: String? = nil,
+ isClickable: Bool? = nil,
+ isLongClickable: Bool? = nil,
+ isScrollable: Bool? = nil,
+ isFocusable: Bool? = nil,
+ isCheckable: Bool? = nil,
+ isPassword: Bool? = nil,
+ iosHittable: Bool? = nil
+ ) {
+ self.androidClassName = androidClassName
+ self.isClickable = isClickable
+ self.isLongClickable = isLongClickable
+ self.isScrollable = isScrollable
+ self.isFocusable = isFocusable
+ self.isCheckable = isCheckable
+ self.isPassword = isPassword
+ self.iosHittable = iosHittable
+ }
+}
+
struct FrameData: Codable {
let x: Double
let y: Double
diff --git a/Studio/Services/AndroidDeviceService.swift b/Studio/Services/AndroidDeviceService.swift
new file mode 100644
index 0000000..5466bda
--- /dev/null
+++ b/Studio/Services/AndroidDeviceService.swift
@@ -0,0 +1,227 @@
+import Foundation
+
+class AndroidDeviceService: DeviceService {
+ func fetchDevices() async throws -> [Device] {
+ async let emulators = fetchAndroidEmulators()
+ async let physicalDevices = fetchPhysicalDevices()
+
+ var allDevices = try await emulators + physicalDevices
+
+ // Sort: physical devices first, then by name
+ allDevices.sort { lhs, rhs in
+ if lhs.isPhysical != rhs.isPhysical {
+ return lhs.isPhysical
+ }
+ return lhs.name < rhs.name
+ }
+
+ return allDevices
+ }
+
+ private func fetchAndroidEmulators() async throws -> [Device] {
+ let devices = try await fetchAllAdbDevices()
+ return devices.filter { !$0.isPhysical }
+ }
+
+ private func fetchPhysicalDevices() async throws -> [Device] {
+ let devices = try await fetchAllAdbDevices()
+ return devices.filter { $0.isPhysical }
+ }
+
+ private func fetchAllAdbDevices() async throws -> [Device] {
+ let adbPath = try getAdbPath()
+
+ let process = Process()
+ process.executableURL = adbPath
+ process.arguments = ["devices", "-l"]
+
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ guard let output = String(data: data, encoding: .utf8) else {
+ throw AndroidDeviceServiceError.invalidResponse
+ }
+
+ return parseAdbDevicesOutput(output)
+ }
+
+ private func parseAdbDevicesOutput(_ output: String) -> [Device] {
+ var devices: [Device] = []
+ let lines = output.components(separatedBy: .newlines)
+
+ for (_, line) in lines.enumerated() {
+ let trimmedLine = line.trimmingCharacters(in: .whitespaces)
+
+ // Skip header line and empty lines
+ if trimmedLine.isEmpty || trimmedLine.hasPrefix("List of devices") {
+ continue
+ }
+
+ // Parse line format: "serial_number state product:xxx model:xxx device:xxx transport_id:xxx"
+ let components = trimmedLine.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
+ guard components.count >= 2 else {
+ continue
+ }
+
+ let serialNumber = components[0]
+ let state = components[1]
+
+ // Only include devices that are available
+ guard state == "device" else {
+ continue
+ }
+
+ // Extract additional info from the line
+ var model = serialNumber
+ var product = ""
+ var isPhysical = true
+
+ // Parse additional device info
+ let infoString = trimmedLine
+ if let modelRange = infoString.range(of: "model:") {
+ let afterModel = infoString[modelRange.upperBound...]
+ if let spaceIndex = afterModel.firstIndex(of: " ") {
+ model = String(afterModel[.. String {
+ guard let adbPath = try? getAdbPath() else {
+ return "Android"
+ }
+
+ let process = Process()
+ process.executableURL = adbPath
+ process.arguments = ["-s", serialNumber, "shell", "getprop", "ro.build.version.release"]
+
+ let outputPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = Pipe()
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+
+ let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ if let version = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !version.isEmpty {
+ return "Android \(version)"
+ }
+ } catch {
+ // Fall back to unknown version
+ }
+
+ return "Android"
+ }
+
+ private func getAdbPath() throws -> URL {
+ let possiblePaths = [
+ "/usr/local/bin/adb",
+ "/opt/homebrew/bin/adb",
+ "/Users/\(NSUserName())/Library/Android/sdk/platform-tools/adb"
+ ]
+
+ for path in possiblePaths {
+ if FileManager.default.fileExists(atPath: path) {
+ return URL(fileURLWithPath: path)
+ }
+ }
+
+ // Try to find adb in PATH
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
+ process.arguments = ["adb"]
+
+ let outputPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = Pipe()
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+
+ if process.terminationStatus == 0 {
+ let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !path.isEmpty {
+ return URL(fileURLWithPath: path)
+ }
+ }
+ } catch {
+ // Fall through to throw error
+ }
+
+ throw AndroidDeviceServiceError.adbNotFound
+ }
+
+ func findDefaultDevice() async throws -> Device? {
+ let devices = try await fetchDevices()
+
+ // Prefer physical devices
+ if let physicalDevice = devices.first(where: { $0.isPhysical && $0.isAvailable }) {
+ return physicalDevice
+ }
+
+ // Fall back to any available emulator
+ return devices.first(where: { $0.isAvailable })
+ }
+}
+
+enum AndroidDeviceServiceError: Error, LocalizedError {
+ case adbNotFound
+ case invalidResponse
+ case noDevicesFound
+
+ var errorDescription: String? {
+ switch self {
+ case .adbNotFound:
+ return "Android Debug Bridge (adb) not found. Please install Android SDK Platform Tools."
+ case .invalidResponse:
+ return "Failed to parse adb devices output"
+ case .noDevicesFound:
+ return "No Android devices or emulators found"
+ }
+ }
+}
diff --git a/Studio/Services/AndroidTestRunnerService.swift b/Studio/Services/AndroidTestRunnerService.swift
new file mode 100644
index 0000000..3496713
--- /dev/null
+++ b/Studio/Services/AndroidTestRunnerService.swift
@@ -0,0 +1,313 @@
+import Foundation
+
+class AndroidTestRunnerService {
+ private var adbProcess: Process?
+ private var instrumentationProcess: Process?
+ private let communicationService: CommunicationService
+
+ init(communicationService: CommunicationService) {
+ self.communicationService = communicationService
+ }
+
+ func startSession(packageName: String, deviceID: String, skipAppLaunch: Bool = false) async throws {
+ try communicationService.setup()
+
+ // Kill any existing test processes to ensure clean start
+ try await killExistingTestProcess(deviceID: deviceID)
+
+ // Install test APK if not already installed
+ try await installTestAPKIfNeeded(deviceID: deviceID)
+
+ // Set up port forwarding
+ try await setupPortForwarding(deviceID: deviceID)
+
+ // Launch instrumentation test
+ try await launchInstrumentationTest(
+ packageName: packageName,
+ deviceID: deviceID,
+ skipAppLaunch: skipAppLaunch
+ )
+
+ // Wait for server to start
+ try await Task.sleep(nanoseconds: 3_000_000_000)
+ }
+
+ func captureSnapshot() async throws -> HierarchySnapshot {
+ guard let jsonString = try await communicationService.sendCommand(.captureSnapshot),
+ let jsonData = jsonString.data(using: .utf8) else {
+ throw AndroidTestRunnerError.noSnapshotData
+ }
+
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try decoder.decode(HierarchySnapshot.self, from: jsonData)
+ }
+
+ func stopSession() async {
+ do {
+ try await communicationService.sendCommand(.stop)
+ try await Task.sleep(nanoseconds: 1_000_000_000)
+ } catch {
+ // Ignore errors when stopping
+ }
+
+ // Kill instrumentation process
+ if let process = instrumentationProcess, process.isRunning {
+ if let stdout = process.standardOutput as? Pipe {
+ stdout.fileHandleForReading.readabilityHandler = nil
+ }
+ if let stderr = process.standardError as? Pipe {
+ stderr.fileHandleForReading.readabilityHandler = nil
+ }
+ process.terminate()
+ }
+
+ // Remove port forwarding
+ if let adbProcess = adbProcess, adbProcess.isRunning {
+ adbProcess.terminate()
+ }
+
+ communicationService.cleanup()
+
+ self.instrumentationProcess = nil
+ self.adbProcess = nil
+ }
+
+ var isRunning: Bool {
+ instrumentationProcess?.isRunning ?? false
+ }
+
+ // MARK: - Private Methods
+
+ private func killExistingTestProcess(deviceID: String) async throws {
+ // Force stop the test host app to kill any running instrumentation tests
+ let process = Process()
+ process.executableURL = try getAdbPath()
+ process.arguments = ["-s", deviceID, "shell", "am", "force-stop", "com.xamrock.testhost"]
+
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ // Give it a moment to clean up
+ try await Task.sleep(nanoseconds: 500_000_000)
+ }
+
+ private func installTestAPKIfNeeded(deviceID: String) async throws {
+ // Check if test app is already installed
+ if try await isTestAppInstalled(deviceID: deviceID) {
+ return
+ }
+
+ // Try to find bundled APK
+ guard let apkPath = try? locateBundledTestAPK() else {
+ throw AndroidTestRunnerError.testAPKNotFound
+ }
+
+ // Install APK
+ try await installAPK(apkPath: apkPath, deviceID: deviceID)
+ }
+
+ private func isTestAppInstalled(deviceID: String) async throws -> Bool {
+ let process = Process()
+ process.executableURL = try getAdbPath()
+ process.arguments = ["-s", deviceID, "shell", "pm", "list", "packages", "com.xamrock.testhost"]
+
+ let outputPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = Pipe()
+
+ try process.run()
+ process.waitUntilExit()
+
+ let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ if let output = String(data: data, encoding: .utf8) {
+ return output.contains("com.xamrock.testhost")
+ }
+
+ return false
+ }
+
+ private func locateBundledTestAPK() throws -> String {
+ guard let resourceURL = Bundle.main.resourceURL else {
+ throw AndroidTestRunnerError.testAPKNotFound
+ }
+
+ let testAPKDir = resourceURL.appendingPathComponent("AndroidTestHost")
+ let fileManager = FileManager.default
+
+ guard fileManager.fileExists(atPath: testAPKDir.path) else {
+ throw AndroidTestRunnerError.testAPKNotFound
+ }
+
+ // Look for app-debug.apk and app-debug-androidTest.apk
+ let appAPK = testAPKDir.appendingPathComponent("app-debug.apk")
+ let testAPK = testAPKDir.appendingPathComponent("app-debug-androidTest.apk")
+
+ if fileManager.fileExists(atPath: appAPK.path) && fileManager.fileExists(atPath: testAPK.path) {
+ return testAPKDir.path
+ }
+
+ throw AndroidTestRunnerError.testAPKNotFound
+ }
+
+ private func installAPK(apkPath: String, deviceID: String) async throws {
+ // Install main app APK
+ let appAPK = "\(apkPath)/app-debug.apk"
+ try await runAdbCommand(["-s", deviceID, "install", "-r", appAPK])
+
+ // Install test APK
+ let testAPK = "\(apkPath)/app-debug-androidTest.apk"
+ try await runAdbCommand(["-s", deviceID, "install", "-r", testAPK])
+ }
+
+ private func setupPortForwarding(deviceID: String) async throws {
+ let process = Process()
+ process.executableURL = try getAdbPath()
+ process.arguments = ["-s", deviceID, "forward", "tcp:8080", "tcp:8080"]
+
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ if process.terminationStatus != 0 {
+ throw AndroidTestRunnerError.portForwardingFailed
+ }
+
+ self.adbProcess = process
+ }
+
+ private func launchInstrumentationTest(
+ packageName: String,
+ deviceID: String,
+ skipAppLaunch: Bool
+ ) async throws {
+ let process = Process()
+ process.executableURL = try getAdbPath()
+
+ var arguments = [
+ "-s", deviceID,
+ "shell", "am", "instrument",
+ "-w",
+ "-e", "targetPackage", packageName,
+ "-e", "class", "com.xamrock.testhost.StudioRecorderInstrumentationTest#testRecordingSession"
+ ]
+
+ if skipAppLaunch {
+ arguments.append(contentsOf: ["-e", "skipAppLaunch", "true"])
+ }
+
+ arguments.append("com.xamrock.testhost.test/androidx.test.runner.AndroidJUnitRunner")
+
+ process.arguments = arguments
+
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+
+ outputPipe.fileHandleForReading.readabilityHandler = { handle in
+ let data = handle.availableData
+ // Log output if needed
+ }
+
+ errorPipe.fileHandleForReading.readabilityHandler = { handle in
+ let data = handle.availableData
+ // Log errors if needed
+ }
+
+ try process.run()
+ self.instrumentationProcess = process
+ }
+
+ private func runAdbCommand(_ arguments: [String]) async throws {
+ let process = Process()
+ process.executableURL = try getAdbPath()
+ process.arguments = arguments
+
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ if process.terminationStatus != 0 {
+ let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
+ let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error"
+ throw AndroidTestRunnerError.adbCommandFailed(message: errorMessage)
+ }
+ }
+
+ private func getAdbPath() throws -> URL {
+ let possiblePaths = [
+ "/usr/local/bin/adb",
+ "/opt/homebrew/bin/adb",
+ "/Users/\(NSUserName())/Library/Android/sdk/platform-tools/adb"
+ ]
+
+ for path in possiblePaths {
+ if FileManager.default.fileExists(atPath: path) {
+ return URL(fileURLWithPath: path)
+ }
+ }
+
+ // Try to find adb in PATH
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/which")
+ process.arguments = ["adb"]
+
+ let outputPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = Pipe()
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+
+ if process.terminationStatus == 0 {
+ let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ if let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !path.isEmpty {
+ return URL(fileURLWithPath: path)
+ }
+ }
+ } catch {
+ // Fall through
+ }
+
+ throw AndroidTestRunnerError.adbNotFound
+ }
+}
+
+enum AndroidTestRunnerError: Error, LocalizedError {
+ case adbNotFound
+ case testAPKNotFound
+ case portForwardingFailed
+ case adbCommandFailed(message: String)
+ case noSnapshotData
+
+ var errorDescription: String? {
+ switch self {
+ case .adbNotFound:
+ return "Android Debug Bridge (adb) not found. Please install Android SDK Platform Tools."
+ case .testAPKNotFound:
+ return "Android test APK not found in app bundle. Please build the Android test host project."
+ case .portForwardingFailed:
+ return "Failed to set up port forwarding to Android device."
+ case .adbCommandFailed(let message):
+ return "ADB command failed: \(message)"
+ case .noSnapshotData:
+ return "Failed to capture UI hierarchy snapshot from Android device"
+ }
+ }
+}
diff --git a/Studio/Services/IOSDeviceService.swift b/Studio/Services/IOSDeviceService.swift
index 2125def..6421095 100644
--- a/Studio/Services/IOSDeviceService.swift
+++ b/Studio/Services/IOSDeviceService.swift
@@ -63,7 +63,8 @@ class IOSDeviceService: DeviceService {
runtime: runtimeName,
state: state,
isAvailable: isAvailable,
- isPhysical: false
+ isPhysical: false,
+ platform: .ios
))
}
}
@@ -134,7 +135,8 @@ class IOSDeviceService: DeviceService {
runtime: "iOS \(version)",
state: "Connected",
isAvailable: true,
- isPhysical: true
+ isPhysical: true,
+ platform: .ios
)
devices.append(device)
}
diff --git a/TestHostUITests/StudioRecorderUITest.swift b/TestHostUITests/StudioRecorderUITest.swift
index 7a58187..ba8ce54 100644
--- a/TestHostUITests/StudioRecorderUITest.swift
+++ b/TestHostUITests/StudioRecorderUITest.swift
@@ -281,7 +281,8 @@ final class StudioRecorderUITest: XCTestCase {
"width": screenshotPixelSize.width,
"height": screenshotPixelSize.height
],
- "displayScale": displayScale
+ "displayScale": displayScale,
+ "platform": "ios"
]
// Convert to JSON string