Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ef62983
feat(ui/debug): add adaptive layout posture readout to debug console
Kimblebee Nov 3, 2026
37ff240
feat(ui): support adaptive landscape framing and LTR isolation in Pre…
Kimblebee Nov 3, 2026
fe40e9d
Improve PreviewDisplay accessibility semantics, custom actions, and k…
Kimblebee Nov 3, 2026
5c284c9
Merge remote-tracking branch 'origin/main' into kim/adaptive/preview-…
Kimblebee Nov 3, 2026
b662087
Merge remote-tracking branch 'origin/main' into kim/adaptive/debug-la…
Kimblebee Nov 3, 2026
56cf9da
Remove placeholder layout mode name and tag each debug layout readout…
Kimblebee Nov 4, 2026
7f089ce
feat(ui/debug): replace unwired rotation readout with DebugFormFactor
Kimblebee Nov 5, 2026
74b15db
feat(ui): wire 3-signal rotation and layout posture pipe into DebugLa…
Kimblebee Nov 5, 2026
3915575
feat(ui/debug): add expectedLayoutRotationDegrees readout and clamp 1…
Kimblebee Nov 5, 2026
19312ba
Merge branch 'main' into kim/adaptive/preview-display-framing
Kimblebee Nov 5, 2026
766dbe0
feat(app, preview): enable landscape viewfinder framing and multi-win…
Kimblebee Nov 5, 2026
3095714
Merge branch 'kim/adaptive/preview-display-framing' into kim/adaptive…
Kimblebee Nov 5, 2026
1de64e5
Merge branch 'kim/adaptive/debug-layout-readout' into kim/adaptive/la…
Kimblebee Nov 6, 2026
2a21e30
feat(ui/capture, debug): add compact layout counter-rotation, cutout …
Kimblebee Nov 6, 2026
8784031
feat(ui/debug): align DebugFormFactor with 6-mode CameraLayoutMode ge…
Kimblebee Nov 7, 2026
e1120ee
Merge branch 'kim/adaptive/debug-layout-readout' into kim/adaptive/la…
Kimblebee Nov 7, 2026
c088793
feat(ui/capture, debug): add compact layout counter-rotation, cutout …
Kimblebee Nov 6, 2026
5605cff
Merge remote-tracking branch 'origin/main' into kim/adaptive/preview-…
Kimblebee Nov 10, 2026
52bcb9f
Merge branch 'kim/adaptive/preview-display-framing' into kim/adaptive…
Kimblebee Nov 10, 2026
39675ef
feat(ui/debug, capture): add 7-mode DebugFormFactor, window orientati…
Kimblebee Nov 10, 2026
a88b8c2
Merge remote-tracking branch 'origin/kim/adaptive/layout-rotation' in…
Kimblebee Nov 10, 2026
723f876
Merge remote-tracking branch 'origin/main' into kim/adaptive/preview-…
Kimblebee Nov 11, 2026
3931a32
Reuse camera viewfinder string, add JUnit4 runner, and strip redundan…
Kimblebee Nov 11, 2026
3ed98f3
Merge branch 'kim/adaptive/preview-display-framing' into kim/adaptive…
Kimblebee Nov 11, 2026
9bdc303
Wire rotation passthrough in CaptureScaffold, restore debug comments,…
Kimblebee Nov 11, 2026
1a4c1ea
Merge branch 'kim/adaptive/layout-orchestrator' into kim/adaptive/lay…
Kimblebee Nov 11, 2026
ce61bfe
Merge branch 'kim/adaptive/debug-layout-readout' into kim/adaptive/la…
Kimblebee Nov 11, 2026
e8d4722
Integrate layout counter-rotation with 7-mode CaptureScaffold and deb…
Kimblebee Nov 11, 2026
0021b61
Merge branch 'kim/adaptive/layout-orchestrator' into kim/adaptive/lay…
Kimblebee Nov 12, 2026
5796d45
refactor(ui/capture): snap gravity rotation by default and keep 90-de…
Kimblebee Nov 13, 2026
cebf8a9
refactor(ui/capture): decouple OuterViewfinderHost and keep SurfaceVi…
Kimblebee Nov 13, 2026
f0f63d1
refactor(ui/capture): animate 90-degree gravity turns by default and …
Kimblebee Nov 13, 2026
1751080
Merge branch 'kim/adaptive/debug-layout-readout' into kim/adaptive/la…
Kimblebee Nov 13, 2026
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
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@
<profileable android:shell="true"/>
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden"
android:exported="true"
android:label="@string/app_name_short"
android:screenOrientation="nosensor"
android:theme="@style/Theme.JetpackCamera">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/com/google/jetpackcamera/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.google.jetpackcamera
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.hardware.Camera
import android.net.Uri
import android.os.Build
Expand All @@ -31,6 +32,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
Expand Down Expand Up @@ -81,6 +83,26 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

private const val TAG = "MainActivity"
private const val LARGE_SCREEN_MIN_WIDTH_DP = 600

/**
* Resolves the Activity's [ActivityInfo] screen orientation policy for the current window posture.
*
* Full-screen compact handhelds (`!isInMultiWindowMode && smallestScreenWidthDp < 600`) lock the
* window to [ActivityInfo.SCREEN_ORIENTATION_NOSENSOR] so the UI counter-rotates via sensor
* gravity without triggering a system window rotation. Multi-window panes and large screens
* (`smallestScreenWidthDp >= 600`) use [ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED] so the OS
* does not apply 1:1 portrait compatibility letterboxing.
*/
@VisibleForTesting
internal fun resolveRequestedOrientation(
isInMultiWindowMode: Boolean,
smallestScreenWidthDp: Int
): Int = if (!isInMultiWindowMode && smallestScreenWidthDp < LARGE_SCREEN_MIN_WIDTH_DP) {
ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}

/**
* Activity for the JetpackCameraApp.
Expand All @@ -103,6 +125,10 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)
)
super.onCreate(savedInstanceState)
updateRequestedOrientation(resources.configuration)
addOnMultiWindowModeChangedListener { info ->
updateRequestedOrientation(resources.configuration, info.isInMultiWindowMode)
}
cameraLaunchConfigProvider.setIntent(intent)
var uiState: MainActivityUiState by mutableStateOf(Loading)

Expand Down Expand Up @@ -194,6 +220,24 @@ class MainActivity : ComponentActivity() {
}
}

override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateRequestedOrientation(newConfig)
}

private fun updateRequestedOrientation(
configuration: Configuration,
multiWindowMode: Boolean = isInMultiWindowMode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Accessing isInMultiWindowMode as a default parameter value will cause a NoSuchMethodError crash on startup on devices running API levels below 24 (Android 7.0), as Activity.isInMultiWindowMode was introduced in API 24. Please guard this platform call with an API level check.

        multiWindowMode: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {\n            isInMultiWindowMode\n        } else {\n            false\n        }

) {
val targetOrientation = resolveRequestedOrientation(
isInMultiWindowMode = multiWindowMode,
smallestScreenWidthDp = configuration.smallestScreenWidthDp
)
if (requestedOrientation != targetOrientation) {
requestedOrientation = targetOrientation
}
}

private val debugSettings: DebugSettings
get() = cameraLaunchConfigProvider.config.value.debugSettings

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera

import android.content.pm.ActivityInfo
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

/**
* Unit tests for [resolveRequestedOrientation].
*/
@RunWith(JUnit4::class)
class MainActivityOrientationTest {

@Test
fun resolveRequestedOrientation_fullScreenCompactPhone_locksToNoSensor() {
assertThat(
resolveRequestedOrientation(
isInMultiWindowMode = false,
smallestScreenWidthDp = 411
)
).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_NOSENSOR)

assertThat(
resolveRequestedOrientation(
isInMultiWindowMode = false,
smallestScreenWidthDp = 599
)
).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_NOSENSOR)
}

@Test
fun resolveRequestedOrientation_multiWindowCompact_allowsUnspecifiedOrientation() {
assertThat(
resolveRequestedOrientation(
isInMultiWindowMode = true,
smallestScreenWidthDp = 411
)
).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
}

@Test
fun resolveRequestedOrientation_largeScreenOrFoldable_allowsUnspecifiedOrientation() {
assertThat(
resolveRequestedOrientation(
isInMultiWindowMode = false,
smallestScreenWidthDp = 600
)
).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)

assertThat(
resolveRequestedOrientation(
isInMultiWindowMode = true,
smallestScreenWidthDp = 673
)
).isEqualTo(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,39 @@ enum class DeviceRotation {
}
}

/**
* Returns the clockwise rotation in degrees needed inside a window at [windowRotation] so that
* UI elements remain oriented upright against physical gravity (`this` device rotation).
*
* An upside-down (`180°`) delta is mapped to `0` so camera controls and layouts never flip
* upside-down.
*/
fun toGravityRotationDegrees(windowRotation: DeviceRotation): Int {
val rawDelta =
(
(windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) %
360 + 360
) % 360
return if (rawDelta == 180) 0 else rawDelta
}

/**
* Returns the clockwise rotation in degrees needed to counter-rotate a root container back to
* the device's natural portrait coordinate frame when the OS window is rotated to `this`
* orientation.
*/
fun toWindowCounterRotationDegrees(): Float {
return when (this) {
Natural -> 0f
Rotated90 -> 90f
Rotated180 -> 180f
Rotated270 -> -90f
}
}

val isLandscape: Boolean
get() = this == Rotated90 || this == Rotated270

companion object {
/**
* Returns the [DeviceRotation] corresponding to a UI [Surface] rotation constant.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.jetpackcamera.model

import android.view.Surface
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class DeviceRotationTest {

@Test
fun fromUiSurfaceRotation_mapsAllSurfaceRotationsAndIsInverseOfToUiSurfaceRotation() {
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_0))
.isEqualTo(DeviceRotation.Natural)
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_90))
.isEqualTo(DeviceRotation.Rotated270)
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_180))
.isEqualTo(DeviceRotation.Rotated180)
assertThat(DeviceRotation.fromUiSurfaceRotation(Surface.ROTATION_270))
.isEqualTo(DeviceRotation.Rotated90)
assertThat(DeviceRotation.fromUiSurfaceRotation(-1))
.isEqualTo(DeviceRotation.Natural)

for (rotation in DeviceRotation.entries) {
assertThat(DeviceRotation.fromUiSurfaceRotation(rotation.toUiSurfaceRotation()))
.isEqualTo(rotation)
}
}

@Test
fun toGravityRotationDegrees_computesInWindowGravityCompensationAngle() {
// Phone upright (Dev 0°, Win 0°) -> Icons 0°
assertThat(
DeviceRotation.Natural.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(0)

// Phone turned 90° CCW with locked portrait window (Dev 270°, Win 0°) -> Icons 90°
assertThat(
DeviceRotation.Rotated270.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(90)

// Phone turned 90° CW with locked portrait window (Dev 90°, Win 0°) -> Icons 270°
assertThat(
DeviceRotation.Rotated90.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(270)

// Foldable/Tablet turned 90° CCW with OS auto-rotate ON (Dev 270°, Win 270°) -> Icons 0°
assertThat(
DeviceRotation.Rotated270.toGravityRotationDegrees(
windowRotation = DeviceRotation.Rotated270
)
).isEqualTo(0)

// Foldable/Tablet turned 90° CCW with OS auto-rotate OFF (Dev 270°, Win 0°) -> Icons 90°
assertThat(
DeviceRotation.Rotated270.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(90)

// Upside-down delta (180°) clamps to 0° so UI never flips upside-down
assertThat(
DeviceRotation.Rotated180.toGravityRotationDegrees(
windowRotation = DeviceRotation.Natural
)
).isEqualTo(0)
assertThat(
DeviceRotation.Rotated90.toGravityRotationDegrees(
windowRotation = DeviceRotation.Rotated270
)
).isEqualTo(0)
}

@Test
fun toWindowCounterRotationDegrees_andIsLandscape_matchAllQuadrants() {
assertThat(DeviceRotation.Natural.toWindowCounterRotationDegrees()).isEqualTo(0f)
assertThat(DeviceRotation.Rotated90.toWindowCounterRotationDegrees()).isEqualTo(90f)
assertThat(DeviceRotation.Rotated180.toWindowCounterRotationDegrees()).isEqualTo(180f)
assertThat(DeviceRotation.Rotated270.toWindowCounterRotationDegrees()).isEqualTo(-90f)

assertThat(DeviceRotation.Natural.isLandscape).isFalse()
assertThat(DeviceRotation.Rotated180.isLandscape).isFalse()
assertThat(DeviceRotation.Rotated90.isLandscape).isTrue()
assertThat(DeviceRotation.Rotated270.isLandscape).isTrue()
}
}
Loading
Loading