diff --git a/Directory.Build.props b/Directory.Build.props index 734374c840..b08283f071 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,7 +35,7 @@ https://github.com/ppy/osu Automated release. ppy Pty Ltd - Copyright (c) 2022 ppy Pty Ltd + Copyright (c) 2024 ppy Pty Ltd osu game diff --git a/LICENCE b/LICENCE index d3e7537cef..3bb8b62d5d 100644 --- a/LICENCE +++ b/LICENCE @@ -1,4 +1,4 @@ -Copyright (c) 2022 ppy Pty Ltd . +Copyright (c) 2024 ppy Pty Ltd . Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d5dc0723af..d7e710f392 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A few resources are available as starting points to getting involved and underst - Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). - You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). -- Track our current efforts [towards full "ranked play" support](https://github.com/orgs/ppy/projects/13?query=is%3Aopen+sort%3Aupdated-desc). +- Track our current efforts [towards improving the game](https://github.com/orgs/ppy/projects/7/views/6). ## Running osu! diff --git a/Templates/osu.Game.Templates.csproj b/Templates/osu.Game.Templates.csproj index b8c3ad373a..186a6093f5 100644 --- a/Templates/osu.Game.Templates.csproj +++ b/Templates/osu.Game.Templates.csproj @@ -1,4 +1,4 @@ - + Template ppy.osu.Game.Templates @@ -8,7 +8,7 @@ https://github.com/ppy/osu/blob/master/Templates https://github.com/ppy/osu Automated release. - Copyright (c) 2022 ppy Pty Ltd + Copyright (c) 2024 ppy Pty Ltd Templates to use when creating a ruleset for consumption in osu!. dotnet-new;templates;osu netstandard2.1 diff --git a/assets/lazer-nuget.png b/assets/lazer-nuget.png index c2a587fdc2..fed2f45149 100644 Binary files a/assets/lazer-nuget.png and b/assets/lazer-nuget.png differ diff --git a/assets/lazer.png b/assets/lazer.png index 1e40e844cc..2ee44225bf 100644 Binary files a/assets/lazer.png and b/assets/lazer.png differ diff --git a/osu.Android.props b/osu.Android.props index a7376aa5a7..969fd52340 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.Android/Resources/drawable/ic_launcher_background.xml b/osu.Android/Resources/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..1af30228ec --- /dev/null +++ b/osu.Android/Resources/drawable/ic_launcher_background.xml @@ -0,0 +1,618 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/osu.Android/Resources/drawable/lazer.png b/osu.Android/Resources/drawable/lazer.png deleted file mode 100644 index fc7aa8a092..0000000000 Binary files a/osu.Android/Resources/drawable/lazer.png and /dev/null differ diff --git a/osu.Android/Resources/drawable/monochrome.xml b/osu.Android/Resources/drawable/monochrome.xml new file mode 100644 index 0000000000..e12af03bfb --- /dev/null +++ b/osu.Android/Resources/drawable/monochrome.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/osu.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml b/osu.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..7133c9c861 --- /dev/null +++ b/osu.Android/Resources/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/osu.Android/Resources/mipmap-hdpi/ic_launcher.png b/osu.Android/Resources/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..7870430484 Binary files /dev/null and b/osu.Android/Resources/mipmap-hdpi/ic_launcher.png differ diff --git a/osu.Android/Resources/mipmap-hdpi/ic_launcher_foreground.png b/osu.Android/Resources/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..b2ec3e49da Binary files /dev/null and b/osu.Android/Resources/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/osu.Android/Resources/mipmap-mdpi/ic_launcher.png b/osu.Android/Resources/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..2a01d8f781 Binary files /dev/null and b/osu.Android/Resources/mipmap-mdpi/ic_launcher.png differ diff --git a/osu.Android/Resources/mipmap-mdpi/ic_launcher_foreground.png b/osu.Android/Resources/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..e22f256562 Binary files /dev/null and b/osu.Android/Resources/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/osu.Android/Resources/mipmap-xhdpi/ic_launcher.png b/osu.Android/Resources/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..b5e1a9e379 Binary files /dev/null and b/osu.Android/Resources/mipmap-xhdpi/ic_launcher.png differ diff --git a/osu.Android/Resources/mipmap-xhdpi/ic_launcher_foreground.png b/osu.Android/Resources/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..1cc3fa9072 Binary files /dev/null and b/osu.Android/Resources/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/osu.Android/Resources/mipmap-xxhdpi/ic_launcher.png b/osu.Android/Resources/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..8a37b0449e Binary files /dev/null and b/osu.Android/Resources/mipmap-xxhdpi/ic_launcher.png differ diff --git a/osu.Android/Resources/mipmap-xxhdpi/ic_launcher_foreground.png b/osu.Android/Resources/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..1b856a31b2 Binary files /dev/null and b/osu.Android/Resources/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/osu.Android/Resources/mipmap-xxxhdpi/ic_launcher.png b/osu.Android/Resources/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..65751e15c9 Binary files /dev/null and b/osu.Android/Resources/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/osu.Android/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png b/osu.Android/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000..05c6829a47 Binary files /dev/null and b/osu.Android/Resources/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/osu.Desktop/lazer.ico b/osu.Desktop/lazer.ico old mode 100755 new mode 100644 index a6aa8abb9f..f84866b8e9 Binary files a/osu.Desktop/lazer.ico and b/osu.Desktop/lazer.ico differ diff --git a/osu.Desktop/osu.nuspec b/osu.Desktop/osu.nuspec index db58c325bd..3b7d6cbe79 100644 --- a/osu.Desktop/osu.nuspec +++ b/osu.Desktop/osu.nuspec @@ -7,11 +7,12 @@ ppy Pty Ltd Dean Herbert https://osu.ppy.sh/ - https://puu.sh/tYyXZ/9a01a5d1b0.ico + https://github.com/ppy/osu/blob/master/assets/lazer-nuget.png?raw=true + icon.png false A free-to-win rhythm game. Rhythm is just a *click* away! testing - Copyright (c) 2022 ppy Pty Ltd + Copyright (c) 2024 ppy Pty Ltd en-AU diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index ccd388192e..e472de1dfe 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu base.ReloadMappings(realmKeyBindings); if (!AllowGameplayInputs) - KeyBindings = KeyBindings.Where(b => b.GetAction() == OsuAction.Smoke).ToList(); + KeyBindings = KeyBindings.Where(static b => b.GetAction() == OsuAction.Smoke).ToList(); } } } diff --git a/osu.Game.Tests/Chat/TestSceneChannelManager.cs b/osu.Game.Tests/Chat/TestSceneChannelManager.cs index eae12edebd..95fd2669e5 100644 --- a/osu.Game.Tests/Chat/TestSceneChannelManager.cs +++ b/osu.Game.Tests/Chat/TestSceneChannelManager.cs @@ -75,8 +75,6 @@ namespace osu.Game.Tests.Chat return false; }; }); - - AddUntilStep("wait for notifications client", () => channelManager.NotificationsConnected); } [Test] diff --git a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs index fbe5a0e4d7..98cb66a234 100644 --- a/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs +++ b/osu.Game.Tests/NonVisual/Skinning/LegacySkinTextureFallbackTest.cs @@ -169,9 +169,9 @@ namespace osu.Game.Tests.NonVisual.Skinning public IRenderer Renderer => new DummyRenderer(); public AudioManager AudioManager => null; - public IResourceStore Files => null; - public IResourceStore Resources => null; - public RealmAccess RealmAccess => null; + public IResourceStore Files => null!; + public IResourceStore Resources => null!; + public RealmAccess RealmAccess => null!; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => textureStore; } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs index 3ac4d25028..636cd78d9c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAutoplay.cs @@ -47,8 +47,8 @@ namespace osu.Game.Tests.Visual.Gameplay seekTo(referenceBeatmap.HitObjects[^1].GetEndTime()); AddUntilStep("results displayed", () => getResultsScreen()?.IsLoaded == true); - AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100); - AddAssert("score has no misses", () => getResultsScreen().Score.Statistics[HitResult.Miss] == 0); + AddAssert("score has combo", () => getResultsScreen().Score!.Combo > 100); + AddAssert("score has no misses", () => getResultsScreen().Score!.Statistics[HitResult.Miss] == 0); AddUntilStep("avatar displayed", () => getAvatar() != null); AddAssert("avatar not clickable", () => getAvatar().ChildrenOfType().First().Action == null); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index fafd1330cc..1660f93384 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -138,8 +138,8 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); // Player creates new instance of mods after gameplay to ensure any runtime references to drawables etc. are not retained. - AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.Not.SameAs(playerMods.First())); - AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(playerMods.First())); + AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score?.Mods.First(), () => Is.Not.SameAs(playerMods.First())); + AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score?.Mods.First(), () => Is.EqualTo(playerMods.First())); AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID))!.Mods.First(), () => Is.EqualTo(playerMods.First())); @@ -184,7 +184,11 @@ namespace osu.Game.Tests.Visual.Gameplay CreateTest(); AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); - AddStep("log back in", () => API.Login("username", "password")); + AddStep("log back in", () => + { + API.Login("username", "password"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs index 0bc71924ce..5fc075ed99 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneLoginOverlay.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using System.Net; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,7 +10,9 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; +using osu.Game.Online.API.Requests; using osu.Game.Overlays; +using osu.Game.Overlays.Login; using osu.Game.Users.Drawables; using osuTK.Input; @@ -18,6 +21,8 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public partial class TestSceneLoginOverlay : OsuManualInputManagerTestScene { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + private LoginOverlay loginOverlay = null!; [BackgroundDependencyLoader] @@ -40,9 +45,69 @@ namespace osu.Game.Tests.Visual.Menus public void TestLoginSuccess() { AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "88800088") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + return true; + } + + return false; + }); + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "88800088"); + assertAPIState(APIState.Online); + AddStep("clear handler", () => dummyAPI.HandleRequest = null); + } + + private void assertAPIState(APIState expected) => + AddUntilStep($"login state is {expected}", () => API.State.Value, () => Is.EqualTo(expected)); + + [Test] + public void TestVerificationFailure() + { + bool verificationHandled = false; + AddStep("reset flag", () => verificationHandled = false); + AddStep("logout", () => API.Logout()); + assertAPIState(APIState.Offline); + + AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); + AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("set up verification handling", () => dummyAPI.HandleRequest = req => + { + switch (req) + { + case VerifySessionRequest verifySessionRequest: + if (verifySessionRequest.VerificationKey == "88800088") + verifySessionRequest.TriggerSuccess(); + else + verifySessionRequest.TriggerFailure(new WebException()); + verificationHandled = true; + return true; + } + + return false; + }); + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "abcdefgh"); + AddUntilStep("wait for verification handled", () => verificationHandled); + assertAPIState(APIState.RequiresSecondFactorAuth); + AddStep("clear handler", () => dummyAPI.HandleRequest = null); } [Test] @@ -78,6 +143,12 @@ namespace osu.Game.Tests.Visual.Menus AddStep("enter password", () => loginOverlay.ChildrenOfType().First().Text = "password"); AddStep("submit", () => loginOverlay.ChildrenOfType().First(b => b.Text.ToString() == "Sign in").TriggerClick()); + assertAPIState(APIState.RequiresSecondFactorAuth); + AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType().SingleOrDefault(), () => Is.Not.Null); + + AddStep("enter code", () => loginOverlay.ChildrenOfType().First().Text = "88800088"); + assertAPIState(APIState.Online); + AddStep("click on flag", () => { InputManager.MoveMouseTo(loginOverlay.ChildrenOfType().First()); diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs index 2bdfc8959d..f0506ed35c 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbarUserButton.cs @@ -16,6 +16,8 @@ namespace osu.Game.Tests.Visual.Menus [TestFixture] public partial class TestSceneToolbarUserButton : OsuManualInputManagerTestScene { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + public TestSceneToolbarUserButton() { Container mainContainer; @@ -69,18 +71,20 @@ namespace osu.Game.Tests.Visual.Menus [Test] public void TestLoginLogout() { - AddStep("Log out", () => ((DummyAPIAccess)API).Logout()); - AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + AddStep("Log out", () => dummyAPI.Logout()); + AddStep("Log in", () => dummyAPI.Login("wang", "jang")); + AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh")); } [Test] public void TestStates() { - AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang")); + AddStep("Log in", () => dummyAPI.Login("wang", "jang")); + AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh")); foreach (var state in Enum.GetValues()) { - AddStep($"Change state to {state}", () => ((DummyAPIAccess)API).SetState(state)); + AddStep($"Change state to {state}", () => dummyAPI.SetState(state)); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 747805edc8..8c7576ff52 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -698,7 +698,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { var scoreInfo = ((ResultsScreen)multiplayerComponents.CurrentScreen).Score; - return !scoreInfo.Passed && scoreInfo.Rank == ScoreRank.F; + return scoreInfo?.Passed == false && scoreInfo.Rank == ScoreRank.F; }); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs index cbeff770c9..aaf85dab7c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs @@ -3,13 +3,18 @@ #nullable disable +using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.Play; @@ -19,14 +24,37 @@ namespace osu.Game.Tests.Visual.Multiplayer { private MultiplayerPlayer player; - [SetUpSteps] - public override void SetUpSteps() + [Test] + public void TestGameplay() { - base.SetUpSteps(); + setup(); + AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value); + } + + [Test] + public void TestFail() + { + setup(() => new[] { new OsuModAutopilot() }); + + AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value); + AddStep("set health zero", () => player.ChildrenOfType().Single().Health.Value = 0); + AddUntilStep("wait for fail", () => player.ChildrenOfType().Single().HasFailed); + AddAssert("fail animation not shown", () => !player.GameplayState.HasFailed); + + // ensure that even after reaching a failed state, score processor keeps accounting for new hit results. + // the testing method used here (autopilot + hold key) is sort-of dodgy, but works enough. + AddAssert("score is zero", () => player.GameplayState.ScoreProcessor.TotalScore.Value == 0); + AddStep("hold key", () => player.ChildrenOfType().First().TriggerPressed(OsuAction.LeftButton)); + AddUntilStep("score changed", () => player.GameplayState.ScoreProcessor.TotalScore.Value > 0); + } + + private void setup(Func> mods = null) + { AddStep("set beatmap", () => { Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); + SelectedMods.Value = mods?.Invoke() ?? Array.Empty(); }); AddStep("Start track playing", () => @@ -52,11 +80,5 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("gameplay clock is not paused", () => !player.ChildrenOfType().Single().IsPaused.Value); AddAssert("gameplay clock is running", () => player.ChildrenOfType().Single().IsRunning); } - - [Test] - public void TestGameplay() - { - AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value); - } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index 459a8b0df5..6590339311 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -193,7 +193,7 @@ namespace osu.Game.Tests.Visual.Navigation case ScorePresentType.Results: AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen); AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen); - AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score.Equals(getImport())); + AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score!.Equals(getImport())); AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Ruleset)); break; diff --git a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs index 0f920643f0..79fb063ea9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneAccountCreationOverlay.cs @@ -10,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.AccountCreation; @@ -59,7 +60,11 @@ namespace osu.Game.Tests.Visual.Online AddStep("click button", () => accountCreation.ChildrenOfType().Single().TriggerClick()); AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType().SingleOrDefault()?.IsPresent == true); - AddStep("log back in", () => API.Login("dummy", "password")); + AddStep("log back in", () => + { + API.Login("dummy", "password"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden); } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs index 10fdffb8e1..f47322b9e0 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentActions.cs @@ -67,6 +67,7 @@ namespace osu.Game.Tests.Visual.Online Schedule(() => { API.Login("test", "test"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); Child = commentsContainer = new CommentsContainer(); }); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs index 3954fd5cff..0acf8336e3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFavouriteButton.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet.Buttons; using osuTK; @@ -34,14 +35,22 @@ namespace osu.Game.Tests.Visual.Online AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 }); AddStep("log out", () => API.Logout()); checkEnabled(false); - AddStep("log in", () => API.Login("test", "test")); + AddStep("log in", () => + { + API.Login("test", "test"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); checkEnabled(true); } [Test] public void TestBeatmapChange() { - AddStep("log in", () => API.Login("test", "test")); + AddStep("log in", () => + { + API.Login("test", "test"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + }); AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 }); checkEnabled(true); AddStep("set invalid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs index be819afa3e..3607b37c7e 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.Online else { int userId = int.Parse(getUserRequest.Lookup); - string rulesetName = getUserRequest.Ruleset.ShortName; + string rulesetName = getUserRequest.Ruleset!.ShortName; var response = new APIUser { Id = userId, @@ -177,7 +177,11 @@ namespace osu.Game.Tests.Visual.Online AddWaitStep("wait a bit", 5); AddAssert("update not received", () => update == null); - AddStep("log in user", () => dummyAPI.Login("user", "password")); + AddStep("log in user", () => + { + dummyAPI.Login("user", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 1375689075..bc8f75d4ce 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -52,7 +52,11 @@ namespace osu.Game.Tests.Visual.Online AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); AddToggleStep("toggle visibility", visible => profile.State.Value = visible ? Visibility.Visible : Visibility.Hidden); AddStep("log out", () => dummyAPI.Logout()); - AddStep("log back in", () => dummyAPI.Login("username", "password")); + AddStep("log back in", () => + { + dummyAPI.Login("username", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); } [Test] @@ -98,7 +102,11 @@ namespace osu.Game.Tests.Visual.Online }); AddStep("logout", () => dummyAPI.Logout()); AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 })); - AddStep("login", () => dummyAPI.Login("username", "password")); + AddStep("login", () => + { + dummyAPI.Login("username", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); AddWaitStep("wait some", 3); AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER)); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs index ce1a9ac6a7..488902c417 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Game.Overlays; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; namespace osu.Game.Tests.Visual.Online { @@ -72,7 +73,11 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible); } - private void logIn() => API.Login("localUser", "password"); + private void logIn() + { + API.Login("localUser", "password"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); + } private Comment getUserComment() => new Comment { diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index e805b03542..25ee20b089 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -420,7 +420,7 @@ namespace osu.Game.Tests.Visual.Playlists public new LoadingSpinner RightSpinner => base.RightSpinner; public new ScorePanelList ScorePanelList => base.ScorePanelList; - public TestResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) + public TestResultsScreen([CanBeNull] ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true) : base(score, roomId, playlistItem, allowRetry) { } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index 866e20d063..5671cbebd7 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -418,7 +418,7 @@ namespace osu.Game.Tests.Visual.Ranking public UnrankedSoloResultsScreen(ScoreInfo score) : base(score, true) { - Score.BeatmapInfo!.OnlineID = 0; + Score!.BeatmapInfo!.OnlineID = 0; Score.BeatmapInfo.Status = BeatmapOnlineStatus.Pending; } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs index b17024ae8f..e1d40882be 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneCommentEditor.cs @@ -125,7 +125,11 @@ namespace osu.Game.Tests.Visual.UserInterface assertLoggedOutState(); // moving from logged out -> logged in - AddStep("log back in", () => dummyAPI.Login("username", "password")); + AddStep("log back in", () => + { + dummyAPI.Login("username", "password"); + dummyAPI.AuthenticateSecondFactor("abcdefgh"); + }); assertLoggedInState(); } diff --git a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs index 706b05f5ad..4143a6d76d 100644 --- a/osu.Game/Graphics/Backgrounds/TrianglesV2.cs +++ b/osu.Game/Graphics/Backgrounds/TrianglesV2.cs @@ -1,17 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Utils; -using osuTK; using System; -using osu.Framework.Graphics.Shaders; -using osu.Framework.Graphics.Textures; -using osu.Framework.Graphics.Primitives; -using osu.Framework.Allocation; using System.Collections.Generic; -using osu.Framework.Graphics.Rendering; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Shaders; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osuTK; namespace osu.Game.Graphics.Backgrounds { @@ -27,6 +27,8 @@ namespace osu.Game.Graphics.Backgrounds public float Thickness { get; set; } = 0.02f; // No need for invalidation since it's happening in Update() + public float ScaleAdjust { get; set; } = 1; + /// /// Whether we should create new triangles as others expire. /// @@ -106,7 +108,7 @@ namespace osu.Game.Graphics.Backgrounds parts[i] = newParticle; - float bottomPos = parts[i].Position.Y + triangle_size * equilateral_triangle_ratio / DrawHeight; + float bottomPos = parts[i].Position.Y + triangle_size * ScaleAdjust * equilateral_triangle_ratio / DrawHeight; if (bottomPos < 0) parts.RemoveAt(i); } @@ -149,7 +151,7 @@ namespace osu.Game.Graphics.Backgrounds if (randomY) { // since triangles are drawn from the top - allow them to be positioned a bit above the screen - float maxOffset = triangle_size * equilateral_triangle_ratio / DrawHeight; + float maxOffset = triangle_size * ScaleAdjust * equilateral_triangle_ratio / DrawHeight; y = Interpolation.ValueAt(nextRandom(), -maxOffset, 1f, 0f, 1f); } @@ -188,7 +190,7 @@ namespace osu.Game.Graphics.Backgrounds private readonly List parts = new List(); - private readonly Vector2 triangleSize = new Vector2(1f, equilateral_triangle_ratio) * triangle_size; + private Vector2 triangleSize; private Vector2 size; private float thickness; @@ -209,6 +211,7 @@ namespace osu.Game.Graphics.Backgrounds size = Source.DrawSize; thickness = Source.Thickness; clampAxes = Source.ClampAxes; + triangleSize = new Vector2(1f, equilateral_triangle_ratio) * triangle_size * Source.ScaleAdjust; Quad triangleQuad = new Quad( Vector2Extensions.Transform(Vector2.Zero, DrawInfo.Matrix), diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 17bf8bcc37..d3707fe74d 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -21,7 +21,7 @@ using osu.Game.Configuration; using osu.Game.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Notifications; +using osu.Game.Online.Chat; using osu.Game.Online.Notifications.WebSocket; using osu.Game.Users; @@ -48,6 +48,8 @@ namespace osu.Game.Online.API public string ProvidedUsername { get; private set; } + public string SecondFactorCode { get; private set; } + private string password; public IBindable LocalUser => localUser; @@ -55,6 +57,8 @@ namespace osu.Game.Online.API public IBindable Activity => activity; public IBindable Statistics => statistics; + public INotificationsClient NotificationsClient { get; } + public Language Language => game.CurrentLanguage.Value; private Bindable localUser { get; } = new Bindable(createGuestUser()); @@ -82,6 +86,7 @@ namespace osu.Game.Online.API APIEndpointUrl = endpointConfiguration.APIEndpointUrl; WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl; + NotificationsClient = setUpNotificationsClient(); authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl); log = Logger.GetLogger(LoggingTarget.Network); @@ -114,6 +119,30 @@ namespace osu.Game.Online.API thread.Start(); } + private WebSocketNotificationsClientConnector setUpNotificationsClient() + { + var connector = new WebSocketNotificationsClientConnector(this); + + connector.MessageReceived += msg => + { + switch (msg.Event) + { + case @"verified": + if (state.Value == APIState.RequiresSecondFactorAuth) + state.Value = APIState.Online; + break; + + case @"logout": + if (state.Value == APIState.Online) + Logout(); + + break; + } + }; + + return connector; + } + private void onTokenChanged(ValueChangedEvent e) => config.SetValue(OsuSetting.Token, config.Get(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty); internal new void Schedule(Action action) => base.Schedule(action); @@ -197,6 +226,7 @@ namespace osu.Game.Online.API /// /// /// This method takes control of and transitions from to either + /// - (pending 2fa) /// - (successful connection) /// - (failed connection but retrying) /// - (failed and can't retry, clear credentials and require user interaction) @@ -204,8 +234,6 @@ namespace osu.Game.Online.API /// Whether the connection attempt was successful. private void attemptConnect() { - state.Value = APIState.Connecting; - if (localUser.IsDefault) { // Show a placeholder user if saved credentials are available. @@ -223,6 +251,7 @@ namespace osu.Game.Online.API if (!authentication.HasValidAccessToken) { + state.Value = APIState.Connecting; LastLoginError = null; try @@ -240,40 +269,79 @@ namespace osu.Game.Online.API } } - var userReq = new GetUserRequest(); - userReq.Failure += ex => + switch (state.Value) { - if (ex is APIException) + case APIState.RequiresSecondFactorAuth: { - LastLoginError = ex; - log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); - Logout(); + if (string.IsNullOrEmpty(SecondFactorCode)) + return; + + state.Value = APIState.Connecting; + LastLoginError = null; + + var verificationRequest = new VerifySessionRequest(SecondFactorCode); + + verificationRequest.Success += () => state.Value = APIState.Online; + verificationRequest.Failure += ex => + { + state.Value = APIState.RequiresSecondFactorAuth; + LastLoginError = ex; + SecondFactorCode = null; + }; + + if (!handleRequest(verificationRequest)) + { + state.Value = APIState.Failing; + return; + } + + if (state.Value != APIState.Online) + return; + + break; } - else if (ex is WebException webException && webException.Message == @"Unauthorized") + + default: { - log.Add(@"Login no longer valid"); - Logout(); + var userReq = new GetMeRequest(); + + userReq.Failure += ex => + { + if (ex is APIException) + { + LastLoginError = ex; + log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!"); + Logout(); + } + else if (ex is WebException webException && webException.Message == @"Unauthorized") + { + log.Add(@"Login no longer valid"); + Logout(); + } + else + { + state.Value = APIState.Failing; + } + }; + + userReq.Success += me => + { + me.Status.Value = configStatus.Value ?? UserStatus.Online; + + setLocalUser(me); + + state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth; + failureCount = 0; + }; + + if (!handleRequest(userReq)) + { + state.Value = APIState.Failing; + return; + } + + break; } - else - { - state.Value = APIState.Failing; - } - }; - userReq.Success += user => - { - user.Status.Value = configStatus.Value ?? UserStatus.Online; - - setLocalUser(user); - - // we're connected! - state.Value = APIState.Online; - failureCount = 0; - }; - - if (!handleRequest(userReq)) - { - state.Value = APIState.Failing; - return; } var friendsReq = new GetFriendsRequest(); @@ -321,11 +389,17 @@ namespace osu.Game.Online.API this.password = password; } + public void AuthenticateSecondFactor(string code) + { + Debug.Assert(State.Value == APIState.RequiresSecondFactorAuth); + + SecondFactorCode = code; + } + public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack); - public NotificationsClientConnector GetNotificationsConnector() => - new WebSocketNotificationsClientConnector(this); + public IChatClient GetChatClient() => new WebSocketChatClient(this); public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password) { @@ -507,6 +581,7 @@ namespace osu.Game.Online.API public void Logout() { password = null; + SecondFactorCode = null; authentication.Clear(); // Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present @@ -566,6 +641,11 @@ namespace osu.Game.Online.API /// Failing, + /// + /// Waiting on second factor authentication. + /// + RequiresSecondFactorAuth, + /// /// We are in the process of (re-)connecting. /// diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 4b4f8061e0..4962838bd9 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -7,8 +7,10 @@ using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Localisation; +using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Notifications; +using osu.Game.Online.Chat; +using osu.Game.Online.Notifications.WebSocket; using osu.Game.Tests; using osu.Game.Users; @@ -30,6 +32,9 @@ namespace osu.Game.Online.API public Bindable Statistics { get; } = new Bindable(); + public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient(); + INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient; + public Language Language => Language.en; public string AccessToken => "token"; @@ -57,6 +62,7 @@ namespace osu.Game.Online.API private bool shouldFailNextLogin; private bool stayConnectingNextLogin; + private bool requiredSecondFactorAuth = true; /// /// The current connectivity state of the API. @@ -117,13 +123,46 @@ namespace osu.Game.Online.API Id = DUMMY_USER_ID, }; + if (requiredSecondFactorAuth) + { + state.Value = APIState.RequiresSecondFactorAuth; + } + else + { + onSuccessfulLogin(); + requiredSecondFactorAuth = true; + } + } + + public void AuthenticateSecondFactor(string code) + { + var request = new VerifySessionRequest(code); + request.Failure += e => + { + state.Value = APIState.RequiresSecondFactorAuth; + LastLoginError = e; + }; + + state.Value = APIState.Connecting; + LastLoginError = null; + + // if no handler installed / handler can't handle verification, just assume that the server would verify for simplicity. + if (HandleRequest?.Invoke(request) != true) + onSuccessfulLogin(); + + // if a handler did handle this, make sure the verification actually passed. + if (request.CompletionState == APIRequestCompletionState.Completed) + onSuccessfulLogin(); + } + + private void onSuccessfulLogin() + { + state.Value = APIState.Online; Statistics.Value = new UserStatistics { GlobalRank = 1, CountryRank = 1 }; - - state.Value = APIState.Online; } public void Logout() @@ -144,7 +183,7 @@ namespace osu.Game.Online.API public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; - public NotificationsClientConnector GetNotificationsConnector() => new PollingNotificationsClientConnector(this); + public IChatClient GetChatClient() => new TestChatClientConnector(this); public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password) { @@ -159,6 +198,11 @@ namespace osu.Game.Online.API IBindable IAPIProvider.Activity => Activity; IBindable IAPIProvider.Statistics => Statistics; + /// + /// Skip 2FA requirement for next login. + /// + public void SkipSecondFactor() => requiredSecondFactorAuth = false; + /// /// During the next simulated login, the process will fail immediately. /// diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index b58d4a363a..66f124f7c3 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -6,7 +6,8 @@ using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Notifications; +using osu.Game.Online.Chat; +using osu.Game.Online.Notifications.WebSocket; using osu.Game.Users; namespace osu.Game.Online.API @@ -111,6 +112,12 @@ namespace osu.Game.Online.API /// The user's password. void Login(string username, string password); + /// + /// Provide a second-factor authentication code for authentication. + /// + /// The 2FA code. + void AuthenticateSecondFactor(string code); + /// /// Log out the current user. /// @@ -130,9 +137,14 @@ namespace osu.Game.Online.API IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true); /// - /// Constructs a new . + /// Accesses the used to receive asynchronous notifications from web. /// - NotificationsClientConnector GetNotificationsConnector(); + INotificationsClient NotificationsClient { get; } + + /// + /// Creates a instance to use in order to chat. + /// + IChatClient GetChatClient(); /// /// Create a new user account. This is a blocking operation. diff --git a/osu.Game/Online/API/Requests/GetMeRequest.cs b/osu.Game/Online/API/Requests/GetMeRequest.cs new file mode 100644 index 0000000000..aab7d7b2f1 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetMeRequest.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets; + +namespace osu.Game.Online.API.Requests +{ + public class GetMeRequest : APIRequest + { + public readonly IRulesetInfo? Ruleset; + + /// + /// Gets the currently logged-in user. + /// + /// The ruleset to get the user's info for. + public GetMeRequest(IRulesetInfo? ruleset = null) + { + Ruleset = ruleset; + } + + protected override string Target => $@"me/{Ruleset?.ShortName}"; + } +} diff --git a/osu.Game/Online/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index 7dcf75950e..90d3268e75 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; @@ -11,24 +9,17 @@ namespace osu.Game.Online.API.Requests public class GetUserRequest : APIRequest { public readonly string Lookup; - public readonly IRulesetInfo Ruleset; + public readonly IRulesetInfo? Ruleset; private readonly LookupType lookupType; - /// - /// Gets the currently logged-in user. - /// - public GetUserRequest() - { - } - /// /// Gets a user from their ID. /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(long? userId = null, IRulesetInfo ruleset = null) + public GetUserRequest(long? userId = null, IRulesetInfo? ruleset = null) { - Lookup = userId.ToString(); + Lookup = userId.ToString()!; lookupType = LookupType.Id; Ruleset = ruleset; } @@ -38,14 +29,14 @@ namespace osu.Game.Online.API.Requests /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(string username = null, IRulesetInfo ruleset = null) + public GetUserRequest(string username, IRulesetInfo? ruleset = null) { Lookup = username; lookupType = LookupType.Username; Ruleset = ruleset; } - protected override string Target => Lookup != null ? $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}" : $@"me/{Ruleset?.ShortName}"; + protected override string Target => $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}"; private enum LookupType { diff --git a/osu.Game/Online/API/Requests/ReissueVerificationCodeRequest.cs b/osu.Game/Online/API/Requests/ReissueVerificationCodeRequest.cs new file mode 100644 index 0000000000..f2a3cf0a16 --- /dev/null +++ b/osu.Game/Online/API/Requests/ReissueVerificationCodeRequest.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class ReissueVerificationCodeRequest : APIRequest + { + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + + return req; + } + + protected override string Target => @"session/verify/reissue"; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIMe.cs b/osu.Game/Online/API/Requests/Responses/APIMe.cs new file mode 100644 index 0000000000..3cbddbe5e7 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APIMe.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APIMe : APIUser + { + [JsonProperty("session_verified")] + public bool SessionVerified { get; set; } + } +} diff --git a/osu.Game/Online/API/Requests/VerifySessionRequest.cs b/osu.Game/Online/API/Requests/VerifySessionRequest.cs new file mode 100644 index 0000000000..b39ec5b79a --- /dev/null +++ b/osu.Game/Online/API/Requests/VerifySessionRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class VerifySessionRequest : APIRequest + { + public readonly string VerificationKey; + + public VerifySessionRequest(string verificationKey) + { + VerificationKey = verificationKey; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + req.AddParameter(@"verification_key", VerificationKey); + + return req; + } + + protected override string Target => @"session/verify"; + } +} diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 23989caae2..74e85c595c 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -16,7 +16,6 @@ using osu.Game.Database; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Online.Notifications; using osu.Game.Overlays.Chat.Listing; namespace osu.Game.Online.Chat @@ -64,13 +63,8 @@ namespace osu.Game.Online.Chat /// public IBindableList AvailableChannels => availableChannels; - /// - /// Whether the client responsible for channel notifications is connected. - /// - public bool NotificationsConnected => connector.IsConnected.Value; - private readonly IAPIProvider api; - private readonly NotificationsClientConnector connector; + private readonly IChatClient chatClient; [Resolved] private UserLookupCache users { get; set; } @@ -85,7 +79,7 @@ namespace osu.Game.Online.Chat { this.api = api; - connector = api.GetNotificationsConnector(); + chatClient = api.GetChatClient(); CurrentChannel.ValueChanged += currentChannelChanged; } @@ -93,15 +87,11 @@ namespace osu.Game.Online.Chat [BackgroundDependencyLoader] private void load() { - connector.ChannelJoined += ch => Schedule(() => joinChannel(ch)); - - connector.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false)); - - connector.NewMessages += msgs => Schedule(() => addMessages(msgs)); - - connector.PresenceReceived += () => Schedule(initializeChannels); - - connector.Start(); + chatClient.ChannelJoined += ch => Schedule(() => joinChannel(ch)); + chatClient.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false)); + chatClient.NewMessages += msgs => Schedule(() => addMessages(msgs)); + chatClient.PresenceReceived += () => Schedule(initializeChannels); + chatClient.RequestPresence(); apiState.BindTo(api.State); apiState.BindValueChanged(_ => SendAck(), true); @@ -655,7 +645,7 @@ namespace osu.Game.Online.Chat protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - connector?.Dispose(); + chatClient?.Dispose(); } } diff --git a/osu.Game/Online/Chat/IChatClient.cs b/osu.Game/Online/Chat/IChatClient.cs new file mode 100644 index 0000000000..290ee22710 --- /dev/null +++ b/osu.Game/Online/Chat/IChatClient.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; + +namespace osu.Game.Online.Chat +{ + /// + /// Interface for consuming online chat. + /// + public interface IChatClient : IDisposable + { + /// + /// Fired when a has been joined. + /// + event Action? ChannelJoined; + + /// + /// Fired when a has been parted. + /// + event Action? ChannelParted; + + /// + /// Fired when new s have arrived from the server. + /// + event Action>? NewMessages; + + /// + /// Requests presence information from the server. + /// + void RequestPresence(); + + /// + /// Fired when the initial user presence information has been received. + /// + event Action? PresenceReceived; + } +} diff --git a/osu.Game/Online/Chat/WebSocketChatClient.cs b/osu.Game/Online/Chat/WebSocketChatClient.cs new file mode 100644 index 0000000000..05d3b7b3ce --- /dev/null +++ b/osu.Game/Online/Chat/WebSocketChatClient.cs @@ -0,0 +1,144 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using Newtonsoft.Json; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Logging; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Notifications.WebSocket; + +namespace osu.Game.Online.Chat +{ + public class WebSocketChatClient : IChatClient + { + public event Action? ChannelJoined; + public event Action? ChannelParted; + public event Action>? NewMessages; + public event Action? PresenceReceived; + + private readonly IAPIProvider api; + private readonly INotificationsClient client; + private readonly ConcurrentDictionary channelsMap = new ConcurrentDictionary(); + + public WebSocketChatClient(IAPIProvider api) + { + this.api = api; + client = api.NotificationsClient; + client.IsConnected.BindValueChanged(start, true); + } + + private void start(ValueChangedEvent connected) + { + if (!connected.NewValue) + return; + + client.MessageReceived += onMessageReceived; + client.SendAsync(new StartChatRequest()).WaitSafely(); + RequestPresence(); + } + + public void RequestPresence() + { + var fetchReq = new GetUpdatesRequest(0); + + fetchReq.Success += updates => + { + if (updates?.Presence != null) + { + foreach (var channel in updates.Presence) + joinChannel(channel); + + handleMessages(updates.Messages); + } + + PresenceReceived?.Invoke(); + }; + + api.Queue(fetchReq); + } + + private void onMessageReceived(SocketMessage message) + { + switch (message.Event) + { + case @"chat.channel.join": + Debug.Assert(message.Data != null); + + Channel? joinedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(joinedChannel != null); + + joinChannel(joinedChannel); + break; + + case @"chat.channel.part": + Debug.Assert(message.Data != null); + + Channel? partedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(partedChannel != null); + + partChannel(partedChannel); + break; + + case @"chat.message.new": + Debug.Assert(message.Data != null); + + NewChatMessageData? messageData = JsonConvert.DeserializeObject(message.Data.ToString()); + Debug.Assert(messageData != null); + + foreach (var msg in messageData.Messages) + postToChannel(msg); + + break; + } + } + + private void postToChannel(Message message) + { + if (channelsMap.TryGetValue(message.ChannelId, out Channel? channel)) + { + joinChannel(channel); + NewMessages?.Invoke(new List { message }); + return; + } + + var req = new GetChannelRequest(message.ChannelId); + + req.Success += response => + { + joinChannel(channelsMap[message.ChannelId] = response.Channel); + NewMessages?.Invoke(new List { message }); + }; + req.Failure += ex => Logger.Error(ex, "Failed to join channel"); + + api.Queue(req); + } + + private void joinChannel(Channel ch) + { + ch.Joined.Value = true; + ChannelJoined?.Invoke(ch); + } + + private void partChannel(Channel channel) => ChannelParted?.Invoke(channel); + + private void handleMessages(List? messages) + { + if (messages == null) + return; + + NewMessages?.Invoke(messages); + } + + public void Dispose() + { + client.IsConnected.ValueChanged -= start; + client.MessageReceived -= onMessageReceived; + } + } +} diff --git a/osu.Game/Online/Notifications/NotificationsClientConnector.cs b/osu.Game/Online/Notifications/NotificationsClientConnector.cs deleted file mode 100644 index 34ce186cb8..0000000000 --- a/osu.Game/Online/Notifications/NotificationsClientConnector.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using osu.Game.Online.API; -using osu.Game.Online.Chat; - -namespace osu.Game.Online.Notifications -{ - /// - /// An abstract connector or s. - /// - public abstract class NotificationsClientConnector : PersistentEndpointClientConnector - { - public event Action? ChannelJoined; - public event Action? ChannelParted; - public event Action>? NewMessages; - public event Action? PresenceReceived; - - protected NotificationsClientConnector(IAPIProvider api) - : base(api) - { - } - - protected sealed override async Task BuildConnectionAsync(CancellationToken cancellationToken) - { - var client = await BuildNotificationClientAsync(cancellationToken).ConfigureAwait(false); - - client.ChannelJoined = c => ChannelJoined?.Invoke(c); - client.ChannelParted = c => ChannelParted?.Invoke(c); - client.NewMessages = m => NewMessages?.Invoke(m); - client.PresenceReceived = () => PresenceReceived?.Invoke(); - - return client; - } - - protected abstract Task BuildNotificationClientAsync(CancellationToken cancellationToken); - } -} diff --git a/osu.Game/Online/Notifications/WebSocket/DummyNotificationsClient.cs b/osu.Game/Online/Notifications/WebSocket/DummyNotificationsClient.cs new file mode 100644 index 0000000000..c1f3d25be7 --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/DummyNotificationsClient.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Bindables; + +namespace osu.Game.Online.Notifications.WebSocket +{ + public class DummyNotificationsClient : INotificationsClient + { + public IBindable IsConnected => new BindableBool(true); + + public event Action? MessageReceived; + + public Func? HandleMessage; + + public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default) + { + if (HandleMessage?.Invoke(message) != true) + throw new InvalidOperationException($@"{nameof(DummyNotificationsClient)} cannot process this message."); + + return Task.CompletedTask; + } + + public void Receive(SocketMessage message) => MessageReceived?.Invoke(message); + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/INotificationsClient.cs b/osu.Game/Online/Notifications/WebSocket/INotificationsClient.cs new file mode 100644 index 0000000000..9a222d0fdd --- /dev/null +++ b/osu.Game/Online/Notifications/WebSocket/INotificationsClient.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Bindables; + +namespace osu.Game.Online.Notifications.WebSocket +{ + /// + /// A client for asynchronous notifications sent by osu-web. + /// + public interface INotificationsClient + { + /// + /// Whether this is currently connected to a server. + /// + IBindable IsConnected { get; } + + /// + /// Invoked when a new arrives for this client. + /// + event Action? MessageReceived; + + /// + /// Sends a to the notification server. + /// + Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default); + } +} diff --git a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs index 73e5dcec6f..854f46880f 100644 --- a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs +++ b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClient.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Concurrent; using System.Diagnostics; using System.Net; using System.Net.WebSockets; @@ -12,23 +11,20 @@ using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Logging; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Online.Chat; namespace osu.Game.Online.Notifications.WebSocket { /// /// A notifications client which receives events via a websocket. /// - public class WebSocketNotificationsClient : NotificationsClient + public class WebSocketNotificationsClient : PersistentEndpointClient { + public event Action? MessageReceived; + private readonly ClientWebSocket socket; private readonly string endpoint; - private readonly ConcurrentDictionary channelsMap = new ConcurrentDictionary(); - public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint, IAPIProvider api) - : base(api) + public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint) { this.socket = socket; this.endpoint = endpoint; @@ -37,11 +33,7 @@ namespace osu.Game.Online.Notifications.WebSocket public override async Task ConnectAsync(CancellationToken cancellationToken) { await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false); - await sendMessage(new StartChatRequest(), CancellationToken.None).ConfigureAwait(false); - runReadLoop(cancellationToken); - - await base.ConnectAsync(cancellationToken).ConfigureAwait(false); } private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () => @@ -73,7 +65,7 @@ namespace osu.Game.Online.Notifications.WebSocket break; } - await onMessageReceivedAsync(message).ConfigureAwait(false); + MessageReceived?.Invoke(message); } break; @@ -105,69 +97,12 @@ namespace osu.Game.Online.Notifications.WebSocket } } - private async Task sendMessage(SocketMessage message, CancellationToken cancellationToken) + public async Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default) { if (socket.State != WebSocketState.Open) return; - await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); - } - - private async Task onMessageReceivedAsync(SocketMessage message) - { - switch (message.Event) - { - case @"chat.channel.join": - Debug.Assert(message.Data != null); - - Channel? joinedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); - Debug.Assert(joinedChannel != null); - - HandleChannelJoined(joinedChannel); - break; - - case @"chat.channel.part": - Debug.Assert(message.Data != null); - - Channel? partedChannel = JsonConvert.DeserializeObject(message.Data.ToString()); - Debug.Assert(partedChannel != null); - - HandleChannelParted(partedChannel); - break; - - case @"chat.message.new": - Debug.Assert(message.Data != null); - - NewChatMessageData? messageData = JsonConvert.DeserializeObject(message.Data.ToString()); - Debug.Assert(messageData != null); - - foreach (var msg in messageData.Messages) - HandleChannelJoined(await getChannel(msg.ChannelId).ConfigureAwait(false)); - - HandleMessages(messageData.Messages); - break; - } - } - - private async Task getChannel(long channelId) - { - if (channelsMap.TryGetValue(channelId, out Channel? channel)) - return channel; - - var tsc = new TaskCompletionSource(); - var req = new GetChannelRequest(channelId); - - req.Success += response => - { - channelsMap[channelId] = response.Channel; - tsc.SetResult(response.Channel); - }; - - req.Failure += ex => tsc.SetException(ex); - - API.Queue(req); - - return await tsc.Task.ConfigureAwait(false); + await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken ?? CancellationToken.None).ConfigureAwait(false); } public override async ValueTask DisposeAsync() diff --git a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs index f50369a06c..73fe29d441 100644 --- a/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs +++ b/osu.Game/Online/Notifications/WebSocket/WebSocketNotificationsClientConnector.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Net; using System.Net.WebSockets; using System.Threading; @@ -13,17 +14,20 @@ namespace osu.Game.Online.Notifications.WebSocket /// /// A connector for s that receive events via a websocket. /// - public class WebSocketNotificationsClientConnector : NotificationsClientConnector + public class WebSocketNotificationsClientConnector : PersistentEndpointClientConnector, INotificationsClient { + public event Action? MessageReceived; + private readonly IAPIProvider api; public WebSocketNotificationsClientConnector(IAPIProvider api) : base(api) { this.api = api; + Start(); } - protected override async Task BuildNotificationClientAsync(CancellationToken cancellationToken) + protected override async Task BuildConnectionAsync(CancellationToken cancellationToken) { var tcs = new TaskCompletionSource(); @@ -40,7 +44,17 @@ namespace osu.Game.Online.Notifications.WebSocket if (socket.Options.Proxy != null) socket.Options.Proxy.Credentials = CredentialCache.DefaultCredentials; - return new WebSocketNotificationsClient(socket, endpoint, api); + var client = new WebSocketNotificationsClient(socket, endpoint); + client.MessageReceived += msg => MessageReceived?.Invoke(msg); + return client; + } + + public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default) + { + if (CurrentConnection is not WebSocketNotificationsClient webSocketClient) + return Task.CompletedTask; + + return webSocketClient.SendAsync(message, cancellationToken); } } } diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index c36e4ab894..dda430ce6f 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -11,6 +11,7 @@ using osu.Framework.Screens; using osu.Game.Online.API; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Notifications.WebSocket; using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; @@ -25,6 +26,8 @@ namespace osu.Game.Online { private readonly Func getCurrentScreen; + private INotificationsClient notificationsClient = null!; + [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; @@ -55,9 +58,11 @@ namespace osu.Game.Online private void load(IAPIProvider api) { apiState = api.State.GetBoundCopy(); + notificationsClient = api.NotificationsClient; multiplayerState = multiplayerClient.IsConnected.GetBoundCopy(); spectatorState = spectatorClient.IsConnected.GetBoundCopy(); + notificationsClient.MessageReceived += notifyAboutForcedDisconnection; multiplayerClient.Disconnecting += notifyAboutForcedDisconnection; spectatorClient.Disconnecting += notifyAboutForcedDisconnection; metadataClient.Disconnecting += notifyAboutForcedDisconnection; @@ -127,10 +132,27 @@ namespace osu.Game.Online }); } + private void notifyAboutForcedDisconnection(SocketMessage obj) + { + if (obj.Event != @"logout") return; + + if (userNotified) return; + + userNotified = true; + notificationOverlay?.Post(new SimpleErrorNotification + { + Icon = FontAwesome.Solid.ExclamationCircle, + Text = "You have been logged out due to a change to your account. Please log in again." + }); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + if (notificationsClient.IsNotNull()) + notificationsClient.MessageReceived += notifyAboutForcedDisconnection; + if (spectatorClient.IsNotNull()) spectatorClient.Disconnecting -= notifyAboutForcedDisconnection; diff --git a/osu.Game/Online/OnlineViewContainer.cs b/osu.Game/Online/OnlineViewContainer.cs index 46f64fbb61..824da152b2 100644 --- a/osu.Game/Online/OnlineViewContainer.cs +++ b/osu.Game/Online/OnlineViewContainer.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -79,10 +80,14 @@ namespace osu.Game.Online case APIState.Failing: case APIState.Connecting: + case APIState.RequiresSecondFactorAuth: PopContentOut(Content); LoadingSpinner.Show(); placeholder.FadeOut(transform_duration / 2, Easing.OutQuint); break; + + default: + throw new ArgumentOutOfRangeException(); } }); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 4e465f59df..2208f7d7ca 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -340,10 +340,6 @@ namespace osu.Game dependencies.Cache(beatmapCache = new BeatmapLookupCache()); base.Content.Add(beatmapCache); - var scorePerformanceManager = new ScorePerformanceCache(); - dependencies.Cache(scorePerformanceManager); - base.Content.Add(scorePerformanceManager); - dependencies.CacheAs(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore)); var powerStatus = CreateBatteryInfo(); diff --git a/osu.Game/Overlays/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index ef2e055eae..576ee92b48 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -118,12 +119,16 @@ namespace osu.Game.Overlays break; case APIState.Connecting: + case APIState.RequiresSecondFactorAuth: break; case APIState.Online: scheduledHide?.Cancel(); scheduledHide = Schedule(Hide); break; + + default: + throw new ArgumentOutOfRangeException(); } } } diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index 0eef55162f..80dfca93d2 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -32,13 +32,7 @@ namespace osu.Game.Overlays.Login public Action? RequestHide; - private void performLogin() - { - if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) - api.Login(username.Text, password.Text); - else - shakeSignIn.Shake(); - } + public override bool AcceptsFocus => true; [BackgroundDependencyLoader(permitNulls: true)] private void load(OsuConfigManager config, AccountCreationOverlay accountCreation) @@ -144,7 +138,13 @@ namespace osu.Game.Overlays.Login } } - public override bool AcceptsFocus => true; + private void performLogin() + { + if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text)) + api.Login(username.Text, password.Text); + else + shakeSignIn.Shake(); + } protected override bool OnClick(ClickEvent e) => true; diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index ea65656eb1..25bf612bc3 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Login { private bool bounding = true; - private LoginForm? form; + private Drawable? form; [Resolved] private OsuColour colours { get; set; } = null!; @@ -81,6 +81,10 @@ namespace osu.Game.Overlays.Login }; break; + case APIState.RequiresSecondFactorAuth: + Child = form = new SecondFactorAuthForm(); + break; + case APIState.Failing: case APIState.Connecting: LinkFlowContainer linkFlow; diff --git a/osu.Game/Overlays/Login/SecondFactorAuthForm.cs b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs new file mode 100644 index 0000000000..dcd3119f33 --- /dev/null +++ b/osu.Game/Overlays/Login/SecondFactorAuthForm.cs @@ -0,0 +1,147 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Logging; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Overlays.Settings; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Overlays.Login +{ + public partial class SecondFactorAuthForm : Container + { + private OsuTextBox codeTextBox = null!; + private LinkFlowContainer explainText = null!; + private ErrorTextFlowContainer errorText = null!; + + private LoadingLayer loading = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, SettingsSection.ITEM_SPACING), + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, SettingsSection.ITEM_SPACING), + Children = new Drawable[] + { + new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = "An email has been sent to you with a verification code. Enter the code.", + }, + codeTextBox = new OsuTextBox + { + PlaceholderText = "Enter code", + RelativeSizeAxes = Axes.X, + TabbableContentContainer = this, + }, + explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + errorText = new ErrorTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Alpha = 0, + }, + }, + }, + new LinkFlowContainer + { + Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }, + loading = new LoadingLayer(true) + { + Padding = new MarginPadding { Vertical = -SettingsSection.ITEM_SPACING }, + } + }; + + explainText.AddParagraph(UserVerificationStrings.BoxInfoCheckSpam); + // We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something). + explainText.AddParagraph("If you can't access your email or have forgotten what you used, please follow the "); + explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.WebsiteRootUrl}/home/password-reset"); + explainText.AddText(". You can also "); + explainText.AddLink(UserVerificationStrings.BoxInfoReissueLink, () => + { + loading.Show(); + + var reissueRequest = new ReissueVerificationCodeRequest(); + reissueRequest.Failure += ex => + { + Logger.Error(ex, @"Failed to retrieve new verification code."); + loading.Hide(); + }; + reissueRequest.Success += () => + { + loading.Hide(); + }; + + Task.Run(() => api.Perform(reissueRequest)); + }); + explainText.AddText(" or "); + explainText.AddLink(UserVerificationStrings.BoxInfoLogoutLink, () => { api.Logout(); }); + explainText.AddText("."); + + codeTextBox.Current.BindValueChanged(code => + { + if (code.NewValue.Length == 8) + { + api.AuthenticateSecondFactor(code.NewValue); + codeTextBox.Current.Disabled = true; + } + }); + + if (api.LastLoginError?.Message is string error) + { + errorText.Alpha = 1; + errorText.AddErrors(new[] { error }); + } + } + + public override bool AcceptsFocus => true; + + protected override bool OnClick(ClickEvent e) => true; + + protected override void OnFocus(FocusEvent e) + { + Schedule(() => { GetContainingInputManager().ChangeFocus(codeTextBox); }); + } + } +} diff --git a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs index 230974cd59..28521e3331 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarUserButton.cs @@ -99,6 +99,7 @@ namespace osu.Game.Overlays.Toolbar switch (state.NewValue) { + case APIState.RequiresSecondFactorAuth: case APIState.Connecting: TooltipText = ToolbarStrings.Connecting; spinner.Show(); diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs index ad9257d4f3..4563c264f7 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs @@ -21,21 +21,29 @@ namespace osu.Game.Rulesets.Difficulty { private readonly IBeatmap playableBeatmap; private readonly BeatmapDifficultyCache difficultyCache; - private readonly ScorePerformanceCache performanceCache; - public PerformanceBreakdownCalculator(IBeatmap playableBeatmap, BeatmapDifficultyCache difficultyCache, ScorePerformanceCache performanceCache) + public PerformanceBreakdownCalculator(IBeatmap playableBeatmap, BeatmapDifficultyCache difficultyCache) { this.playableBeatmap = playableBeatmap; this.difficultyCache = difficultyCache; - this.performanceCache = performanceCache; } [ItemCanBeNull] public async Task CalculateAsync(ScoreInfo score, CancellationToken cancellationToken = default) { + var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); + + var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); + + // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. + if (attributes?.Attributes == null || performanceCalculator == null) + return null; + + cancellationToken.ThrowIfCancellationRequested(); + PerformanceAttributes[] performanceArray = await Task.WhenAll( // compute actual performance - performanceCache.CalculatePerformanceAsync(score, cancellationToken), + performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken), // compute performance for perfect play getPerfectPerformance(score, cancellationToken) ).ConfigureAwait(false); @@ -88,8 +96,12 @@ namespace osu.Game.Rulesets.Difficulty cancellationToken ).ConfigureAwait(false); - // ScorePerformanceCache is not used to avoid caching multiple copies of essentially identical perfect performance attributes - return difficulty == null ? null : ruleset.CreatePerformanceCalculator()?.Calculate(perfectPlay, difficulty.Value.Attributes.AsNonNull()); + var performanceCalculator = ruleset.CreatePerformanceCalculator(); + + if (performanceCalculator == null || difficulty == null) + return null; + + return await performanceCalculator.CalculateAsync(perfectPlay, difficulty.Value.Attributes.AsNonNull(), cancellationToken).ConfigureAwait(false); }, cancellationToken); } diff --git a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs index f5e826f8c7..966da0ff12 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceCalculator.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading; +using System.Threading.Tasks; using osu.Game.Beatmaps; using osu.Game.Scoring; @@ -15,6 +17,9 @@ namespace osu.Game.Rulesets.Difficulty Ruleset = ruleset; } + public Task CalculateAsync(ScoreInfo score, DifficultyAttributes attributes, CancellationToken cancellationToken) + => Task.Run(() => CreatePerformanceAttributes(score, attributes), cancellationToken); + public PerformanceAttributes Calculate(ScoreInfo score, DifficultyAttributes attributes) => CreatePerformanceAttributes(score, attributes); diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index bce28361cb..5662fb2293 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -768,6 +768,13 @@ namespace osu.Game.Rulesets.Objects.Drawables if (CurrentSkin != null) CurrentSkin.SourceChanged -= skinSourceChanged; + + // Safeties against shooting in foot in cases where these are bound by external entities (like playfield) that don't clean up. + OnNestedDrawableCreated = null; + OnNewResult = null; + OnRevertResult = null; + DefaultsApplied = null; + HitObjectApplied = null; } public Bindable AnimationStartTime { get; } = new BindableDouble(); diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index ec2a4a31f6..ef8bd08bf4 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -38,6 +38,8 @@ namespace osu.Game.Rulesets.Objects /// /// Invoked after has completed on this . /// + // TODO: This has no implicit unbind flow. Currently, if a Playfield manages HitObjects it will leave a bound event on this and cause the + // playfield to remain in memory. public event Action DefaultsApplied; public readonly Bindable StartTimeBindable = new BindableDouble(); diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index a092829317..9d12daad04 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -181,6 +181,8 @@ namespace osu.Game.Rulesets.Scoring private readonly List hitEvents = new List(); private HitObject? lastHitObject; + public bool ApplyNewJudgementsWhenFailed { get; set; } + public ScoreProcessor(Ruleset ruleset) { Ruleset = ruleset; @@ -211,7 +213,7 @@ namespace osu.Game.Rulesets.Scoring result.ComboAtJudgement = Combo.Value; result.HighestComboAtJudgement = HighestCombo.Value; - if (result.FailedAtJudgement) + if (result.FailedAtJudgement && !ApplyNewJudgementsWhenFailed) return; ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1; @@ -267,7 +269,7 @@ namespace osu.Game.Rulesets.Scoring Combo.Value = result.ComboAtJudgement; HighestCombo.Value = result.HighestComboAtJudgement; - if (result.FailedAtJudgement) + if (result.FailedAtJudgement && !ApplyNewJudgementsWhenFailed) return; ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) - 1; diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 041c7a13ae..a08c3bab08 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -218,7 +218,7 @@ namespace osu.Game.Rulesets.UI { base.ReloadMappings(realmKeyBindings); - KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); + KeyBindings = KeyBindings.Where(static b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList(); RealmKeyBindingStore.ClearDuplicateBindings(KeyBindings); } } diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs deleted file mode 100644 index 1f2b1aeb95..0000000000 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Threading; -using System.Threading.Tasks; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Rulesets.Difficulty; - -namespace osu.Game.Scoring -{ - /// - /// A component which performs and acts as a central cache for performance calculations of locally databased scores. - /// Currently not persisted between game sessions. - /// - public partial class ScorePerformanceCache : MemoryCachingComponent - { - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } = null!; - - protected override bool CacheNullValues => false; - - /// - /// Calculates performance for the given . - /// - /// The score to do the calculation on. - /// An optional to cancel the operation. - public Task CalculatePerformanceAsync(ScoreInfo score, CancellationToken token = default) => - GetAsync(new PerformanceCacheLookup(score), token); - - protected override async Task ComputeValueAsync(PerformanceCacheLookup lookup, CancellationToken token = default) - { - var score = lookup.ScoreInfo; - - var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, token).ConfigureAwait(false); - - // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. - if (attributes?.Attributes == null) - return null; - - token.ThrowIfCancellationRequested(); - - return score.Ruleset.CreateInstance().CreatePerformanceCalculator()?.Calculate(score, attributes.Value.Attributes); - } - - public readonly struct PerformanceCacheLookup - { - public readonly ScoreInfo ScoreInfo; - - public PerformanceCacheLookup(ScoreInfo info) - { - ScoreInfo = info; - } - - public override int GetHashCode() - { - var hash = new HashCode(); - - hash.Add(ScoreInfo.Hash); - hash.Add(ScoreInfo.ID); - - return hash.ToHashCode(); - } - } - } -} diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 75ef8be02e..25101730e7 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; @@ -31,15 +32,13 @@ namespace osu.Game.Screens.Menu /// public partial class OsuLogo : BeatSyncedContainer { - public readonly Color4 OsuPink = Color4Extensions.FromHex(@"e967a1"); - private const double transition_length = 300; /// /// The osu! logo sprite has a shadow included in its texture. /// This adjustment vector is used to match the precise edge of the border of the logo. /// - public static readonly Vector2 SCALE_ADJUST = new Vector2(0.96f); + public static readonly Vector2 SCALE_ADJUST = new Vector2(0.94f); private readonly Sprite logo; private readonly CircularContainer logoContainer; @@ -58,7 +57,7 @@ namespace osu.Game.Screens.Menu private Sample sampleDownbeat; private readonly Container colourAndTriangles; - private readonly Triangles triangles; + private readonly TrianglesV2 triangles; /// /// Return value decides whether the logo should play its own sample for the click action. @@ -184,13 +183,16 @@ namespace osu.Game.Screens.Menu new Box { RelativeSizeAxes = Axes.Both, - Colour = OsuPink, + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex(@"ff66ab"), Color4Extensions.FromHex(@"cc5289")), }, - triangles = new Triangles + triangles = new TrianglesV2 { - TriangleScale = 4, - ColourLight = Color4Extensions.FromHex(@"ff7db7"), - ColourDark = Color4Extensions.FromHex(@"de5b95"), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Thickness = 0.009f, + ScaleAdjust = 3, + SpawnRatio = 1.4f, + Colour = ColourInfo.GradientVertical(Color4Extensions.FromHex(@"ff66ab"), Color4Extensions.FromHex(@"b6346f")), RelativeSizeAxes = Axes.Both, }, } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs index d9043df1d5..c5c536eae6 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayer.cs @@ -67,6 +67,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!LoadedBeatmapSuccessfully) return; + ScoreProcessor.ApplyNewJudgementsWhenFailed = true; + LoadComponentAsync(new GameplayChatDisplay(Room) { Expanded = { BindTarget = LeaderboardExpandedState }, diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index aa72394ac9..6a1924dea2 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -41,7 +41,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved] private RulesetStore rulesets { get; set; } - public PlaylistsResultsScreen(ScoreInfo score, long roomId, PlaylistItem playlistItem, bool allowRetry, bool allowWatchingReplay = true) + public PlaylistsResultsScreen([CanBeNull] ScoreInfo score, long roomId, PlaylistItem playlistItem, bool allowRetry, bool allowWatchingReplay = true) : base(score, allowRetry, allowWatchingReplay) { this.roomId = roomId; diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs index cb38854bca..7db3f9fd3c 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs @@ -114,12 +114,7 @@ namespace osu.Game.Screens.Play.HUD protected override void UpdateProgress(double progress, bool isIntro) { - bar.TrackTime = GameplayClock.CurrentTime; - - if (isIntro) - bar.CurrentTime = 0; - else - bar.CurrentTime = FrameStableClock.CurrentTime; + bar.Progress = isIntro ? 0 : progress; } } } diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs index beaee0e9ee..7a7870a775 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs @@ -3,96 +3,59 @@ using System; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; -using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics; using osuTK; namespace osu.Game.Screens.Play.HUD { - public partial class ArgonSongProgressBar : SliderBar + public partial class ArgonSongProgressBar : SongProgressBar { - public Action? OnSeek { get; set; } - // Parent will handle restricting the area of valid input. public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; private readonly float barHeight; private readonly RoundedBar playfieldBar; - private readonly RoundedBar catchupBar; + private readonly RoundedBar audioBar; private readonly Box background; private readonly ColourInfo mainColour; private ColourInfo catchUpColour; - public double StartTime - { - private get => CurrentNumber.MinValue; - set => CurrentNumber.MinValue = value; - } + public double Progress { get; set; } - public double EndTime - { - private get => CurrentNumber.MaxValue; - set => CurrentNumber.MaxValue = value; - } - - public double CurrentTime - { - private get => CurrentNumber.Value; - set => CurrentNumber.Value = value; - } - - public double TrackTime - { - private get => currentTrackTime.Value; - set => currentTrackTime.Value = value; - } - - private double length => EndTime - StartTime; - - private readonly BindableNumber currentTrackTime; - - public bool Interactive { get; set; } + private double trackTime => (EndTime - StartTime) * Progress; public ArgonSongProgressBar(float barHeight) { - currentTrackTime = new BindableDouble(); - setupAlternateValue(); - - StartTime = 0; - EndTime = 1; - RelativeSizeAxes = Axes.X; Height = this.barHeight = barHeight; CornerRadius = 5; Masking = true; - Children = new Drawable[] + InternalChildren = new Drawable[] { background = new Box { RelativeSizeAxes = Axes.Both, Alpha = 0, Colour = OsuColour.Gray(0.2f), + Depth = float.MaxValue, }, - catchupBar = new RoundedBar + audioBar = new RoundedBar { Name = "Audio bar", Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, CornerRadius = 5, - AlwaysPresent = true, RelativeSizeAxes = Axes.Both }, playfieldBar = new RoundedBar @@ -107,24 +70,6 @@ namespace osu.Game.Screens.Play.HUD }; } - private void setupAlternateValue() - { - CurrentNumber.MaxValueChanged += v => currentTrackTime.MaxValue = v; - CurrentNumber.MinValueChanged += v => currentTrackTime.MinValue = v; - CurrentNumber.PrecisionChanged += v => currentTrackTime.Precision = v; - } - - private float normalizedReference - { - get - { - if (EndTime - StartTime == 0) - return 1; - - return (float)((TrackTime - StartTime) / length); - } - } - [BackgroundDependencyLoader] private void load(OsuColour colours) { @@ -153,47 +98,28 @@ namespace osu.Game.Screens.Play.HUD base.OnHoverLost(e); } - protected override void UpdateValue(float value) - { - // Handled in Update - } - protected override void Update() { base.Update(); - playfieldBar.Length = (float)Interpolation.Lerp(playfieldBar.Length, NormalizedValue, Math.Clamp(Time.Elapsed / 40, 0, 1)); - catchupBar.Length = (float)Interpolation.Lerp(catchupBar.Length, normalizedReference, Math.Clamp(Time.Elapsed / 40, 0, 1)); + playfieldBar.Length = (float)Interpolation.Lerp(playfieldBar.Length, Progress, Math.Clamp(Time.Elapsed / 40, 0, 1)); + audioBar.Length = (float)Interpolation.Lerp(audioBar.Length, AudioProgress, Math.Clamp(Time.Elapsed / 40, 0, 1)); - if (TrackTime < CurrentTime) - ChangeChildDepth(catchupBar, -1); + if (trackTime > AudioTime) + ChangeInternalChildDepth(audioBar, -1); else - ChangeChildDepth(catchupBar, 0); + ChangeInternalChildDepth(audioBar, 1); - float timeDelta = (float)(Math.Abs(CurrentTime - TrackTime)); + float timeDelta = (float)Math.Abs(AudioTime - trackTime); const float colour_transition_threshold = 20000; - catchupBar.AccentColour = Interpolation.ValueAt( + audioBar.AccentColour = Interpolation.ValueAt( Math.Min(timeDelta, colour_transition_threshold), mainColour, catchUpColour, 0, colour_transition_threshold, Easing.OutQuint); - - catchupBar.Alpha = Math.Max(1, catchupBar.Length); - } - - private ScheduledDelegate? scheduledSeek; - - protected override void OnUserChange(double value) - { - scheduledSeek?.Cancel(); - scheduledSeek = Schedule(() => - { - if (Interactive) - OnSeek?.Invoke(value); - }); } private partial class RoundedBar : Container diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index 48809796f3..f01c11855c 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -98,12 +98,7 @@ namespace osu.Game.Screens.Play.HUD protected override void UpdateProgress(double progress, bool isIntro) { - bar.CurrentTime = GameplayClock.CurrentTime; - - if (isIntro) - graph.Progress = 0; - else - graph.Progress = (int)(graph.ColumnCount * progress); + graph.Progress = isIntro ? 0 : (int)(graph.ColumnCount * progress); } protected override void Update() diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgressBar.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgressBar.cs index 0e16067dcc..d5a6a75793 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgressBar.cs @@ -7,71 +7,27 @@ using osuTK.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.UserInterface; using osu.Framework.Utils; -using osu.Framework.Threading; namespace osu.Game.Screens.Play.HUD { - public partial class DefaultSongProgressBar : SliderBar + public partial class DefaultSongProgressBar : SongProgressBar { - /// - /// Action which is invoked when a seek is requested, with the proposed millisecond value for the seek operation. - /// - public Action? OnSeek { get; set; } - - /// - /// Whether the progress bar should allow interaction, ie. to perform seek operations. - /// - public bool Interactive - { - get => showHandle; - set - { - if (value == showHandle) - return; - - showHandle = value; - - handleBase.FadeTo(showHandle ? 1 : 0, 200); - } - } - public Color4 FillColour { set => fill.Colour = value; } - public double StartTime - { - set => CurrentNumber.MinValue = value; - } - - public double EndTime - { - set => CurrentNumber.MaxValue = value; - } - - public double CurrentTime - { - set => CurrentNumber.Value = value; - } - private readonly Box fill; private readonly Container handleBase; private readonly Container handleContainer; - private bool showHandle; - public DefaultSongProgressBar(float barHeight, float handleBarHeight, Vector2 handleSize) { - CurrentNumber.MinValue = 0; - CurrentNumber.MaxValue = 1; - RelativeSizeAxes = Axes.X; Height = barHeight + handleBarHeight + handleSize.Y; - Children = new Drawable[] + InternalChildren = new Drawable[] { new Box { @@ -130,9 +86,14 @@ namespace osu.Game.Screens.Play.HUD }; } - protected override void UpdateValue(float value) + public override bool Interactive { - // handled in update + get => base.Interactive; + set + { + base.Interactive = value; + handleBase.FadeTo(value ? 1 : 0, 200); + } } protected override void Update() @@ -140,22 +101,10 @@ namespace osu.Game.Screens.Play.HUD base.Update(); handleBase.Height = Height - handleContainer.Height; - float newX = (float)Interpolation.Lerp(handleBase.X, NormalizedValue * UsableWidth, Math.Clamp(Time.Elapsed / 40, 0, 1)); + float newX = (float)Interpolation.Lerp(handleBase.X, AudioProgress * DrawWidth, Math.Clamp(Time.Elapsed / 40, 0, 1)); fill.Width = newX; handleBase.X = newX; } - - private ScheduledDelegate? scheduledSeek; - - protected override void OnUserChange(double value) - { - scheduledSeek?.Cancel(); - scheduledSeek = Schedule(() => - { - if (showHandle) - OnSeek?.Invoke(value); - }); - } } } diff --git a/osu.Game/Screens/Play/HUD/SongProgress.cs b/osu.Game/Screens/Play/HUD/SongProgress.cs index 4391193df8..296306ec89 100644 --- a/osu.Game/Screens/Play/HUD/SongProgress.cs +++ b/osu.Game/Screens/Play/HUD/SongProgress.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -70,7 +71,13 @@ namespace osu.Game.Screens.Play.HUD protected double LastHitTime { get; private set; } + /// + /// Called every update frame with current progress information. + /// + /// Current (visual) progress through the beatmap (0..1). + /// If true, progress is (0..1) through the intro. protected abstract void UpdateProgress(double progress, bool isIntro); + protected virtual void UpdateObjects(IEnumerable objects) { } [BackgroundDependencyLoader] @@ -96,7 +103,7 @@ namespace osu.Game.Screens.Play.HUD if (objects == null) return; - double currentTime = FrameStableClock.CurrentTime; + double currentTime = Math.Min(FrameStableClock.CurrentTime, LastHitTime); bool isInIntro = currentTime < FirstHitTime; diff --git a/osu.Game/Screens/Play/HUD/SongProgressBar.cs b/osu.Game/Screens/Play/HUD/SongProgressBar.cs new file mode 100644 index 0000000000..40c4e587b9 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SongProgressBar.cs @@ -0,0 +1,97 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public abstract partial class SongProgressBar : CompositeDrawable + { + /// + /// The current seek position of the audio, on a (0..1) range. + /// This is generally the seek target, which will eventually match the gameplay clock when it catches up. + /// + protected double AudioProgress => length == 0 ? 1 : AudioTime / length; + + /// + /// The current (non-frame-stable) audio time. + /// + protected double AudioTime => Math.Clamp(GameplayClock.CurrentTime - StartTime, 0.0, length); + + [Resolved] + protected IGameplayClock GameplayClock { get; private set; } = null!; + + /// + /// Action which is invoked when a seek is requested, with the proposed millisecond value for the seek operation. + /// + public Action? OnSeek { get; set; } + + /// + /// Whether the progress bar should allow interaction, ie. to perform seek operations. + /// + public virtual bool Interactive { get; set; } + + public double StartTime { get; set; } + + public double EndTime { get; set; } = 1.0; + + private double length => EndTime - StartTime; + + private bool handleClick; + + protected override bool OnMouseDown(MouseDownEvent e) + { + handleClick = true; + return base.OnMouseDown(e); + } + + protected override bool OnClick(ClickEvent e) + { + if (handleClick) + handleMouseInput(e); + + return true; + } + + protected override void OnDrag(DragEvent e) + { + handleMouseInput(e); + } + + protected override bool OnDragStart(DragStartEvent e) + { + Vector2 posDiff = e.MouseDownPosition - e.MousePosition; + + if (Math.Abs(posDiff.X) < Math.Abs(posDiff.Y)) + { + handleClick = false; + return false; + } + + handleMouseInput(e); + return true; + } + + private void handleMouseInput(UIEvent e) + { + if (!Interactive) + return; + + double relativeX = Math.Clamp(ToLocalSpace(e.ScreenSpaceMousePosition).X / DrawWidth, 0, 1); + onUserChange(StartTime + (EndTime - StartTime) * relativeX); + } + + private ScheduledDelegate? scheduledSeek; + + private void onUserChange(double value) + { + scheduledSeek?.Cancel(); + scheduledSeek = Schedule(() => OnSeek?.Invoke(value)); + } + } +} diff --git a/osu.Game/Screens/Play/SpectatorResultsScreen.cs b/osu.Game/Screens/Play/SpectatorResultsScreen.cs index 001d3b4bbc..393cbddb34 100644 --- a/osu.Game/Screens/Play/SpectatorResultsScreen.cs +++ b/osu.Game/Screens/Play/SpectatorResultsScreen.cs @@ -28,7 +28,7 @@ namespace osu.Game.Screens.Play private void userBeganPlaying(int userId, SpectatorState state) { - if (userId == Score.UserID) + if (userId == Score?.UserID) { Schedule(() => { diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index 22509b2cea..22c1e26d43 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -5,10 +5,11 @@ using System; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; @@ -32,7 +33,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics } [BackgroundDependencyLoader] - private void load(ScorePerformanceCache performanceCache) + private void load(BeatmapDifficultyCache difficultyCache, CancellationToken? cancellationToken) { if (score.PP.HasValue) { @@ -40,8 +41,19 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics } else { - performanceCache.CalculatePerformanceAsync(score, cancellationTokenSource.Token) - .ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely()?.Total)), cancellationTokenSource.Token); + Task.Run(async () => + { + var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, cancellationToken ?? default).ConfigureAwait(false); + var performanceCalculator = score.Ruleset.CreateInstance().CreatePerformanceCalculator(); + + // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. + if (attributes?.Attributes == null || performanceCalculator == null) + return; + + var result = await performanceCalculator.CalculateAsync(score, attributes.Value.Attributes, cancellationToken ?? default).ConfigureAwait(false); + + Schedule(() => setPerformanceValue(result.Total)); + }, cancellationToken ?? default); } } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 697d62ad6e..82dade40eb 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -45,6 +46,7 @@ namespace osu.Game.Screens.Ranking public readonly Bindable SelectedScore = new Bindable(); + [CanBeNull] public readonly ScoreInfo Score; protected ScorePanelList ScorePanelList { get; private set; } @@ -69,7 +71,7 @@ namespace osu.Game.Screens.Ranking private Sample popInSample; - protected ResultsScreen(ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true) + protected ResultsScreen([CanBeNull] ScoreInfo score, bool allowRetry, bool allowWatchingReplay = true) { Score = score; this.allowRetry = allowRetry; @@ -275,6 +277,11 @@ namespace osu.Game.Screens.Ranking if (base.OnExiting(e)) return true; + // This is a stop-gap safety against components holding references to gameplay after exiting the gameplay flow. + // Right now, HitEvents are only used up to the results screen. If this changes in the future we need to remove + // HitObject references from HitEvent. + Score?.HitEvents.Clear(); + this.FadeOut(100); return false; } diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index da08a26a58..22d631e137 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -45,12 +46,16 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); + Debug.Assert(Score != null); + if (ShowUserStatistics) statisticsSubscription = soloStatisticsWatcher.RegisterForStatisticsUpdateAfter(Score, update => statisticsUpdate.Value = update); } protected override StatisticsPanel CreateStatisticsPanel() { + Debug.Assert(Score != null); + if (ShowUserStatistics) { return new SoloStatisticsPanel(Score) @@ -64,6 +69,8 @@ namespace osu.Game.Screens.Ranking protected override APIRequest? FetchScores(Action>? scoresCallback) { + Debug.Assert(Score != null); + if (Score.BeatmapInfo!.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending) return null; diff --git a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs index ee0ce6183d..8b13f0951c 100644 --- a/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs +++ b/osu.Game/Screens/Ranking/Statistics/PerformanceBreakdownChart.cs @@ -39,9 +39,6 @@ namespace osu.Game.Screens.Ranking.Statistics private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); - [Resolved] - private ScorePerformanceCache performanceCache { get; set; } - [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } @@ -148,7 +145,7 @@ namespace osu.Game.Screens.Ranking.Statistics spinner.Show(); - new PerformanceBreakdownCalculator(playableBeatmap, difficultyCache, performanceCache) + new PerformanceBreakdownCalculator(playableBeatmap, difficultyCache) .CalculateAsync(score, cancellationTokenSource.Token) .ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely()))); } diff --git a/osu.Game/Online/Notifications/NotificationsClient.cs b/osu.Game/Tests/PollingChatClient.cs similarity index 59% rename from osu.Game/Online/Notifications/NotificationsClient.cs rename to osu.Game/Tests/PollingChatClient.cs index 5762e0e588..eb29b35c1d 100644 --- a/osu.Game/Online/Notifications/NotificationsClient.cs +++ b/osu.Game/Tests/PollingChatClient.cs @@ -6,34 +6,39 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Chat; -namespace osu.Game.Online.Notifications +namespace osu.Game.Tests { - /// - /// An abstract client which receives notification-related events (chat/notifications). - /// - public abstract class NotificationsClient : PersistentEndpointClient + public class PollingChatClient : PersistentEndpointClient { - public Action? ChannelJoined; - public Action? ChannelParted; - public Action>? NewMessages; - public Action? PresenceReceived; + public event Action? ChannelJoined; + public event Action>? NewMessages; + public event Action? PresenceReceived; - protected readonly IAPIProvider API; + private readonly IAPIProvider api; private long lastMessageId; - protected NotificationsClient(IAPIProvider api) + public PollingChatClient(IAPIProvider api) { - API = api; + this.api = api; } public override Task ConnectAsync(CancellationToken cancellationToken) { - API.Queue(CreateInitialFetchRequest(0)); + Task.Run(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + await api.PerformAsync(CreateInitialFetchRequest()).ConfigureAwait(true); + await Task.Delay(1000, cancellationToken).ConfigureAwait(true); + } + }, cancellationToken); + return Task.CompletedTask; } @@ -46,11 +51,11 @@ namespace osu.Game.Online.Notifications if (updates?.Presence != null) { foreach (var channel in updates.Presence) - HandleChannelJoined(channel); + handleChannelJoined(channel); //todo: handle left channels - HandleMessages(updates.Messages); + handleMessages(updates.Messages); } PresenceReceived?.Invoke(); @@ -59,15 +64,13 @@ namespace osu.Game.Online.Notifications return fetchReq; } - protected void HandleChannelJoined(Channel channel) + private void handleChannelJoined(Channel channel) { channel.Joined.Value = true; ChannelJoined?.Invoke(channel); } - protected void HandleChannelParted(Channel channel) => ChannelParted?.Invoke(channel); - - protected void HandleMessages(List? messages) + private void handleMessages(List? messages) { if (messages == null) return; diff --git a/osu.Game/Tests/PollingNotificationsClient.cs b/osu.Game/Tests/PollingNotificationsClient.cs deleted file mode 100644 index 450c763170..0000000000 --- a/osu.Game/Tests/PollingNotificationsClient.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Threading; -using System.Threading.Tasks; -using osu.Game.Online.API; -using osu.Game.Online.Notifications; - -namespace osu.Game.Tests -{ - /// - /// A notifications client which polls for new messages every second. - /// - public class PollingNotificationsClient : NotificationsClient - { - public PollingNotificationsClient(IAPIProvider api) - : base(api) - { - } - - public override Task ConnectAsync(CancellationToken cancellationToken) - { - Task.Run(async () => - { - while (!cancellationToken.IsCancellationRequested) - { - await API.PerformAsync(CreateInitialFetchRequest()).ConfigureAwait(true); - await Task.Delay(1000, cancellationToken).ConfigureAwait(true); - } - }, cancellationToken); - - return Task.CompletedTask; - } - } -} diff --git a/osu.Game/Tests/PollingNotificationsClientConnector.cs b/osu.Game/Tests/PollingNotificationsClientConnector.cs deleted file mode 100644 index 823fc9d157..0000000000 --- a/osu.Game/Tests/PollingNotificationsClientConnector.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Threading; -using System.Threading.Tasks; -using osu.Game.Online.API; -using osu.Game.Online.Notifications; - -namespace osu.Game.Tests -{ - /// - /// A connector for s that poll for new messages. - /// - public class PollingNotificationsClientConnector : NotificationsClientConnector - { - public PollingNotificationsClientConnector(IAPIProvider api) - : base(api) - { - } - - protected override Task BuildNotificationClientAsync(CancellationToken cancellationToken) - => Task.FromResult((NotificationsClient)new PollingNotificationsClient(API)); - } -} diff --git a/osu.Game/Tests/TestChatClientConnector.cs b/osu.Game/Tests/TestChatClientConnector.cs new file mode 100644 index 0000000000..40e15b5ef5 --- /dev/null +++ b/osu.Game/Tests/TestChatClientConnector.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.Chat; + +namespace osu.Game.Tests +{ + public class TestChatClientConnector : PersistentEndpointClientConnector, IChatClient + { + public event Action? ChannelJoined; + + public event Action? ChannelParted + { + add { } + remove { } + } + + public event Action>? NewMessages; + public event Action? PresenceReceived; + + public void RequestPresence() + { + // don't really need to do anything special if we poll every second anyway. + } + + public TestChatClientConnector(IAPIProvider api) + : base(api) + { + Start(); + } + + protected sealed override Task BuildConnectionAsync(CancellationToken cancellationToken) + { + var client = new PollingChatClient(API); + + client.ChannelJoined += c => ChannelJoined?.Invoke(c); + client.NewMessages += m => NewMessages?.Invoke(m); + client.PresenceReceived += () => PresenceReceived?.Invoke(); + + return Task.FromResult(client); + } + } +} diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 947305439e..6069fe4fb0 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -178,6 +178,7 @@ namespace osu.Game.Tests.Visual LocalConfig.SetValue(OsuSetting.ShowFirstRunSetup, false); API.Login("Rhythm Champion", "osu!"); + ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"); Dependencies.Get().SetValue(Static.MutedAudioNotificationShownOnce, true); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 1b1abe3971..6f71424130 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,8 +36,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index 98e8b136e5..bbcabc6360 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -23,6 +23,6 @@ iossimulator-x64 - + diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png new file mode 100644 index 0000000000..21f5f0f3a0 Binary files /dev/null and b/osu.iOS/Assets.xcassets/AppIcon.appiconset/300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/osu.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json index af4b103867..29df54b400 100644 --- a/osu.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/osu.iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1,14 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"iPhoneNotification2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"iPhoneNotification3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"iPhoneSettings2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"iPhoneSettings3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"iPhoneSpotlight2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"iPhoneSpotlight3x.png","scale":"3x"},{"size":"60x60","idiom":"iphone","filename":"iPhoneApp2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"iPhoneApp3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"iPadNotification1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"iPadNotification2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"iPadSettings1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"iPadSettings2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"iPadSpotlight1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"iPadSpotlight2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"iPadApp1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"iPadApp2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"iPadProApp2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"iOSAppStore.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file +{ + "images" : [ + { + "filename" : "300076680-5cbe0121-ed68-414f-9ddc-dd993ac97e62.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iOSAppStore.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iOSAppStore.png deleted file mode 100644 index 0e8bb029bc..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iOSAppStore.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadApp1x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadApp1x.png deleted file mode 100644 index 42fead2364..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadApp1x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadApp2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadApp2x.png deleted file mode 100644 index 785db50cb2..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadApp2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadNotification1x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadNotification1x.png deleted file mode 100644 index 8c483a0a7a..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadNotification1x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadNotification2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadNotification2x.png deleted file mode 100644 index a45b01b91c..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadNotification2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadProApp2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadProApp2x.png deleted file mode 100644 index d2ba8f3a7e..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadProApp2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSettings1x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSettings1x.png deleted file mode 100644 index 43d577040e..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSettings1x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSettings2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSettings2x.png deleted file mode 100644 index 1ebec1390b..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSettings2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSpotlight1x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSpotlight1x.png deleted file mode 100644 index a45b01b91c..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSpotlight1x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSpotlight2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSpotlight2x.png deleted file mode 100644 index 717603dd68..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPadSpotlight2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneApp2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneApp2x.png deleted file mode 100644 index 6b61c09db5..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneApp2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneApp3x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneApp3x.png deleted file mode 100644 index 78ef8d12b7..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneApp3x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneNotification2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneNotification2x.png deleted file mode 100644 index a45b01b91c..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneNotification2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneNotification3x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneNotification3x.png deleted file mode 100644 index 46ddf1179d..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneNotification3x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSettings2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSettings2x.png deleted file mode 100644 index 1ebec1390b..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSettings2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSettings3x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSettings3x.png deleted file mode 100644 index a8145f0246..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSettings3x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSpotlight2x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSpotlight2x.png deleted file mode 100644 index 717603dd68..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSpotlight2x.png and /dev/null differ diff --git a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSpotlight3x.png b/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSpotlight3x.png deleted file mode 100644 index 6b61c09db5..0000000000 Binary files a/osu.iOS/Assets.xcassets/AppIcon.appiconset/iPhoneSpotlight3x.png and /dev/null differ diff --git a/osu.iOS/iTunesArtwork b/osu.iOS/iTunesArtwork deleted file mode 100644 index 1939459992..0000000000 Binary files a/osu.iOS/iTunesArtwork and /dev/null differ diff --git a/osu.iOS/iTunesArtwork@2x b/osu.iOS/iTunesArtwork@2x deleted file mode 100644 index 0e8bb029bc..0000000000 Binary files a/osu.iOS/iTunesArtwork@2x and /dev/null differ