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
47 changes: 47 additions & 0 deletions .github/workflows/build-agent-mode-apk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Build Agent Mode APK

on:
workflow_dispatch:
push:
branches: [fix/agent-mode-display-orientation]

permissions:
contents: read

concurrency:
group: agent-mode-apk-${{ github.ref }}
cancel-in-progress: true

jobs:
android:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '17'
- uses: actions/setup-node@v4
with:
node-version: '22.19.0'
cache: npm
cache-dependency-path: pi-bridge/package-lock.json
- uses: gradle/actions/setup-gradle@v6
- uses: android-actions/setup-android@v4
with:
packages: platform-tools
- run: chmod +x ./gradlew
- name: Unit tests
run: ./gradlew :shared:testDebugUnitTest :app:testDebugUnitTest --stacktrace
- name: Build unsigned release APK (original application ID)
run: ./gradlew :app:assembleRelease --stacktrace -Paether.versionName="2.1.6-landscape-${GITHUB_SHA::7}"
- name: Upload unsigned APK
uses: actions/upload-artifact@v7
with:
name: Aether-agent-mode-unsigned-${{ github.sha }}
path: app/build/outputs/apk/release/*-unsigned.apk
if-no-files-found: error
retention-days: 14
Original file line number Diff line number Diff line change
Expand Up @@ -407,11 +407,22 @@ class AetherSelfManagementTool(
} else {
current.agentModeAuthorizationMethod
},
agentModeDisplayOrientation = if (patch.has("display_orientation")) {
val value = patch.optString("display_orientation")
AgentModeDisplayOrientation.entries.firstOrNull { it.storageValue == value }
?: return failure("display_orientation must be device, portrait, or landscape.")
} else {
current.agentModeDisplayOrientation
},
)

else -> return failure("Unsupported or read-only category '$category'.")
}
if (category == "agent_mode") {
if (updated.agentModeDisplayOrientation != current.agentModeDisplayOrientation) {
agentModeController.stopDisplay()
settingsRepository.updateAgentModeDisplayOrientation(updated.agentModeDisplayOrientation)
}
settingsRepository.updateAgentModeAuthorization(
updated.agentModeAuthorizationEnabled,
updated.agentModeAuthorizationMethod,
Expand Down Expand Up @@ -797,6 +808,7 @@ class AetherSelfManagementTool(
JSONObject()
.put("authorization_enabled", settings.agentModeAuthorizationEnabled)
.put("authorization_method", settings.agentModeAuthorizationMethod.storageValue)
.put("display_orientation", settings.agentModeDisplayOrientation.storageValue)
.put("authorization", agentModeAuthorizationStateJson(agentModeController.authorizationState.value))
.put("display", agentModeDisplayStateJson(agentModeController.displayState.value))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,14 @@ private fun agentModeToolDefinition(): JSONObject = JSONObject().apply {
put("include_system", booleanProperty("For list_apps: whether to include system apps."))
put("max_results", integerProperty("For list_apps: maximum number of apps to return."))
put("target", stringProperty("For launch: package name or exact app label."))
put(
"orientation",
stringProperty(
"For start/launch: optional device, portrait, or landscape. Uses the saved setting when omitted. " +
"Changing orientation recreates the virtual display; relaunch the target app afterwards. " +
"Explicit override lasts until stop, without changing the saved preference.",
).put("enum", JSONArray().put("device").put("portrait").put("landscape")),
)
listOf("x", "y", "x1", "y1", "x2", "y2").forEach { key ->
val axis = if (key.startsWith("x")) "width" else "height"
put(
Expand Down
53 changes: 42 additions & 11 deletions app/src/main/java/com/zhousl/aether/data/AgentModeController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,20 @@ class AgentModeController(
)
}
val action = arguments.optString("action").trim().lowercase()
val previousOrientationOverride = displayOrientationOverride
if (action == "launch" && arguments.optString("target").trim().isBlank()) {
return@withContext invalidArguments("Missing required 'target' argument.")
}
if (arguments.has("orientation")) {
if (action != "start" && action != "launch") {
return@withContext invalidArguments("'orientation' is only supported for start or launch.")
}
val value = arguments.optString("orientation").trim().lowercase()
val orientation = AgentModeDisplayOrientation.entries.firstOrNull { it.storageValue == value }
?: return@withContext invalidArguments("orientation must be device, portrait, or landscape.")
// An explicit override lasts for this managed display session, not the saved preference.
displayOrientationOverride = orientation
}
diagnosticLogger.event(
category = "agent_mode",
event = "action_start",
Expand Down Expand Up @@ -345,7 +359,7 @@ class AgentModeController(
)
}
"stop" -> {
releaseDisplay()
stopDisplay()
JSONObject().apply {
put("ok", true)
put("stdout", "Agent Mode virtual display stopped.")
Expand Down Expand Up @@ -375,6 +389,10 @@ class AgentModeController(
)
}
}.getOrElse { throwable ->
// Do not keep an override from a request that failed to create its managed display.
if (arguments.has("orientation") && createdDisplayOrientation != displayOrientationOverride) {
displayOrientationOverride = previousOrientationOverride
}
captureAgentModeFailed(
settings = settings,
action = action.ifBlank { "unknown" },
Expand Down Expand Up @@ -457,23 +475,28 @@ class AgentModeController(
}.also { _authorizationState.value = it }
}

private var displayOrientationOverride: AgentModeDisplayOrientation? = null
private var createdDisplayOrientation: AgentModeDisplayOrientation? = null

private suspend fun ensureDisplay(settings: AppSettings): Int {
val orientation = displayOrientationOverride ?: settings.agentModeDisplayOrientation
val service = requireAgentModeService(settings)
val serviceBinder = service.asBinder()
shizukuDisplayId?.let { displayId ->
if (isCurrentDisplayOwner(settings, serviceBinder)) {
if (isCurrentDisplayOwner(settings, serviceBinder) && createdDisplayOrientation == orientation) {
return displayId
}
releaseDisplay("Virtual display reset because Agent Mode authorization service changed.")
releaseDisplay("Virtual display reset because Agent Mode service or orientation changed. Relaunch the target app.")
}
val displaySpec = currentDeviceDisplaySpec()
val displaySpec = currentDeviceDisplaySpec(orientation)
val displayId = service.createOwnedDisplay(
AgentDisplayName,
displaySpec.width,
displaySpec.height,
displaySpec.densityDpi,
)
shizukuDisplayId = displayId
createdDisplayOrientation = orientation
displayOwnerMethod = settings.agentModeAuthorizationMethod
displayOwnerBinder = serviceBinder
diagnosticLogger.event(
Expand Down Expand Up @@ -501,6 +524,7 @@ class AgentModeController(
}

fun stopDisplay() {
displayOrientationOverride = null
releaseDisplay()
}

Expand Down Expand Up @@ -848,6 +872,7 @@ class AgentModeController(
return JSONObject().apply {
put("ok", true)
put("active", state.isActive)
put("orientation", (createdDisplayOrientation ?: settings.agentModeDisplayOrientation).storageValue)
put("display_id", state.displayId)
put("width", state.width)
put("height", state.height)
Expand Down Expand Up @@ -894,6 +919,7 @@ class AgentModeController(
}
}
shizukuDisplayId = null
createdDisplayOrientation = null
displayOwnerMethod = null
displayOwnerBinder = null
_displayState.value = AgentModeDisplayState(
Expand Down Expand Up @@ -1353,19 +1379,24 @@ class AgentModeController(
put("stdout", "")
}.toString()

private fun currentDeviceDisplaySpec(): DisplaySpec {
private fun currentDeviceDisplaySpec(orientation: AgentModeDisplayOrientation): DisplaySpec {
val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
val size = Point()
@Suppress("DEPRECATION")
display?.getRealSize(size)
val metrics = context.resources.displayMetrics
val width = (display?.mode?.physicalWidth ?: size.x).takeIf { it > 0 }
?: metrics.widthPixels.takeIf { it > 0 }
?: FallbackAgentDisplayWidth
val height = (display?.mode?.physicalHeight ?: size.y).takeIf { it > 0 }
?: metrics.heightPixels.takeIf { it > 0 }
?: FallbackAgentDisplayHeight
val dimensions = agentModeDisplayDimensions(width, height, orientation)
// The display, ImageReader, preview state and normalized input all share these dimensions.
// Recreate rather than rotate only the output Surface, which would leave capture/input stale.
return DisplaySpec(
width = (display?.mode?.physicalWidth ?: size.x).takeIf { it > 0 }
?: metrics.widthPixels.takeIf { it > 0 }
?: FallbackAgentDisplayWidth,
height = (display?.mode?.physicalHeight ?: size.y).takeIf { it > 0 }
?: metrics.heightPixels.takeIf { it > 0 }
?: FallbackAgentDisplayHeight,
width = dimensions.first,
height = dimensions.second,
densityDpi = metrics.densityDpi.takeIf { it > 0 } ?: FallbackAgentDisplayDensityDpi,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.zhousl.aether.data

/** Physical display size for a newly created managed display; no coordinate-space rotation. */
internal fun agentModeDisplayDimensions(
width: Int,
height: Int,
orientation: AgentModeDisplayOrientation,
): Pair<Int, Int> {
require(width > 0 && height > 0)
return when (orientation) {
AgentModeDisplayOrientation.Device -> width to height
AgentModeDisplayOrientation.Portrait -> minOf(width, height) to maxOf(width, height)
AgentModeDisplayOrientation.Landscape -> maxOf(width, height) to minOf(width, height)
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/com/zhousl/aether/data/SettingsRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ class SettingsRepository(
preferences[AGENT_MODE_AUTHORIZATION_METHOD],
defaultValue = defaultAgentModeAuthorizationMethod(context),
),
agentModeDisplayOrientation = AgentModeDisplayOrientation.fromStorage(
preferences[AGENT_MODE_DISPLAY_ORIENTATION],
),
language = AppLanguage.fromStorage(preferences[LANGUAGE]),
themeMode = AppThemeMode.fromStorage(preferences[THEME_MODE]),
defaultChatModelKey = preferences[DEFAULT_CHAT_MODEL_KEY].orEmpty(),
Expand Down Expand Up @@ -453,6 +456,7 @@ class SettingsRepository(
serializeAlpineEnvironmentVariables(settings.alpineEnvironmentVariables)
it[AGENT_MODE_AUTHORIZATION_ENABLED] = settings.agentModeAuthorizationEnabled
it[AGENT_MODE_AUTHORIZATION_METHOD] = settings.agentModeAuthorizationMethod.storageValue
it[AGENT_MODE_DISPLAY_ORIENTATION] = settings.agentModeDisplayOrientation.storageValue
it[LANGUAGE] = settings.language.storageValue
it[THEME_MODE] = settings.themeMode.storageValue
it[DEFAULT_CHAT_MODEL_KEY] = settings.defaultChatModelKey
Expand Down Expand Up @@ -518,6 +522,10 @@ class SettingsRepository(
}
}

suspend fun updateAgentModeDisplayOrientation(orientation: AgentModeDisplayOrientation) {
context.dataStore.edit { it[AGENT_MODE_DISPLAY_ORIENTATION] = orientation.storageValue }
}

suspend fun updateAgentModeAuthorization(
enabled: Boolean,
method: AgentModeAuthorizationMethod,
Expand Down Expand Up @@ -681,6 +689,8 @@ class SettingsRepository(
booleanPreferencesKey("agent_mode_authorization_enabled")
val AGENT_MODE_AUTHORIZATION_METHOD =
stringPreferencesKey("agent_mode_authorization_method")
val AGENT_MODE_DISPLAY_ORIENTATION =
stringPreferencesKey("agent_mode_display_orientation")
val LANGUAGE = stringPreferencesKey("language")
val THEME_MODE = stringPreferencesKey("theme_mode")
val DEFAULT_CHAT_MODEL_KEY = stringPreferencesKey("default_chat_model_key")
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/zhousl/aether/ui/AetherApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,8 @@ private fun AetherAppContent(
appUpdate = uiState.appUpdate,
onSave = viewModel::saveSettings,
onSaveDefaultModelKeys = viewModel::saveDefaultModelKeys,
agentModeDisplayOrientation = uiState.settings.agentModeDisplayOrientation,
onSaveAgentModeDisplayOrientation = viewModel::saveAgentModeDisplayOrientation,
onSaveAgentModeAuthorization = viewModel::saveAgentModeAuthorization,
onUpdateLanguage = { language ->
viewModel.updateAppLanguage(language)
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.zhousl.aether.data.AetherModServiceMethod
import com.zhousl.aether.data.AppUpdateManager
import com.zhousl.aether.data.AutomaticModelPurpose
import com.zhousl.aether.data.AgentModeAuthorizationMethod
import com.zhousl.aether.data.AgentModeDisplayOrientation
import com.zhousl.aether.data.AgentWorkspaceMode
import com.zhousl.aether.data.AlpineEnvironmentVariable
import com.zhousl.aether.data.AppLanguage
Expand Down Expand Up @@ -2493,6 +2494,18 @@ class AetherViewModel(
}
}

fun saveAgentModeDisplayOrientation(orientation: AgentModeDisplayOrientation) {
if (_uiState.value.settings.agentModeDisplayOrientation == orientation) return
// Recreate capture and preview surfaces together on the next launch.
agentModeController.stopDisplay()
_uiState.update { current ->
current.copy(settings = current.settings.copy(agentModeDisplayOrientation = orientation))
}
viewModelScope.launch {
settingsRepository.updateAgentModeDisplayOrientation(orientation)
}
}

fun saveAgentModeAuthorization(
enabled: Boolean,
method: AgentModeAuthorizationMethod,
Expand Down Expand Up @@ -6048,6 +6061,7 @@ class AetherViewModel(
put("oldCommandHistoryRetentionHours", oldCommandHistoryRetentionHours)
put("agentModeAuthorizationEnabled", agentModeAuthorizationEnabled)
put("agentModeAuthorizationMethod", agentModeAuthorizationMethod.storageValue)
put("agentModeDisplayOrientation", agentModeDisplayOrientation.storageValue)
put("language", language.storageValue)
put("themeMode", themeMode.storageValue)
put("defaultChatModelKey", defaultChatModelKey)
Expand Down Expand Up @@ -6152,6 +6166,9 @@ class AetherViewModel(
json.optString("agentModeAuthorizationMethod"),
defaults.agentModeAuthorizationMethod,
),
agentModeDisplayOrientation = AgentModeDisplayOrientation.fromStorage(
json.optString("agentModeDisplayOrientation"),
),
language = AppLanguage.fromStorage(
json.optString("language"),
defaults.language,
Expand Down
Loading