diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 04d7b31f8b..e0ef891c26 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -32,6 +32,7 @@ android:launchMode="singleTask" android:exported="true" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" + android:screenOrientation="userLandscape" tools:ignore="LockedOrientationActivity"> diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index 3b05d4d794..a714c435d3 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -1378,6 +1378,9 @@ Can be created instantly by = Defence bonus = Movement cost = for = +Landscape (fixed) = +Portrait (fixed) = +Auto (sensor adjusted) = Missing translations: = Screen Size = Screen Mode = diff --git a/android/src/com/unciv/app/AndroidDisplay.kt b/android/src/com/unciv/app/AndroidDisplay.kt index 8e659d073c..8f8224ab37 100644 --- a/android/src/com/unciv/app/AndroidDisplay.kt +++ b/android/src/com/unciv/app/AndroidDisplay.kt @@ -1,7 +1,11 @@ package com.unciv.app import android.app.Activity +import android.content.pm.ActivityInfo +import android.database.ContentObserver import android.os.Build +import android.os.Handler +import android.provider.Settings import android.view.Display import android.view.Display.Mode import android.view.WindowManager @@ -11,6 +15,7 @@ import com.unciv.models.translations.tr import com.unciv.utils.Log import com.unciv.utils.PlatformDisplay import com.unciv.utils.ScreenMode +import com.unciv.utils.ScreenOrientation class AndroidScreenMode( @@ -65,10 +70,6 @@ class AndroidDisplay(private val activity: Activity) : PlatformDisplay { return displayModes } - override fun getDefaultMode(): ScreenMode { - return displayModes[0]!! - } - override fun setScreenMode(id: Int, settings: GameSettings) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { activity.runOnUiThread { @@ -80,4 +81,56 @@ class AndroidDisplay(private val activity: Activity) : PlatformDisplay { } + override fun hasCutout(): Boolean { + return when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + activity.display?.cutout != null + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + @Suppress("DEPRECATION") + activity.windowManager.defaultDisplay.cutout != null + Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> + activity.window.decorView.rootWindowInsets.displayCutout != null + else -> false + } + } + + override fun setCutout(enabled: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val params = activity.window.attributes + params.layoutInDisplayCutoutMode = when { + enabled -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + else -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER + } + activity.window.attributes = params + } + } + + + /* + Sources for Info about current orientation in case need: + val windowManager = (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager) + val displayRotation = windowManager.defaultDisplay.rotation + val currentOrientation = activity.resources.configuration.orientation + const val ORIENTATION_UNDEFINED = 0 + const val ORIENTATION_PORTRAIT = 1 + const val ORIENTATION_LANDSCAPE = 2 + */ + + override fun hasOrientation(): Boolean { + return true + } + + override fun setOrientation(orientation: ScreenOrientation) { + + val mode = when (orientation) { + ScreenOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + ScreenOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + ScreenOrientation.Auto -> ActivityInfo.SCREEN_ORIENTATION_SENSOR + } + + // Ensure ActivityTaskManager.getService().setRequestedOrientation isn't called unless necessary! + if (activity.requestedOrientation != mode) + activity.requestedOrientation = mode + } + } diff --git a/android/src/com/unciv/app/AndroidGame.kt b/android/src/com/unciv/app/AndroidGame.kt index 2b44ed89b2..689419968b 100644 --- a/android/src/com/unciv/app/AndroidGame.kt +++ b/android/src/com/unciv/app/AndroidGame.kt @@ -1,35 +1,72 @@ package com.unciv.app -import android.os.Build +import android.content.Intent +import android.graphics.Rect +import android.net.Uri +import android.view.View +import android.view.ViewTreeObserver +import com.badlogic.gdx.Gdx +import com.badlogic.gdx.backends.android.AndroidGraphics +import com.badlogic.gdx.math.Rectangle import com.unciv.UncivGame +import com.unciv.logic.event.EventBus +import com.unciv.ui.screens.basescreen.BaseScreen +import com.unciv.ui.screens.basescreen.UncivStage +import com.unciv.utils.concurrency.Concurrency -class AndroidGame(private val activity: AndroidLauncher) : UncivGame() { +class AndroidGame : UncivGame() { - /* - Sources for Info about current orientation in case need: - val windowManager = (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager) - val displayRotation = windowManager.defaultDisplay.rotation - val currentOrientation = activity.resources.configuration.orientation - const val ORIENTATION_UNDEFINED = 0 - const val ORIENTATION_PORTRAIT = 1 - const val ORIENTATION_LANDSCAPE = 2 - */ + fun addScreenObscuredListener() { + val contentView = (Gdx.graphics as AndroidGraphics).view + contentView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - override fun hasDisplayCutout(): Boolean { - return when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> - activity.display?.cutout != null - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> - @Suppress("DEPRECATION") - activity.windowManager.defaultDisplay.cutout != null - Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> - activity.window.decorView.rootWindowInsets.displayCutout != null - else -> false + /** [onGlobalLayout] gets triggered not only when the [View.getWindowVisibleDisplayFrame] + * changes, but also on other things. So we need to check if that was actually + * the thing that changed. */ + private var lastFrame: Rect? = null + private var lastVisibleStage: Rectangle? = null + + override fun onGlobalLayout() { + + if (!isInitialized || screen == null) + return + + val currentFrame = Rect() + contentView.getWindowVisibleDisplayFrame(currentFrame) + + val stage = (screen as BaseScreen).stage + val horizontalRatio = stage.width / contentView.width + val verticalRatio = stage.height / contentView.height + + // Android coordinate system has the origin in the top left, + // while GDX uses bottom left. + + val visibleStage = Rectangle( + currentFrame.left * horizontalRatio, + (contentView.height - currentFrame.bottom) * verticalRatio, + currentFrame.width() * horizontalRatio, + currentFrame.height() * verticalRatio + ) + + if (lastFrame == currentFrame && lastVisibleStage == visibleStage) + return + lastFrame = currentFrame + lastVisibleStage = visibleStage + + Concurrency.runOnGLThread { + EventBus.send(UncivStage.VisibleAreaChanged(visibleStage)) + } + } + }) + } + + /** This is needed in onCreate _and_ onNewIntent to open links and notifications + * correctly even if the app was not running */ + fun setDeepLinkedGame(intent: Intent) { + deepLinkedMultiplayerGame = if (intent.action != Intent.ACTION_VIEW) null else { + val uri: Uri? = intent.data + uri?.getQueryParameter("id") } } - override fun allowPortrait(allow: Boolean) { - activity.allowPortrait(allow) - } - } diff --git a/android/src/com/unciv/app/AndroidLauncher.kt b/android/src/com/unciv/app/AndroidLauncher.kt index ec52dde01a..a5762341ee 100644 --- a/android/src/com/unciv/app/AndroidLauncher.kt +++ b/android/src/com/unciv/app/AndroidLauncher.kt @@ -1,39 +1,20 @@ package com.unciv.app import android.content.Intent -import android.content.pm.ActivityInfo -import android.graphics.Rect -import android.net.Uri -import android.opengl.GLSurfaceView -import android.os.Build import android.os.Bundle -import android.view.Surface -import android.view.SurfaceHolder -import android.view.View -import android.view.ViewTreeObserver -import android.view.WindowManager import androidx.core.app.NotificationManagerCompat import androidx.work.WorkManager -import com.badlogic.gdx.Gdx import com.badlogic.gdx.backends.android.AndroidApplication import com.badlogic.gdx.backends.android.AndroidApplicationConfiguration -import com.badlogic.gdx.backends.android.AndroidGraphics -import com.badlogic.gdx.math.Rectangle -import com.unciv.UncivGame import com.unciv.logic.files.UncivFiles -import com.unciv.logic.event.EventBus import com.unciv.ui.components.Fonts -import com.unciv.ui.screens.basescreen.UncivStage -import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.utils.Display import com.unciv.utils.Log -import com.unciv.utils.concurrency.Concurrency import java.io.File open class AndroidLauncher : AndroidApplication() { private var game: AndroidGame? = null - private var deepLinkedMultiplayerGame: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -59,68 +40,15 @@ open class AndroidLauncher : AndroidApplication() { val config = AndroidApplicationConfiguration().apply { useImmersiveMode = true } val settings = UncivFiles.getSettingsForPlatformLaunchers(filesDir.path) - // Setup orientation lock and display cutout - allowPortrait(settings.allowAndroidPortrait) - setDisplayCutout(settings.androidCutout) + // Setup orientation and display cutout + Display.setOrientation(settings.displayOrientation) + Display.setCutout(settings.androidCutout) - game = AndroidGame(this) + game = AndroidGame() initialize(game, config) - setDeepLinkedGame(intent) - - val glView = (Gdx.graphics as AndroidGraphics).view as GLSurfaceView - - addScreenObscuredListener(glView) - } - - fun allowPortrait(allow: Boolean) { - val orientation = when { - allow -> ActivityInfo.SCREEN_ORIENTATION_USER - else -> ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE - } - // Comparison ensures ActivityTaskManager.getService().setRequestedOrientation isn't called unless necessary - if (requestedOrientation != orientation) requestedOrientation = orientation - } - - private fun setDisplayCutout(cutout: Boolean) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return - window.attributes.layoutInDisplayCutoutMode = when { - cutout -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - else -> WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER - } - } - - private fun addScreenObscuredListener(surfaceView: GLSurfaceView) { - surfaceView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - /** [onGlobalLayout] gets triggered not only when the [windowVisibleDisplayFrame][View.getWindowVisibleDisplayFrame] changes, but also on other things. - * So we need to check if that was actually the thing that changed. */ - private var lastVisibleDisplayFrame: Rect? = null - - override fun onGlobalLayout() { - if (!UncivGame.isCurrentInitialized() || UncivGame.Current.screen == null) { - return - } - val r = Rect() - surfaceView.getWindowVisibleDisplayFrame(r) - if (r.equals(lastVisibleDisplayFrame)) return - lastVisibleDisplayFrame = r - - val stage = (UncivGame.Current.screen as BaseScreen).stage - - val horizontalRatio = stage.width / surfaceView.width - val verticalRatio = stage.height / surfaceView.height - - val visibleStage = Rectangle( - r.left * horizontalRatio, - (surfaceView.height - r.bottom) * verticalRatio, // Android coordinate system has the origin in the top left, while GDX uses bottom left - r.width() * horizontalRatio, - r.height() * verticalRatio - ) - Concurrency.runOnGLThread { - EventBus.send(UncivStage.VisibleAreaChanged(visibleStage)) - } - } - }) + game!!.setDeepLinkedGame(intent) + game!!.addScreenObscuredListener() } /** @@ -141,34 +69,29 @@ open class AndroidLauncher : AndroidApplication() { } override fun onPause() { - if (UncivGame.isCurrentInitialized() - && UncivGame.Current.gameInfo != null - && UncivGame.Current.settings.multiplayer.turnCheckerEnabled - && UncivGame.Current.files.getMultiplayerSaves().any() + val game = this.game!! + if (game.isInitialized + && game.gameInfo != null + && game.settings.multiplayer.turnCheckerEnabled + && game.files.getMultiplayerSaves().any() ) { MultiplayerTurnCheckWorker.startTurnChecker( - applicationContext, UncivGame.Current.files, - UncivGame.Current.gameInfo!!, UncivGame.Current.settings.multiplayer - ) + applicationContext, game.files, game.gameInfo!!, game.settings.multiplayer) } super.onPause() } override fun onResume() { - try { // Sometimes this fails for no apparent reason - the multiplayer checker failing to cancel should not be enough of a reason for the game to crash! + try { WorkManager.getInstance(applicationContext).cancelAllWorkByTag(MultiplayerTurnCheckWorker.WORK_TAG) with(NotificationManagerCompat.from(this)) { cancel(MultiplayerTurnCheckWorker.NOTIFICATION_ID_INFO) cancel(MultiplayerTurnCheckWorker.NOTIFICATION_ID_SERVICE) } - } catch (ex: Exception) { + } catch (ignore: Exception) { + /* Sometimes this fails for no apparent reason - the multiplayer checker failing to + cancel should not be enough of a reason for the game to crash! */ } - - if (deepLinkedMultiplayerGame != null) { - game?.deepLinkedMultiplayerGame = deepLinkedMultiplayerGame - deepLinkedMultiplayerGame = null - } - super.onResume() } @@ -176,17 +99,7 @@ open class AndroidLauncher : AndroidApplication() { super.onNewIntent(intent) if (intent == null) return - - setDeepLinkedGame(intent) - } - - private fun setDeepLinkedGame(intent: Intent) { - // This is needed in onCreate _and_ onNewIntent to open links and notifications - // correctly even if the app was not running - deepLinkedMultiplayerGame = if (intent.action != Intent.ACTION_VIEW) null else { - val uri: Uri? = intent.data - uri?.getQueryParameter("id") - } + game?.setDeepLinkedGame(intent) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index 2c211ffe37..ab623a91e6 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -9,8 +9,7 @@ import com.unciv.logic.multiplayer.FriendList import com.unciv.models.UncivSound import com.unciv.ui.components.FontFamilyData import com.unciv.ui.components.Fonts -import com.unciv.utils.Display -import com.unciv.utils.ScreenMode +import com.unciv.utils.ScreenOrientation import java.text.Collator import java.time.Duration import java.util.* @@ -90,7 +89,8 @@ class GameSettings { var lastOverviewPage: String = "Cities" - var allowAndroidPortrait = false // Opt-in to allow Unciv to follow a screen rotation to portrait + /** Orientation for mobile platforms */ + var displayOrientation = ScreenOrientation.Landscape /** Saves the last successful new game's setup */ var lastGameSetup: GameSetupInfo? = null diff --git a/core/src/com/unciv/ui/popups/options/AdvancedTab.kt b/core/src/com/unciv/ui/popups/options/AdvancedTab.kt index 4b5af2e3cb..57f48bdd3c 100644 --- a/core/src/com/unciv/ui/popups/options/AdvancedTab.kt +++ b/core/src/com/unciv/ui/popups/options/AdvancedTab.kt @@ -25,6 +25,7 @@ import com.unciv.ui.components.FontFamilyData import com.unciv.ui.components.Fonts import com.unciv.ui.components.UncivSlider import com.unciv.ui.components.UncivTooltip.Companion.addTooltip +import com.unciv.ui.components.extensions.center import com.unciv.ui.components.extensions.disable import com.unciv.ui.components.extensions.keyShortcuts import com.unciv.ui.components.extensions.onActivation @@ -35,6 +36,8 @@ import com.unciv.ui.components.extensions.toCheckBox import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.extensions.toTextButton import com.unciv.ui.components.extensions.withoutItem +import com.unciv.utils.Display +import com.unciv.utils.ScreenOrientation import com.unciv.utils.concurrency.Concurrency import com.unciv.utils.concurrency.launchOnGLThread import kotlinx.coroutines.CoroutineScope @@ -54,21 +57,16 @@ fun advancedTab( addAutosaveTurnsSelectBox(this, settings) - if (UncivGame.Current.hasDisplayCutout()) - optionsPopup.addCheckbox(this, "Enable display cutout (requires restart)", settings.androidCutout) { - settings.androidCutout = it - } + if (Display.hasOrientation()) { + addOrientationSelectBox(this, optionsPopup) + } + + if (Display.hasCutout()) { + addCutoutCheckbox(this, optionsPopup) + } addMaxZoomSlider(this, settings) - if (Gdx.app.type == Application.ApplicationType.Android) { - optionsPopup.addCheckbox(this, "Enable portrait orientation", settings.allowAndroidPortrait) { - settings.allowAndroidPortrait = it - // Note the following might close the options screen indirectly and delayed - UncivGame.Current.allowPortrait(it) - } - } - addFontFamilySelect(this, settings, optionsPopup.selectBoxMinWidth, onFontChange) addFontSizeMultiplier(this, settings, onFontChange) @@ -80,6 +78,31 @@ fun advancedTab( addEasterEggsCheckBox(this, settings) } +private fun addCutoutCheckbox(table: Table, optionsPopup: OptionsPopup) { + optionsPopup.addCheckbox(table, "Enable display cutout (requires restart)", optionsPopup.settings.androidCutout) + { + optionsPopup.settings.androidCutout = it + } +} + +private fun addOrientationSelectBox(table: Table, optionsPopup: OptionsPopup) { + + val settings = optionsPopup.settings + + table.add("Screen orientation".toLabel()).left().fillX() + + val selectBox = SelectBox(table.skin) + selectBox.items = Array(ScreenOrientation.values()) + selectBox.selected = settings.displayOrientation + selectBox.onChange { + val orientation = selectBox.selected + settings.displayOrientation = orientation + Display.setOrientation(orientation) + } + + table.add(selectBox).minWidth(optionsPopup.selectBoxMinWidth).pad(10f).row() +} + private fun addAutosaveTurnsSelectBox(table: Table, settings: GameSettings) { table.add("Turns between autosaves".toLabel()).left().fillX() diff --git a/core/src/com/unciv/ui/popups/options/OptionsPopup.kt b/core/src/com/unciv/ui/popups/options/OptionsPopup.kt index c95440c1d9..b4670d8e47 100644 --- a/core/src/com/unciv/ui/popups/options/OptionsPopup.kt +++ b/core/src/com/unciv/ui/popups/options/OptionsPopup.kt @@ -122,7 +122,7 @@ class OptionsPopup( addCloseButton { screen.game.musicController.onChange(null) - screen.game.allowPortrait(settings.allowAndroidPortrait) + center(screen.stage) onClose() }.padBottom(10f) diff --git a/core/src/com/unciv/utils/Display.kt b/core/src/com/unciv/utils/Display.kt index af0d2c9db6..bc0ce46890 100644 --- a/core/src/com/unciv/utils/Display.kt +++ b/core/src/com/unciv/utils/Display.kt @@ -1,6 +1,17 @@ package com.unciv.utils import com.unciv.models.metadata.GameSettings +import com.unciv.models.translations.tr + +enum class ScreenOrientation(val description: String) { + Landscape("Landscape (fixed)"), + Portrait("Portrait (fixed)"), + Auto("Auto (sensor adjusted)"); + + override fun toString(): String { + return description.tr() + } +} interface ScreenMode { fun getId(): Int @@ -10,24 +21,25 @@ interface PlatformDisplay { fun setScreenMode(id: Int, settings: GameSettings) {} fun getScreenModes(): Map { return hashMapOf() } - fun getDefaultMode(): ScreenMode + fun hasCutout(): Boolean { return false } + fun setCutout(enabled: Boolean) {} + + fun hasOrientation(): Boolean { return false } + fun setOrientation(orientation: ScreenOrientation) {} } object Display { lateinit var platform: PlatformDisplay - fun getDefaultMode(): ScreenMode { - return platform.getDefaultMode() - } + fun hasOrientation(): Boolean { return platform.hasOrientation() } + fun setOrientation(orientation: ScreenOrientation) { platform.setOrientation(orientation) } - fun getScreenModes(): Map { - return platform.getScreenModes() - } + fun hasCutout(): Boolean { return platform.hasCutout() } + fun setCutout(enabled: Boolean) { platform.setCutout(enabled) } - fun setScreenMode(id: Int, settings: GameSettings) { - platform.setScreenMode(id, settings) - } + fun getScreenModes(): Map { return platform.getScreenModes() } + fun setScreenMode(id: Int, settings: GameSettings) { platform.setScreenMode(id, settings) } } diff --git a/core/src/com/unciv/utils/PlatformSpecific.kt b/core/src/com/unciv/utils/PlatformSpecific.kt index 6c9a86ba9e..25146e3549 100644 --- a/core/src/com/unciv/utils/PlatformSpecific.kt +++ b/core/src/com/unciv/utils/PlatformSpecific.kt @@ -8,10 +8,4 @@ interface PlatformSpecific { /** Install system audio hooks */ fun installAudioHooks() {} - /** (Android) allow screen orientation switch */ - fun allowPortrait(allow: Boolean) {} - - /** (Android) returns whether display has cutout */ - fun hasDisplayCutout(): Boolean { return false } - } diff --git a/desktop/src/com/unciv/app/desktop/DesktopDisplay.kt b/desktop/src/com/unciv/app/desktop/DesktopDisplay.kt index fad93c9c82..0c2c1cd690 100644 --- a/desktop/src/com/unciv/app/desktop/DesktopDisplay.kt +++ b/desktop/src/com/unciv/app/desktop/DesktopDisplay.kt @@ -40,10 +40,6 @@ class DesktopDisplay : PlatformDisplay { modes[2] = DesktopScreenMode(2, ScreenWindowType.Borderless) } - override fun getDefaultMode(): ScreenMode { - return modes[0]!! - } - override fun getScreenModes(): Map { return modes }