From 8adddd0bdb346555746960805b1655fca2828c57 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 9 Aug 2024 15:26:42 +0100 Subject: [PATCH 01/13] Consistent naming and behavior for the video player controls. --- .../amethyst/ui/components/VideoView.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index faf018590..9bc1908c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -350,7 +350,7 @@ fun VideoViewInner( defaultToStart: Boolean = false, title: String? = null, thumb: VideoThumb? = null, - showControls: Boolean = false, + showControls: Boolean = true, isFiniteHeight: Boolean, borderModifier: Modifier, waveform: ImmutableList? = null, @@ -375,7 +375,7 @@ fun VideoViewInner( mimeType = mimeType, controller = controller, thumbData = thumb, - hideControls = showControls, + showControls = showControls, isFiniteHeight = isFiniteHeight, nostrUriCallback = nostrUriCallback, waveform = waveform, @@ -723,7 +723,7 @@ private fun RenderVideoPlayer( mimeType: String?, controller: MediaController, thumbData: VideoThumb?, - hideControls: Boolean = false, + showControls: Boolean = true, isFiniteHeight: Boolean, nostrUriCallback: String?, waveform: ImmutableList? = null, @@ -754,7 +754,7 @@ private fun RenderVideoPlayer( setBackgroundColor(Color.Transparent.toArgb()) setShutterBackgroundColor(Color.Transparent.toArgb()) controllerAutoShow = false - useController = !hideControls + useController = showControls thumbData?.thumb?.let { defaultArtwork = it } hideController() resizeMode = @@ -763,7 +763,7 @@ private fun RenderVideoPlayer( } else { AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH } - if (!hideControls) { + if (showControls) { onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { controller.pause() @@ -783,7 +783,7 @@ private fun RenderVideoPlayer( waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) } - if (!hideControls) { + if (showControls) { val startingMuteState = remember(controller) { controller.volume < 0.001 } MuteButton( From fd0523085d850f7dcc337e2d739cc5dd9fe0b18b Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Tue, 13 Aug 2024 10:42:49 +0100 Subject: [PATCH 02/13] Initial implementation for auto-fullscreen on rotation to landscape mode. --- .../ui/components/ZoomableContentView.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index eefbde261..47fff2a96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.components import android.app.Activity import android.content.Context import android.content.ContextWrapper +import android.content.res.Configuration import android.util.Log import android.view.Window import androidx.compose.animation.AnimatedVisibility @@ -46,12 +47,14 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView @@ -118,6 +121,19 @@ fun ZoomableContentView( ) { var dialogOpen by remember(content) { mutableStateOf(false) } + val orientation = LocalConfiguration.current.orientation + + val orientationState by rememberUpdatedState(newValue = orientation) + val (sOrientation, isLandscapeMode) = + when (orientationState) { + Configuration.ORIENTATION_LANDSCAPE -> Pair("Landscape", true) + Configuration.ORIENTATION_PORTRAIT -> Pair("Portrait", false) + + else -> Pair("Unknown", false) + } + + Log.d("AmethystConf", "Device orientation is: $sOrientation") + val contentScale = if (isFiniteHeight) { ContentScale.Fit @@ -179,6 +195,7 @@ fun ZoomableContentView( } } } + if (isLandscapeMode) dialogOpen = true if (dialogOpen) { ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) From 83018e621e979ddbb59b8b528579541e8bd79cc4 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 16 Aug 2024 14:19:55 +0100 Subject: [PATCH 03/13] Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. --- .../vitorpamplona/quartz/utils/DeviceUtils.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt new file mode 100644 index 000000000..c2b4e3f1a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import android.app.Activity +import android.content.Context +import android.content.pm.ActivityInfo + +object DeviceUtils { + fun getDeviceOrientation(context: Context): Int { + val deviceConfiguration = context.resources.configuration + return deviceConfiguration.orientation + } + + /** + * Alternative for determining if the device is + * in landscape mode. + * The [getDeviceOrientation] method could be used as well + * to achieve the same purpose. + * Credits: Newpipe devs + * + */ + fun isLandscape(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels + + fun changeDeviceOrientation( + isInLandscape: Boolean, + context: Activity, + ) { + val newOrientation = + if (isInLandscape) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + context.requestedOrientation = newOrientation + } +} From a8efc9d3482be6567495fe282f25a5304eb8e743 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 16 Aug 2024 14:53:40 +0100 Subject: [PATCH 04/13] Put device in landscape mode when fullscreen button is pressed. --- .../ui/components/ZoomableContentView.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 47fff2a96..98dd32c5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -85,6 +84,7 @@ import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon @@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.utils.DeviceUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException @@ -122,14 +123,14 @@ fun ZoomableContentView( var dialogOpen by remember(content) { mutableStateOf(false) } val orientation = LocalConfiguration.current.orientation + val context = LocalView.current.context.getActivity() - val orientationState by rememberUpdatedState(newValue = orientation) val (sOrientation, isLandscapeMode) = - when (orientationState) { - Configuration.ORIENTATION_LANDSCAPE -> Pair("Landscape", true) - Configuration.ORIENTATION_PORTRAIT -> Pair("Portrait", false) + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> "Landscape" to true + Configuration.ORIENTATION_PORTRAIT -> "Portrait" to false - else -> Pair("Unknown", false) + else -> "Unknown" to false } Log.d("AmethystConf", "Device orientation is: $sOrientation") @@ -165,7 +166,10 @@ fun ZoomableContentView( roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, + onDialog = { + dialogOpen = true + DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + }, accountViewModel = accountViewModel, ) } @@ -195,10 +199,17 @@ fun ZoomableContentView( } } } - if (isLandscapeMode) dialogOpen = true if (dialogOpen) { - ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) + ZoomableImageDialog( + content, + images, + onDismiss = { + dialogOpen = false + if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + }, + accountViewModel, + ) } } From 2fb1cbe11fdb60d45c95cc53a04758e9309c7359 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sat, 17 Aug 2024 15:14:24 +0100 Subject: [PATCH 05/13] Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere). Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works. --- amethyst/build.gradle | 1 + .../compose/material3/adaptive/Posture.kt | 182 ++++++++++++++++++ .../material3/adaptive/WindowAdaptiveInfo.kt | 122 ++++++++++++ .../ui/components/ZoomableContentView.kt | 49 +++-- .../ui/components/util}/DeviceUtils.kt | 43 ++++- gradle/libs.versions.toml | 2 + 6 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt create mode 100644 amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt rename {quartz/src/main/java/com/vitorpamplona/quartz/utils => amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util}/DeviceUtils.kt (53%) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 084bd7a3b..18d917acf 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -252,6 +252,7 @@ dependencies { // Language picker and Theme chooser implementation libs.androidx.appcompat + implementation libs.androidx.window.core.android // Local model for language identification playImplementation libs.google.mlkit.language.id diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt new file mode 100644 index 000000000..8331338f9 --- /dev/null +++ b/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package androidx.compose.material3.adaptive + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.toComposeRect +import androidx.window.layout.FoldingFeature + +/** + * Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework + * folding info into the Material-opinionated posture info. + */ +fun calculatePosture(foldingFeatures: List): Posture { + var isTableTop = false + val hingeList = mutableListOf() + @Suppress("ListIterator") + foldingFeatures.forEach { + if (it.orientation == FoldingFeature.Orientation.HORIZONTAL && + it.state == FoldingFeature.State.HALF_OPENED + ) { + isTableTop = true + } + hingeList.add( + HingeInfo( + bounds = it.bounds.toComposeRect(), + isFlat = it.state == FoldingFeature.State.FLAT, + isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL, + isSeparating = it.isSeparating, + isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL, + ), + ) + } + return Posture(isTableTop, hingeList) +} + +/** + * Posture info that can help make layout adaptation decisions. For example when + * [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any + * content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this + * class in applications, unless you have a strong need of customization that cannot be fulfilled by + * the default implementation. + * + * Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout + * coordinate. + * + * @constructor create an instance of [Posture] + * @property isTabletop `true` if the current window is considered as in the table top mode, i.e. + * there is one half-opened horizontal hinge in the middle of the current window. When + * this is `true` it usually means it's hard for users to interact with the window area + * around the hinge and developers may consider separating the layout along the hinge and + * show software keyboard or other controls in the bottom half of the window. + * @property hingeList a list of all hinges that are relevant to the posture. + */ +@Immutable +class Posture( + val isTabletop: Boolean = false, + val hingeList: List = emptyList(), +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Posture) return false + if (isTabletop != other.isTabletop) return false + if (hingeList != other.hingeList) return false + return true + } + + override fun hashCode(): Int { + var result = isTabletop.hashCode() + result = 31 * result + hingeList.hashCode() + return result + } + + override fun toString(): String { + @Suppress("ListIterator") + return "Posture(isTabletop=$isTabletop, " + + "hinges=[${hingeList.joinToString(", ")}])" + } +} + +/** + * Returns the list of vertical hinge bounds that are separating. + */ +val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating } + +/** + * Returns the list of vertical hinge bounds that are occluding. + */ +val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding } + +/** + * Returns the list of all vertical hinge bounds. + */ +val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical } + +/** + * Returns the list of horizontal hinge bounds that are separating. + */ +val Posture.separatingHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical && isSeparating } + +/** + * Returns the list of horizontal hinge bounds that are occluding. + */ +val Posture.occludingHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical && isOccluding } + +/** + * Returns the list of all horizontal hinge bounds. + */ +val Posture.allHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical } + +/** + * A class that contains the info of a hinge relevant to a [Posture]. + * + * @param bounds the bounds of the hinge in the relevant viewport. + * @param isFlat `true` if the hinge is fully open and the relevant window space presented to the + * user is flat. + * @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into + * left and right; `false` if the hinge is horizontal, i.e., it separates the viewport + * into top and bottom. + * @param isSeparating `true` if the hinge creates two logical display areas. + * @param isOccluding `true` if the hinge conceals part of the display. + */ +@Immutable +class HingeInfo( + val bounds: Rect, + val isFlat: Boolean, + val isVertical: Boolean, + val isSeparating: Boolean, + val isOccluding: Boolean, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HingeInfo) return false + if (bounds != other.bounds) return false + if (isFlat != other.isFlat) return false + if (isVertical != other.isVertical) return false + if (isSeparating != other.isSeparating) return false + if (isOccluding != other.isOccluding) return false + return true + } + + override fun hashCode(): Int { + var result = bounds.hashCode() + result = 31 * result + isFlat.hashCode() + result = 31 * result + isVertical.hashCode() + result = 31 * result + isSeparating.hashCode() + result = 31 * result + isOccluding.hashCode() + return result + } + + override fun toString(): String = + "HingeInfo(bounds=$bounds, " + + "isFlat=$isFlat, " + + "isVertical=$isVertical, " + + "isSeparating=$isSeparating, " + + "isOccluding=$isOccluding)" +} + +private inline fun List.getBounds(predicate: HingeInfo.() -> Boolean): List = + @Suppress("ListIterator") + mapNotNull { if (it.predicate()) it.bounds else null } diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt new file mode 100644 index 000000000..056c1e154 --- /dev/null +++ b/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package androidx.compose.material3.adaptive + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import androidx.window.core.layout.WindowSizeClass +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowMetricsCalculator +import kotlinx.coroutines.flow.map + +@Composable +fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo { + val windowSize = + with(LocalDensity.current) { + currentWindowSize().toSize().toDpSize() + } + return WindowAdaptiveInfo( + WindowSizeClass.compute(windowSize.width.value, windowSize.height.value), + calculatePosture(collectFoldingFeaturesAsState().value), + ) +} + +/** + * Returns and automatically update the current window size from [WindowMetricsCalculator]. + * + * @return an [IntSize] that represents the current window size. + */ +@Composable +fun currentWindowSize(): IntSize { + // Observe view configuration changes and recalculate the size class on each change. We can't + // use Activity#onConfigurationChanged as this will sometimes fail to be called on different + // API levels, hence why this function needs to be @Composable so we can observe the + // ComposeView's configuration changes. + LocalConfiguration.current + val windowBounds = + WindowMetricsCalculator + .getOrCreate() + .computeCurrentWindowMetrics(LocalContext.current) + .bounds + return IntSize(windowBounds.width(), windowBounds.height()) +} + +/** + * Collects the current window folding features from [WindowInfoTracker] in to a [State]. + * + * @return a [State] of a [FoldingFeature] list. + */ +@Composable +fun collectFoldingFeaturesAsState(): State> { + val context = LocalContext.current + return remember(context) { + if (context is Activity) { + // TODO(b/284347941) remove the instance check after the test bug is fixed. + WindowInfoTracker + .getOrCreate(context) + .windowLayoutInfo(context) + } else { + WindowInfoTracker + .getOrCreate(context) + .windowLayoutInfo(context) + }.map { it.displayFeatures.filterIsInstance() } + }.collectAsState(emptyList()) +} + +/** + * This class collects window info that affects adaptation decisions. An adaptive layout is supposed + * to use the info from this class to decide how the layout is supposed to be adapted. + * + * @constructor create an instance of [WindowAdaptiveInfo] + * @param windowSizeClass [WindowSizeClass] of the current window. + * @param windowPosture [Posture] of the current window. + */ +@Immutable +class WindowAdaptiveInfo( + val windowSizeClass: WindowSizeClass, + val windowPosture: Posture, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WindowAdaptiveInfo) return false + if (windowSizeClass != other.windowSizeClass) return false + if (windowPosture != other.windowPosture) return false + return true + } + + override fun hashCode(): Int { + var result = windowSizeClass.hashCode() + result = 31 * result + windowPosture.hashCode() + return result + } + + override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 98dd32c5c..61ad27b8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -41,13 +41,16 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -65,6 +68,8 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.window.DialogWindowProvider import androidx.core.net.toUri +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.compose.AsyncImagePainter @@ -84,6 +89,7 @@ import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon @@ -102,7 +108,6 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.utils.DeviceUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException @@ -122,18 +127,38 @@ fun ZoomableContentView( ) { var dialogOpen by remember(content) { mutableStateOf(false) } - val orientation = LocalConfiguration.current.orientation - val context = LocalView.current.context.getActivity() + val activity = LocalView.current.context.getActivity() - val (sOrientation, isLandscapeMode) = - when (orientation) { - Configuration.ORIENTATION_LANDSCAPE -> "Landscape" to true - Configuration.ORIENTATION_PORTRAIT -> "Portrait" to false + val orientation by snapshotFlow { DeviceUtils.getDeviceOrientation() } + .collectAsState(initial = LocalConfiguration.current.orientation) + val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass - else -> "Unknown" to false + val detectedWindowSize = + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + when (currentWindowSize.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> "Likely a Normal device in Landscape mode" + WindowHeightSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Landscape" + WindowHeightSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Landscape" + else -> "Unknown device, likely in Landscape" + } + } else { + when (currentWindowSize.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> "Likely a Normal device in Portrait mode" + WindowWidthSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Portrait" + WindowWidthSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Portrait" + else -> "Unknown device, likely in Portrait" + } } + val (windowSize, sOrientation, isLandscapeMode) = + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> Triple(detectedWindowSize, "Landscape", true) + Configuration.ORIENTATION_PORTRAIT -> Triple(detectedWindowSize, "Portrait", false) - Log.d("AmethystConf", "Device orientation is: $sOrientation") + else -> Triple(detectedWindowSize, "Unknown orientation(maybe a foldable device?)", false) + } + val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode) + + Log.d("AmethystConf", "Device type based on window size is $windowSize, and orientation is: $sOrientation") val contentScale = if (isFiniteHeight) { @@ -168,7 +193,9 @@ fun ZoomableContentView( nostrUriCallback = content.uri, onDialog = { dialogOpen = true - DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + if (!isFoldableOrLarge) { + DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) + } }, accountViewModel = accountViewModel, ) @@ -206,7 +233,7 @@ fun ZoomableContentView( images, onDismiss = { dialogOpen = false - if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) }, accountViewModel, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt similarity index 53% rename from quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt index c2b4e3f1a..26e5c9b00 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt @@ -18,16 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.utils +package com.vitorpamplona.amethyst.ui.components.util import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass object DeviceUtils { - fun getDeviceOrientation(context: Context): Int { - val deviceConfiguration = context.resources.configuration - return deviceConfiguration.orientation + fun getDeviceOrientation(): Int { + val config = Resources.getSystem().configuration + return config.orientation } /** @@ -38,11 +44,11 @@ object DeviceUtils { * Credits: Newpipe devs * */ - fun isLandscape(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels + fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels fun changeDeviceOrientation( isInLandscape: Boolean, - context: Activity, + currentActivity: Activity, ) { val newOrientation = if (isInLandscape) { @@ -50,6 +56,29 @@ object DeviceUtils { } else { ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE } - context.requestedOrientation = newOrientation + currentActivity.requestedOrientation = newOrientation } + + @Composable + fun windowIsLarge( + isInLandscapeMode: Boolean, + windowSize: WindowSizeClass, + ): Boolean = + remember(windowSize) { + if (isInLandscapeMode) { + when (windowSize.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> false + WindowHeightSizeClass.MEDIUM -> true + WindowHeightSizeClass.EXPANDED -> true + else -> true + } + } else { + when (windowSize.windowWidthSizeClass) { + WindowWidthSizeClass.EXPANDED -> true + WindowWidthSizeClass.MEDIUM -> true + WindowWidthSizeClass.COMPACT -> false + else -> true + } + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5279de134..0f88125f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ zelory = "3.0.1" zoomable = "1.6.1" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" +windowCoreAndroid = "1.3.0" [libraries] abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } @@ -120,6 +121,7 @@ zelory-video-compressor = { group = "id.zelory", name = "compressor", version.re zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } +androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From d3a639a2ee2c0a6617113e80a2302163c71d5b8e Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 9 Aug 2024 15:26:42 +0100 Subject: [PATCH 06/13] Consistent naming and behavior for the video player controls. --- .../amethyst/ui/components/VideoView.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index faf018590..9bc1908c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -350,7 +350,7 @@ fun VideoViewInner( defaultToStart: Boolean = false, title: String? = null, thumb: VideoThumb? = null, - showControls: Boolean = false, + showControls: Boolean = true, isFiniteHeight: Boolean, borderModifier: Modifier, waveform: ImmutableList? = null, @@ -375,7 +375,7 @@ fun VideoViewInner( mimeType = mimeType, controller = controller, thumbData = thumb, - hideControls = showControls, + showControls = showControls, isFiniteHeight = isFiniteHeight, nostrUriCallback = nostrUriCallback, waveform = waveform, @@ -723,7 +723,7 @@ private fun RenderVideoPlayer( mimeType: String?, controller: MediaController, thumbData: VideoThumb?, - hideControls: Boolean = false, + showControls: Boolean = true, isFiniteHeight: Boolean, nostrUriCallback: String?, waveform: ImmutableList? = null, @@ -754,7 +754,7 @@ private fun RenderVideoPlayer( setBackgroundColor(Color.Transparent.toArgb()) setShutterBackgroundColor(Color.Transparent.toArgb()) controllerAutoShow = false - useController = !hideControls + useController = showControls thumbData?.thumb?.let { defaultArtwork = it } hideController() resizeMode = @@ -763,7 +763,7 @@ private fun RenderVideoPlayer( } else { AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH } - if (!hideControls) { + if (showControls) { onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { controller.pause() @@ -783,7 +783,7 @@ private fun RenderVideoPlayer( waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) } - if (!hideControls) { + if (showControls) { val startingMuteState = remember(controller) { controller.volume < 0.001 } MuteButton( From 223c0c1263ceaf7fe62825cf2f8a36d7c76ccfc8 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Tue, 13 Aug 2024 10:42:49 +0100 Subject: [PATCH 07/13] Initial implementation for auto-fullscreen on rotation to landscape mode. --- .../ui/components/ZoomableContentView.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index eefbde261..47fff2a96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.components import android.app.Activity import android.content.Context import android.content.ContextWrapper +import android.content.res.Configuration import android.util.Log import android.view.Window import androidx.compose.animation.AnimatedVisibility @@ -46,12 +47,14 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView @@ -118,6 +121,19 @@ fun ZoomableContentView( ) { var dialogOpen by remember(content) { mutableStateOf(false) } + val orientation = LocalConfiguration.current.orientation + + val orientationState by rememberUpdatedState(newValue = orientation) + val (sOrientation, isLandscapeMode) = + when (orientationState) { + Configuration.ORIENTATION_LANDSCAPE -> Pair("Landscape", true) + Configuration.ORIENTATION_PORTRAIT -> Pair("Portrait", false) + + else -> Pair("Unknown", false) + } + + Log.d("AmethystConf", "Device orientation is: $sOrientation") + val contentScale = if (isFiniteHeight) { ContentScale.Fit @@ -179,6 +195,7 @@ fun ZoomableContentView( } } } + if (isLandscapeMode) dialogOpen = true if (dialogOpen) { ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) From 7ab6322886287a913f13173cabdffa984459eba0 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 16 Aug 2024 14:19:55 +0100 Subject: [PATCH 08/13] Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. --- .../vitorpamplona/quartz/utils/DeviceUtils.kt | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt new file mode 100644 index 000000000..c2b4e3f1a --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import android.app.Activity +import android.content.Context +import android.content.pm.ActivityInfo + +object DeviceUtils { + fun getDeviceOrientation(context: Context): Int { + val deviceConfiguration = context.resources.configuration + return deviceConfiguration.orientation + } + + /** + * Alternative for determining if the device is + * in landscape mode. + * The [getDeviceOrientation] method could be used as well + * to achieve the same purpose. + * Credits: Newpipe devs + * + */ + fun isLandscape(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels + + fun changeDeviceOrientation( + isInLandscape: Boolean, + context: Activity, + ) { + val newOrientation = + if (isInLandscape) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + context.requestedOrientation = newOrientation + } +} From 0f817dbf0003b9e4aca21bfe36760c9ac8070a88 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 16 Aug 2024 14:53:40 +0100 Subject: [PATCH 09/13] Put device in landscape mode when fullscreen button is pressed. --- .../ui/components/ZoomableContentView.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 47fff2a96..98dd32c5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -85,6 +84,7 @@ import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon @@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey +import com.vitorpamplona.quartz.utils.DeviceUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException @@ -122,14 +123,14 @@ fun ZoomableContentView( var dialogOpen by remember(content) { mutableStateOf(false) } val orientation = LocalConfiguration.current.orientation + val context = LocalView.current.context.getActivity() - val orientationState by rememberUpdatedState(newValue = orientation) val (sOrientation, isLandscapeMode) = - when (orientationState) { - Configuration.ORIENTATION_LANDSCAPE -> Pair("Landscape", true) - Configuration.ORIENTATION_PORTRAIT -> Pair("Portrait", false) + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> "Landscape" to true + Configuration.ORIENTATION_PORTRAIT -> "Portrait" to false - else -> Pair("Unknown", false) + else -> "Unknown" to false } Log.d("AmethystConf", "Device orientation is: $sOrientation") @@ -165,7 +166,10 @@ fun ZoomableContentView( roundedCorner = roundedCorner, isFiniteHeight = isFiniteHeight, nostrUriCallback = content.uri, - onDialog = { dialogOpen = true }, + onDialog = { + dialogOpen = true + DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + }, accountViewModel = accountViewModel, ) } @@ -195,10 +199,17 @@ fun ZoomableContentView( } } } - if (isLandscapeMode) dialogOpen = true if (dialogOpen) { - ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel) + ZoomableImageDialog( + content, + images, + onDismiss = { + dialogOpen = false + if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + }, + accountViewModel, + ) } } From 59ff9007a790a2e5070d0f68033bf5449dfaed91 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sat, 17 Aug 2024 15:14:24 +0100 Subject: [PATCH 10/13] Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere). Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works. --- amethyst/build.gradle | 1 + .../compose/material3/adaptive/Posture.kt | 182 ++++++++++++++++++ .../material3/adaptive/WindowAdaptiveInfo.kt | 122 ++++++++++++ .../ui/components/ZoomableContentView.kt | 49 +++-- .../ui/components/util}/DeviceUtils.kt | 43 ++++- gradle/libs.versions.toml | 2 + 6 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt create mode 100644 amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt rename {quartz/src/main/java/com/vitorpamplona/quartz/utils => amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util}/DeviceUtils.kt (53%) diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 524d38620..7fd13c397 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -252,6 +252,7 @@ dependencies { // Language picker and Theme chooser implementation libs.androidx.appcompat + implementation libs.androidx.window.core.android // Local model for language identification playImplementation libs.google.mlkit.language.id diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt new file mode 100644 index 000000000..8331338f9 --- /dev/null +++ b/amethyst/src/main/java/androidx/compose/material3/adaptive/Posture.kt @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package androidx.compose.material3.adaptive + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.toComposeRect +import androidx.window.layout.FoldingFeature + +/** + * Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework + * folding info into the Material-opinionated posture info. + */ +fun calculatePosture(foldingFeatures: List): Posture { + var isTableTop = false + val hingeList = mutableListOf() + @Suppress("ListIterator") + foldingFeatures.forEach { + if (it.orientation == FoldingFeature.Orientation.HORIZONTAL && + it.state == FoldingFeature.State.HALF_OPENED + ) { + isTableTop = true + } + hingeList.add( + HingeInfo( + bounds = it.bounds.toComposeRect(), + isFlat = it.state == FoldingFeature.State.FLAT, + isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL, + isSeparating = it.isSeparating, + isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL, + ), + ) + } + return Posture(isTableTop, hingeList) +} + +/** + * Posture info that can help make layout adaptation decisions. For example when + * [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any + * content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this + * class in applications, unless you have a strong need of customization that cannot be fulfilled by + * the default implementation. + * + * Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout + * coordinate. + * + * @constructor create an instance of [Posture] + * @property isTabletop `true` if the current window is considered as in the table top mode, i.e. + * there is one half-opened horizontal hinge in the middle of the current window. When + * this is `true` it usually means it's hard for users to interact with the window area + * around the hinge and developers may consider separating the layout along the hinge and + * show software keyboard or other controls in the bottom half of the window. + * @property hingeList a list of all hinges that are relevant to the posture. + */ +@Immutable +class Posture( + val isTabletop: Boolean = false, + val hingeList: List = emptyList(), +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Posture) return false + if (isTabletop != other.isTabletop) return false + if (hingeList != other.hingeList) return false + return true + } + + override fun hashCode(): Int { + var result = isTabletop.hashCode() + result = 31 * result + hingeList.hashCode() + return result + } + + override fun toString(): String { + @Suppress("ListIterator") + return "Posture(isTabletop=$isTabletop, " + + "hinges=[${hingeList.joinToString(", ")}])" + } +} + +/** + * Returns the list of vertical hinge bounds that are separating. + */ +val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating } + +/** + * Returns the list of vertical hinge bounds that are occluding. + */ +val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding } + +/** + * Returns the list of all vertical hinge bounds. + */ +val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical } + +/** + * Returns the list of horizontal hinge bounds that are separating. + */ +val Posture.separatingHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical && isSeparating } + +/** + * Returns the list of horizontal hinge bounds that are occluding. + */ +val Posture.occludingHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical && isOccluding } + +/** + * Returns the list of all horizontal hinge bounds. + */ +val Posture.allHorizontalHingeBounds + get() = hingeList.getBounds { !isVertical } + +/** + * A class that contains the info of a hinge relevant to a [Posture]. + * + * @param bounds the bounds of the hinge in the relevant viewport. + * @param isFlat `true` if the hinge is fully open and the relevant window space presented to the + * user is flat. + * @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into + * left and right; `false` if the hinge is horizontal, i.e., it separates the viewport + * into top and bottom. + * @param isSeparating `true` if the hinge creates two logical display areas. + * @param isOccluding `true` if the hinge conceals part of the display. + */ +@Immutable +class HingeInfo( + val bounds: Rect, + val isFlat: Boolean, + val isVertical: Boolean, + val isSeparating: Boolean, + val isOccluding: Boolean, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HingeInfo) return false + if (bounds != other.bounds) return false + if (isFlat != other.isFlat) return false + if (isVertical != other.isVertical) return false + if (isSeparating != other.isSeparating) return false + if (isOccluding != other.isOccluding) return false + return true + } + + override fun hashCode(): Int { + var result = bounds.hashCode() + result = 31 * result + isFlat.hashCode() + result = 31 * result + isVertical.hashCode() + result = 31 * result + isSeparating.hashCode() + result = 31 * result + isOccluding.hashCode() + return result + } + + override fun toString(): String = + "HingeInfo(bounds=$bounds, " + + "isFlat=$isFlat, " + + "isVertical=$isVertical, " + + "isSeparating=$isSeparating, " + + "isOccluding=$isOccluding)" +} + +private inline fun List.getBounds(predicate: HingeInfo.() -> Boolean): List = + @Suppress("ListIterator") + mapNotNull { if (it.predicate()) it.bounds else null } diff --git a/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt b/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt new file mode 100644 index 000000000..056c1e154 --- /dev/null +++ b/amethyst/src/main/java/androidx/compose/material3/adaptive/WindowAdaptiveInfo.kt @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package androidx.compose.material3.adaptive + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import androidx.window.core.layout.WindowSizeClass +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowMetricsCalculator +import kotlinx.coroutines.flow.map + +@Composable +fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo { + val windowSize = + with(LocalDensity.current) { + currentWindowSize().toSize().toDpSize() + } + return WindowAdaptiveInfo( + WindowSizeClass.compute(windowSize.width.value, windowSize.height.value), + calculatePosture(collectFoldingFeaturesAsState().value), + ) +} + +/** + * Returns and automatically update the current window size from [WindowMetricsCalculator]. + * + * @return an [IntSize] that represents the current window size. + */ +@Composable +fun currentWindowSize(): IntSize { + // Observe view configuration changes and recalculate the size class on each change. We can't + // use Activity#onConfigurationChanged as this will sometimes fail to be called on different + // API levels, hence why this function needs to be @Composable so we can observe the + // ComposeView's configuration changes. + LocalConfiguration.current + val windowBounds = + WindowMetricsCalculator + .getOrCreate() + .computeCurrentWindowMetrics(LocalContext.current) + .bounds + return IntSize(windowBounds.width(), windowBounds.height()) +} + +/** + * Collects the current window folding features from [WindowInfoTracker] in to a [State]. + * + * @return a [State] of a [FoldingFeature] list. + */ +@Composable +fun collectFoldingFeaturesAsState(): State> { + val context = LocalContext.current + return remember(context) { + if (context is Activity) { + // TODO(b/284347941) remove the instance check after the test bug is fixed. + WindowInfoTracker + .getOrCreate(context) + .windowLayoutInfo(context) + } else { + WindowInfoTracker + .getOrCreate(context) + .windowLayoutInfo(context) + }.map { it.displayFeatures.filterIsInstance() } + }.collectAsState(emptyList()) +} + +/** + * This class collects window info that affects adaptation decisions. An adaptive layout is supposed + * to use the info from this class to decide how the layout is supposed to be adapted. + * + * @constructor create an instance of [WindowAdaptiveInfo] + * @param windowSizeClass [WindowSizeClass] of the current window. + * @param windowPosture [Posture] of the current window. + */ +@Immutable +class WindowAdaptiveInfo( + val windowSizeClass: WindowSizeClass, + val windowPosture: Posture, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is WindowAdaptiveInfo) return false + if (windowSizeClass != other.windowSizeClass) return false + if (windowPosture != other.windowPosture) return false + return true + } + + override fun hashCode(): Int { + var result = windowSizeClass.hashCode() + result = 31 * result + windowPosture.hashCode() + return result + } + + override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 98dd32c5c..61ad27b8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -41,13 +41,16 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -65,6 +68,8 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.window.DialogWindowProvider import androidx.core.net.toUri +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.compose.AsyncImagePainter @@ -84,6 +89,7 @@ import com.vitorpamplona.amethyst.service.BlurHashRequester import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation +import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils import com.vitorpamplona.amethyst.ui.navigation.getActivity import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon @@ -102,7 +108,6 @@ import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.encoders.toHexKey -import com.vitorpamplona.quartz.utils.DeviceUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CancellationException @@ -122,18 +127,38 @@ fun ZoomableContentView( ) { var dialogOpen by remember(content) { mutableStateOf(false) } - val orientation = LocalConfiguration.current.orientation - val context = LocalView.current.context.getActivity() + val activity = LocalView.current.context.getActivity() - val (sOrientation, isLandscapeMode) = - when (orientation) { - Configuration.ORIENTATION_LANDSCAPE -> "Landscape" to true - Configuration.ORIENTATION_PORTRAIT -> "Portrait" to false + val orientation by snapshotFlow { DeviceUtils.getDeviceOrientation() } + .collectAsState(initial = LocalConfiguration.current.orientation) + val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass - else -> "Unknown" to false + val detectedWindowSize = + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + when (currentWindowSize.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> "Likely a Normal device in Landscape mode" + WindowHeightSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Landscape" + WindowHeightSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Landscape" + else -> "Unknown device, likely in Landscape" + } + } else { + when (currentWindowSize.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> "Likely a Normal device in Portrait mode" + WindowWidthSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Portrait" + WindowWidthSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Portrait" + else -> "Unknown device, likely in Portrait" + } } + val (windowSize, sOrientation, isLandscapeMode) = + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> Triple(detectedWindowSize, "Landscape", true) + Configuration.ORIENTATION_PORTRAIT -> Triple(detectedWindowSize, "Portrait", false) - Log.d("AmethystConf", "Device orientation is: $sOrientation") + else -> Triple(detectedWindowSize, "Unknown orientation(maybe a foldable device?)", false) + } + val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode) + + Log.d("AmethystConf", "Device type based on window size is $windowSize, and orientation is: $sOrientation") val contentScale = if (isFiniteHeight) { @@ -168,7 +193,9 @@ fun ZoomableContentView( nostrUriCallback = content.uri, onDialog = { dialogOpen = true - DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + if (!isFoldableOrLarge) { + DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) + } }, accountViewModel = accountViewModel, ) @@ -206,7 +233,7 @@ fun ZoomableContentView( images, onDismiss = { dialogOpen = false - if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, context) + if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) }, accountViewModel, ) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt similarity index 53% rename from quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt index c2b4e3f1a..26e5c9b00 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/DeviceUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt @@ -18,16 +18,22 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.quartz.utils +package com.vitorpamplona.amethyst.ui.components.util import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowSizeClass +import androidx.window.core.layout.WindowWidthSizeClass object DeviceUtils { - fun getDeviceOrientation(context: Context): Int { - val deviceConfiguration = context.resources.configuration - return deviceConfiguration.orientation + fun getDeviceOrientation(): Int { + val config = Resources.getSystem().configuration + return config.orientation } /** @@ -38,11 +44,11 @@ object DeviceUtils { * Credits: Newpipe devs * */ - fun isLandscape(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels + fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels fun changeDeviceOrientation( isInLandscape: Boolean, - context: Activity, + currentActivity: Activity, ) { val newOrientation = if (isInLandscape) { @@ -50,6 +56,29 @@ object DeviceUtils { } else { ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE } - context.requestedOrientation = newOrientation + currentActivity.requestedOrientation = newOrientation } + + @Composable + fun windowIsLarge( + isInLandscapeMode: Boolean, + windowSize: WindowSizeClass, + ): Boolean = + remember(windowSize) { + if (isInLandscapeMode) { + when (windowSize.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> false + WindowHeightSizeClass.MEDIUM -> true + WindowHeightSizeClass.EXPANDED -> true + else -> true + } + } else { + when (windowSize.windowWidthSizeClass) { + WindowWidthSizeClass.EXPANDED -> true + WindowWidthSizeClass.MEDIUM -> true + WindowWidthSizeClass.COMPACT -> false + else -> true + } + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 328e53412..eed49df3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ zelory = "3.0.1" zoomable = "1.6.1" zxing = "3.5.3" zxingAndroidEmbedded = "4.3.0" +windowCoreAndroid = "1.3.0" [libraries] abedElazizShe-image-compressor = { group = "com.github.AbedElazizShe", name = "LightCompressor", version.ref = "lightcompressor" } @@ -120,6 +121,7 @@ zelory-video-compressor = { group = "id.zelory", name = "compressor", version.re zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } +androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From 26a12f2dbea811ac41308b5ef7255cc2419482bb Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sat, 17 Aug 2024 17:03:07 +0100 Subject: [PATCH 11/13] Remove the external config method and use the built-in Compose API. Add some documentation. --- .../ui/components/ZoomableContentView.kt | 5 +--- .../ui/components/util/DeviceUtils.kt | 24 ++++++++++--------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 61ad27b8c..b6e776f23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -45,12 +45,10 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -129,8 +127,7 @@ fun ZoomableContentView( val activity = LocalView.current.context.getActivity() - val orientation by snapshotFlow { DeviceUtils.getDeviceOrientation() } - .collectAsState(initial = LocalConfiguration.current.orientation) + val orientation = LocalConfiguration.current.orientation val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass val detectedWindowSize = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt index 26e5c9b00..575058939 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/util/DeviceUtils.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components.util import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo -import android.content.res.Resources import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.window.core.layout.WindowHeightSizeClass @@ -31,18 +30,11 @@ import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowWidthSizeClass object DeviceUtils { - fun getDeviceOrientation(): Int { - val config = Resources.getSystem().configuration - return config.orientation - } - /** - * Alternative for determining if the device is - * in landscape mode. - * The [getDeviceOrientation] method could be used as well - * to achieve the same purpose. - * Credits: Newpipe devs + * Tries to determine if the device is + * in landscape mode, by using the [android.util.DisplayMetrics] API. * + * Credits: NewPipe devs */ fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels @@ -59,6 +51,16 @@ object DeviceUtils { currentActivity.requestedOrientation = newOrientation } + /** + * This method looks at the window in which the app resides, + * and determines if it is large, while making sure not to be affected + * by configuration changes(such as screen rotation), + * as the device display metrics can be affected as well. + * + * It could be used as an approximation of the type of device(as is the case here), + * though one ought to be careful about multi-window situations. + */ + @Composable fun windowIsLarge( isInLandscapeMode: Boolean, From 9613afb7eafaf8a258b3d05acc227bdf33595f8f Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sat, 17 Aug 2024 17:07:43 +0100 Subject: [PATCH 12/13] Fix ZoomableImageDialog. isFoldableOrLarge already takes into account the orientation. --- .../vitorpamplona/amethyst/ui/components/ZoomableContentView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index b6e776f23..7f36ad41c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -230,7 +230,7 @@ fun ZoomableContentView( images, onDismiss = { dialogOpen = false - if (isLandscapeMode) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) + if (!isFoldableOrLarge) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity) }, accountViewModel, ) From 1b429475aaab52f4f93f8f1e6d2450c1c305d6e5 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Sat, 17 Aug 2024 18:51:07 +0100 Subject: [PATCH 13/13] Remove the manual testing/debugging code. Remove the LocalConfiguration API in favour of DeviceUtils.isLandscapeMetric. --- .../ui/components/ZoomableContentView.kt | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 7f36ad41c..cf3ee362d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.content.res.Configuration import android.util.Log import android.view.Window import androidx.compose.animation.AnimatedVisibility @@ -54,7 +53,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView @@ -66,8 +64,6 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.window.DialogWindowProvider import androidx.core.net.toUri -import androidx.window.core.layout.WindowHeightSizeClass -import androidx.window.core.layout.WindowWidthSizeClass import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.compose.AsyncImagePainter @@ -126,37 +122,11 @@ fun ZoomableContentView( var dialogOpen by remember(content) { mutableStateOf(false) } val activity = LocalView.current.context.getActivity() - - val orientation = LocalConfiguration.current.orientation val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass - val detectedWindowSize = - if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - when (currentWindowSize.windowHeightSizeClass) { - WindowHeightSizeClass.COMPACT -> "Likely a Normal device in Landscape mode" - WindowHeightSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Landscape" - WindowHeightSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Landscape" - else -> "Unknown device, likely in Landscape" - } - } else { - when (currentWindowSize.windowWidthSizeClass) { - WindowWidthSizeClass.COMPACT -> "Likely a Normal device in Portrait mode" - WindowWidthSizeClass.MEDIUM -> "Likely Small tablet, or Foldable device in Portrait" - WindowWidthSizeClass.EXPANDED -> "Likely a Large tablet, Foldable or Desktop device in Portrait" - else -> "Unknown device, likely in Portrait" - } - } - val (windowSize, sOrientation, isLandscapeMode) = - when (orientation) { - Configuration.ORIENTATION_LANDSCAPE -> Triple(detectedWindowSize, "Landscape", true) - Configuration.ORIENTATION_PORTRAIT -> Triple(detectedWindowSize, "Portrait", false) - - else -> Triple(detectedWindowSize, "Unknown orientation(maybe a foldable device?)", false) - } + val isLandscapeMode = DeviceUtils.isLandscapeMetric(LocalContext.current) val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode) - Log.d("AmethystConf", "Device type based on window size is $windowSize, and orientation is: $sOrientation") - val contentScale = if (isFiniteHeight) { ContentScale.Fit