From 98553ce4c519a1f34ac96d849b14229d5d7c491e Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:45:22 +0800 Subject: [PATCH 1/9] feat(android): support landscape Agent Mode virtual displays --- .github/workflows/build-agent-mode-apk.yml | 47 +++++++++++++++++++ .../aether/data/AetherSelfManagementTool.kt | 12 +++++ .../zhousl/aether/data/AetherToolExecutor.kt | 8 ++++ .../zhousl/aether/data/AgentModeController.kt | 44 ++++++++++++----- .../aether/data/AgentModeDisplayDimensions.kt | 15 ++++++ .../zhousl/aether/data/SettingsRepository.kt | 10 ++++ .../java/com/zhousl/aether/ui/AetherApp.kt | 2 + .../com/zhousl/aether/ui/AetherViewModel.kt | 17 +++++++ .../com/zhousl/aether/ui/ConversationUi.kt | 31 ++++++------ .../com/zhousl/aether/ui/SettingsScreen.kt | 30 ++++++++++++ .../aether/data/AetherToolExecutorTest.kt | 9 ++++ .../data/AgentModeDisplayDimensionsTest.kt | 39 +++++++++++++++ .../values-zh-rCN/strings.xml | 5 ++ .../composeResources/values/strings.xml | 5 ++ .../com/zhousl/aether/data/AppSettings.kt | 14 ++++++ .../data/AppSettingsSerializationTest.kt | 9 ++++ 16 files changed, 272 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/build-agent-mode-apk.yml create mode 100644 app/src/main/java/com/zhousl/aether/data/AgentModeDisplayDimensions.kt create mode 100644 app/src/test/java/com/zhousl/aether/data/AgentModeDisplayDimensionsTest.kt diff --git a/.github/workflows/build-agent-mode-apk.yml b/.github/workflows/build-agent-mode-apk.yml new file mode 100644 index 00000000..a404e904 --- /dev/null +++ b/.github/workflows/build-agent-mode-apk.yml @@ -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 diff --git a/app/src/main/java/com/zhousl/aether/data/AetherSelfManagementTool.kt b/app/src/main/java/com/zhousl/aether/data/AetherSelfManagementTool.kt index c6a47e09..c411594d 100644 --- a/app/src/main/java/com/zhousl/aether/data/AetherSelfManagementTool.kt +++ b/app/src/main/java/com/zhousl/aether/data/AetherSelfManagementTool.kt @@ -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, @@ -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)) diff --git a/app/src/main/java/com/zhousl/aether/data/AetherToolExecutor.kt b/app/src/main/java/com/zhousl/aether/data/AetherToolExecutor.kt index ea7a690e..e695df5d 100644 --- a/app/src/main/java/com/zhousl/aether/data/AetherToolExecutor.kt +++ b/app/src/main/java/com/zhousl/aether/data/AetherToolExecutor.kt @@ -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( diff --git a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt index a624b750..2914d6b6 100644 --- a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt +++ b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt @@ -213,6 +213,16 @@ class AgentModeController( ) } val action = arguments.optString("action").trim().lowercase() + 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", @@ -345,7 +355,7 @@ class AgentModeController( ) } "stop" -> { - releaseDisplay() + stopDisplay() JSONObject().apply { put("ok", true) put("stdout", "Agent Mode virtual display stopped.") @@ -457,16 +467,20 @@ 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, @@ -474,6 +488,7 @@ class AgentModeController( displaySpec.densityDpi, ) shizukuDisplayId = displayId + createdDisplayOrientation = orientation displayOwnerMethod = settings.agentModeAuthorizationMethod displayOwnerBinder = serviceBinder diagnosticLogger.event( @@ -501,6 +516,7 @@ class AgentModeController( } fun stopDisplay() { + displayOrientationOverride = null releaseDisplay() } @@ -894,6 +910,7 @@ class AgentModeController( } } shizukuDisplayId = null + createdDisplayOrientation = null displayOwnerMethod = null displayOwnerBinder = null _displayState.value = AgentModeDisplayState( @@ -1353,19 +1370,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, ) } diff --git a/app/src/main/java/com/zhousl/aether/data/AgentModeDisplayDimensions.kt b/app/src/main/java/com/zhousl/aether/data/AgentModeDisplayDimensions.kt new file mode 100644 index 00000000..8f91347e --- /dev/null +++ b/app/src/main/java/com/zhousl/aether/data/AgentModeDisplayDimensions.kt @@ -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 { + 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) + } +} diff --git a/app/src/main/java/com/zhousl/aether/data/SettingsRepository.kt b/app/src/main/java/com/zhousl/aether/data/SettingsRepository.kt index dd5e11ce..9c5f75a9 100644 --- a/app/src/main/java/com/zhousl/aether/data/SettingsRepository.kt +++ b/app/src/main/java/com/zhousl/aether/data/SettingsRepository.kt @@ -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(), @@ -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 @@ -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, @@ -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") diff --git a/app/src/main/java/com/zhousl/aether/ui/AetherApp.kt b/app/src/main/java/com/zhousl/aether/ui/AetherApp.kt index 8ce2a87f..7f0ed765 100644 --- a/app/src/main/java/com/zhousl/aether/ui/AetherApp.kt +++ b/app/src/main/java/com/zhousl/aether/ui/AetherApp.kt @@ -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) diff --git a/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt b/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt index 058ca010..cbbfb394 100644 --- a/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt +++ b/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt @@ -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 @@ -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, @@ -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) @@ -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, diff --git a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt index 967d540a..edc7fac4 100644 --- a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt @@ -3243,20 +3243,23 @@ private fun AgentModePreviewPanel( label = "agent_mode_cursor_offset", ) if (displayState.isActive && useLiveSurface) { - AgentModeLivePreviewSurface( - displayState = displayState, - onAttachSurface = onAttachSurface, - onDetachSurface = onDetachSurface, - modifier = Modifier - .fillMaxHeight() - .padding(10.dp) - .aspectRatio( - displayState.width.coerceAtLeast(1).toFloat() / - displayState.height.coerceAtLeast(1).toFloat(), - matchHeightConstraintsFirst = true, - ) - .clip(RoundedCornerShape(14.dp)), - ) + // Replace listeners capturing old buffer dimensions after a display recreation. + key(displayState.displayId, displayState.width, displayState.height) { + AgentModeLivePreviewSurface( + displayState = displayState, + onAttachSurface = onAttachSurface, + onDetachSurface = onDetachSurface, + modifier = Modifier + .fillMaxHeight() + .padding(10.dp) + .aspectRatio( + displayState.width.coerceAtLeast(1).toFloat() / + displayState.height.coerceAtLeast(1).toFloat(), + matchHeightConstraintsFirst = true, + ) + .clip(RoundedCornerShape(14.dp)), + ) + } } else if (displayState.isActive && !useLiveSurface) { PlatformWebView( url = AlpineChromeViewerUrl, diff --git a/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt b/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt index a8e724be..fefd582c 100644 --- a/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt +++ b/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt @@ -140,6 +140,7 @@ import kotlin.math.roundToInt import com.zhousl.aether.data.AetherAppExtensionError import com.zhousl.aether.data.AgentModeAuthorizationIssue import com.zhousl.aether.data.AgentModeAuthorizationMethod +import com.zhousl.aether.data.AgentModeDisplayOrientation import com.zhousl.aether.data.AgentModeAuthorizationState import com.zhousl.aether.data.AgentModeDisplayState import com.zhousl.aether.data.AgentWorkspaceMode @@ -462,6 +463,8 @@ fun SettingsScreen( List, ) -> Unit, onSaveDefaultModelKeys: (String, String, String, String) -> Unit, + agentModeDisplayOrientation: AgentModeDisplayOrientation, + onSaveAgentModeDisplayOrientation: (AgentModeDisplayOrientation) -> Unit, onSaveAgentModeAuthorization: (Boolean, AgentModeAuthorizationMethod) -> Unit, onUpdateLanguage: (AppLanguage) -> Unit, onUpdateThemeMode: (AppThemeMode) -> Unit, @@ -1246,6 +1249,8 @@ fun SettingsScreen( agentModeAuthorizationMethodValue = it onSaveAgentModeAuthorization(agentModeAuthorizationEnabledValue, it) }, + displayOrientation = agentModeDisplayOrientation, + onDisplayOrientationChanged = onSaveAgentModeDisplayOrientation, agentModeDisplayState = agentModeDisplayState, onRequestShizukuPermission = onRequestShizukuPermission, onRefreshAgentModeAuthorization = onRefreshAgentModeAuthorization, @@ -5797,6 +5802,8 @@ private fun AgentModeSettingsPage( rootSetupState: RootSetupState, onAgentModeAuthorizationEnabledChanged: (Boolean) -> Unit, onAgentModeAuthorizationMethodChanged: (AgentModeAuthorizationMethod) -> Unit, + displayOrientation: AgentModeDisplayOrientation, + onDisplayOrientationChanged: (AgentModeDisplayOrientation) -> Unit, agentModeDisplayState: AgentModeDisplayState, onRequestShizukuPermission: () -> Unit, onRefreshAgentModeAuthorization: (Boolean, AgentModeAuthorizationMethod) -> Unit, @@ -5990,6 +5997,29 @@ private fun AgentModeSettingsPage( style = MaterialTheme.typography.bodySmall, color = AetherOnSurfaceVariant, ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.settings_agent_mode_orientation), + style = MaterialTheme.typography.labelMedium, + color = AetherOnSurface, + ) + AgentModeDisplayOrientation.entries.forEach { orientation -> + SettingsChoiceRow( + title = stringResource(when (orientation) { + AgentModeDisplayOrientation.Device -> R.string.settings_agent_mode_orientation_device + AgentModeDisplayOrientation.Portrait -> R.string.settings_agent_mode_orientation_portrait + AgentModeDisplayOrientation.Landscape -> R.string.settings_agent_mode_orientation_landscape + }), + subtitle = "", + selected = displayOrientation == orientation, + onClick = { onDisplayOrientationChanged(orientation) }, + ) + } + Text( + text = stringResource(R.string.settings_agent_mode_orientation_hint), + style = MaterialTheme.typography.bodySmall, + color = AetherOnSurfaceVariant, + ) if (agentModeDisplayState.latestWorkspacePath.isNotBlank()) { Spacer(Modifier.height(6.dp)) Text( diff --git a/app/src/test/java/com/zhousl/aether/data/AetherToolExecutorTest.kt b/app/src/test/java/com/zhousl/aether/data/AetherToolExecutorTest.kt index 2b67cd27..878c7ae3 100644 --- a/app/src/test/java/com/zhousl/aether/data/AetherToolExecutorTest.kt +++ b/app/src/test/java/com/zhousl/aether/data/AetherToolExecutorTest.kt @@ -52,6 +52,15 @@ class AetherToolExecutorTest { ) } + @Test + fun agentDisplayExposesExplicitOrientationOverride() { + val definition = AetherToolExecutor.hostToolDefinitions(agentModeEnabled = true).getJSONObject(0) + val orientation = definition.getJSONObject("parameters") + .getJSONObject("properties").getJSONObject("orientation") + assertEquals(listOf("device", "portrait", "landscape"), + (0 until orientation.getJSONArray("enum").length()).map { orientation.getJSONArray("enum").getString(it) }) + } + @Test fun inferToolOutputOkHonorsAetherJsonFlags() { assertTrue(AetherToolExecutor.inferToolOutputOk("""{"ok":true}""")) diff --git a/app/src/test/java/com/zhousl/aether/data/AgentModeDisplayDimensionsTest.kt b/app/src/test/java/com/zhousl/aether/data/AgentModeDisplayDimensionsTest.kt new file mode 100644 index 00000000..dbebac29 --- /dev/null +++ b/app/src/test/java/com/zhousl/aether/data/AgentModeDisplayDimensionsTest.kt @@ -0,0 +1,39 @@ +package com.zhousl.aether.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AgentModeDisplayDimensionsTest { + @Test fun deviceKeepsOriginalDimensions() { + assertEquals(1220 to 2712, agentModeDisplayDimensions(1220, 2712, AgentModeDisplayOrientation.Device)) + assertEquals(2712 to 1220, agentModeDisplayDimensions(2712, 1220, AgentModeDisplayOrientation.Device)) + } + + @Test fun landscapeSwapsPortraitDimensionsAndPreservesLandscape() { + assertEquals(2712 to 1220, agentModeDisplayDimensions(1220, 2712, AgentModeDisplayOrientation.Landscape)) + assertEquals(2712 to 1220, agentModeDisplayDimensions(2712, 1220, AgentModeDisplayOrientation.Landscape)) + } + + @Test fun portraitSwapsLandscapeDimensionsAndPreservesPortrait() { + assertEquals(1220 to 2712, agentModeDisplayDimensions(2712, 1220, AgentModeDisplayOrientation.Portrait)) + assertEquals(1220 to 2712, agentModeDisplayDimensions(1220, 2712, AgentModeDisplayOrientation.Portrait)) + } + + @Test fun squareDisplayIsUnchanged() { + AgentModeDisplayOrientation.entries.forEach { + assertEquals(1000 to 1000, agentModeDisplayDimensions(1000, 1000, it)) + } + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsInvalidDimensions() { + agentModeDisplayDimensions(0, 2712, AgentModeDisplayOrientation.Landscape) + } + + @Test fun normalizedCoordinatesUseLandscapeExtent() { + val (width, height) = agentModeDisplayDimensions(1220, 2712, AgentModeDisplayOrientation.Landscape) + assertEquals(AgentModeCoordinateResult.Valid(1356), resolveAgentModeCoordinate("x", 500.0, width)) + assertEquals(AgentModeCoordinateResult.Valid(610), resolveAgentModeCoordinate("y", 500.0, height)) + assertEquals(1280 to 575, agentModeScreenshotSize(width, height, 1280)) + } +} diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index 6d3650da..59f83072 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -1051,4 +1051,9 @@ 网格视图 列表视图 确定 + 显示方向 + 设备默认 + 竖屏 + 横屏 + 切换方向将停止当前虚拟屏幕,请随后重新启动应用。 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index be3dde53..ae92d7b7 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -1053,4 +1053,9 @@ Grid view List view Confirm + Display orientation + Device default + Portrait + Landscape + Changing orientation stops the current virtual display. Relaunch the app afterwards. diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt index db215708..68799ad4 100644 --- a/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt +++ b/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt @@ -38,6 +38,19 @@ enum class AgentModeAuthorizationMethod( } } +/** Android Agent Mode managed-display geometry; Device preserves the existing physical size. */ +@Serializable +enum class AgentModeDisplayOrientation(val storageValue: String) { + Device("device"), + Portrait("portrait"), + Landscape("landscape"); + + companion object { + fun fromStorage(value: String?): AgentModeDisplayOrientation = + entries.firstOrNull { it.storageValue == value } ?: Device + } +} + @Serializable enum class AppLanguage( val storageValue: String, @@ -164,6 +177,7 @@ data class AppSettings( val alpineEnvironmentVariables: List = emptyList(), val agentModeAuthorizationEnabled: Boolean = false, val agentModeAuthorizationMethod: AgentModeAuthorizationMethod = AgentModeAuthorizationMethod.Shizuku, + val agentModeDisplayOrientation: AgentModeDisplayOrientation = AgentModeDisplayOrientation.Device, val language: AppLanguage = defaultAppLanguage(), val themeMode: AppThemeMode = AppThemeMode.System, val defaultChatModelKey: String = "", diff --git a/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt b/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt index 3f54984c..58e39cd0 100644 --- a/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt +++ b/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt @@ -21,6 +21,7 @@ class AppSettingsSerializationTest { defaultRuntimeId = LocalRuntimeId.Alpine, alpinePackageProfiles = mapOf("chrome" to PackageProfileState("chrome", installed = true)), alpineEnvironmentVariables = listOf(AlpineEnvironmentVariable("A", "B")), + agentModeDisplayOrientation = AgentModeDisplayOrientation.Landscape, language = AppLanguage.SimplifiedChinese, themeMode = AppThemeMode.Dark, defaultChatModelKey = "provider/chat-model", @@ -45,6 +46,14 @@ class AppSettingsSerializationTest { assertEquals(DefaultReasoningEffort, parsed.reasoningEffort) } + @Test + fun legacySettingsUseDeviceOrientation() { + assertEquals(AgentModeDisplayOrientation.Device, parseAppSettings("{}").agentModeDisplayOrientation) + assertEquals(AgentModeDisplayOrientation.Device, AgentModeDisplayOrientation.fromStorage(null)) + assertEquals(AgentModeDisplayOrientation.Device, AgentModeDisplayOrientation.fromStorage("future")) + assertEquals(AgentModeDisplayOrientation.Landscape, AgentModeDisplayOrientation.fromStorage("landscape")) + } + @Test fun acceptedPrivacyPolicyCannotBeOverwrittenByStaleSettings() { assertEquals(true, privacyPolicyAccepted(persisted = true, requested = false)) From 5d5b43fcb2072e0936989edee81e3f034a0421d6 Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:48:28 +0800 Subject: [PATCH 2/9] docs: explain display orientation and hardware validation --- .../zhousl/aether/data/AgentModeController.kt | 4 ++ docs/AGENT_MODE_DISPLAY_ORIENTATION.md | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 docs/AGENT_MODE_DISPLAY_ORIENTATION.md diff --git a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt index 2914d6b6..6c476d0b 100644 --- a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt +++ b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt @@ -213,6 +213,9 @@ class AgentModeController( ) } val action = arguments.optString("action").trim().lowercase() + 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.") @@ -864,6 +867,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) diff --git a/docs/AGENT_MODE_DISPLAY_ORIENTATION.md b/docs/AGENT_MODE_DISPLAY_ORIENTATION.md new file mode 100644 index 00000000..5bab4cdd --- /dev/null +++ b/docs/AGENT_MODE_DISPLAY_ORIENTATION.md @@ -0,0 +1,43 @@ +# Android Agent Mode display orientation + +Agent Mode virtual displays now support a saved orientation in **Settings → Agent Mode → Virtual Display**: + +- **Device default** preserves the physical display dimensions used by previous versions. +- **Portrait** puts the shorter edge on the width axis. +- **Landscape** puts the longer edge on the width axis, useful for landscape-only games. + +This is an Android-only feature: iOS does not implement Android virtual displays. The preference is represented in the shared settings model so settings serialization and import/export preserve it. + +Changing the saved orientation stops the managed display. Relaunch the target application in Agent Mode afterwards. No main-device display rotation, resolution or global Android settings are changed. + +The agent can also explicitly request a session orientation: + +```json +{"action":"launch","target":"com.miniclip.plagueinc","orientation":"landscape"} +``` + +`orientation` is accepted only for `start` and `launch`, with values `device`, `portrait`, or `landscape`. A session override does not change the saved setting and lasts until the managed display is stopped. Changing orientation recreates the display rather than rotating only its output surface. A `start` call changing orientation must be followed by `launch` to put the target app on the new display. + +The saved setting is also exposed by `aether_config_get` under `agent_mode.display_orientation`, and can be changed using `aether_config_set` with category `agent_mode` and settings `{"display_orientation":"landscape"}`. + +## Geometry contract + +The virtual display and screenshot ImageReader are created with the same dimensions. Display state supplies those dimensions to the live preview and normalized touch mapping. The live TextureView is recreated with a new display ID/geometry so its callbacks do not retain old buffer dimensions. + +Tap/swipe coordinates remain normalized 0–1000 independently on each axis. Do not send screenshot pixels as coordinates. Convert using `x / image_width * 1000` and `y / image_height * 1000`. + +## Validation + +Automated tests cover default/portrait/landscape/square dimensions, invalid dimensions, screenshot sizing, normalized coordinates, tool schema, legacy settings defaults and serialization round-trip. + +Hardware validation is still necessary for both Root and Shizuku: + +1. Start with Device default; confirm existing portrait behavior. +2. Choose Landscape, relaunch a landscape-only app, and confirm reported width > height, landscape screenshot and undistorted live preview. +3. Tap visible controls and verify cursor and input alignment. +4. Capture screenshots while live preview is attached; ensure preview resumes. +5. Switch to Portrait, relaunch, and repeat input/capture checks. +6. Restart Aether and confirm the saved selection persists. +7. Test explicit launch orientation, then a screenshot without orientation: geometry should stay unchanged. Stop and launch again: the saved setting should apply. + +The optional Build Agent Mode APK workflow runs unit tests and produces an **unsigned release APK** with application ID `com.baimoqilin.aether`. Standard Android devices will not install an unsigned APK; sign it appropriately for normal installation. Back up application data before replacing an existing installation. From 95266ecbfde566a2c281e111bbf26b240a23af21 Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:58:20 +0800 Subject: [PATCH 3/9] fix: discard orientation overrides when display creation fails --- .../main/java/com/zhousl/aether/data/AgentModeController.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt index 6c476d0b..048941cf 100644 --- a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt +++ b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt @@ -213,6 +213,7 @@ 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.") } @@ -388,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" }, From 5e2fce6323c757673e240049e366e4d1258336ae Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:16:40 +0800 Subject: [PATCH 4/9] feat(android): add adaptive fullscreen Agent Mode preview --- .../com/zhousl/aether/ui/ConversationUi.kt | 336 ++++++++++++------ .../values-zh-rCN/strings.xml | 2 + .../composeResources/values/strings.xml | 2 + 3 files changed, 227 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt index edc7fac4..4f19adc5 100644 --- a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt @@ -1,5 +1,9 @@ package com.zhousl.aether.ui +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider import android.graphics.BitmapFactory import android.graphics.SurfaceTexture import android.os.Build @@ -137,7 +141,6 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource @@ -3185,6 +3188,8 @@ private fun AgentModePreviewPanel( contentDescription ?: stringResource(R.string.chat_agent_mode_virtual_display) val resolvedPendingText = pendingText ?: stringResource(R.string.chat_agent_mode_preview_pending) + var fullscreen by rememberSaveable { mutableStateOf(false) } + var showFullscreenButton by remember { mutableStateOf(false) } val bitmap = remember(displayState.latestPreviewPath, displayState.lastUpdatedMillis) { displayState.latestPreviewPath .takeIf { it.isNotBlank() } @@ -3209,120 +3214,30 @@ private fun AgentModePreviewPanel( ) } if (displayState.isActive || bitmap != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(280.dp) - .clip(RoundedCornerShape(18.dp)) - .background(agentModePreviewBackdropBrush()), - contentAlignment = Alignment.Center, - ) { - var previewSize by remember { mutableStateOf(IntSize.Zero) } - val density = LocalDensity.current - val imagePaddingPx = with(density) { 10.dp.toPx() } - val cursorOffset = remember( - previewSize, - displayState.width, - displayState.height, - displayState.cursorX, - displayState.cursorY, - ) { - resolveAgentModeCursorOffset( - previewSize = previewSize, - imagePaddingPx = imagePaddingPx, - displayWidth = displayState.width, - displayHeight = displayState.height, - cursorX = displayState.cursorX, - cursorY = displayState.cursorY, - ) - } - val animationDurationMillis = displayState.cursorAnimationDurationMillis.coerceIn(80, 1_200) - val animatedCursorOffset by animateIntOffsetAsState( - targetValue = cursorOffset, - animationSpec = tween(durationMillis = animationDurationMillis, easing = ChatGptMotionEasing), - label = "agent_mode_cursor_offset", - ) - if (displayState.isActive && useLiveSurface) { - // Replace listeners capturing old buffer dimensions after a display recreation. - key(displayState.displayId, displayState.width, displayState.height) { - AgentModeLivePreviewSurface( - displayState = displayState, - onAttachSurface = onAttachSurface, - onDetachSurface = onDetachSurface, - modifier = Modifier - .fillMaxHeight() - .padding(10.dp) - .aspectRatio( - displayState.width.coerceAtLeast(1).toFloat() / - displayState.height.coerceAtLeast(1).toFloat(), - matchHeightConstraintsFirst = true, - ) - .clip(RoundedCornerShape(14.dp)), - ) - } - } else if (displayState.isActive && !useLiveSurface) { - PlatformWebView( - url = AlpineChromeViewerUrl, - scrollEnabled = false, - modifier = Modifier - .fillMaxHeight() - .padding(10.dp) - .aspectRatio( - displayState.width.coerceAtLeast(1).toFloat() / - displayState.height.coerceAtLeast(1).toFloat(), - matchHeightConstraintsFirst = true, - ) - .clip(RoundedCornerShape(14.dp)), - ) - } else if (bitmap != null) { - Image( - bitmap = bitmap.asImageBitmap(), + if (!fullscreen) { + AgentModePreviewViewport( + displayState = displayState, + bitmap = bitmap, contentDescription = resolvedContentDescription, - modifier = Modifier - .fillMaxSize() - .padding(10.dp) - .clip(RoundedCornerShape(14.dp)), - contentScale = ContentScale.Fit, - ) + useLiveSurface = useLiveSurface, + overlayText = overlayText, + workspaceDirectory = workspaceDirectory, + allowRootImageRead = allowRootImageRead, + onOpenLink = onOpenLink, + onAttachSurface = onAttachSurface, + onDetachSurface = onDetachSurface, + modifier = Modifier.fillMaxWidth().height(280.dp) + .clip(RoundedCornerShape(18.dp)).clickable { showFullscreenButton = !showFullscreenButton }, + ) + } else { + // Only one live Surface/WebView exists at a time. + Box(Modifier.fillMaxWidth().height(280.dp), contentAlignment = Alignment.Center) { + Text(stringResource(R.string.agent_mode_fullscreen), color = AetherOnSurfaceVariant) } - Box( - modifier = Modifier - .matchParentSize() - .onSizeChanged { previewSize = it }, - ) { - AgentModeCursor( - modifier = Modifier - .offset { - val tipInsetPx = with(density) { 5.dp.roundToPx() } - IntOffset(animatedCursorOffset.x - tipInsetPx, animatedCursorOffset.y - tipInsetPx) - } - .size(30.dp), - ) - if (overlayText.isNotBlank()) { - val bubbleOffset = remember( - cursorOffset, - previewSize, - density, - ) { - resolveAgentModeBubbleOffset( - cursorOffset = cursorOffset, - previewSize = previewSize, - density = density, - ) - } - val animatedBubbleOffset by animateIntOffsetAsState( - targetValue = bubbleOffset, - animationSpec = tween(durationMillis = animationDurationMillis, easing = ChatGptMotionEasing), - label = "agent_mode_bubble_offset", - ) - AgentModeCursorTextBubble( - text = overlayText, - workspaceDirectory = workspaceDirectory, - allowRootImageRead = allowRootImageRead, - onOpenLink = onOpenLink, - modifier = Modifier.offset { animatedBubbleOffset }, - ) - } + } + if (showFullscreenButton && !fullscreen) Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomEnd) { + TextButton(onClick = { fullscreen = true }) { + Text(stringResource(R.string.agent_mode_fullscreen)) } } } else { @@ -3333,6 +3248,201 @@ private fun AgentModePreviewPanel( ) } } + if (fullscreen && (displayState.isActive || bitmap != null)) { + AgentModeFullscreenPreview( + displayState = displayState, + bitmap = bitmap, + contentDescription = resolvedContentDescription, + useLiveSurface = useLiveSurface, + overlayText = overlayText, + workspaceDirectory = workspaceDirectory, + allowRootImageRead = allowRootImageRead, + onOpenLink = onOpenLink, + onAttachSurface = onAttachSurface, + onDetachSurface = onDetachSurface, + onDismiss = { fullscreen = false }, + ) + } + +} + + +@Composable +private fun AgentModePreviewViewport( + displayState: AgentModeDisplayState, + bitmap: android.graphics.Bitmap?, + contentDescription: String, + useLiveSurface: Boolean, + overlayText: String, + workspaceDirectory: String, + allowRootImageRead: Boolean, + onOpenLink: (String) -> Unit, + onAttachSurface: (Surface) -> Unit, + onDetachSurface: (Surface) -> Unit, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier.background(agentModePreviewBackdropBrush()), + contentAlignment = Alignment.Center, + ) { + val ratio = displayState.width.coerceAtLeast(1).toFloat() / displayState.height.coerceAtLeast(1) + val availableWidth = (maxWidth - 20.dp).coerceAtLeast(1.dp) + val availableHeight = (maxHeight - 20.dp).coerceAtLeast(1.dp) + val previewWidth = minOf(availableWidth, availableHeight * ratio) + val previewHeight = previewWidth / ratio + var previewSize by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + val imagePaddingPx = with(density) { 10.dp.toPx() } + val cursorOffset = remember( + previewSize, + displayState.width, + displayState.height, + displayState.cursorX, + displayState.cursorY, + ) { + resolveAgentModeCursorOffset( + previewSize = previewSize, + imagePaddingPx = imagePaddingPx, + displayWidth = displayState.width, + displayHeight = displayState.height, + cursorX = displayState.cursorX, + cursorY = displayState.cursorY, + ) + } + val animationDurationMillis = displayState.cursorAnimationDurationMillis.coerceIn(80, 1_200) + val animatedCursorOffset by animateIntOffsetAsState( + targetValue = cursorOffset, + animationSpec = tween(durationMillis = animationDurationMillis, easing = ChatGptMotionEasing), + label = "agent_mode_cursor_offset", + ) + if (displayState.isActive && useLiveSurface) { + // Replace listeners capturing old buffer dimensions after a display recreation. + key(displayState.displayId, displayState.width, displayState.height) { + AgentModeLivePreviewSurface( + displayState = displayState, + onAttachSurface = onAttachSurface, + onDetachSurface = onDetachSurface, + modifier = Modifier + .size(previewWidth, previewHeight) + .clip(RoundedCornerShape(14.dp)), + ) + } + } else if (displayState.isActive && !useLiveSurface) { + PlatformWebView( + url = AlpineChromeViewerUrl, + scrollEnabled = false, + modifier = Modifier + .size(previewWidth, previewHeight) + .clip(RoundedCornerShape(14.dp)), + ) + } else if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = contentDescription, + modifier = Modifier + .fillMaxSize() + .padding(10.dp) + .clip(RoundedCornerShape(14.dp)), + contentScale = ContentScale.Fit, + ) + } + Box( + modifier = Modifier + .matchParentSize() + .onSizeChanged { previewSize = it }, + ) { + AgentModeCursor( + modifier = Modifier + .offset { + val tipInsetPx = with(density) { 5.dp.roundToPx() } + IntOffset(animatedCursorOffset.x - tipInsetPx, animatedCursorOffset.y - tipInsetPx) + } + .size(30.dp), + ) + if (overlayText.isNotBlank()) { + val bubbleOffset = remember( + cursorOffset, + previewSize, + density, + ) { + resolveAgentModeBubbleOffset( + cursorOffset = cursorOffset, + previewSize = previewSize, + density = density, + ) + } + val animatedBubbleOffset by animateIntOffsetAsState( + targetValue = bubbleOffset, + animationSpec = tween(durationMillis = animationDurationMillis, easing = ChatGptMotionEasing), + label = "agent_mode_bubble_offset", + ) + AgentModeCursorTextBubble( + text = overlayText, + workspaceDirectory = workspaceDirectory, + allowRootImageRead = allowRootImageRead, + onOpenLink = onOpenLink, + modifier = Modifier.offset { animatedBubbleOffset }, + ) + } + } + } +} + +@Composable +private fun AgentModeFullscreenPreview( + displayState: AgentModeDisplayState, + bitmap: android.graphics.Bitmap?, + contentDescription: String, + useLiveSurface: Boolean, + overlayText: String, + workspaceDirectory: String, + allowRootImageRead: Boolean, + onOpenLink: (String) -> Unit, + onAttachSurface: (Surface) -> Unit, + onDetachSurface: (Surface) -> Unit, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + val window = (LocalView.current.parent as? DialogWindowProvider)?.window + DisposableEffect(window) { + @Suppress("DEPRECATION") + val previousVisibility = window?.decorView?.systemUiVisibility + @Suppress("DEPRECATION") + window?.decorView?.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + onDispose { + @Suppress("DEPRECATION") + if (previousVisibility != null) window?.decorView?.systemUiVisibility = previousVisibility + } + } + Box(Modifier.fillMaxSize().background(Color.Black)) { + AgentModePreviewViewport( + displayState = displayState, + bitmap = bitmap, + contentDescription = contentDescription, + useLiveSurface = useLiveSurface, + overlayText = overlayText, + workspaceDirectory = workspaceDirectory, + allowRootImageRead = allowRootImageRead, + onOpenLink = onOpenLink, + onAttachSurface = onAttachSurface, + onDetachSurface = onDetachSurface, + modifier = Modifier.fillMaxSize(), + ) + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.TopEnd).padding(16.dp) + .background(Color.Black.copy(alpha = 0.6f), RoundedCornerShape(12.dp)), + ) { + Text(stringResource(R.string.agent_mode_exit_fullscreen), color = Color.White) + } + } + } } @Composable diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index 59f83072..10ef98f3 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -1056,4 +1056,6 @@ 竖屏 横屏 切换方向将停止当前虚拟屏幕,请随后重新启动应用。 + 全屏观看 + 退出全屏 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index ae92d7b7..e8bdd953 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -1058,4 +1058,6 @@ Portrait Landscape Changing orientation stops the current virtual display. Relaunch the app afterwards. + Fullscreen + Exit fullscreen From 65a1af9e901be79b774a6f90debcf2f79b13a381 Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:22:15 +0800 Subject: [PATCH 5/9] fix: import LocalView for fullscreen dialog window --- app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt index 4f19adc5..249b558b 100644 --- a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt @@ -113,6 +113,7 @@ import androidx.compose.runtime.key import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur From 78c6d8f0dfec1b64549a02383a66062354d81b81 Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:23:40 +0800 Subject: [PATCH 6/9] docs: describe adaptive fullscreen viewing controls --- docs/AGENT_MODE_DISPLAY_ORIENTATION.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/AGENT_MODE_DISPLAY_ORIENTATION.md b/docs/AGENT_MODE_DISPLAY_ORIENTATION.md index 5bab4cdd..517feab4 100644 --- a/docs/AGENT_MODE_DISPLAY_ORIENTATION.md +++ b/docs/AGENT_MODE_DISPLAY_ORIENTATION.md @@ -41,3 +41,9 @@ Hardware validation is still necessary for both Root and Shizuku: 7. Test explicit launch orientation, then a screenshot without orientation: geometry should stay unchanged. Stop and launch again: the saved setting should apply. The optional Build Agent Mode APK workflow runs unit tests and produces an **unsigned release APK** with application ID `com.baimoqilin.aether`. Standard Android devices will not install an unsigned APK; sign it appropriately for normal installation. Back up application data before replacing an existing installation. + +## Fullscreen viewing + +Tap the preview frame to reveal the **Fullscreen** button below its bottom-right corner. Fullscreen opens an immersive, edge-to-edge viewer; use **Exit fullscreen** or Android Back to return. The viewer fits the source aspect ratio within both available axes without stretching or cropping, in portrait or landscape device layouts. It does not change the virtual display geometry or force the device orientation. Android rotation settings still apply. + +Only one live preview Surface (or Chrome WebView) is composed at a time: the inline view is removed while fullscreen is open. Cursor and message overlays remain visible. Hardware validation should additionally cover enter/exit, Back, device rotation, display stop/recreation while open, and screenshot capture during fullscreen. From f02c023fd9100c8baacf22aeaa6237a3a7dfdf5e Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:37:51 +0800 Subject: [PATCH 7/9] fix(android): do not restore fullscreen preview when reopening Aether --- .../java/com/zhousl/aether/ui/ConversationUi.kt | 17 ++++++++++++++++- docs/AGENT_MODE_DISPLAY_ORIENTATION.md | 2 ++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt index 249b558b..05deb48c 100644 --- a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt @@ -4,6 +4,9 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import android.graphics.BitmapFactory import android.graphics.SurfaceTexture import android.os.Build @@ -3189,8 +3192,20 @@ private fun AgentModePreviewPanel( contentDescription ?: stringResource(R.string.chat_agent_mode_virtual_display) val resolvedPendingText = pendingText ?: stringResource(R.string.chat_agent_mode_preview_pending) - var fullscreen by rememberSaveable { mutableStateOf(false) } + // Fullscreen is an explicit, foreground-only action, never restored from saved UI state. + var fullscreen by remember { mutableStateOf(false) } var showFullscreenButton by remember { mutableStateOf(false) } + val previewLifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(previewLifecycle) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + fullscreen = false + showFullscreenButton = false + } + } + previewLifecycle.addObserver(observer) + onDispose { previewLifecycle.removeObserver(observer) } + } val bitmap = remember(displayState.latestPreviewPath, displayState.lastUpdatedMillis) { displayState.latestPreviewPath .takeIf { it.isNotBlank() } diff --git a/docs/AGENT_MODE_DISPLAY_ORIENTATION.md b/docs/AGENT_MODE_DISPLAY_ORIENTATION.md index 517feab4..3ea926d0 100644 --- a/docs/AGENT_MODE_DISPLAY_ORIENTATION.md +++ b/docs/AGENT_MODE_DISPLAY_ORIENTATION.md @@ -47,3 +47,5 @@ The optional Build Agent Mode APK workflow runs unit tests and produces an **uns Tap the preview frame to reveal the **Fullscreen** button below its bottom-right corner. Fullscreen opens an immersive, edge-to-edge viewer; use **Exit fullscreen** or Android Back to return. The viewer fits the source aspect ratio within both available axes without stretching or cropping, in portrait or landscape device layouts. It does not change the virtual display geometry or force the device orientation. Android rotation settings still apply. Only one live preview Surface (or Chrome WebView) is composed at a time: the inline view is removed while fullscreen is open. Cursor and message overlays remain visible. Hardware validation should additionally cover enter/exit, Back, device rotation, display stop/recreation while open, and screenshot capture during fullscreen. + +Fullscreen is foreground-only and is not restored from saved UI state. Moving Aether to the background exits fullscreen and hides its entry control, preventing a restored fullscreen dialog from flashing when reopening Aether. From fc509907383a660de520ae3961d9396662b1fad4 Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:40:30 +0800 Subject: [PATCH 8/9] fix(android): space orientation choices and cover preview capture transitions --- .../zhousl/aether/data/AgentModeController.kt | 4 + .../com/zhousl/aether/ui/ConversationUi.kt | 94 ++++++++++++------- .../com/zhousl/aether/ui/SettingsScreen.kt | 25 ++--- 3 files changed, 78 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt index 048941cf..01692911 100644 --- a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt +++ b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt @@ -64,6 +64,7 @@ data class AgentModeDisplayState( val cursorY: Int? = null, val cursorAnimationDurationMillis: Int = 220, val isLivePreviewActive: Boolean = false, + val isCapturingPreview: Boolean = false, val lastUpdatedMillis: Long = 0L, val status: String = "", ) @@ -840,6 +841,7 @@ class AgentModeController( captureMutex.withLock { outputFile.parentFile?.mkdirs() runCatching { outputFile.delete() } + _displayState.value = _displayState.value.copy(isCapturingPreview = true) try { ParcelFileDescriptor.open( outputFile, @@ -857,6 +859,8 @@ class AgentModeController( } catch (throwable: Throwable) { runCatching { outputFile.delete() } throw throwable + } finally { + _displayState.value = _displayState.value.copy(isCapturingPreview = false) } } } diff --git a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt index 05deb48c..c9a8a83e 100644 --- a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt @@ -3471,6 +3471,18 @@ private fun AgentModeLivePreviewSurface( val latestOnAttachSurface by rememberUpdatedState(onAttachSurface) val latestOnDetachSurface by rememberUpdatedState(onDetachSurface) var attachedSurface by remember { mutableStateOf(null) } + val capturingPreview by rememberUpdatedState(displayState.isCapturingPreview) + var previewView by remember { mutableStateOf(null) } + var captureCover by remember { mutableStateOf(null) } + LaunchedEffect(displayState.isCapturingPreview) { + if (displayState.isCapturingPreview) { + captureCover = runCatching { previewView?.bitmap }.getOrNull() + } else { + // Keep the last frame until the preview receives a frame after capture. + delay(250) + captureCover = null + } + } fun fixedWidth() = displayState.width.coerceAtLeast(1) fun fixedHeight() = displayState.height.coerceAtLeast(1) @@ -3495,44 +3507,58 @@ private fun AgentModeLivePreviewSurface( } } - AndroidView( - modifier = modifier.background(Color.Black), - factory = { context -> - TextureView(context).apply { - isOpaque = true - surfaceTextureListener = object : TextureView.SurfaceTextureListener { - override fun onSurfaceTextureAvailable( - surfaceTexture: SurfaceTexture, - width: Int, - height: Int, - ) { - attach(surfaceTexture) - } + Box(modifier = modifier.background(Color.Black)) { + AndroidView( + modifier = Modifier.fillMaxSize().background(Color.Black), + factory = { context -> + TextureView(context).apply { + previewView = this + isOpaque = true + surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable( + surfaceTexture: SurfaceTexture, + width: Int, + height: Int, + ) { + attach(surfaceTexture) + } - override fun onSurfaceTextureSizeChanged( - surfaceTexture: SurfaceTexture, - width: Int, - height: Int, - ) { - surfaceTexture.setDefaultBufferSize(fixedWidth(), fixedHeight()) - } + override fun onSurfaceTextureSizeChanged( + surfaceTexture: SurfaceTexture, + width: Int, + height: Int, + ) { + surfaceTexture.setDefaultBufferSize(fixedWidth(), fixedHeight()) + } - override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - detach() - return true - } + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + detach() + return true + } - override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { + if (!capturingPreview) captureCover = null + } + } } - } - }, - update = { view -> - view.surfaceTexture?.setDefaultBufferSize(fixedWidth(), fixedHeight()) - if (displayState.isActive) { - attach(view.surfaceTexture) - } - }, - ) + }, + update = { view -> + view.surfaceTexture?.setDefaultBufferSize(fixedWidth(), fixedHeight()) + if (displayState.isActive) { + attach(view.surfaceTexture) + } + }, + ) + captureCover?.let { cover -> + Image( + bitmap = cover.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } + } @Composable diff --git a/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt b/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt index fefd582c..0e99cee5 100644 --- a/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt +++ b/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt @@ -6003,18 +6003,21 @@ private fun AgentModeSettingsPage( style = MaterialTheme.typography.labelMedium, color = AetherOnSurface, ) - AgentModeDisplayOrientation.entries.forEach { orientation -> - SettingsChoiceRow( - title = stringResource(when (orientation) { - AgentModeDisplayOrientation.Device -> R.string.settings_agent_mode_orientation_device - AgentModeDisplayOrientation.Portrait -> R.string.settings_agent_mode_orientation_portrait - AgentModeDisplayOrientation.Landscape -> R.string.settings_agent_mode_orientation_landscape - }), - subtitle = "", - selected = displayOrientation == orientation, - onClick = { onDisplayOrientationChanged(orientation) }, - ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + AgentModeDisplayOrientation.entries.forEach { orientation -> + SettingsChoiceRow( + title = stringResource(when (orientation) { + AgentModeDisplayOrientation.Device -> R.string.settings_agent_mode_orientation_device + AgentModeDisplayOrientation.Portrait -> R.string.settings_agent_mode_orientation_portrait + AgentModeDisplayOrientation.Landscape -> R.string.settings_agent_mode_orientation_landscape + }), + subtitle = "", + selected = displayOrientation == orientation, + onClick = { onDisplayOrientationChanged(orientation) }, + ) + } } + Spacer(Modifier.height(10.dp)) Text( text = stringResource(R.string.settings_agent_mode_orientation_hint), style = MaterialTheme.typography.bodySmall, From fb6604ded223c95402e0fd0c21c72d83a8ab9f95 Mon Sep 17 00:00:00 2001 From: SlightNekoQAQ <318380344+SlightNekoQAQ@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:49:04 +0800 Subject: [PATCH 9/9] revert: remove unstable preview capture cover --- .../zhousl/aether/data/AgentModeController.kt | 4 - .../com/zhousl/aether/ui/ConversationUi.kt | 94 +++++++------------ 2 files changed, 34 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt index 01692911..048941cf 100644 --- a/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt +++ b/app/src/main/java/com/zhousl/aether/data/AgentModeController.kt @@ -64,7 +64,6 @@ data class AgentModeDisplayState( val cursorY: Int? = null, val cursorAnimationDurationMillis: Int = 220, val isLivePreviewActive: Boolean = false, - val isCapturingPreview: Boolean = false, val lastUpdatedMillis: Long = 0L, val status: String = "", ) @@ -841,7 +840,6 @@ class AgentModeController( captureMutex.withLock { outputFile.parentFile?.mkdirs() runCatching { outputFile.delete() } - _displayState.value = _displayState.value.copy(isCapturingPreview = true) try { ParcelFileDescriptor.open( outputFile, @@ -859,8 +857,6 @@ class AgentModeController( } catch (throwable: Throwable) { runCatching { outputFile.delete() } throw throwable - } finally { - _displayState.value = _displayState.value.copy(isCapturingPreview = false) } } } diff --git a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt index c9a8a83e..05deb48c 100644 --- a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt @@ -3471,18 +3471,6 @@ private fun AgentModeLivePreviewSurface( val latestOnAttachSurface by rememberUpdatedState(onAttachSurface) val latestOnDetachSurface by rememberUpdatedState(onDetachSurface) var attachedSurface by remember { mutableStateOf(null) } - val capturingPreview by rememberUpdatedState(displayState.isCapturingPreview) - var previewView by remember { mutableStateOf(null) } - var captureCover by remember { mutableStateOf(null) } - LaunchedEffect(displayState.isCapturingPreview) { - if (displayState.isCapturingPreview) { - captureCover = runCatching { previewView?.bitmap }.getOrNull() - } else { - // Keep the last frame until the preview receives a frame after capture. - delay(250) - captureCover = null - } - } fun fixedWidth() = displayState.width.coerceAtLeast(1) fun fixedHeight() = displayState.height.coerceAtLeast(1) @@ -3507,58 +3495,44 @@ private fun AgentModeLivePreviewSurface( } } - Box(modifier = modifier.background(Color.Black)) { - AndroidView( - modifier = Modifier.fillMaxSize().background(Color.Black), - factory = { context -> - TextureView(context).apply { - previewView = this - isOpaque = true - surfaceTextureListener = object : TextureView.SurfaceTextureListener { - override fun onSurfaceTextureAvailable( - surfaceTexture: SurfaceTexture, - width: Int, - height: Int, - ) { - attach(surfaceTexture) - } - - override fun onSurfaceTextureSizeChanged( - surfaceTexture: SurfaceTexture, - width: Int, - height: Int, - ) { - surfaceTexture.setDefaultBufferSize(fixedWidth(), fixedHeight()) - } + AndroidView( + modifier = modifier.background(Color.Black), + factory = { context -> + TextureView(context).apply { + isOpaque = true + surfaceTextureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable( + surfaceTexture: SurfaceTexture, + width: Int, + height: Int, + ) { + attach(surfaceTexture) + } - override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { - detach() - return true - } + override fun onSurfaceTextureSizeChanged( + surfaceTexture: SurfaceTexture, + width: Int, + height: Int, + ) { + surfaceTexture.setDefaultBufferSize(fixedWidth(), fixedHeight()) + } - override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { - if (!capturingPreview) captureCover = null - } + override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { + detach() + return true } - } - }, - update = { view -> - view.surfaceTexture?.setDefaultBufferSize(fixedWidth(), fixedHeight()) - if (displayState.isActive) { - attach(view.surfaceTexture) - } - }, - ) - captureCover?.let { cover -> - Image( - bitmap = cover.asImageBitmap(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) - } - } + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) = Unit + } + } + }, + update = { view -> + view.surfaceTexture?.setDefaultBufferSize(fixedWidth(), fixedHeight()) + if (displayState.isActive) { + attach(view.surfaceTexture) + } + }, + ) } @Composable