Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,27 @@ package com.zhousl.aether.agentmode;
import android.os.ParcelFileDescriptor;
import android.view.Surface;

/**
* Agent Mode service that runs as shell (Shizuku) or root, never in Aether's own user. Every call
* that acts on "the user" (resolving and launching packages, writing the clipboard) must carry the
* user id of the Aether process that is being served, because the service process itself belongs to
* user 0 and Shizuku may even reuse one user service for every user.
*/
interface IAetherAgentModeService {
int createDisplay(String name, int width, int height, int density, in Surface surface) = 1;
int createOwnedDisplay(String name, int width, int height, int density) = 2;
void attachPreviewSurface(int displayId, in Surface surface) = 3;
void detachPreviewSurface(int displayId) = 4;
void releaseDisplay(int displayId) = 5;
void launchPackage(String packageName, int displayId) = 6;
void launchPackage(String packageName, int displayId, int userId) = 6;
void runInputCommand(String command) = 7;
void tap(int displayId, int x, int y) = 8;
void swipe(int displayId, int x1, int y1, int x2, int y2, int durationMs) = 9;
void key(int displayId, String keyCode) = 10;
String text(int displayId, String text) = 11;
void captureImageToFd(int displayId, in ParcelFileDescriptor output, int maxEdge, int quality) = 12;
String text(int displayId, String text, int userId) = 11;
String captureImageToFd(int displayId, in ParcelFileDescriptor output, int maxEdge, int quality) = 12;
String listDisplaysJson() = 13;
String listInstalledAppsJson() = 14;
String listInstalledAppsJson(int userId) = 14;
String focusedWindowJson(int displayId) = 15;
void destroy() = 16777114;
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.zhousl.aether.agentmode

import org.json.JSONObject

/**
* Capture contract between the Agent Mode user service and the app.
*
* A capture either returns real pixels of the virtual display or a black placeholder, because a
* freshly created display has nothing to show yet. The service reports which one it was, and why, so
* the agent cannot mistake a placeholder for the state of an app (issue #100).
*/
internal const val AgentModeCaptureSourceDisplay = "display"
internal const val AgentModeCaptureSourceBlank = "blank"
internal const val AgentModeBlankReasonLaunchFailed = "launch_failed"
internal const val AgentModeBlankReasonNoLaunchedContent = "no_launched_content"
internal const val AgentModeBlankReasonEmptyDisplay = "empty_display"

internal data class AgentModeCaptureOutcome(
val isBlank: Boolean,
val blankReason: String,
) {
companion object {
val Display = AgentModeCaptureOutcome(isBlank = false, blankReason = "")
}
}

/** Builds the status string the Agent Mode service returns from `captureImageToFd`. */
internal fun agentModeCaptureStatus(source: String, blankReason: String = ""): String =
JSONObject().apply {
put("source", source)
if (blankReason.isNotBlank()) put("blank_reason", blankReason)
}.toString()

/** Parses the capture status; payloads that do not describe a placeholder count as a real capture. */
internal fun parseAgentModeCaptureOutcome(rawValue: String): AgentModeCaptureOutcome {
val status = runCatching { JSONObject(rawValue) }.getOrNull()
?: return AgentModeCaptureOutcome.Display
if (status.optString("source") != AgentModeCaptureSourceBlank) return AgentModeCaptureOutcome.Display
return AgentModeCaptureOutcome(
isBlank = true,
blankReason = status.optString("blank_reason").ifBlank { AgentModeBlankReasonEmptyDisplay },
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.zhousl.aether.agentmode

/**
* Android allocates [AgentModePerUserUidRange] uids per user, so a process uid also identifies the
* user that process runs as. `UserHandle.getUserId` computes the same value but is hidden API and
* cannot be referenced from app code, hence this constant.
*/
internal const val AgentModePerUserUidRange = 100_000

/** Returns the Android user id that [uid] belongs to. */
internal fun agentModeUserIdFromUid(uid: Int): Int = uid / AgentModePerUserUidRange
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,10 @@ private fun agentModeToolDefinition(): JSONObject = JSONObject().apply {
"Operate Aether Agent Mode on an isolated Android virtual display. Use this only when Agent Mode is selected in the chat composer. " +
"tap/swipe coordinates are normalized 0..1000 on each axis, independent of resolution; values above 1000 are rejected. " +
"Results report width/height (display pixels), image_width/image_height (screenshot pixels), " +
"cursor_norm_x/cursor_norm_y (last touch point, normalized) and cursor_x/cursor_y (the same point in display pixels).",
"cursor_norm_x/cursor_norm_y (last touch point, normalized) and cursor_x/cursor_y (the same point in display pixels). " +
"When the display has nothing to show yet the result carries screenshot_blank=true and " +
"screenshot_blank_reason: that image is a black placeholder, not app content, so launch an app " +
"and check the launch result before judging the screen.",
)
put(
"parameters",
Expand Down
55 changes: 42 additions & 13 deletions app/src/main/java/com/zhousl/aether/data/AgentModeController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import android.graphics.Point
import android.hardware.display.DisplayManager
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.Process
import android.util.Base64
import android.view.Display
import android.view.Surface
import androidx.core.content.getSystemService
import com.rosan.app_process.AppProcess
import com.zhousl.aether.agentmode.AetherAgentModeShizukuService
import com.zhousl.aether.agentmode.AgentModeCaptureOutcome
import com.zhousl.aether.agentmode.IAetherAgentModeService
import com.zhousl.aether.agentmode.agentModeUserIdFromUid
import com.zhousl.aether.agentmode.parseAgentModeCaptureOutcome
import com.zhousl.aether.termux.TermuxBashTool
import java.io.File
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -51,7 +55,9 @@ private const val ShizukuPermissionRequestCode = 4201
private const val RootAuthorizationProbeTimeoutMillis = 2_000L
private const val ShizukuUserServiceBindTimeoutMillis = 20_000L
private const val ShizukuUserServiceTag = "aether-agent-mode"
private const val ShizukuUserServiceVersion = 2
// Bumped whenever the service AIDL or its user scoping changes: Shizuku reuses a running user
// service until this version differs, and an old process would keep acting on the owner user.
private const val ShizukuUserServiceVersion = 3

private val ShizukuManagerPackages = listOf(
"moe.shizuku.privileged.api",
Expand Down Expand Up @@ -118,6 +124,11 @@ class AgentModeController(
private val diagnosticLogger: AetherDiagnosticLogger = AetherDiagnosticLogger.NoOp,
) {
private val displayManager = context.getSystemService<DisplayManager>()!!
/**
* The user Aether runs as. The Shizuku and root user services run in user 0, so every call that
* acts on "the user" carries this id instead of letting the service guess (issue #100).
*/
private val agentModeUserId: Int = agentModeUserIdFromUid(Process.myUid())
private val cacheDirectory = File(context.cacheDir, "agent-mode").apply { mkdirs() }
private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val captureMutex = Mutex()
Expand Down Expand Up @@ -331,7 +342,7 @@ class AgentModeController(
action = action,
)
} else {
val method = requireAgentModeService(settings).text(displayId, text)
val method = requireAgentModeService(settings).text(displayId, text, agentModeUserId)
captureAfterDelay(
settings,
workspaceDirectory,
Expand Down Expand Up @@ -492,6 +503,7 @@ class AgentModeController(
"height" to displaySpec.height,
"density_dpi" to displaySpec.densityDpi,
"method" to settings.agentModeAuthorizationMethod.storageValue,
"user_id" to agentModeUserId,
),
)
_displayState.value = AgentModeDisplayState(
Expand Down Expand Up @@ -583,7 +595,7 @@ class AgentModeController(
val displayId = ensureDisplay(settings)
val launchPackage = resolveLaunchPackage(settings, target)
?: error("No launchable app matched '$target'. Try a package name such as com.android.chrome, or a shorter app label.")
requireAgentModeService(settings).launchPackage(launchPackage, displayId)
requireAgentModeService(settings).launchPackage(launchPackage, displayId, agentModeUserId)
}

private suspend fun resolveLaunchPackage(
Expand Down Expand Up @@ -684,7 +696,7 @@ class AgentModeController(

private suspend fun currentInstalledApps(settings: AppSettings): List<AgentModeInstalledAppInfo> {
val privilegedApps = runCatching {
parseInstalledApps(requireAgentModeService(settings).listInstalledAppsJson())
parseInstalledApps(requireAgentModeService(settings).listInstalledAppsJson(agentModeUserId))
}.getOrNull()
return (privilegedApps?.takeIf { it.isNotEmpty() } ?: currentInstalledAppsLocal())
.distinctBy { it.packageName }
Expand Down Expand Up @@ -747,7 +759,7 @@ class AgentModeController(
if (delayMillis > 0) delay(delayMillis)
val captureId = "capture-${System.currentTimeMillis()}"
val previewFile = File(cacheDirectory, "$captureId.$AgentModeCaptureExtension")
captureImageFile(settings, previewFile)
val captureOutcome = captureImageFile(settings, previewFile)
if (!previewFile.isFile || previewFile.length() <= 0L) {
error("Agent Mode screenshot capture produced an empty file.")
}
Expand Down Expand Up @@ -795,9 +807,24 @@ class AgentModeController(
put("cursor_norm_y", normalizeAgentModePixel(it, state.height))
}
extras?.keys()?.forEach { key -> put(key, extras.get(key)) }
if (captureOutcome.isBlank) {
put("screenshot_blank", true)
put("screenshot_blank_reason", captureOutcome.blankReason)
}
put("screenshot_mime_type", AgentModeCaptureMimeType)
put("screenshot_base64", Base64.encodeToString(bytes, Base64.NO_WRAP))
put("stdout", "Captured Agent Mode screenshot: $workspacePath")
put(
"stdout",
buildString {
append("Captured Agent Mode screenshot: ")
append(workspacePath)
if (captureOutcome.isBlank) {
append("\nThe virtual display has no drawn content yet (")
append(captureOutcome.blankReason)
append("); the image is a black placeholder, not app content.")
}
},
)
}.toString()
}

Expand All @@ -818,9 +845,9 @@ class AgentModeController(
private suspend fun captureImageFile(
settings: AppSettings,
outputFile: File,
) {
): AgentModeCaptureOutcome {
val displayId = ensureDisplay(settings)
captureMutex.withLock {
return captureMutex.withLock {
outputFile.parentFile?.mkdirs()
runCatching { outputFile.delete() }
try {
Expand All @@ -830,11 +857,13 @@ class AgentModeController(
ParcelFileDescriptor.MODE_WRITE_ONLY or
ParcelFileDescriptor.MODE_TRUNCATE,
).use { descriptor ->
requireAgentModeService(settings).captureImageToFd(
displayId,
descriptor,
AgentModeCaptureMaxEdge,
AgentModeCaptureJpegQuality,
parseAgentModeCaptureOutcome(
requireAgentModeService(settings).captureImageToFd(
displayId,
descriptor,
AgentModeCaptureMaxEdge,
AgentModeCaptureJpegQuality,
),
)
}
} catch (throwable: Throwable) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.zhousl.aether.agentmode

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class AgentModeCaptureStatusTest {
@Test
fun displayCaptureIsNotAPlaceholder() {
val outcome = parseAgentModeCaptureOutcome(agentModeCaptureStatus(AgentModeCaptureSourceDisplay))
assertFalse(outcome.isBlank)
assertEquals("", outcome.blankReason)
}

@Test
fun failedLaunchBlankFrameExplainsItself() {
// Issue #100: after a failed launch every screenshot used to be a silent black image.
val outcome = parseAgentModeCaptureOutcome(
agentModeCaptureStatus(
source = AgentModeCaptureSourceBlank,
blankReason = AgentModeBlankReasonLaunchFailed,
),
)
assertTrue(outcome.isBlank)
assertEquals(AgentModeBlankReasonLaunchFailed, outcome.blankReason)
}

@Test
fun emptyDisplayBlankFrameExplainsItself() {
val outcome = parseAgentModeCaptureOutcome(
agentModeCaptureStatus(
source = AgentModeCaptureSourceBlank,
blankReason = AgentModeBlankReasonNoLaunchedContent,
),
)
assertTrue(outcome.isBlank)
assertEquals(AgentModeBlankReasonNoLaunchedContent, outcome.blankReason)
}

@Test
fun blankFrameWithoutReasonFallsBackToEmptyDisplay() {
val outcome = parseAgentModeCaptureOutcome("""{"source":"blank"}""")
assertTrue(outcome.isBlank)
assertEquals(AgentModeBlankReasonEmptyDisplay, outcome.blankReason)
}

@Test
fun unknownPayloadCountsAsARealCapture() {
assertFalse(parseAgentModeCaptureOutcome("").isBlank)
assertFalse(parseAgentModeCaptureOutcome("not json").isBlank)
assertFalse(parseAgentModeCaptureOutcome("""{"source":"display"}""").isBlank)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.zhousl.aether.agentmode

import org.junit.Assert.assertEquals
import org.junit.Test

class AgentModeUserScopeTest {
@Test
fun ownerUserUidsResolveToUserZero() {
assertEquals(0, agentModeUserIdFromUid(0))
assertEquals(0, agentModeUserIdFromUid(10_000))
assertEquals(0, agentModeUserIdFromUid(10_199))
}

@Test
fun secondaryUserAndWorkProfileUidsResolveToTheirUser() {
// Issue #100 was reported from a secondary user (10); work profiles are the same scheme.
assertEquals(10, agentModeUserIdFromUid(10 * 100_000 + 10_123))
assertEquals(11, agentModeUserIdFromUid(11 * 100_000 + 10_123))
assertEquals(999, agentModeUserIdFromUid(999 * 100_000))
}

@Test
fun systemUidsResolveToTheOwnerUser() {
// Shizuku and the su fallback start the user service as shell (2000) or root (0). Both live
// in user 0, which is exactly why the service has to be told which user it acts for.
assertEquals(0, agentModeUserIdFromUid(2000))
assertEquals(0, agentModeUserIdFromUid(1000))
assertEquals(0, agentModeUserIdFromUid(0))
}
}
Loading