Skip to main content
Glama
MaestroDriverService.kt22.4 kB
package dev.mobile.maestro import android.app.UiAutomation import android.content.Context import android.content.Context.LOCATION_SERVICE import android.graphics.Bitmap import android.location.Criteria import android.location.Location import android.location.LocationManager import android.os.Build import android.os.SystemClock import android.util.DisplayMetrics import android.util.Log import android.view.KeyEvent.KEYCODE_1 import android.view.KeyEvent.KEYCODE_4 import android.view.KeyEvent.KEYCODE_5 import android.view.KeyEvent.KEYCODE_6 import android.view.KeyEvent.KEYCODE_7 import android.view.KeyEvent.KEYCODE_APOSTROPHE import android.view.KeyEvent.KEYCODE_AT import java.util.concurrent.TimeUnit import android.view.KeyEvent.KEYCODE_BACKSLASH import android.view.KeyEvent.KEYCODE_COMMA import android.view.KeyEvent.KEYCODE_EQUALS import android.view.KeyEvent.KEYCODE_GRAVE import android.view.KeyEvent.KEYCODE_LEFT_BRACKET import android.view.KeyEvent.KEYCODE_MINUS import android.view.KeyEvent.KEYCODE_NUMPAD_ADD import android.view.KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN import android.view.KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN import android.view.KeyEvent.KEYCODE_PERIOD import android.view.KeyEvent.KEYCODE_POUND import android.view.KeyEvent.KEYCODE_RIGHT_BRACKET import android.view.KeyEvent.KEYCODE_SEMICOLON import android.view.KeyEvent.KEYCODE_SLASH import android.view.KeyEvent.KEYCODE_SPACE import android.view.KeyEvent.KEYCODE_STAR import android.view.KeyEvent.META_SHIFT_LEFT_ON import android.view.WindowManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.Configurator import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiDeviceExt.clickExt import com.google.android.gms.location.LocationServices import com.google.protobuf.ByteString import dev.mobile.maestro.location.FusedLocationProvider import dev.mobile.maestro.location.LocationManagerProvider import dev.mobile.maestro.location.MockLocationProvider import dev.mobile.maestro.location.PlayServices import io.grpc.Status import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder import io.grpc.stub.StreamObserver import maestro_android.MaestroAndroid import maestro_android.MaestroDriverGrpc import maestro_android.addMediaResponse import maestro_android.checkWindowUpdatingResponse import maestro_android.deviceInfo import maestro_android.emptyResponse import maestro_android.eraseAllTextResponse import maestro_android.inputTextResponse import maestro_android.launchAppResponse import maestro_android.screenshotResponse import maestro_android.setLocationResponse import maestro_android.tapResponse import maestro_android.viewHierarchyResponse import org.junit.Test import org.junit.runner.RunWith import java.io.ByteArrayOutputStream import java.io.OutputStream import java.util.Timer import java.util.TimerTask import kotlin.system.measureTimeMillis /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ @RunWith(AndroidJUnit4::class) class MaestroDriverService { @Test fun grpcServer() { Configurator.getInstance() .setActionAcknowledgmentTimeout(0L) .setWaitForIdleTimeout(0L) .setWaitForSelectorTimeout(0L) val instrumentation = InstrumentationRegistry.getInstrumentation() val uiDevice = UiDevice.getInstance(instrumentation) val uiAutomation = instrumentation.uiAutomation val port = InstrumentationRegistry.getArguments().getString("port", "7001").toInt() println("Server running on port [ $port ]") NettyServerBuilder.forPort(port) .addService(Service(uiDevice, uiAutomation)) .permitKeepAliveTime(30, TimeUnit.SECONDS) // If a client pings more than once every 30 seconds, terminate the connection .permitKeepAliveWithoutCalls(true) // Allow pings even when there are no active streams. .keepAliveTimeout(20, TimeUnit.SECONDS) // wait 20 seconds for client to ack the keep alive .maxConnectionIdle(30, TimeUnit.MINUTES) // If a client is idle for 30 minutes, send a GOAWAY frame. .build() .start() while (!Thread.interrupted()) { Thread.sleep(100) } } } class Service( private val uiDevice: UiDevice, private val uiAutomation: UiAutomation, ) : MaestroDriverGrpc.MaestroDriverImplBase() { private var locationTimerTask : TimerTask? = null private val locationTimer = Timer() private val mockLocationProviderList = mutableListOf<MockLocationProvider>() private val toastAccessibilityListener = ToastAccessibilityListener.start(uiAutomation) companion object { private const val TAG = "Maestro" private const val UPDATE_INTERVAL_IN_MILLIS = 2000L } override fun launchApp( request: MaestroAndroid.LaunchAppRequest, responseObserver: StreamObserver<MaestroAndroid.LaunchAppResponse> ) { try { val context = InstrumentationRegistry.getInstrumentation().targetContext val intent = context.packageManager.getLaunchIntentForPackage(request.packageName) if (intent == null) { Log.e("Maestro", "No launcher intent found for package ${request.packageName}") responseObserver.onError(RuntimeException("No launcher intent found for package ${request.packageName}")) return } request.argumentsList .forEach { when (it.type) { String::class.java.name -> intent.putExtra(it.key, it.value) Boolean::class.java.name -> intent.putExtra(it.key, it.value.toBoolean()) Int::class.java.name -> intent.putExtra(it.key, it.value.toInt()) Double::class.java.name -> intent.putExtra(it.key, it.value.toDouble()) Long::class.java.name -> intent.putExtra(it.key, it.value.toLong()) else -> intent.putExtra(it.key, it.value) } } context.startActivity(intent) responseObserver.onNext(launchAppResponse { }) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun deviceInfo( request: MaestroAndroid.DeviceInfoRequest, responseObserver: StreamObserver<MaestroAndroid.DeviceInfo> ) { try { val windowManager = InstrumentationRegistry.getInstrumentation() .context .getSystemService(Context.WINDOW_SERVICE) as WindowManager val displayMetrics = DisplayMetrics() windowManager.defaultDisplay.getRealMetrics(displayMetrics) responseObserver.onNext( deviceInfo { widthPixels = displayMetrics.widthPixels heightPixels = displayMetrics.heightPixels } ) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun viewHierarchy( request: MaestroAndroid.ViewHierarchyRequest, responseObserver: StreamObserver<MaestroAndroid.ViewHierarchyResponse> ) { try { refreshAccessibilityCache() val stream = ByteArrayOutputStream() val ms = measureTimeMillis { if (toastAccessibilityListener.getToastAccessibilityNode() != null && !toastAccessibilityListener.isTimedOut()) { Log.d("Maestro", "Requesting view hierarchy with toast") ViewHierarchy.dump( uiDevice, uiAutomation, stream, toastAccessibilityListener.getToastAccessibilityNode() ) } else { Log.d("Maestro", "Requesting view hierarchy") ViewHierarchy.dump( uiDevice, uiAutomation, stream ) } } Log.d("Maestro", "View hierarchy received in $ms ms") responseObserver.onNext( viewHierarchyResponse { hierarchy = stream.toString(Charsets.UTF_8.name()) } ) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } /** * Clears the in-process Accessibility cache, removing any stale references. Because the * AccessibilityInteractionClient singleton stores copies of AccessibilityNodeInfo instances, * calls to public APIs such as `recycle` do not guarantee cached references get updated. */ private fun refreshAccessibilityCache() { try { uiDevice.waitForIdle(500) uiAutomation.serviceInfo = null } catch (nullExp: NullPointerException) { /* no-op */ } } override fun tap( request: MaestroAndroid.TapRequest, responseObserver: StreamObserver<MaestroAndroid.TapResponse> ) { try { uiDevice.clickExt( request.x, request.y ) responseObserver.onNext(tapResponse {}) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun addMedia(responseObserver: StreamObserver<MaestroAndroid.AddMediaResponse>): StreamObserver<MaestroAndroid.AddMediaRequest> { return object : StreamObserver<MaestroAndroid.AddMediaRequest> { var outputStream: OutputStream? = null override fun onNext(value: MaestroAndroid.AddMediaRequest) { if (outputStream == null) { outputStream = MediaStorage.getOutputStream( value.mediaName, value.mediaExt ) } value.payload.data.writeTo(outputStream) } override fun onError(t: Throwable) { responseObserver.onError(t.internalError()) } override fun onCompleted() { responseObserver.onNext(addMediaResponse { }) responseObserver.onCompleted() } } } override fun eraseAllText( request: MaestroAndroid.EraseAllTextRequest, responseObserver: StreamObserver<MaestroAndroid.EraseAllTextResponse> ) { try { val charactersToErase = request.charactersToErase Log.d("Maestro", "Erasing text $charactersToErase") for (i in 1..charactersToErase) { uiDevice.pressDelete() } responseObserver.onNext(eraseAllTextResponse { }) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } override fun inputText( request: MaestroAndroid.InputTextRequest, responseObserver: StreamObserver<MaestroAndroid.InputTextResponse> ) { try { Log.d("Maestro", "Inputting text") request.text.forEach { setText(it.toString()) Thread.sleep(75) } responseObserver.onNext(inputTextResponse { }) responseObserver.onCompleted() } catch (e: Throwable) { responseObserver.onError(e.internalError()) } } override fun screenshot( request: MaestroAndroid.ScreenshotRequest, responseObserver: StreamObserver<MaestroAndroid.ScreenshotResponse> ) { val outputStream = ByteString.newOutput() val bitmap = uiAutomation.takeScreenshot() if (bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)) { responseObserver.onNext(screenshotResponse { bytes = outputStream.toByteString() }) responseObserver.onCompleted() } else { Log.e("Maestro", "Failed to compress bitmap") responseObserver.onError(Throwable("Failed to compress bitmap").internalError()) } } override fun isWindowUpdating( request: MaestroAndroid.CheckWindowUpdatingRequest, responseObserver: StreamObserver<MaestroAndroid.CheckWindowUpdatingResponse> ) { try { responseObserver.onNext(checkWindowUpdatingResponse { isWindowUpdating = uiDevice.waitForWindowUpdate(request.appId, 500) }) responseObserver.onCompleted() } catch (e: Throwable) { responseObserver.onError(e.internalError()) } } override fun disableLocationUpdates( request: MaestroAndroid.EmptyRequest, responseObserver: StreamObserver<MaestroAndroid.EmptyResponse> ) { try { Log.d(TAG, "[Start] Disabling location updates") locationTimerTask?.cancel() locationTimer.cancel() mockLocationProviderList.forEach { it.disable() } Log.d(TAG, "[Done] Disabling location updates") responseObserver.onNext(emptyResponse { }) responseObserver.onCompleted() } catch (exception: Exception) { responseObserver.onError(exception.internalError()) } } override fun enableMockLocationProviders( request: MaestroAndroid.EmptyRequest, responseObserver: StreamObserver<MaestroAndroid.EmptyResponse> ) { try { Log.d(TAG, "[Start] Enabling mock location providers") val context = InstrumentationRegistry.getInstrumentation().targetContext val locationManager = context.getSystemService(LOCATION_SERVICE) as LocationManager mockLocationProviderList.addAll( createMockProviders(context, locationManager) ) mockLocationProviderList.forEach { it.enable() } Log.d(TAG, "[Done] Enabling mock location providers") responseObserver.onNext(emptyResponse { }) responseObserver.onCompleted() } catch (exception: Exception) { Log.e(TAG, "Error while enabling mock location provider", exception) responseObserver.onError(exception.internalError()) } } private fun createMockProviders( context: Context, locationManager: LocationManager ): List<MockLocationProvider> { val playServices = PlayServices() val fusedLocationProvider: MockLocationProvider? = if (playServices.isAvailable(context)) { val fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) FusedLocationProvider(fusedLocationProviderClient) } else { null } return (locationManager.allProviders.mapNotNull { if (it.equals(LocationManager.PASSIVE_PROVIDER)) { null } else { val mockProvider = createLocationManagerMockProvider(locationManager, it) mockProvider } } + fusedLocationProvider).mapNotNull { it } } private fun createLocationManagerMockProvider( locationManager: LocationManager, providerName: String? ): MockLocationProvider? { if (providerName == null) { return null } // API level check for existence of provider properties if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // API level 31 and above val providerProperties = locationManager.getProviderProperties(providerName) ?: return null return LocationManagerProvider( locationManager, providerName, providerProperties.hasNetworkRequirement(), providerProperties.hasSatelliteRequirement(), providerProperties.hasCellRequirement(), providerProperties.hasMonetaryCost(), providerProperties.hasAltitudeSupport(), providerProperties.hasSpeedSupport(), providerProperties.hasBearingSupport(), providerProperties.powerUsage, providerProperties.accuracy ) } val provider = locationManager.getProvider(providerName) ?: return null return LocationManagerProvider( locationManager, provider.name, provider.requiresNetwork(), provider.requiresSatellite(), provider.requiresCell(), provider.hasMonetaryCost(), provider.supportsAltitude(), provider.supportsSpeed(), provider.supportsBearing(), provider.powerRequirement, provider.accuracy ) } override fun setLocation( request: MaestroAndroid.SetLocationRequest, responseObserver: StreamObserver<MaestroAndroid.SetLocationResponse> ) { try { if (locationTimerTask != null) { locationTimerTask?.cancel() } locationTimerTask = object : TimerTask() { override fun run() { mockLocationProviderList.forEach { val latitude = request.latitude val longitude = request.longitude Log.d(TAG, "Setting location latitude: $latitude and longitude: $longitude for ${it.getProviderName()}") val location = Location(it.getProviderName()).apply { setLatitude(latitude) setLongitude(longitude) accuracy = Criteria.ACCURACY_FINE.toFloat() altitude = 0.0 time = System.currentTimeMillis() elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() } it.setLocation(location) } } } locationTimer.schedule( locationTimerTask, 0, UPDATE_INTERVAL_IN_MILLIS ) responseObserver.onNext(setLocationResponse { }) responseObserver.onCompleted() } catch (t: Throwable) { responseObserver.onError(t.internalError()) } } private fun setText(text: String) { for (element in text) { Log.d("Maestro", element.code.toString()) when (element.code) { in 48..57 -> { /** 0~9 **/ uiDevice.pressKeyCode(element.code - 41) } in 65..90 -> { /** A~Z **/ uiDevice.pressKeyCode(element.code - 36, 1) } in 97..122 -> { /** a~z **/ uiDevice.pressKeyCode(element.code - 68) } ';'.code -> uiDevice.pressKeyCode(KEYCODE_SEMICOLON) '='.code -> uiDevice.pressKeyCode(KEYCODE_EQUALS) ','.code -> uiDevice.pressKeyCode(KEYCODE_COMMA) '-'.code -> uiDevice.pressKeyCode(KEYCODE_MINUS) '.'.code -> uiDevice.pressKeyCode(KEYCODE_PERIOD) '/'.code -> uiDevice.pressKeyCode(KEYCODE_SLASH) '`'.code -> uiDevice.pressKeyCode(KEYCODE_GRAVE) '\''.code -> uiDevice.pressKeyCode(KEYCODE_APOSTROPHE) '['.code -> uiDevice.pressKeyCode(KEYCODE_LEFT_BRACKET) ']'.code -> uiDevice.pressKeyCode(KEYCODE_RIGHT_BRACKET) '\\'.code -> uiDevice.pressKeyCode(KEYCODE_BACKSLASH) ' '.code -> uiDevice.pressKeyCode(KEYCODE_SPACE) '@'.code -> uiDevice.pressKeyCode(KEYCODE_AT) '#'.code -> uiDevice.pressKeyCode(KEYCODE_POUND) '*'.code -> uiDevice.pressKeyCode(KEYCODE_STAR) '('.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_LEFT_PAREN) ')'.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_RIGHT_PAREN) '+'.code -> uiDevice.pressKeyCode(KEYCODE_NUMPAD_ADD) '!'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_1) '$'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_4) '%'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_5) '^'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_6) '&'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_7) '"'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_APOSTROPHE) '{'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_LEFT_BRACKET) '}'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_RIGHT_BRACKET) ':'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_SEMICOLON) '|'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_BACKSLASH) '<'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_COMMA) '>'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_PERIOD) '?'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_SLASH) '~'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_GRAVE) '_'.code -> keyPressShiftedToEvents(uiDevice, KEYCODE_MINUS) } } } private fun keyPressShiftedToEvents(uiDevice: UiDevice, keyCode: Int) { uiDevice.pressKeyCode(keyCode, META_SHIFT_LEFT_ON) } internal fun Throwable.internalError() = Status.INTERNAL.withDescription(message).asException() enum class FileType(val ext: String, val mimeType: String) { JPG("jpg", "image/jpg"), JPEG("jpeg", "image/jpeg"), PNG("png", "image/png"), GIF("gif", "image/gif"), MP4("mp4", "video/mp4"), } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mobile-dev-inc/Maestro'

If you have feedback or need assistance with the MCP directory API, please join our Discord server