diff --git a/.github/workflows/client-android.yml b/.github/workflows/client-android.yml index d6ae8510..38a063c0 100644 --- a/.github/workflows/client-android.yml +++ b/.github/workflows/client-android.yml @@ -1,12 +1,65 @@ name: SysDVR-Client android -# This workflow is not implemented in the main branch yet, it's here so github lets us run it in the 6.0 branch - on: workflow_dispatch: jobs: build: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 \ No newline at end of file + steps + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.x + - name: Setup JDK 17 + uses: actions/setup-java@v3 + with: + java-version: 17 + distribution: temurin + - name: Setup Android SDK + uses: amyu/setup-android@v3 + with: + ndk-version: "25.2.9519653" + - name: Configure native cache + uses: actions/cache@v3 + with: + path: | + ${{ github.workspace }}/Client/Platform/Android/app/jni/cimgui + ${{ github.workspace }}/Client/Platform/Android/app/jni/libusb + ${{ github.workspace }}/Client/Platform/Android/app/jni/SDL + ${{ github.workspace }}/Client/Platform/Android/app/jni/SDL_Image + ${{ github.workspace }}/Client/Platform/Android/app/libs + ${{ github.workspace }}/Client/Platform/Android/bflat + key: deps-cache-${{ hashFiles('Client/Platform/Android/buildbinaries.sh') }}-${{ hashFiles('Client/Platform/Android/patches') }} + restore-keys: deps-cache-${{ hashFiles('Client/Platform/Android/buildbinaries.sh') }}-${{ hashFiles('Client/Platform/Android/patches') }} + - name: Build client native lib + run: | + export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/:$PATH + cd ./Client/Platform/Android + ls -la + ln -s $(pwd)/../Resources/resources $(pwd)/app/src/main/assets + chmod +x buildbinaries.sh + ./buildbinaries.sh + - name: Extract keystore from secrets + run: | + echo ${{ secrets.ANDROID_CI_CERT }} | base64 -d > /tmp/CI.jks + - name: Configure gradle cache + uses: actions/cache@v3 + with: + path: | + ${{ github.workspace }}/Client/Platform/Android/.gradle + ${{ github.workspace }}/Client/Platform/Android/app/build + key: build-cache-${{ hashFiles('Client/Platform/Android/buildbinaries.sh') }}-${{ hashFiles('Client/Platform/Android/patches') }} + restore-keys: build-cache- + - name: Build app with gradle + run: | + cd ./Client/Platform/Android + chmod +x gradlew + ./gradlew assembleRelease + env: + ANDROID_CI_KEY: ${{ secrets.ANDROID_CI_KEY }} + - uses: actions/upload-artifact@v3 + with: + name: SysDVR-Client + path: ./Client/Platform/Android/app/build/outputs/apk/release/app-release.apk \ No newline at end of file diff --git a/.github/workflows/client-cross.yml b/.github/workflows/client-cross.yml index 13d6a176..4a870320 100644 --- a/.github/workflows/client-cross.yml +++ b/.github/workflows/client-cross.yml @@ -1,12 +1,37 @@ name: SysDVR-Client dotnet build -# This workflow is not implemented in the main branch yet, it's here so github lets us run it in the 6.0 branch +# This is the "cross platform" build of SysDVR-Client, it only produces clean .net binaries without dependencies +# All the other builds are platform specific and produce a native AOT build with all the dependencies included on: - workflow_dispatch: + push: + branches: [ master ] + paths: + - Client/** + # but not the multiplatform builds + - '!Client/Platform/Android/**' + - '!Client/Platform/Linux/**' + pull_request: + branches: [ master ] + paths: + - Client/** + - '!Client/Platform/Android/**' + - '!Client/Platform/Linux/**' jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 \ No newline at end of file + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 8.x + - name: Run dotnet build + run: | + cd Client + dotnet build -c Release + - uses: actions/upload-artifact@v3 + with: + name: SysDVR-Client-dotnet.zip + path: Client/bin/Release/net7.0/ diff --git a/.github/workflows/client-flatpak.yml b/.github/workflows/client-flatpak.yml deleted file mode 100644 index 41a5c752..00000000 --- a/.github/workflows/client-flatpak.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: SysDVR-Client flatpak - -on: - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Install flatpak - run: | - sudo apt update - sudo apt install flatpak flatpak-builder -y - flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - - name: Setup .NET SDK - uses: actions/setup-dotnet@v3 - with: - dotnet-version: "6.x" - - name: Cache flatpak builds - uses: actions/cache@v3 - with: - path: Client/linux/.flatpak-builder - key: flatpak-${{ runner.os }} - - name: Run build script - run: | - cd Client/linux - chmod +x build-flatpak.sh - ./build-flatpak.sh - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: SysDVR-Client.flatpak - path: Client/linux/SysDVR-Client.flatpak diff --git a/.github/workflows/client-linux-x64.yml b/.github/workflows/client-linux-x64.yml index dd6c21a0..29ab17d6 100644 --- a/.github/workflows/client-linux-x64.yml +++ b/.github/workflows/client-linux-x64.yml @@ -1,7 +1,5 @@ name: SysDVR-Client flatpak linux x64 -# This workflow is not implemented in the main branch yet, it's here so github lets us run it in the 6.0 branch - on: workflow_dispatch: @@ -9,4 +7,28 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 \ No newline at end of file + - uses: actions/checkout@v3 + - name: Install flatpak + run: | + sudo apt update + sudo apt install flatpak flatpak-builder -y + flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "8.x" + - name: Cache flatpak builds + uses: actions/cache@v3 + with: + path: Client/Platform/Linux/.flatpak-builder + key: flatpak-${{ runner.os }} + - name: Run build script + run: | + cd Client/Platform/Linux + chmod +x build-flatpak.sh + ./build-flatpak.sh + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: SysDVR-Client.flatpak + path: Client/Platform/Linux/SysDVR-Client.flatpak \ No newline at end of file diff --git a/.github/workflows/client-macos.yml b/.github/workflows/client-macos.yml index 3c411ec3..ce45361f 100644 --- a/.github/workflows/client-macos.yml +++ b/.github/workflows/client-macos.yml @@ -1,12 +1,33 @@ name: SysDVR-Client macos (cross platform) -# This workflow is not implemented in the main branch yet, it's here so github lets us run it in the 6.0 branch - on: workflow_dispatch: jobs: build: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v3 + - name: Install dependencies + shell: bash + run: brew install dmg2img + - name: Setup .NET SDK + uses: actions/setup-dotnet@v3 + with: + dotnet-version: "8.x" + - name: Run build script + shell: bash + run: | + cd Client/Platform/ + chmod +x BuildMacos.sh + ./BuildMacos.sh + - name: Upload artifact (intel) + uses: actions/upload-artifact@v3 + with: + name: SysDVR-Cilent macos intel + path: Client/SysDVRClient-MacOs-x64.zip + - name: Upload artifact (arm) + uses: actions/upload-artifact@v3 + with: + name: SysDVR-Cilent macos arm + path: Client/SysDVRClient-MacOs-arm64.zip \ No newline at end of file diff --git a/.github/workflows/client-windows-x64.yml b/.github/workflows/client-windows-x64.yml index b385bab4..c00e6ca3 100644 --- a/.github/workflows/client-windows-x64.yml +++ b/.github/workflows/client-windows-x64.yml @@ -1,12 +1,23 @@ name: SysDVR-Client Windows x64 -# This workflow is not implemented in the main branch yet, it's here so github lets us run it in the 6.0 branch - on: workflow_dispatch: jobs: build: - runs-on: ubuntu-latest + runs-on: windows-latest steps: - - uses: actions/checkout@v3 \ No newline at end of file + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v2 + with: + dotnet-version: 8.x + - name: Run build script + shell: cmd + run: | + cd Client\Platform + .\BuildWindows.bat + - uses: actions/upload-artifact@v3 + with: + name: SysDVR-Client-Windows-x64 + path: Client\Client.7z \ No newline at end of file diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml deleted file mode 100644 index 6c720c46..00000000 --- a/.github/workflows/client.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: SysDVR-Client - -on: - workflow_dispatch: - push: - branches: [ master ] - paths: - - Client/** - - ClientGUI/** - - Libs/** - - ReleaseClient.bat - # but not the linux folder - - '!Client/linux/**' - pull_request: - branches: [ master ] - paths: - - Client/** - - ClientGUI/** - - Libs/** - - ReleaseClient.bat - - '!Client/linux/**' - -jobs: - build: - # Must use windows-2019 as windows-latest doesn't have .net framework 4.5 needed to build the GUI - runs-on: windows-2019 - steps: - - uses: actions/checkout@v3 - # SysDVR-Client uses .NET6 - - name: Setup .NET - uses: actions/setup-dotnet@v2 - with: - dotnet-version: 6.x - # SysDVR-ClientGUI uses net framework - - name: Add msbuild to PATH - uses: microsoft/setup-msbuild@v1.1 - - name: Run build script - run: .\ReleaseClient.bat - - uses: actions/upload-artifact@v3 - with: - name: SysDVR-Client - path: Client.7z diff --git a/.gitignore b/.gitignore index 16942f33..47deb61a 100644 --- a/.gitignore +++ b/.gitignore @@ -363,3 +363,5 @@ SysmoduleRelease/ *.o *.lst *.map +Client/Platform/Resources/resources/buildid.txt +Client/Platform/Android/app/src/main/assets diff --git a/Client/Client.csproj b/Client/Client.csproj index ee698b71..9acad705 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -1,51 +1,110 @@  + + AnyCPU + SysDVR-Client + https://github.com/exelix11/SysDVR + exelix + + https://github.com/exelix11/SysDVR + https://github.com/exelix11/SysDVR + 6.0 + SysDVR.Client + 6.0 + 6.0 + Debug;Release + Client.ico + Major + true + annotations + - - Exe - net6.0 - AnyCPU - SysDVR-Client - https://github.com/exelix11/SysDVR - exelix - - https://github.com/exelix11/SysDVR - https://github.com/exelix11/SysDVR - 5.5.0.6 - SysDVR.Client - 5.5.0.6 - 5.5.0.6 - Debug;Release - Client.ico - Major - annotations - + + + + + net8.0 + $(DefineConstants);ANDROID_LIB + true + + + + + + - - true - false - + + + + + + + Exe + net8.0 + + + + + + true + false + - - true - none - false - + + true + none + false + - - - - - - + + + $(DefineConstants);NETSTANDARD2_1_OR_GREATER;NETSTANDARD2;NETSTANDARD2_0;NETSTANDARD2_1 + - - - PreserveNewest - runtimes\%(RecursiveDir)\%(Filename)%(Extension) - - - - PreserveNewest - - + + + PreserveNewest + runtimes\%(RecursiveDir)\%(Filename)%(Extension) + + + + + + + + + + + + + + + + + unknown + + + + + + + + + + + $(IntermediateOutputPath)CustomAssemblyInfo.cs + + + + + + + + + <_Parameter1>BuildCommit + <_Parameter2>$(GitCommitHash) + + + + + diff --git a/Client/ClientApp.cs b/Client/ClientApp.cs new file mode 100644 index 00000000..46623b07 --- /dev/null +++ b/Client/ClientApp.cs @@ -0,0 +1,358 @@ +namespace SysDVR.Client; + +using ImGuiNET; +using SDL2; +using SysDVR.Client.Core; +using SysDVR.Client.GUI; +using SysDVR.Client.GUI.Components; +using SysDVR.Client.Platform; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Numerics; +using System.Runtime.InteropServices; +using System.Threading; + +public class ClientApp +{ + public static string Version; + + public float UiScale { get; private set; } + + readonly CommandLineOptions CommandLine; + + SDLContext sdlCtx => Program.SdlCtx; + + public ClientApp(CommandLineOptions args) + { + CommandLine = args; + ShowDebugInfo = Program.Options.Debug.Log; + } + + public ImFontPtr FontH1 { get; private set; } + public ImFontPtr FontH2 { get; private set; } + public ImFontPtr FontText { get; private set; } + + public bool IsPortrait { get; private set; } + + // Special input state + public bool ShiftDown { get; private set; } + + public event Action OnExit; + + // View state management + Stack Views = new(); + View? CurrentView = null; + ImGuiStyle DefaultStyle; + bool PendingViewChanges; + FramerateCap Cap = new(); + + // Async actions that must take place on the main thread outside of the rendering loop + List PendingActions = new(); + + // Debug window state + public bool ShowDebugInfo = false; + bool ShowImguiDemo; + + unsafe void BackupDeafaultStyle() + { + DefaultStyle = *ImGui.GetStyle().NativePtr; + } + + unsafe void RestoreDefaultStyle() + { + *ImGui.GetStyle().NativePtr = DefaultStyle; + } + + // Private view API, has immediate effect + void HandlePopView() + { + PendingViewChanges = false; + + CurrentView?.LeaveForeground(); + CurrentView?.Destroy(); + CurrentView = null; + if (Views.Count > 0 ) + { + CurrentView = Views.Pop(); + Cap.SetMode(CurrentView.RenderMode); + CurrentView?.ResolutionChanged(); + CurrentView?.EnterForeground(); + } + } + + void HandleReplaceView(View v) + { + PendingViewChanges = false; + + CurrentView?.LeaveForeground(); + CurrentView?.Destroy(); + CurrentView = v; + + Cap.SetMode(CurrentView.RenderMode); + CurrentView?.Created(); + CurrentView?.ResolutionChanged(); + CurrentView?.EnterForeground(); + } + + void HandlePushView(View v) + { + PendingViewChanges = false; + + if (CurrentView != null) + Views.Push(CurrentView); + + CurrentView?.LeaveForeground(); + + CurrentView = v; + Cap.SetMode(CurrentView.RenderMode); + CurrentView?.Created(); + CurrentView?.ResolutionChanged(); + CurrentView?.EnterForeground(); + } + + // Post an action to be handled in the main thread + public void PostAction(Action act) + { + lock (PendingActions) + { + PendingActions.Add(act); + } + } + + private void ExecutePendingActions() + { + lock (PendingActions) + { + for (int i = 0; i < PendingActions.Count; i++) + PendingActions[i](); + PendingActions.Clear(); + } + } + + // Public view management API, these are deferred as they can be called mid-drawing + public void PushView(View view) + { + if (PendingViewChanges) + throw new Exception("A view action is already scheduled"); + + PendingViewChanges = true; + PostAction(() => HandlePushView(view)); + } + + public void PopView() + { + if (PendingViewChanges) + throw new Exception("A view action is already scheduled"); + + PendingViewChanges = true; + PostAction(HandlePopView); + } + + public void ReplaceView(View view) + { + if (PendingViewChanges) + throw new Exception("A view action is already scheduled"); + + PendingViewChanges = true; + PostAction(() => HandleReplaceView(view)); + } + + // Called to simulate input when rendering mode is adaptive, we need it so the video decoding thread can force a re-render even when there is no user input active + public void KickRendering(bool important) + { + Cap.OnEvent(important); + } + + void DebugWindow() + { + ImGui.Begin("Info"); + ImGui.Text($"FPS: {ImGui.GetIO().Framerate} Cap {Cap.CapMode}"); + ImGui.Text(sdlCtx.GetDebugInfo()); + ImGui.Text($"scale: {UiScale} mode: {(IsPortrait ? "Portrait" : "Landscape")} stack: {Views.Count}"); + ImGui.Checkbox("Show imgui demo", ref ShowImguiDemo); + CurrentView?.DrawDebug(); + ImGui.End(); + + if (ShowImguiDemo) + ImGui.ShowDemoWindow(); + } + + private void UpdateSize() + { + var w = sdlCtx.WindowSize.X; + var h = sdlCtx.WindowSize.Y; + + IsPortrait = w / (float)h < 1.3; + + // Apply scale so the biggest dimension matches 1280 + var oldscale = UiScale; + var biggest = Math.Max(w, h); + UiScale = biggest / 1280f; + if (UiScale != oldscale) + { + RestoreDefaultStyle(); + ImGui.GetStyle().ScaleAllSizes(UiScale); + ImGui.GetIO().FontGlobalScale = UiScale / 2; + } + + CurrentView?.ResolutionChanged(); + } + + unsafe void UnsafeImguiInitialization() + { + ImGui.GetIO().NativePtr->IniFilename = null; + } + + internal void Initialize() + { + if (Program.Options.Debug.Log) + Console.WriteLine("Initializing app"); + + ImGui.CreateContext(); + + UnsafeImguiInitialization(); + ImGui.GetIO().ConfigFlags |= ImGuiConfigFlags.NavEnableKeyboard; + ImGui.GetIO().NavVisible = true; + + InitializeFonts(); + + BackupDeafaultStyle(); + } + + internal void InitializeFonts() + { + // Fonts are loaded at double the resolution to reduce blurriness when scaling on high DPI + const int FontMultiplier = 2; + const int FontTextSize = 30 * FontMultiplier; + const int FontH1Size = 45 * FontMultiplier; + const int FontH2Size = 40 * FontMultiplier; + + var fontData = Resources.ReadResouce(Resources.MainFont); + + unsafe + { + // TODO: Multilanguage support, we need to add unicode ranges of the languages we want to support + // However this also needs using a font file that includes them, we could try Google Noto but those come as multiple files + // and we would need to manually sort out which languages we want to support by unicode ranges + // There is also probably a limit in texture size cause using range 0x0020 to 0xFFFF seems to fail... + // For now let imgui figure it out on its own + ushort* fontRange = null; //stackalloc ushort[] { 0x0020, 0x1FFF, 0 }; + + fixed (byte* fontPtr = fontData) + { + FontText = ImGui.GetIO().Fonts.AddFontFromMemoryTTF(fontPtr, fontData.Length, FontTextSize, null, fontRange); + FontH1 = ImGui.GetIO().Fonts.AddFontFromMemoryTTF(fontPtr, fontData.Length, FontH1Size, null, fontRange); + FontH2 = ImGui.GetIO().Fonts.AddFontFromMemoryTTF(fontPtr, fontData.Length, FontH2Size, null, fontRange); + + // fontPtr and FontRange must be kept pinned until the atlases have actually been built or else bad things will happen + ImGui.GetIO().Fonts.Build(); + } + } + } + + internal void PushMainview() + { + // If no streaming has been requested boot into the main menu + if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.None) + HandlePushView(new MainView()); + // If mode = network and no IP specified, connect to the first available console + else if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.Network && string.IsNullOrWhiteSpace(CommandLine.NetStreamHostname)) + HandlePushView(new NetworkScanView(Program.Options.Streaming, CommandLine.ConsoleSerial ?? "")); // ConsoleSerial is null when non specified, make it "" so the network view takes it as a 'cnnect to anything command' + // If mode = network and IP specified, connect directly to that + else if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.Network) + HandlePushView(new ConnectingView(DeviceInfo.ForIp(CommandLine.NetStreamHostname), Program.Options.Streaming)); + else if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.Usb) + HandlePushView(new UsbDevicesView(Program.Options.Streaming, CommandLine.ConsoleSerial ?? "")); + else + { + Debugger.Break(); + } + } + + internal void EntryPoint() + { + sdlCtx.CreateWindow(CommandLine.WindowTitle); + + ImGuiSDL2Impl.InitForSDLRenderer(sdlCtx.WindowHandle, sdlCtx.RendererHandle); + ImGuiSDL2Impl.Init(sdlCtx.RendererHandle); + + if (CommandLine.LaunchFullscreen) + sdlCtx.SetFullScreen(true); + + UpdateSize(); + + PushMainview(); + + while (true) + { + ExecutePendingActions(); + + if (CurrentView is null) + break; + + GuiMessage msg = GuiMessage.None; + while ((msg = sdlCtx.PumpEvents(out var evt)) != GuiMessage.None) + { + Cap.OnEvent(true); + + if (msg == GuiMessage.Resize) + UpdateSize(); + else if (msg == GuiMessage.BackButton) + CurrentView.BackPressed(); + else if (msg == GuiMessage.KeyDown && evt.key.keysym.scancode is SDL.SDL_Scancode.SDL_SCANCODE_LSHIFT or SDL.SDL_Scancode.SDL_SCANCODE_RSHIFT) + ShiftDown = true; + else if (msg == GuiMessage.KeyUp && evt.key.keysym.scancode is SDL.SDL_Scancode.SDL_SCANCODE_LSHIFT or SDL.SDL_Scancode.SDL_SCANCODE_RSHIFT) + ShiftDown = false; + else if (msg == GuiMessage.KeyUp) + CurrentView.OnKeyPressed(evt.key.keysym); + else if (msg == GuiMessage.Quit) + goto break_main_loop; + + ImGuiSDL2Impl.ProcessEvent(in evt); + +#if ANDROID_LIB + if (ImGui.GetIO().WantTextInput) + sdlCtx.StartMobileTextInput(); + else if (!ImGui.GetIO().WantTextInput) + sdlCtx.StopMobileTextInput(); +#endif + } + + if (Cap.Cap()) + continue; + + ImGuiSDL2Impl.Renderer_NewFrame(); + ImGuiSDL2Impl.SDL2_NewFrame(); + ImGui.NewFrame(); + + CurrentView.Draw(); + + if (ShowDebugInfo) + DebugWindow(); + + ImGui.Render(); + + CurrentView.RawDraw(); + + ImGuiSDL2Impl.RenderDrawData(ImGui.GetDrawData()); + + sdlCtx.Render(); + } + break_main_loop: + + while (CurrentView != null) + { + HandlePopView(); + } + + OnExit?.Invoke(); + + ImGuiSDL2Impl.Renderer_Shutdown(); + ImGuiSDL2Impl.SDL2_Shutdown(); + // Seems to crash: + // ImGui.DestroyContext(ctx); + + sdlCtx.DestroyWindow(); + } +} \ No newline at end of file diff --git a/Client/CommandLineOptions.cs b/Client/CommandLineOptions.cs new file mode 100644 index 00000000..122ff09f --- /dev/null +++ b/Client/CommandLineOptions.cs @@ -0,0 +1,262 @@ +using LibUsbDotNet.LibUsb; +using SysDVR.Client.Core; +using SysDVR.Client.Platform; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; + +namespace SysDVR.Client +{ + public class CommandLineOptions + { + abstract record Option(string Name, bool StopParsing); + record OptionNoArg(string Name, Action Handle, bool StopParsing = false) : Option(Name, StopParsing); + record OptionArg(string Name, Action Handle, bool StopParsing = false) : Option(Name, StopParsing); + + static readonly Option[] Options = new Option[] + { + new OptionArg("--libdir", (x, v) => x.LibDir = v), + new OptionArg("--debug", (x, v) => x.DebugFlags = v), + + new OptionNoArg("--help", x => x.Help = true, true), + new OptionNoArg("-h", x => x.Help = true, true), + + new OptionNoArg("--version", x => x.Version = true, true), + new OptionNoArg("-v", x => x.Version = true, true), + + new OptionNoArg("--show-decoders", x => x.ShowDecoders = true, true), + new OptionNoArg("--debug-list", x => x.DebugList = true, true), + + new OptionNoArg("--no-audio", x => x.NoAudio = true), + new OptionNoArg("--no-video", x => x.NoVideo = true), + new OptionNoArg("--fullscreen", x => x.LaunchFullscreen = true), + new OptionArg("--title", (x, v) => x.WindowTitle = v), + + new OptionArg("--serial", (x, v) => x.ConsoleSerial = v), + + // Legacy usb arg + new OptionArg("--usb-serial", (x, v) => x.ConsoleSerial = v), + new OptionNoArg("--usb-debug", x => x.UsbLogging = UsbLogLevel.Debug), + new OptionNoArg("--usb-warn", x => x.UsbLogging = UsbLogLevel.Warning), + + // Advanced options + new OptionArg("--decoder", (x, v) => x.RequestedDecoderName = v), + + // Deprecated RTSP options + new OptionNoArg("--rtsp", x => x.RTSPDeprecationWarning = true), + new OptionNoArg("--rtsp-port", x => x.RTSPDeprecationWarning = true), + new OptionNoArg("--rtsp-rtsp-any-addr", x => x.RTSPDeprecationWarning = true), + + // Deprecated stdout options + new OptionNoArg("--mpv", x => x.LowLatencyDeprecationWarning = true), + new OptionNoArg("--stdout", x => x.LowLatencyDeprecationWarning = true), + + // Deprecated file option + new OptionNoArg("--file", x => x.FileDeprecationWarning = true), + +#if !ANDROID_LIB + // This only makes sense on desktop systems + new OptionNoArg("--legacy", x => x.LegacyPlayer= true), +#endif + }; + + record DebugOptionArg(string Name, string Description, Action Handle); + readonly static DebugOptionArg[] DebugOptions = new DebugOptionArg[] { + new ("log", "Enable verbose logging, auto enabled when a debugger is connected", x => x.Log = true), + new ("stats", "Print received packets info in real time", x => x.Stats = true), + new ("keyframe", "Decode NALs and print IDR frames", x => x.Keyframe = true), + new ("nal", "Decode all NALs and print the type", x => x.Nal = true), + new ("nosync", "Disable A/V synchronization", x => x.NoSync = true), + new ("noprot", "Disable dll-injection protection on Windows", x => x.NoProt = true), + new ("dynlib", "Enable verbose logging for native library loading", x => x.DynLib = true), + }; + + public static CommandLineOptions Parse(string[] args) + { + var opt = new CommandLineOptions(); + + opt.QuickLaunch = args.Length == 0; + if (opt.QuickLaunch) return opt; + + int argParseStart = 0; + + // We must handle all the following cases: + // Debug option but no streaming client.exe --debug log + // Streaming with other options client.exe usb --debug log ... + // Error on invalid streaming type client.exe invalid --debug log ... + if (!args[0].StartsWith("--")) + { + opt.StreamingMode = args[0] switch + { + "bridge" => StreamMode.Network, + "usb" => StreamMode.Usb, + "stub" => StreamMode.Stub, + _ => throw new Exception($"Unknown streaming mode {args[0]}") + }; + + argParseStart = 1; + } + + // In network mode also take the ip to connect to, this is now optional and if not specified it will connect to the first console that is detected by the network scanner + if (opt.StreamingMode == StreamMode.Network && args.Length > 1 && !args[1].StartsWith("--")) + { + opt.NetStreamHostname = args[1]; + argParseStart = 2; + } + + for (int i = argParseStart; i < args.Length; i++) + { + var hanlde = Options.FirstOrDefault(x => x.Name == args[i]); + if (hanlde == null) + continue; // Ignore unknown options to keep accepting old incompatible args + + if (hanlde is OptionNoArg noArg) + noArg.Handle(opt); + else if (hanlde is OptionArg arg) + arg.Handle(opt, args[++i]); + } + + return opt; + } + + // Special command line options that interrupt the normal cli parse flow + public bool ShowDecoders; + public bool Help; + public bool Version; + public bool DebugList; + public bool QuickLaunch; + + // Streaming options + public enum StreamMode + { + None, + Network, + Usb, + Stub, + }; + + public StreamMode StreamingMode = StreamMode.None; + + public bool NoVideo; + public bool NoAudio; + + public bool RTSPDeprecationWarning; + public bool LowLatencyDeprecationWarning; + public bool FileDeprecationWarning; + + public string? ConsoleSerial; + public string? NetStreamHostname; + + public bool LaunchFullscreen; + + // Other advanced options + public string? LibDir; + public string? DebugFlags; + public string? RequestedDecoderName; + public string? WindowTitle; + public UsbLogLevel? UsbLogging; + + public bool LegacyPlayer; + + public void ApplyOptionOverrides() + { + DynamicLibraryLoader.LibLoaderOverride = LibDir; + + if (NoVideo) + Program.Options.Streaming.Kind = StreamKind.Audio; + if (NoAudio) + Program.Options.Streaming.Kind = StreamKind.Video; + + if (UsbLogging.HasValue) + Program.Options.UsbLogging = UsbLogging.Value; + + if (DebugFlags is not null) + { + var dbg = DebugFlags.Split(',') + .Select(x => DebugOptions.FirstOrDefault(y => y.Name == x)) + .Where(x => x is not null) + .ToList(); + + dbg.ForEach(x => x.Handle(Program.Options.Debug)); + + // Force logging in case a debug option is specified + if (dbg.Any()) + Program.Options.Debug.Log = true; + } + + Program.Options.DecoderName = RequestedDecoderName; + } + + public void PrintDeprecationWarnings() + { + if (FileDeprecationWarning) + Console.WriteLine("The --file option has been removed starting from SysDVR 6.0, you can use the gameplay recording feature whitin the new player instead."); + if (LowLatencyDeprecationWarning) + Console.WriteLine("The --mpv and --stdout options have been removed starting from SysDVR 6.0, you should use the default player."); + if (RTSPDeprecationWarning) + Console.WriteLine("The --rtsp options have been removed starting from SysDVR 6.0, to stram over RTSP use simple network mode from your console."); + } + + public static string GetDebugFlagsList() + { + return "Available debug options: \n" + + string.Join("\n", DebugOptions.Select(x => $"\t{x.Name}: {x.Description}")); + + } + + public const string HelpMessage = @"Usage: +SysDVR-Client.exe [Source options] [Stream options] [Output options] + +Stream sources: + The source mode is how the client connects to SysDVR running on the console. Make sure to set the correct mode with SysDVR-Settings. + `usb` : Connects to SysDVR via USB, used if no source is specified. Remember to setup the driver as explained on the guide + `bridge [IP address]` : Connects to SysDVR via network at the specified IP address, requires a strong connection between the PC and switch (LAN or full signal wireless). If the IP address is not specified the client will connect to the first console it detects, you can also use the --serial option to pick a specific one by serial + Note that the `Simple network mode` option in SysDVR-Settings does not require the client, you must open it directly in a video player. + +Source options: + `--serial NX0000000` : When multiple consoles are plugged in via USB or are detected via LAN scanning use this option to automatically select one by serial number. + This also matches partial serials starting from the end, for example NX012345 will be matched by doing --usb-serial 45 + `--usb-warn` : Enables printing warnings from the usb stack, use it to debug USB issues + `--usb-debug` : Same as `--usb-warn` but more detailed + +Stream options: + `--no-video` : Disable video streaming, only streams audio + `--no-audio` : Disable audio streaming, only streams video + +Player options: + Since 6.0 SysDVR-Client only comes with the built-in player, the following options are available: + `--hw-acc` : Try to use hardware acceleration for decoding, this option uses the first detected decoder, it's recommended to manually specify the decoder name with --decoder + `--decoder ` : Use a specific decoder for ffmpeg decoding, you can see all supported codecs with --show-decoders + `--fullscreen` : Start in full screen mode. Press F11 to toggle manually + `--title ` : Adds the argument string to the title of the player window + `--legacy` : Use the old player without the GUI, not all the other options are available when this is used + `--debug ` : Enables debug options. Multiple options are comma separated for example: --debug log,stats + When a debugger is attached `log` is enabled by default. + +Extra options: + These options will not stream, they just print the output and then quit. + `--show-decoders` : Prints all video codecs available for the built-in video player + `--version` : Prints the version + `--libdir` : Overrides the dynami library loading path, use only if dotnet can't locate your ffmpeg/avcoded/SDL2 libraries automatically + this option effect depend on your platform, some libraries may still be handled by dotnet, it's better to use your loader path environment variable. + `--debug-list` : Prints all available debug options. + +Command examples: + SysDVR-Client.exe usb + Connects to switch via USB and streams video and audio in the built-in player + + SysDVR-Client.exe usb --rtsp + Connects to switch via USB and streams video and audio via rtsp at rtsp://127.0.0.1:6666/ + + SysDVR-Client.exe bridge 192.168.1.20 --no-video --rtsp-port 9090 + Connects to switch via network at 192.168.1.20 and streams the audio over rtsp at rtsp://127.0.0.1:9090/ + + SysDVR-Client.exe usb --mpv `C:\Program Files\mpv\mpv.com` + Connects to switch via USB and streams the video in low-latency mode via mpv +"; + + } +} diff --git a/Client/Core/DebugHelpers.cs b/Client/Core/DebugHelpers.cs new file mode 100644 index 00000000..4d6ec434 --- /dev/null +++ b/Client/Core/DebugHelpers.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SysDVR.Client.Core +{ + public class TimeTrace : IDisposable + { + Stopwatch sw = new Stopwatch(); + StringBuilder sb = new(); + + public TimeTrace Begin(string kind, string extra, string funcname) + { + sb.Clear(); + sb.Append($"[{kind}] [{funcname}] {extra} "); + sw.Restart(); + return this; + } + + public void Mark(string name) + { + sb.Append($"{sw.ElapsedMilliseconds} ms | {name} "); + sw.Restart(); + } + + // Abuses the using statement for RAII-like behavior, is not actually disposed + public void Dispose() + { + sw.Stop(); + sb.Append($"{sw.ElapsedMilliseconds} ms"); + Console.WriteLine(sb.ToString()); + } + } +} diff --git a/Client/Core/DebugOptions.cs b/Client/Core/DebugOptions.cs new file mode 100644 index 00000000..d4fc5831 --- /dev/null +++ b/Client/Core/DebugOptions.cs @@ -0,0 +1,124 @@ +using SysDVR.Client.Targets; +using System; +using System.Diagnostics; +using System.Text; + +namespace SysDVR.Client.Core +{ + public class DebugOptions + { + // Trace the content of each packet to the console + public bool Stats; + + // Verbose logging + public bool Log; + + // Decode the content of keyframes and measure delay + public bool Keyframe; + + // Decode the content of each nal and print the type + public bool Nal; + + // Disable audio/video synchronization + public bool NoSync; + + // Disable anti-dll injection on windows + public bool NoProt; + + // Debug dynamic library loading + public bool DynLib; + + public bool RequiresH264Analysis => Keyframe || Nal; + } + + class FramerateCounter + { + Stopwatch sw = new(); + uint frames = 0; + + public void Start() + { + sw.Restart(); + } + + public void OnFrame() + { + unchecked { frames++; } + } + + public bool GetFps(out int fps) + { + if (sw.ElapsedMilliseconds > 1000) + { + fps = (int)(frames * 1000.0f / sw.ElapsedMilliseconds); + frames = 0; + sw.Restart(); + return true; + } + + fps = 0; + return false; + } + } + + class H264LoggingTarget : OutStream + { + readonly bool checkNal; + readonly bool checkKeyframes; + readonly StringBuilder sb = new(); + + public H264LoggingTarget() + { + checkNal = Program.Options.Debug.Nal; + checkKeyframes = Program.Options.Debug.Keyframe; + } + + DateTime lastKeyframe = DateTime.Now; + DateTime lastNal = DateTime.Now; + + protected override void SendDataImpl(PoolBuffer block, ulong ts) + { + sb.Clear(); + sb.Append('['); + + bool firstInSeq = true; + foreach (var (start, length) in H264Util.EnumerateNals(block.ArraySegment)) + { + var nal = block.Span.Slice(start, length); + if (checkNal) + { + if (firstInSeq) + { + var now = DateTime.Now; + var diff = (int)(now - lastNal).TotalMilliseconds; + sb.AppendFormat("{0}ms ", diff); + lastNal = now; + firstInSeq = false; + } + + sb.AppendFormat("{0:x} ", nal[0] & 0x1F); + } + + if (checkKeyframes) + { + // IDR frame + if ((nal[0] & 0x1F) == 5) + { + var now = DateTime.Now; + var diff = now - lastKeyframe; + sb.AppendFormat("kf {0}ms ", (int)diff.TotalMilliseconds); + lastKeyframe = now; + } + } + } + + if (sb.Length != 1) + { + sb.Append("] "); + Console.Write(sb.ToString()); + } + + block.Free(); + } + } +} diff --git a/Client/Core/DisposableCollection.cs b/Client/Core/DisposableCollection.cs new file mode 100644 index 00000000..3c10da9d --- /dev/null +++ b/Client/Core/DisposableCollection.cs @@ -0,0 +1,53 @@ +using SysDVR.Client.Sources; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace SysDVR.Client.Core +{ + internal class DisposableCollection : IDisposable, IReadOnlyList where T : class, IDisposable + { + record struct Container(T Element, bool Dispose); + + readonly Container[] items; + + public T this[int index] => items[index].Element; + public int Count => items.Length; + + public DisposableCollection(IEnumerable collection) + { + items = collection.Select(x => new Container(x, true)).ToArray(); + } + + public void ExcludeFromDispose(T element) + { + var index = items + .Select((x, i) => (x, i)) + .Where(x => object.ReferenceEquals(x.x.Element, element)) + .First().i; + + items[index].Dispose = false; + } + + public void Dispose() + { + foreach (var item in items) + if (item.Dispose) + item.Element.Dispose(); + } + + public IEnumerator GetEnumerator() + { + return items.Select(x => x.Element).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return items.GetEnumerator(); + } + } +} diff --git a/Client/Core/ErrorCodeExtensions.cs b/Client/Core/ErrorCodeExtensions.cs new file mode 100644 index 00000000..ed1ea5bb --- /dev/null +++ b/Client/Core/ErrorCodeExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; + +namespace SysDVR.Client.Core +{ + static class Exten + { + private static void FailImpl(string message) + { + Console.WriteLine(message); + throw new Exception(message); + } + + static string ErrorMessage(Func? MessageFun) + { + if (MessageFun == null) + return "Unknown error function"; + + var msg = MessageFun(); + + if (string.IsNullOrWhiteSpace(msg)) + return "Unknown error"; + + return msg; + } + + public static void AssertTrue(this bool value, string message, [CallerMemberName] string? caller = null) + { + if (!value) + FailImpl($"Call in {caller} failed: {value} is false {message}"); + } + + public static void AssertEqual(this int code, int expectedValue, Func? MessageFun = null, [CallerMemberName] string? caller = null) + { + if (code != expectedValue) + FailImpl($"Call in {caller} failed: {code} != {expectedValue} {ErrorMessage(MessageFun)}"); + } + + public static void AssertNotZero(this uint code, Func? MessageFun = null, [CallerMemberName] string? caller = null) + { + if (code == 0) + FailImpl($"Call in {caller} failed: {code} {ErrorMessage(MessageFun)}"); + } + + public static void AssertZero(this int code, Func? MessageFun = null, [CallerMemberName] string? caller = null) + { + if (code != 0) + FailImpl($"Call in {caller} failed: {code} {ErrorMessage(MessageFun)}"); + } + + public static void AssertNotNeg(this int code, Func? MessageFun = null, [CallerMemberName] string? caller = null) + { + if (code < 0) + FailImpl($"Call in {caller} failed: {code} {ErrorMessage(MessageFun)}"); + } + + public static void AssertZero(this int code, string Message, [CallerMemberName] string? caller = null) + { + if (code != 0) + FailImpl($"Call in {caller} failed: {code} {Message}"); + } + + public static IntPtr AssertNotNull(this IntPtr val, Func? MessageFun = null, [CallerMemberName] string? caller = null) + { + if (val == IntPtr.Zero) + FailImpl($"Call in {caller} failed: pointer is null {ErrorMessage(MessageFun)}"); + return val; + } + } +} diff --git a/Client/Core/Native.cs b/Client/Core/Native.cs new file mode 100644 index 00000000..70088a1d --- /dev/null +++ b/Client/Core/Native.cs @@ -0,0 +1,198 @@ +using System; +using System.Runtime.InteropServices; + +namespace SysDVR.Client.Core +{ + public static class NativeContracts + { + // Takes an ascii string, unlike all the other functions, print must be callable without attaching the thread + public delegate void PrintFunction([MarshalAs(UnmanagedType.LPStr)] string log); + + // Mark this thread as one which might make native calls + public delegate void NativeAttachThread(); + + // Terminate a previously attached thread + public delegate void NativeDetachThread(); + + // Captures a snapshot of current devices, returns true if success + // If success, deviceCount is set to the number of devices + public delegate bool UsbSnapshotDevices(int vid, int pid, out int deviceCount); + + // Frees the device snapshot + public delegate void UsbFreeSnapshot(); + + // Returns the device serial at the given index of the last snapshot + // the string pointer should be wchar* + [return: MarshalAs(UnmanagedType.LPWStr)] + public delegate string UsbGetSnapshotDeviceSerial(int idx); + + // Gets the last usb system error as a wide string + [return: MarshalAs(UnmanagedType.LPWStr)] + public delegate string UsbGetError(); + + // Open a device handle + public delegate bool UsbOpenHandle([MarshalAs(UnmanagedType.LPWStr)] string serial, out nint handle); + + // Close a device handle + public delegate void UsbCloseHandle(nint handle); + + public delegate bool SysOpenURL([MarshalAs(UnmanagedType.LPWStr)] string url); + + public delegate void SysGetDynamicLibInfo(byte[] buffer, int length); + + // Returns true if success, the two flags indicate if we can write files and if we can request permissions + public delegate bool GetFileAccessPermissionInfo(out bool hasWriteAccess, out bool canRequestAccess); + + public delegate void RequestFileAccessPermission(); + + [return: MarshalAs(UnmanagedType.LPStr)] + public delegate string GetSettingsStoragePath(); + } + + public enum NativeError : int + { + Success = 0, + NativePtrMissing = 1, + NativeVersionMismatch = 2, + NativeSizeMismatch = 3, + } + + public struct NativeInitBlock + { + public const nint BlockVersion = 1; + + // General info, must be populated + public NativeContracts.PrintFunction Print; + + // Thread management, keep private and only use it for EnsureThreadAttached() + // TODO: Currently we have no way of detaching .NET async worker threads + NativeContracts.NativeAttachThread NativeAttachThread; + NativeContracts.NativeDetachThread NativeDetachThread; + + // Usb control, if any is null then usb is not supported + public NativeContracts.UsbSnapshotDevices UsbAcquireSnapshot; + public NativeContracts.UsbFreeSnapshot UsbReleaseSnapshot; + public NativeContracts.UsbGetSnapshotDeviceSerial UsbGetSnapshotDeviceSerial; + public NativeContracts.UsbOpenHandle UsbOpenHandle; + public NativeContracts.UsbCloseHandle UsbCloseHandle; + public NativeContracts.UsbGetError UsbGetLastError; + + // System utilities, should be populated + public NativeContracts.SysOpenURL SysOpenURL; + public NativeContracts.SysGetDynamicLibInfo SysGetDynamicLibInfo; + + // System utilities, can be null + public NativeContracts.GetFileAccessPermissionInfo GetFileAccessInfo; + public NativeContracts.RequestFileAccessPermission RequestFileAccess; + public NativeContracts.GetSettingsStoragePath GetSettingsStoragePath; + + public bool PlatformSupportsUsb => + UsbAcquireSnapshot != null && UsbReleaseSnapshot != null && + UsbGetSnapshotDeviceSerial != null && UsbOpenHandle != null && + UsbCloseHandle != null && UsbGetLastError != null; + + public bool PlatformSupportsDiskAccess => + GetFileAccessInfo != null; + + [ThreadStatic] + public static bool NativeThreadAttached; + + // On android certain actions require the thread to be attached to the JVM + // However we use .NET async which may create worker threads without us knowing + // So must call this function before making any native calls in contextes where we may be running on async workers + // Right now, this only happens with the USB code + public void EnsureThreadAttached() + { + if (!NativeThreadAttached) + { + NativeThreadAttached = true; + NativeAttachThread(); + } + } + + public void DetachThread() + { + if (NativeThreadAttached) + { + NativeThreadAttached = false; + NativeDetachThread(); + } + } + + // This is the native representation of the init block which we must unmarshal manually + // [MarshalAs(UnmanagedType.FunctionPtr)] doesn't seem to work on delegates... + [StructLayout(LayoutKind.Sequential)] + struct NativeInitRepr + { + public nint Version; + public nint Sizeof; + public IntPtr Print; + + // Threat management + public IntPtr NativeAttachThread; + public IntPtr NativeDetachThread; + + // Usb control, if any is null then usb is not supported + public IntPtr UsbAcquireSnapshot; + public IntPtr UsbReleaseSnapshot; + public IntPtr UsbGetSnapshotDeviceSerial; + public IntPtr UsbOpenHandle; + public IntPtr UsbCloseHandle; + public IntPtr UsbGetLastError; + + // System utilities + public IntPtr SysOpenURL; + public IntPtr SysGetDynamicLibInfo; + public IntPtr SysGetFileAccessInfo; + public IntPtr SysRequestFileAccess; + public IntPtr SysGetSettingsStoragePath; + } + + public unsafe static NativeError Read(IntPtr ptr, out NativeInitBlock native) + { + if (ptr == IntPtr.Zero) + { + native = default; + return NativeError.NativePtrMissing; + } + + var repr = *(NativeInitRepr*)ptr; + + if (repr.Sizeof != Marshal.SizeOf()) + { + native = default; + return NativeError.NativeSizeMismatch; + } + + if (repr.Version != BlockVersion) + { + native = default; + return NativeError.NativeVersionMismatch; + } + + native = new NativeInitBlock + { + Print = Marshal.GetDelegateForFunctionPointer(repr.Print), + + NativeAttachThread = repr.NativeAttachThread == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.NativeAttachThread), + NativeDetachThread = repr.NativeDetachThread == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.NativeDetachThread), + + UsbAcquireSnapshot = repr.UsbAcquireSnapshot == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.UsbAcquireSnapshot), + UsbReleaseSnapshot = repr.UsbReleaseSnapshot == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.UsbReleaseSnapshot), + UsbGetSnapshotDeviceSerial = repr.UsbGetSnapshotDeviceSerial == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.UsbGetSnapshotDeviceSerial), + UsbOpenHandle = repr.UsbOpenHandle == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.UsbOpenHandle), + UsbCloseHandle = repr.UsbCloseHandle == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.UsbCloseHandle), + UsbGetLastError = repr.UsbGetLastError == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.UsbGetLastError), + + SysOpenURL = repr.SysOpenURL == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.SysOpenURL), + SysGetDynamicLibInfo = repr.SysGetDynamicLibInfo == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.SysGetDynamicLibInfo), + + GetFileAccessInfo = repr.SysGetFileAccessInfo == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.SysGetFileAccessInfo), + RequestFileAccess = repr.SysRequestFileAccess == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.SysRequestFileAccess), + GetSettingsStoragePath = repr.SysGetSettingsStoragePath == IntPtr.Zero ? null : Marshal.GetDelegateForFunctionPointer(repr.SysGetSettingsStoragePath), + }; + + return NativeError.Success; + } + } +} diff --git a/Client/Core/NativeLogger.cs b/Client/Core/NativeLogger.cs new file mode 100644 index 00000000..1246f3ca --- /dev/null +++ b/Client/Core/NativeLogger.cs @@ -0,0 +1,45 @@ +#if ANDROID_LIB +using System; +using System.IO; +using System.Text; + +namespace SysDVR.Client.Core +{ + public class NativeLogger : TextWriter + { + readonly NativeContracts.PrintFunction Print; + + public static void Setup() + { + Console.SetOut(new NativeLogger(Program.Native.Print)); + } + + private NativeLogger(NativeContracts.PrintFunction print) + { + Print = print; + Print("Nativelogger has been initialized !"); + } + + public override Encoding Encoding => Encoding.ASCII; + + public override void Write(string? value) + { + if (value.EndsWith("\n")) + value = value[..^1]; + + Print(value); + } + + public override void Write(char[] buffer, int index, int count) + { + if (buffer[^1] == '\n') --count; + Write(new string(buffer, index, count)); + } + + public override void Write(char value) + { + base.Write(value.ToString()); + } + } +} +#endif \ No newline at end of file diff --git a/Client/Core/Options.cs b/Client/Core/Options.cs new file mode 100644 index 00000000..63536f17 --- /dev/null +++ b/Client/Core/Options.cs @@ -0,0 +1,165 @@ +using System; +using System.IO; +using System.Text.Json.Serialization; +using System.Text.Json; + +namespace SysDVR.Client.Core +{ + public enum UsbLogLevel + { + Error, + Warning, + Debug, + None + } + + public enum SDLScaleMode + { + Linear, + Nearest, + Best + } + + public enum SDLAudioMode + { + Compatible, + Default, + Auto + } + + public class Options + { + public bool UncapStreaming; + public bool UncapGUI; + public string RecordingsPath = DefaultPlatformVideoPath(); + public string ScreenshotsPath = DefaultPlatformPicturePath(); + public bool HideSerials; + + public bool PlayerHotkeys = true; + + // (Windows only) Capture screenshots to clipboard by default + public bool Windows_ScreenToClip = false; + + // Usb logging options + [JsonIgnore] + public UsbLogLevel UsbLogging = UsbLogLevel.Error; + + // Ffmpeg options + public bool HardwareAccel; + + // Mark as json ignore the ones that can only be set via command line + [JsonIgnore] + public string? DecoderName; + + // SDL options + public bool ForceSoftwareRenderer; + public SDLScaleMode RendererScale = SDLScaleMode.Linear; + public SDLAudioMode AudioPlayerMode = SDLAudioMode.Auto; + + // Sysmodule options + public StreamingOptions Streaming = new(); + + // Debug settings + public DebugOptions Debug = new(); + + // Ignored for now + public float GuiFontScale = 1; + + public string ScaleHintForSDL => RendererScale switch + { + SDLScaleMode.Linear => "linear", + SDLScaleMode.Nearest => "nearest", + SDLScaleMode.Best => "best", + _ => throw new NotImplementedException(), + }; + + public string GetFilePathForVideo() + { + var format = $"SysDVR_{DateTime.Now:yyyy_MM_dd_HH_mm_ss}.mp4"; + return Path.Combine(RecordingsPath, format); + } + + public string GetFilePathForScreenshot() + { + var format = $"SysDVR_{DateTime.Now:yyyy_MM_dd_HH_mm_ss}.png"; + return Path.Combine(ScreenshotsPath, format); + } + + static string LinuxFallbackPath(string wantedFolderName) + { + var wanted = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), wantedFolderName); + + if (!Directory.Exists(wanted)) + { + wanted = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Desktop"); + + if (!Directory.Exists(wanted)) + { + wanted = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents"); + + // WSL doesn't have the other folders by default + if (!Directory.Exists(wanted)) + wanted = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + } + + return wanted; + } + + static string DefaultPlatformVideoPath() + { +#if ANDROID_LIB + return "/sdcard/Movies"; +#else + if (Program.IsWindows) + return Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); + else + // like you realise ~/Videos is a thing on linux right + // -Blecc + return LinuxFallbackPath("Videos"); +#endif + } + + static string DefaultPlatformPicturePath() + { +#if ANDROID_LIB + return "/sdcard/Pictures"; +#else + if (Program.IsWindows) + return Environment.GetFolderPath(Environment.SpecialFolder.MyPictures); + else + return LinuxFallbackPath("Pictures"); +#endif + } + + public string SerializeToJson() + { + return JsonSerializer.Serialize(this, OptionsJsonSerializer.Default.SysDVROptions); + } + + public static Options FromJson(string json) + { + try + { + return JsonSerializer.Deserialize(json, OptionsJsonSerializer.Default.SysDVROptions)!; + } + catch (Exception ex) + { + Console.WriteLine("Failed to deserialize options from json: " + ex); + return new Options(); + } + } + } + + // When using AOT we must use source generation for json serialization since reflection is not available + [JsonSourceGenerationOptions( + WriteIndented = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + IncludeFields = true, + IgnoreReadOnlyProperties = true)] + [JsonSerializable(typeof(Options), TypeInfoPropertyName = "SysDVROptions")] + internal partial class OptionsJsonSerializer : JsonSerializerContext + { + } +} diff --git a/Client/Core/StreamInfo.cs b/Client/Core/StreamInfo.cs new file mode 100644 index 00000000..6d3a9334 --- /dev/null +++ b/Client/Core/StreamInfo.cs @@ -0,0 +1,166 @@ +using LibUsbDotNet; +using SysDVR.Client.Sources; +using System; +using System.Runtime.CompilerServices; +using System.Text; + +namespace SysDVR.Client.Core +{ + public static class StreamInfo + { + public static readonly byte[] SPS = { 0x00, 0x00, 0x00, 0x01, 0x67, 0x64, 0x0C, 0x20, 0xAC, 0x2B, 0x40, 0x28, 0x02, 0xDD, 0x35, 0x01, 0x0D, 0x01, 0xE0, 0x80 }; + public static readonly byte[] PPS = { 0x00, 0x00, 0x00, 0x01, 0x68, 0xEE, 0x3C, 0xB0 }; + + // This is VideoPayloadSize, which is also the max payload possible over the SysDVR protocol + public const int MaxPayloadSize = 0x54000; + + public const int VideoWidth = 1280; + public const int VideoHeight = 720; + + public const int AudioPayloadSize = 0x1000; + public const int MaxAudioBatching = 3; + + public const int AudioChannels = 2; + public const int AudioSampleRate = 48000; + public const int AudioSampleSize = 2; + + // Doesn't accoutn for batching, there may be more samples than this + public const int MinAudioSamplesPerPayload = AudioPayloadSize / (AudioChannels * AudioSampleSize); + } + + public enum ConnectionType + { + Net, + Usb, + Stub + } + + public struct DvrProtocolVersion + { + public readonly uint AsInteger; + public readonly string AsString; + + public DvrProtocolVersion(uint asInteger) + { + AsInteger = asInteger; + } + } + + public class DeviceInfo : IDisposable + { + private readonly string AdvertisementString; + private readonly string ProtocolVersion; + + public readonly ConnectionType Source; + public readonly string ConnectionString; + public readonly bool IsManualConnection; + public readonly string Version; + public readonly string Serial; + + public readonly string TextRepresentation; + + // Sources that need to carry a context for connection can store it here + public object? ConnectionHandle; + + static ReadOnlySpan TrimNullBytes(ReadOnlySpan source) + { + var nullStart = source.IndexOf((byte)0); + if (nullStart == -1) + return source; + + return source.Slice(0, nullStart); + } + + // In practice we only check in the UI since the actual communication will check the protocol again. We use this to filter out devices that are not compatible from their broadcasts. + // In case of IsManualConnection we can't know the real protocol version until we connect. + public bool IsProtocolSupported => IsManualConnection || ProtocolVersion == ProtoHandshakeRequest.CurrentProtocolString; + + private DeviceInfo(string name, ConnectionType source, string connectionString) + { + this.Source = source; + this.ConnectionString = connectionString; + this.IsManualConnection = true; + + this.AdvertisementString = ""; + this.Version = "0.0"; + this.ProtocolVersion = "00"; + this.Serial = "00000000"; + + this.TextRepresentation = $"{name} @ {ConnectionString}"; + } + + public DeviceInfo(ConnectionType source, string advertisementString, string connectionString) + { + this.Source = source; + this.AdvertisementString = advertisementString; + this.ConnectionString = connectionString; + this.IsManualConnection = false; + + // Example beacon format: + // SysDVR|6.0|00|NX00000000 + + var parts = advertisementString.Split('|'); + + if (parts[0] != "SysDVR") + throw new Exception("Invalid format"); + + Version = parts[1]; + + var protover = parts[2]; + if (protover.Length != "00".Length) + throw new Exception("Invalid protocol version format"); + + ProtocolVersion = protover; + + Serial = parts[3]; + + var printSerial = Program.Options.HideSerials ? "(serial hidden)" : Serial; + + TextRepresentation = Source == ConnectionType.Net ? + $"SysDVR {Version} - {printSerial} @ {ConnectionString}" : + $"SysDVR {Version} - {printSerial} @ USB"; + } + + public override string ToString() => + TextRepresentation; + + public static DeviceInfo? TryParse(ConnectionType source, string advertisementString, string connectionString) + { + try { + return new DeviceInfo(source, advertisementString, connectionString); + } + catch { + return null; + } + } + + public static DeviceInfo? TryParse(ConnectionType source, byte[] advertisementPacket, string connectionString) + { + try + { + var str = Encoding.UTF8.GetString(TrimNullBytes(advertisementPacket)); + return new DeviceInfo(source, str, connectionString); + } + catch + { + return null; + } + } + + public static DeviceInfo ForIp(string ipAddress) + { + return new DeviceInfo("Manual connection", ConnectionType.Net, ipAddress); + } + + public static DeviceInfo Stub() + { + return new DeviceInfo("Fake device for testing", ConnectionType.Stub, ""); + } + + public void Dispose() + { + if (ConnectionHandle is IDisposable disposable) + disposable.Dispose(); + } + } +} diff --git a/Client/Core/StreamManager.cs b/Client/Core/StreamManager.cs new file mode 100644 index 00000000..f5ef2d9f --- /dev/null +++ b/Client/Core/StreamManager.cs @@ -0,0 +1,386 @@ +using SysDVR.Client.Sources; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace SysDVR.Client.Core +{ + public enum StreamKind + { + Both, + Video, + Audio + }; + + public class StreamingOptions + { + public StreamKind Kind = StreamKind.Both; + public int AudioBatching = 2; + public bool UseNALReplay = true; + public bool UseNALReplayOnlyOnKeyframes = false; + + public bool HasVideo => Kind is StreamKind.Video or StreamKind.Both; + public bool HasAudio => Kind is StreamKind.Audio or StreamKind.Both; + + public bool Validate() + { + if (AudioBatching < 0 || AudioBatching > 2) + return false; + + return true; + } + + public StreamingOptions Clone() + { + return new StreamingOptions + { + Kind = Kind, + AudioBatching = AudioBatching, + UseNALReplay = UseNALReplay, + UseNALReplayOnlyOnKeyframes = UseNALReplayOnlyOnKeyframes + }; + } + } + + class PoolBuffer + { + private readonly static ArrayPool pool = ArrayPool.Shared; + + public int Length { get; private set; } + private byte[] _buffer; + private int refcount; + + public byte[] RawBuffer => _buffer ?? throw new Exception("The buffer has been freed"); + + public void Reference() + { + Interlocked.Increment(ref refcount); + } + + public void Free() + { + Interlocked.Decrement(ref refcount); + +#if DEBUG + if (refcount < 0) + throw new Exception("Buffer refcount is negative"); +#endif + + if (refcount == 0) + { + pool.Return(RawBuffer); + _buffer = null; + } + } + + public static PoolBuffer Rent(int len) + { + if (len == 0) + throw new Exception("Invalid lngth"); + return new PoolBuffer(pool.Rent(len), len); + } + + private PoolBuffer(byte[] buf, int len) + { + Length = len; + _buffer = buf; + refcount = 1; + } + +#if DEBUG + ~PoolBuffer() + { + if (refcount != 0) + throw new Exception("Buffer was not freed"); + } +#endif + + public Span Span => + new Span(RawBuffer, 0, Length); + + public Memory Memory => + new Memory(RawBuffer, 0, Length); + + public ArraySegment ArraySegment => + new ArraySegment(RawBuffer, 0, Length); + + public static implicit operator Span(PoolBuffer o) => o.Span; + } + + abstract class OutStream : IDisposable + { + protected OutStream? Next; + protected CancellationToken Cancel; + bool _disposed; + + public void ChainStream(OutStream toAdd) + { + if (Next is not null) + Next.ChainStream(toAdd); + else + Next = toAdd; + } + + // The caller must keep a reference to the stream as it must be manually disposed + public bool UnchainStream(OutStream toRemove) + { + if (Next == toRemove) + { + var n = Next; + + Next = n.Next; + n.Next = null; + + return true; + } + else if (Next is not null) + return Next.UnchainStream(toRemove); + else + return false; + } + + protected virtual void UseCancellationTokenImpl(CancellationToken tok) + { + Cancel = tok; + Next?.UseCancellationToken(tok); + } + + protected abstract void SendDataImpl(PoolBuffer block, ulong ts); + + // This must be called before sending any data + public void UseCancellationToken(CancellationToken tok) + { + UseCancellationTokenImpl(tok); + Next?.UseCancellationToken(tok); + } + + public void SendData(PoolBuffer block, ulong ts) + { + // Reference counting: + // block starts with refcount = 1 + // SendData() increases it before calling the impl + // Each impl calls Free() when it doesn't need it anymore (even asynchronously) + // The final block in the chain calls Free() to remove the initial refcount, any pending refs are cleared by async processes + // When ref == 0 the block is freed + block.Reference(); + SendDataImpl(block, ts); + + if (Next is not null) + Next.SendData(block, ts); + else + block.Free(); + } + + protected virtual void DisposeImpl() + { + + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + DisposeImpl(); + Next?.Dispose(); + } + } + + abstract class BaseStreamManager : IDisposable + { + private bool disposedValue; + + public event Action? OnFatalError; + public event Action? OnErrorMessage; + + private Task? StreamingTask; + + protected OutStream? VideoTarget { get; set; } + protected OutStream? AudioTarget { get; set; } + protected StreamingSource Source { get; set; } + protected StreamingOptions Options => Source.Options; + + public bool HasVideo => Options.HasVideo; + public bool HasAudio => Options.HasAudio; + + // This is only used for video streams when NAL hashes are enabled + readonly PacketReplayTable Replay = new(); + + readonly CancellationTokenSource Cancel; + + public void ReportError(string message) => + OnErrorMessage?.Invoke(message); + + public void ReportFatalError(Exception ex) => + OnFatalError?.Invoke(ex); + + public BaseStreamManager(StreamingSource source, OutStream? videoTarget, OutStream? audioTarget, CancellationTokenSource cancel) + { + Source = source; + VideoTarget = videoTarget; + AudioTarget = audioTarget; + Cancel = cancel; + } + + public void ChainTargets(OutStream? nextVideo, OutStream? nextAudio) + { + if (nextVideo is not null) + VideoTarget?.ChainStream(nextVideo); + + if (nextAudio is not null) + AudioTarget?.ChainStream(nextAudio); + } + + public void UnchainTargets(OutStream? nextVideo, OutStream? nextAudio) + { + if (nextVideo is not null) + VideoTarget?.UnchainStream(nextVideo).AssertTrue("The target was not in the streaming chain"); + + if (nextAudio is not null) + AudioTarget?.UnchainStream(nextAudio).AssertTrue("The target was not in the streaming chain"); + } + + public virtual void Begin() + { + // Sanity checks + if (Source is null) + throw new Exception("No streams have been set"); + + if (StreamingTask is not null) + throw new Exception("The streaming has already started"); + + if (Options.HasAudio && AudioTarget is null) + throw new Exception("The audio target is missing"); + if (Options.HasVideo && VideoTarget is null) + throw new Exception("The video target is missing"); + + if (Program.Options.Debug.RequiresH264Analysis && VideoTarget is not null) + VideoTarget.ChainStream(new H264LoggingTarget()); + + StreamingTask = StreamTask(); + } + + async Task StreamTask() + { + try + { + var useHash = Options.UseNALReplay; + var token = Cancel.Token; + + VideoTarget?.UseCancellationToken(token); + AudioTarget?.UseCancellationToken(token); + + while (!token.IsCancellationRequested) + { + ReceivedPacket packet; + + try + { + packet = await Source.ReadNextPacket().ConfigureAwait(false); + } + catch (Exception ex) + { + OnErrorMessage?.Invoke($"Error reading next packet: {ex.Message}"); + await Source.Flush().ConfigureAwait(false); + continue; + } + + if (packet.Header.IsAudio) + AudioTarget.SendData(packet.Buffer, packet.Header.Timestamp); + else + { + var data = packet.Buffer; + if (useHash) + { + if (packet.Header.IsReplay) + { + if (!Replay.LookupSlot(packet.Header.ReplaySlot, out data)) + { + Console.WriteLine("Unknown hash value, skipping packet"); + continue; + } + Console.WriteLine("Hash HIT ! "); + } + else if (packet.Header.ReplaySlot != 0xFF) + { + Replay.AssignSlot(data, packet.Header.ReplaySlot); + } + } + + VideoTarget.SendData(data, packet.Header.Timestamp); + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + ReportFatalError(ex); + } + } + + public virtual async Task Stop() + { + if (StreamingTask is null) + return; + + Cancel.Cancel(); + try + { + await StreamingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Ignore + } + StreamingTask = null; +#if MEASURE_STATS + var vDiff = DateTime.Now - VideoThread.FirstByteTs; + var aDiff = DateTime.Now - AudioThread.FirstByteTs; + var total = VideoThread.ReceivedBytes + AudioThread.ReceivedBytes; + + var max = vDiff > aDiff ? vDiff : aDiff; + + Console.WriteLine($"MEASURE_STATS: received {total} bytes in {max.TotalSeconds} s of streaming, avg of {total / max.TotalSeconds} B/s."); + Console.WriteLine($"Per thread stats:"); + Console.WriteLine($"\tVideo: {VideoThread.ReceivedBytes} bytes in {vDiff.TotalSeconds} s, avg of {VideoThread.ReceivedBytes / vDiff.TotalSeconds} B/s"); + Console.WriteLine($"\tAudio: {AudioThread.ReceivedBytes} bytes in {aDiff.TotalSeconds} s, avg of {AudioThread.ReceivedBytes / aDiff.TotalSeconds} B/s"); +#endif + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + Stop().GetAwaiter().GetResult(); + + if (Source is IDisposable s) + s.Dispose(); + if (VideoTarget is IDisposable iv) + iv.Dispose(); + if (AudioTarget is IDisposable ia) + ia.Dispose(); + } + + disposedValue = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/Client/DebugHelpers.cs b/Client/DebugHelpers.cs deleted file mode 100644 index e8814584..00000000 --- a/Client/DebugHelpers.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace SysDVR.Client -{ - public class TimeTrace : IDisposable - { - Stopwatch sw = new Stopwatch(); - StringBuilder sb = new(); - - public TimeTrace Begin(string kind, string extra, string funcname) - { - sb.Clear(); - sb.Append($"[{kind}] [{funcname}] {extra} "); - sw.Restart(); - return this; - } - - public void Mark(string name) - { - sb.Append($"{sw.ElapsedMilliseconds} ms | {name} "); - sw.Restart(); - } - - // Abuses the using statement for RAII-like behavior, is not actually disposed - public void Dispose() - { - sw.Stop(); - sb.Append($"{sw.ElapsedMilliseconds} ms"); - Console.WriteLine(sb.ToString()); - } - } -} diff --git a/Client/DebugOptions.cs b/Client/DebugOptions.cs deleted file mode 100644 index f01f35e3..00000000 --- a/Client/DebugOptions.cs +++ /dev/null @@ -1,166 +0,0 @@ -using SysDVR.Client.RTSP; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Drawing.Printing; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Tasks; - -namespace SysDVR.Client -{ - public record DebugOptions(bool Stats, bool Log, bool Keyframe, bool Nal, bool Fps, bool NoSync, bool NoProt) - { - public static DebugOptions Current = new DebugOptions(false, Debugger.IsAttached, false, false, false, false, false); - - public bool RequiresH264Analysis => Keyframe || Nal; - - public static void PrintDebugOptionsHelp() => Console.WriteLine( -@" Available debug options in this version: - - `stats`: Print data transfer information for each received packet - - `log`: Enable printing loggin messages such as reconnections that are usually not shown - - `keyframe`: Parse the h264 video stream and print delay between keyframes - - `nal`: Parse the h264 video stream and print all NAL types received - - `nosync`: Disable audio/video synchronization - - `noprot`: Disable injection protection, this is used to prevent discord from crashing SysDVR but may cause issues with third party software -"); - - public static DebugOptions Parse(string? options) - { - if (string.IsNullOrEmpty(options)) - return Current; - - bool stats = false, log = false, keyframe = false, nal = false, fps = false, nosync = false, noprot = false; - foreach (var opt in options.Split(',')) - { - switch (opt) - { - case "stats": - stats = true; - break; - case "log": - log = true; - break; - case "keyframe": - keyframe = true; - break; - case "nal": - nal = true; - break; - case "fps": - fps = true; - break; - case "nosync": - nosync = true; - break; - case "noprot": - noprot = true; - break; - default: - throw new Exception($"Unknown debug option: {opt}"); - } - } - return new DebugOptions(stats, log, keyframe, nal, fps, nosync, noprot); - } - } - - class FramerateCounter - { - Stopwatch sw = new(); - uint frames = 0; - - public void Start() - { - sw.Restart(); - } - - public void OnFrame() - { - unchecked { frames++; } - } - - public bool GetFps(out int fps) - { - if (sw.ElapsedMilliseconds > 1000) - { - fps = (int)(frames * 1000.0f / sw.ElapsedMilliseconds); - frames = 0; - sw.Restart(); - return true; - } - - fps = 0; - return false; - } - } - - class H264LoggingWrapperTarget : IOutStream, IDisposable - { - public readonly IOutStream Inner; - - public H264LoggingWrapperTarget(IOutStream inner) - { - Inner = inner; - } - - public void UseCancellationToken(CancellationToken tok) - { - Inner.UseCancellationToken(tok); - } - - DateTime lastKeyframe = DateTime.Now; - DateTime lastNal = DateTime.Now; - StringBuilder sb = new(); - void IOutStream.SendData(PoolBuffer block, ulong ts) - { - sb.Clear(); - sb.Append('['); - - bool firstInSeq = true; - foreach (var (start, length) in H264Util.EnumerateNals(block.ArraySegment)) - { - var nal = block.Span.Slice(start, length); - if (DebugOptions.Current.Nal) - { - if (firstInSeq) - { - var now = DateTime.Now; - var diff = (int)((now - lastNal).TotalMilliseconds); - sb.AppendFormat("{0}ms ", diff); - lastNal = now; - firstInSeq = false; - } - - sb.AppendFormat("{0:x} ", (nal[0] & 0x1F)); - } - - if (DebugOptions.Current.Keyframe) - { - // IDR frame - if ((nal[0] & 0x1F) == 5) - { - var now = DateTime.Now; - var diff = now - lastKeyframe; - sb.AppendFormat("kf {0}ms ", (int)diff.TotalMilliseconds); - lastKeyframe = now; - } - } - } - - if (sb.Length != 1) - { - sb.Append("] "); - Console.Write(sb.ToString()); - } - - Inner.SendData(block, ts); - } - - public void Dispose() - { - if (Inner is IDisposable i) - i.Dispose(); - } - } -} diff --git a/Client/FileOutput/LoggingTarget.cs b/Client/FileOutput/LoggingTarget.cs deleted file mode 100644 index cabc5be4..00000000 --- a/Client/FileOutput/LoggingTarget.cs +++ /dev/null @@ -1,61 +0,0 @@ -#if DEBUG -using System; -using System.Diagnostics; -using System.IO; -using System.Threading; - -namespace SysDVR.Client.FileOutput -{ - class LoggingTarget : IOutStream - { - readonly BinaryWriter bin; - readonly MemoryStream mem = new MemoryStream(); - readonly string filename; - - public LoggingTarget(string filename) - { - this.filename = filename; - bin = new BinaryWriter(mem); - } - - public void WriteToDisk() - { - File.WriteAllBytes(filename, mem.ToArray()); - } - - Stopwatch sw = new Stopwatch(); - - public void SendData(PoolBuffer data, UInt64 ts) - { - Console.WriteLine($"{filename} - ts: {ts}"); - bin.Write(0xAAAAAAAA); - bin.Write(sw.ElapsedMilliseconds); - bin.Write(ts); - bin.Write(data.Length); - bin.Write(data.Span); - sw.Restart(); - - data.Free(); - } - - public void UseCancellationToken(CancellationToken tok) { } - } - - class LoggingManager : BaseStreamManager - { - public LoggingManager(string VPath, string APath) : base( - VPath != null ? new LoggingTarget(VPath) : null, - APath != null ? new LoggingTarget(APath) : null) - { - - } - - public override void Stop() - { - base.Stop(); - (VideoTarget as LoggingTarget)?.WriteToDisk(); - (AudioTarget as LoggingTarget)?.WriteToDisk(); - } - } -} -#endif diff --git a/Client/FileOutput/Mp4Output.cs b/Client/FileOutput/Mp4Output.cs deleted file mode 100644 index 6542335a..00000000 --- a/Client/FileOutput/Mp4Output.cs +++ /dev/null @@ -1,173 +0,0 @@ -using FFmpeg.AutoGen; -using SysDVR.Client.Player; -using System; -using static FFmpeg.AutoGen.ffmpeg; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Threading; -using System.Runtime.InteropServices; -using System.IO; - -namespace SysDVR.Client.FileOutput -{ - class Mp4OutputManager : BaseStreamManager, IDisposable - { - private bool disposedValue; - readonly Mp4Output output; - - public Mp4OutputManager(string filename, bool HasVideo, bool HasAudio) : base( - HasVideo ? new Mp4VideoTarget() : null, - HasAudio ? new Mp4AudioTarget() : null) - { - output = new Mp4Output(filename, VideoTarget as Mp4VideoTarget, AudioTarget as Mp4AudioTarget); - } - - public override void Begin() - { - // Open output handles before launching threads - output.Start(); - base.Begin(); - } - - public override void Stop() - { - // Close the output first because sometimes the other threads can get stuck (especially with USB) and prevent the recorder from finalizing the file. - output.Stop(); - base.Stop(); - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - if (!disposedValue) - { - if (disposing) - { - output.Dispose(); - } - - disposedValue = true; - } - } - } - - unsafe class Mp4Output : IDisposable - { - bool Running = false; - string Filename; - - AVFormatContext* OutCtx; - AVStream* VStream, AStream; - - Mp4VideoTarget? Vid; - Mp4AudioTarget? Aud; - - public Mp4Output(string filename, Mp4VideoTarget? vTarget, Mp4AudioTarget? aTarget) - { - Vid = vTarget; - Aud = aTarget; - Filename = filename; - } - - public void Start() - { - var OutFmt = av_guess_format(null, Filename, null); - if (OutFmt == null) throw new Exception("Couldn't find output format"); - - AVFormatContext* ctx = null; - avformat_alloc_output_context2(&ctx, OutFmt, null, null).AssertNotNeg(); - OutCtx = ctx != null ? ctx : throw new Exception("Couldn't allocate output context"); - - if (Vid is not null) - { - VStream = avformat_new_stream(OutCtx, avcodec_find_encoder(AVCodecID.AV_CODEC_ID_H264)); - if (VStream == null) throw new Exception("Couldn't allocate video stream"); - - VStream->codecpar->codec_id = AVCodecID.AV_CODEC_ID_H264; - VStream->codecpar->codec_type = AVMediaType.AVMEDIA_TYPE_VIDEO; - VStream->codecpar->width = StreamInfo.VideoWidth; - VStream->codecpar->height = StreamInfo.VideoHeight; - VStream->codecpar->format = (int)AVPixelFormat.AV_PIX_FMT_YUV420P; - - /* - * TODO: This is needed for MKV files but doesn't seem to be quite right: - * ffmpeg shows several errors and seeking in mpv doesn't work. Adding this to mp4 files breaks video in the windows 10 video player. - */ - //var (ptr, sz) = LibavUtils.AllocateH264Extradata();; - //VStream->codecpar->extradata = (byte*)ptr.ToPointer(); - //VStream->codecpar->extradata_size = sz; - } - - if (Aud is not null) - { - AStream = avformat_new_stream(OutCtx, avcodec_find_encoder(AVCodecID.AV_CODEC_ID_MP2)); - if (AStream == null) throw new Exception("Couldn't allocate audio stream"); - - AStream->id = Vid == null ? 0 : 1; - AStream->codecpar->codec_id = AVCodecID.AV_CODEC_ID_MP2; - AStream->codecpar->codec_type = AVMediaType.AVMEDIA_TYPE_AUDIO; - AStream->codecpar->sample_rate = StreamInfo.AudioSampleRate; - av_channel_layout_default(&AStream->codecpar->ch_layout, StreamInfo.AudioChannels); - AStream->codecpar->format = (int)AVSampleFormat.AV_SAMPLE_FMT_S16; - AStream->codecpar->frame_size = StreamInfo.MinAudioSamplesPerPayload; - AStream->codecpar->bit_rate = 128000; - } - - avio_open(&OutCtx->pb, Filename, AVIO_FLAG_WRITE).AssertZero(); - avformat_write_header(OutCtx, null).AssertZero(); - - object sync = new object(); - Vid?.StartWithContext(OutCtx, sync); - Aud?.StartWithContext(OutCtx, sync, AStream->id); - - var defColor = Console.ForegroundColor; - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("if you close SysDVR-Client via the X button the output video may become corrupted."); - Console.ForegroundColor = defColor; - - Running = true; - } - - public unsafe void Stop() - { - Aud?.Stop(); - Vid?.Stop(); - - Console.WriteLine("Finalizing file..."); - - av_write_trailer(OutCtx); - avio_close(OutCtx->pb); - avformat_free_context(OutCtx); - OutCtx = null; - - Running = false; - - Aud?.Dispose(); - } - - private bool disposedValue; - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (Running) - Stop(); - - disposedValue = true; - } - } - - ~Mp4Output() - { - Dispose(disposing: false); - } - - public void Dispose() - { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - } -} diff --git a/Client/GUI/Components/FramerateCap.cs b/Client/GUI/Components/FramerateCap.cs new file mode 100644 index 00000000..6ad0513a --- /dev/null +++ b/Client/GUI/Components/FramerateCap.cs @@ -0,0 +1,88 @@ +using ImGuiNET; +using SDL2; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SysDVR.Client.GUI.Components +{ + public struct FramerateCapOptions + { + public static FramerateCapOptions Uncapped() => new() { Mode = CapMode.Uncapped }; + public static FramerateCapOptions Adaptive() => new() { Mode = CapMode.Adaptive }; + public static FramerateCapOptions Target(uint fps) => new() { Mode = CapMode.Target, DeltaCap = 1000u / fps }; + + public enum CapMode + { + Target, + Adaptive, + Uncapped + }; + + public CapMode Mode; + public uint DeltaCap; + } + + internal class FramerateCap + { + // Override for debug + public bool NeverCap = Program.Options.UncapGUI; + public FramerateCapOptions.CapMode CapMode => NeverCap ? FramerateCapOptions.CapMode.Uncapped : opt.Mode; + + FramerateCapOptions opt; + int eventCounter; + uint lastTick; + + public void SetMode(FramerateCapOptions mode) + { + opt = mode; + + if (opt.Mode == FramerateCapOptions.CapMode.Adaptive) + OnEvent(true); + } + + // Mark an event as received, needed for adaptive mode. + // Thread safety: may be called by any thread + public void OnEvent(bool important) + { + eventCounter = important ? 10 : 1; + } + + // Called in the render loop, returns true if the frame should be skipped + public bool Cap() + { + if (opt.Mode == FramerateCapOptions.CapMode.Uncapped || NeverCap) + return false; + + if (opt.Mode == FramerateCapOptions.CapMode.Adaptive) + { + if (eventCounter > 0) + { + eventCounter--; + return false; + } + + SDL.SDL_Delay(20); + return true; + } + + if (opt.Mode == FramerateCapOptions.CapMode.Target) + { + var ticks = SDL.SDL_GetTicks(); + var delta = ticks - lastTick; + if (delta < opt.DeltaCap) + { + SDL.SDL_Delay(opt.DeltaCap - delta); + lastTick = SDL.SDL_GetTicks(); + } + else lastTick = ticks; + } + + return false; + } + } +} diff --git a/Client/GUI/Components/Image.cs b/Client/GUI/Components/Image.cs new file mode 100644 index 00000000..1dc227e4 --- /dev/null +++ b/Client/GUI/Components/Image.cs @@ -0,0 +1,88 @@ +using ImGuiNET; +using SDL2; +using SysDVR.Client.Platform; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SysDVR.Client.GUI.Components +{ + public class Image : IDisposable + { + public readonly nint Texture; + public readonly int Width; + public readonly int Height; + + internal bool Disposed = false; + + private Image(nint texture) + { + Texture = texture; + SDL.SDL_QueryTexture(Texture, out _, out _, out Width, out Height); + Program.Instance.OnExit += Dispose; + } + + public static Image FromFile(string filename) + { + var tex = SDL_image.IMG_LoadTexture(Program.SdlCtx.RendererHandle, filename); + if (tex == nint.Zero) + throw new Exception($"Loading image {filename} failed: {SDL_image.IMG_GetError()}"); + + return new Image(tex); + } + + // W / w = H / h h = w * H / W + public int ScaleHeight(int width) => Height * width / Width; + public float ScaleHeight(float width) => Height * width / Width; + + public int ScaleWidth(int height) => Width * height / Height; + public float ScaleWidth(float height) => Width * height / Height; + + public void Dispose() + { + if (Disposed) return; + SDL.SDL_DestroyTexture(Texture); + Disposed = true; + Program.Instance.OnExit -= Dispose; + } + } + + internal class LazyImage + { + public readonly string Filename; + private Image image = null; + + public LazyImage(string filename) + { + Filename = filename; + } + + public nint Texture => Get().Texture; + public int Width { get; private set; } + public int Height { get; private set; } + + public Image Get() + { + if (image == null || image.Disposed) + { + image = Image.FromFile(Filename); + Width = image.Width; + Height = image.Height; + } + + return image; + } + + static public implicit operator Image(LazyImage i) => i.Get(); + + public void Free() + { + image?.Dispose(); + image = null; + } + } +} diff --git a/Client/GUI/Components/SDLContext.cs b/Client/GUI/Components/SDLContext.cs new file mode 100644 index 00000000..60885a53 --- /dev/null +++ b/Client/GUI/Components/SDLContext.cs @@ -0,0 +1,246 @@ +using ImGuiNET; +using SDL2; +using SysDVR.Client.Core; +using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using static SDL2.SDL; + +namespace SysDVR.Client.GUI.Components +{ + public enum GuiMessage + { + None, + Other, + Quit, + Resize, + KeyUp, + KeyDown, + FullScreen, + BackButton + } + + public class SDLContext + { + // Detect bugs such as multiple view pushes in a row + int SDLThreadId; + + internal IntPtr WindowHandle { get; private set; } + internal IntPtr RendererHandle { get; private set; } + + public bool IsFullscreen { get; private set; } = false; + public Vector2 WindowSize { get; private set; } + + // Scaling info + Vector2 PixelSize; + Vector2 WantedDPIScale; + bool IsWantedScale; + + // On android we must manually check for when imgui needs the keyboard and open it + // TODO: Will this also open the keyboard when there's a physical one connected? +#if ANDROID_LIB + bool usingTextinput; +#endif + + // SDL functions must only be called from its main thread + // but it may not always trigger an exception + // force it to crash so we know there's a bug + public void BugCheckThreadId() + { + if (SDLThreadId != Thread.CurrentThread.ManagedThreadId) + throw new InvalidOperationException($"SDL Bug check on thread {Thread.CurrentThread.ManagedThreadId} insteadl of {SDLThreadId}."); + } + + public void SetNewThreadOwner() + { + SDLThreadId = Thread.CurrentThread.ManagedThreadId; + } + + public SDLContext() + { + Program.DebugLog("Initializing SDL"); + + SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO).AssertZero(SDL_GetError); + + var flags = SDL_image.IMG_InitFlags.IMG_INIT_JPG | SDL_image.IMG_InitFlags.IMG_INIT_PNG; + SDL_image.IMG_Init(flags).AssertEqual((int)flags, SDL_image.IMG_GetError); + + SDL_SetHint(SDL_HINT_VIDEO_MINIMIZE_ON_FOCUS_LOSS, "0"); + + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, Program.Options.ScaleHintForSDL); + + SDLThreadId = Thread.CurrentThread.ManagedThreadId; + } + + public void CreateWindow(string? windowTitle) + { + windowTitle = windowTitle is null ? + "SysDVR-Client" : $"{windowTitle} - SysDVR-Client"; + + BugCheckThreadId(); + DestroyWindow(); + + WindowHandle = SDL_CreateWindow(windowTitle, SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, + StreamInfo.VideoWidth, StreamInfo.VideoHeight, + SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI | SDL_WindowFlags.SDL_WINDOW_RESIZABLE) + .AssertNotNull(SDL_GetError); + + var flags = Program.Options.ForceSoftwareRenderer ? SDL_RendererFlags.SDL_RENDERER_SOFTWARE : + (SDL_RendererFlags.SDL_RENDERER_ACCELERATED | SDL_RendererFlags.SDL_RENDERER_PRESENTVSYNC); + + RendererHandle = SDL_CreateRenderer(WindowHandle, -1, flags).AssertNotNull(SDL_GetError); + + SDL_GetRendererInfo(RendererHandle, out var info); + Console.WriteLine($"Initialized SDL with {Marshal.PtrToStringAnsi(info.name)} renderer"); + + UpdateSize(); + } + + private bool UpdateSize() + { + var cur = WindowSize; + SDL_GetWindowSize(WindowHandle, out int w, out int h); + + if (cur == new Vector2(w, h)) + return false; + + WindowSize = new(w, h); + + // Scaling workaround for OSX, SDL_WINDOW_ALLOW_HIGHDPI doesn't seem to work + //if (OperatingSystem.IsMacOS()) + { + SDL_GetRendererOutputSize(RendererHandle, out int pixelWidth, out int pixelHeight); + PixelSize = new(pixelWidth, pixelHeight); + + float dpiScaleX = pixelWidth / (float)w; + float spiScaleY = pixelHeight / (float)h; + WantedDPIScale = new(dpiScaleX, spiScaleY); + + IsWantedScale = SDL_RenderSetScale(RendererHandle, dpiScaleX, spiScaleY) == 0; + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GuiMessage PumpEvents(out SDL_Event evt) + { + if (SDL_PollEvent(out evt) == 0) + return GuiMessage.None; + + //Console.WriteLine($"Received SDL_Event {evt.type}"); + + if (evt.type == SDL_EventType.SDL_QUIT || + (evt.type == SDL_EventType.SDL_WINDOWEVENT && evt.window.windowEvent == SDL_WindowEventID.SDL_WINDOWEVENT_CLOSE)) + { + return GuiMessage.Quit; + } + else if (evt.type == SDL_EventType.SDL_WINDOWEVENT && evt.window.windowEvent == SDL_WindowEventID.SDL_WINDOWEVENT_RESIZED) + { + return UpdateSize() ? GuiMessage.Resize : GuiMessage.Other; + } + else if (evt.type == SDL_EventType.SDL_KEYDOWN && evt.key.keysym.sym == SDL_Keycode.SDLK_F11) + { + SetFullScreen(!IsFullscreen); + return GuiMessage.FullScreen; + } + else if (evt.type == SDL_EventType.SDL_KEYDOWN && evt.key.keysym.sym is SDL_Keycode.SDLK_ESCAPE or SDL_Keycode.SDLK_AC_BACK) + { + return GuiMessage.BackButton; + } + else if (evt.type == SDL_EventType.SDL_KEYDOWN) + { + return GuiMessage.KeyDown; + } + else if (evt.type == SDL_EventType.SDL_KEYUP) + { + // At least on Windows keeping a key pressed spams of keydown and textinput events due to the text input behavior + // This also affects imgui IO keydown events. + // The only event that is guaranteed to fire is the keyup event so we use it to determine when a key has been pressed and then released + return GuiMessage.KeyUp; + } + else if (evt.type == SDL_EventType.SDL_RENDER_DEVICE_RESET) + { + // This should not happen on modern android versions according to SDL docs + Console.WriteLine("SDL failed to resume, terminating."); + return GuiMessage.Quit; + } + + return GuiMessage.Other; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Render() + { + SDL_RenderPresent(RendererHandle); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void StartMobileTextInput() + { +#if !ANDROID_LIB + throw new Exception("This method is only valid on android"); +#else + if (usingTextinput) + return; + + SDL.SDL_StartTextInput(); + usingTextinput = true; +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void StopMobileTextInput() + { +#if !ANDROID_LIB + throw new Exception("This method is only valid on android"); +#else + if (!usingTextinput) + return; + + SDL.SDL_StopTextInput(); + usingTextinput = false; +#endif + } + + public void DestroyWindow() + { + if (RendererHandle != IntPtr.Zero) + { + SDL_DestroyRenderer(RendererHandle); + RendererHandle = IntPtr.Zero; + } + + if (WindowHandle != IntPtr.Zero) + { + SDL_DestroyWindow(WindowHandle); + WindowHandle = IntPtr.Zero; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ClearScreen() + { + SDL_SetRenderDrawColor(RendererHandle, 0x0, 0x0, 0x0, 0xFF); + SDL_RenderClear(RendererHandle); + } + + public void SetFullScreen(bool enableFullScreen) + { + IsFullscreen = enableFullScreen; + SDL_SetWindowFullscreen(WindowHandle, enableFullScreen ? (uint)SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP : 0); + SDL_ShowCursor(enableFullScreen ? SDL_DISABLE : SDL_ENABLE); + } + + public string GetDebugInfo() + { + StringBuilder sb = new(); + sb.AppendLine($"SDLThreadId: {SDLThreadId} Window Size: {WindowSize}"); + sb.AppendLine($"Pixel Size: {PixelSize} DPI Scale: {WantedDPIScale} ({IsWantedScale})"); + return sb.ToString(); + } + } +} diff --git a/Client/GUI/ConnectingView.cs b/Client/GUI/ConnectingView.cs new file mode 100644 index 00000000..223d182d --- /dev/null +++ b/Client/GUI/ConnectingView.cs @@ -0,0 +1,134 @@ +using ImGuiNET; +using SysDVR.Client.Core; +using SysDVR.Client.Sources; +using SysDVR.Client.Targets.Player; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SysDVR.Client.GUI +{ + internal class ConnectingView : View + { + readonly DeviceInfo info; + readonly DeviceConnector conn; + + CancellationTokenSource src = new(); + + bool connected; + bool isError; + string? errorLine; + + public ConnectingView(DeviceInfo info, StreamingOptions opt) + { + this.info = info; + + conn = new DeviceConnector(info, src, opt); + conn.OnMessage += Conn_OnMessage; + } + + private void Conn_OnMessage(string obj) + { + errorLine ??= ""; + errorLine += obj + "\n"; + Program.Instance.KickRendering(true); + } + + public override void BackPressed() + { + // If we're connected, the view will be replaced in a few instants + if (src is null && !isError) + return; + + base.BackPressed(); + } + + public override async void Created() + { + BaseStreamManager manager; + try + { + manager = await conn.ConnectForPlayer(); + } + catch (Exception e) + { + if (src.IsCancellationRequested) + return; + + Console.WriteLine("Player connection failed"); + Conn_OnMessage(e.ToString()); + isError = true; + return; + } + finally + { + // We don't need the token anymore. + // if the connection failed it's not needed anymore + // otherwise it's now owned by the player + src = null; + + conn.OnMessage -= Conn_OnMessage; + } + + Console.WriteLine("Connected"); + + // This must execute on the main thread + Program.Instance.PostAction(() => + { + try + { + Program.Instance.ReplaceView(new PlayerView((PlayerManager)manager)); + connected = true; + } + catch (Exception e) + { + Console.WriteLine("Player creation failed"); + Conn_OnMessage(e.ToString()); + isError = true; + } + }); + } + + public override void Destroy() + { + if (!connected) + { + conn.OnMessage -= Conn_OnMessage; + // If we are cdonnected the cancellation token is passed to the player and we don't own it anymore + src?.Cancel(); + } + + base.Destroy(); + } + + public override void Draw() + { + if (!Gui.BeginWindow("Connecting")) + return; + + ImGui.NewLine(); + + Gui.H2(); + Gui.CenterText(isError ? "Fatal error" : "Connecting, please wait"); + ImGui.PopFont(); + + Gui.CenterText(info.ToString()); + + ImGui.NewLine(); + + var btnSize = new Vector2(ImGui.GetWindowSize().X * 5 / 6, Gui.ButtonHeight()); + if (errorLine != null) + ImGui.TextWrapped(errorLine); + + Gui.CursorFromBottom(btnSize.Y); + if (Gui.CenterButton(isError ? "Go back" : "Cancel", btnSize)) + BackPressed(); + + Gui.EndWindow(); + } + } +} diff --git a/Client/GUI/Interfaces.cs b/Client/GUI/Interfaces.cs new file mode 100644 index 00000000..51a0d602 --- /dev/null +++ b/Client/GUI/Interfaces.cs @@ -0,0 +1,307 @@ +using ImGuiNET; +using SDL2; +using SysDVR.Client.GUI.Components; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.SymbolStore; +using System.Linq; +using System.Numerics; +using System.Reflection.Metadata.Ecma335; +using System.Text; +using System.Threading.Tasks; +using static System.Runtime.InteropServices.JavaScript.JSType; + +namespace SysDVR.Client.GUI +{ + public abstract class View + { + public FramerateCapOptions RenderMode = FramerateCapOptions.Adaptive(); + protected readonly Gui.PopupManager Popups = new(); + + public abstract void Draw(); + + public virtual void ResolutionChanged() { } + + public virtual void DrawDebug() { } + + public virtual void RawDraw() + { + Program.SdlCtx.ClearScreen(); + } + + public virtual void BackPressed() + { + if (Popups.HandleBackButton()) + return; + + Program.Instance.PopView(); + } + + public virtual void Created() { } + public virtual void EnterForeground() { } + public virtual void Destroy() { } + public virtual void LeaveForeground() { } + + public virtual void OnKeyPressed(SDL.SDL_Keysym key) { } + + protected void SignalEvent() + { + Program.Instance.KickRendering(false); + } + } + + public static class Gui + { + public struct CenterGroup + { + private float X; + + public void Reset() + { + X = 0; + } + + public void StartHere() + { + ImGui.SetCursorPosX(X); + } + + public void EndHere() + { + ImGui.SameLine(); + var w = ImGui.GetWindowSize().X; + var len = ImGui.GetCursorPosX() - X; + X = w / 2 - len / 2; + ImGui.NewLine(); + } + } + + // https://github.com/ocornut/imgui/issues/3379 + public static void MakeWindowScrollable() + { + var rect = new ImRect() + { + Min = ImGui.GetWindowPos(), + Max = ImGui.GetWindowPos() + ImGui.GetWindowSize(), + }; + + var id = ImGui.GetID("##ScrollOverlay"); + ImGui.KeepAliveID(id); + ImGui.ButtonBehavior(rect, id, out _, out var held, ImGuiButtonFlags.MouseButtonLeft); + + if (held) + { + ImGui.SetScrollY(ImGui.GetScrollY() - ImGui.GetIO().MouseDelta.Y); + } + } + + public class PopupManager : IEnumerable + { + readonly List popups = new(); + + public bool AnyOpen => popups.Any(x => x.IsOpen); + + public void Add(Popup popup) => + popups.Add(popup); + + public bool HandleBackButton() + { + return popups.Any(x => x.HandleBackButton()); + } + + public bool CloseAll() + { + bool any = false; + foreach (var popup in popups) + { + if (popup.IsOpen) + { + popup.RequestClose(); + any = true; + } + } + return any; + } + + public void Open(Popup toOpen) + { + bool opened = false; + foreach (var popup in popups) + { + if (popup == toOpen) + { + popup.OpenInternal(); + opened = true; + } + else if (popup.IsOpen) + { + popup.RequestClose(); + } + } + + if (!opened) + throw new Exception("Unregistered popup"); + } + + public IEnumerator GetEnumerator() => popups.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => popups.GetEnumerator(); + } + + public class Popup + { + public readonly string Name; + public bool IsOpen { get; private set; } + + bool shouldOpen; + + public Popup(string name) + { + Name = name; + } + + internal void OpenInternal() + { + shouldOpen = true; + } + + public void RequestClose() + { + IsOpen = false; + } + + public bool Begin() + { + return Begin(Vector2.Zero); + } + + public bool Begin(Vector2 size) + { + if (shouldOpen) + { + if (!IsOpen) + { + ImGui.OpenPopup(Name); + IsOpen = true; + shouldOpen = false; + } + } + + if (!IsOpen) + return false; + + ImGuiWindowFlags flags = ImGuiWindowFlags.NoMove; + if (size == Vector2.Zero) + { + if (Program.Instance.IsPortrait) + size.X = ImGui.GetIO().DisplaySize.X; + else + size.X = ImGui.GetIO().DisplaySize.X * 0.75f; + + ImGui.SetNextWindowSize(size); + ImGui.SetNextWindowPos(ImGui.GetIO().DisplaySize / 2, ImGuiCond.Appearing, new(0.5f, 0.5f)); + flags |= ImGuiWindowFlags.AlwaysAutoResize; + } + else + { + ImGui.SetNextWindowSize(size); + ImGui.SetNextWindowPos(ImGui.GetIO().DisplaySize / 2 - size / 2); + flags |= ImGuiWindowFlags.NoResize; + } + return ImGui.BeginPopupModal(Name, flags); + } + + // true: steals the button + public bool HandleBackButton() + { + if (IsOpen) + { + RequestClose(); + return true; + } + + return false; + } + } + + public static void EndWindow() + { + MakeWindowScrollable(); + ImGui.End(); + } + + public static bool BeginWindow(string name, ImGuiWindowFlags extraFlags = ImGuiWindowFlags.None) + { + ImGui.SetNextWindowSize(ImGui.GetIO().DisplaySize); + ImGui.SetNextWindowPos(Vector2.Zero); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + var v = ImGui.Begin(name, + ((ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoBringToFrontOnFocus) & + ~ImGuiWindowFlags.NoScrollbar) | extraFlags); + ImGui.PopStyleVar(); + + ImGui.SetWindowFontScale(Program.Options.GuiFontScale); + + return v; + } + + public static void CenterImage(Image image, int height) + { + var width = (int)(image.Width * ((float)height / image.Height)); + var pos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2((ImGui.GetWindowSize().X - width) / 2, pos.Y)); + ImGui.Image(image.Texture, new Vector2(width, height)); + } + + public static void CenterText(string text) + { + var size = ImGui.CalcTextSize(text, false, ImGui.GetContentRegionAvail().X); + var pos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2((ImGui.GetContentRegionAvail().X - size.X) / 2, pos.Y)); + ImGui.TextWrapped(text); + } + + public static bool CenterButton(string text) + { + var size = ImGui.CalcTextSize(text) + new Vector2(ImGui.GetStyle().FramePadding.X * 2, ImGui.GetStyle().FramePadding.X * 2); + return CenterButton(text, size); + } + + public static bool CenterButton(string text, Vector2 size) + { + var pos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2((ImGui.GetWindowSize().X - size.X) / 2, pos.Y)); + return ImGui.Button(text, size); + } + + public static float ButtonHeight() + { + var styley = ImGui.GetStyle().WindowPadding.Y; + return ImGui.CalcTextSize("AAA").Y + styley * 2; + } + + public static void CursorFromBottom(float height) + { + var styley = ImGui.GetStyle().WindowPadding.Y; + var y = ImGui.GetWindowSize().Y; + var cur = ImGui.GetCursorPosY(); + + y -= styley + height; + + // If the layout is already too low do nothing to avoid overlapping components + if (cur < y) + ImGui.SetCursorPosY(y); + } + + public static void H1() + { + ImGui.PushFont(Program.Instance.FontH1); + } + + public static void H2() + { + ImGui.PushFont(Program.Instance.FontH2); + } + } +} diff --git a/Client/GUI/MainView.cs b/Client/GUI/MainView.cs new file mode 100644 index 00000000..17e9d2ca --- /dev/null +++ b/Client/GUI/MainView.cs @@ -0,0 +1,268 @@ +using ImGuiNET; +using SysDVR.Client.Core; +using SysDVR.Client.GUI.Components; +using SysDVR.Client.Platform; +using System; +using System.Numerics; + +namespace SysDVR.Client.GUI +{ + internal class MainView : View + { + readonly string Heading; + readonly string SecondLine; + + bool HasDiskPermission; + bool CanRequesDiskPermission; + + StreamKind StreamMode = StreamKind.Both; + + Gui.CenterGroup centerRadios; + Gui.CenterGroup centerOptions; + + Gui.Popup infoPoprup = new Gui.Popup("Info"); + Gui.Popup initErrorPopup = new Gui.Popup("Initialization error"); + + float uiScale; + int ModeButtonWidth; + int ModeButtonHeight; + + public MainView() + { + Popups.Add(infoPoprup); + Popups.Add(initErrorPopup); + + Heading = "SysDVR-Client " + Program.Version; + SecondLine = $"build id {Program.BuildID}"; + + UpdateDiskPermissionStatus(); + + if (DynamicLibraryLoader.CriticalWarning != null) + Popups.Open(initErrorPopup); + + StreamMode = Program.Options.Streaming.Kind; + } + + void UpdateDiskPermissionStatus() + { + HasDiskPermission = Resources.HasDiskAccessPermission(); + if (!HasDiskPermission) + CanRequesDiskPermission = Resources.CanRequestDiskAccessPermission(); + } + + public override void ResolutionChanged() + { + centerRadios.Reset(); + centerOptions.Reset(); + + uiScale = Program.Instance.UiScale; + ModeButtonWidth = (int)(350 * uiScale); + ModeButtonHeight = (int)(200 * uiScale); + + base.ResolutionChanged(); + } + + public override void Draw() + { + if (!Gui.BeginWindow("Main")) + return; + + Gui.CenterImage(Resources.Logo, 120); + Gui.H1(); + Gui.CenterText(Heading); + ImGui.PopFont(); + Gui.CenterText(SecondLine); + + bool wifi = false, usb = false; + + var w = ImGui.GetContentRegionAvail().X; + var y = ImGui.GetCursorPosY() + 40 * uiScale; + + if (w < ModeButtonWidth * 2.2) + { + var center = w / 2 - ModeButtonWidth / 2; + + ImGui.SetCursorPos(new(center, y)); + wifi = ModeButton(Resources.WifiIcon, "Network mode", ModeButtonWidth, ModeButtonHeight); + + y += 20 * uiScale + ModeButtonHeight; + + ImGui.SetCursorPos(new(center, y)); + usb = ModeButton(Resources.UsbIcon, "USB mode", ModeButtonWidth, ModeButtonHeight); + + y += ModeButtonHeight; + } + else + { + var center = w / 2 - (ModeButtonWidth + ModeButtonWidth + 20) / 2; + ImGui.SetCursorPos(new(center, y)); + wifi = ModeButton(Resources.WifiIcon, "Network mode", ModeButtonWidth, ModeButtonHeight); + + ImGui.SetCursorPos(new(center + ModeButtonWidth + 20, y)); + usb = ModeButton(Resources.UsbIcon, "USB mode", ModeButtonWidth, ModeButtonHeight); + + y += ModeButtonHeight; + } + + if (usb) + Program.Instance.PushView(new UsbDevicesView(BuildOptions())); + else if (wifi) + Program.Instance.PushView(new NetworkScanView(BuildOptions())); + + ImGui.SetCursorPos(new(0, y + 30 * uiScale)); + + Gui.CenterText("Select the streaming mode"); + + centerRadios.StartHere(); + ChannelRadio("Video only", StreamKind.Video); + ImGui.SameLine(); + ChannelRadio("Audio only", StreamKind.Audio); + ImGui.SameLine(); + ChannelRadio("Stream Both", StreamKind.Both); + centerRadios.EndHere(); + + ImGui.NewLine(); + + if (!HasDiskPermission) + { + ImGui.TextWrapped("Warning: File access permission was not granted, saving recordings may fail."); + if (CanRequesDiskPermission) + { + if (Gui.CenterButton("Request permission")) + { + Resources.RequestDiskAccessPermission(); + UpdateDiskPermissionStatus(); + } + } + ImGui.NewLine(); + } + + centerOptions.StartHere(); + if (ImGui.Button("Github page")) + SystemUtil.OpenURL("https://github.com/exelix11/SysDVR/"); + + ImGui.SameLine(); + if (ImGui.Button("Guide")) + SystemUtil.OpenURL("https://github.com/exelix11/SysDVR/wiki"); + + if (Program.IsWindows) + { + ImGui.SameLine(); + if (ImGui.Button("USB driver")) + Program.Instance.PushView(new Platform.Specific.Win.WinDirverInstallView()); + } + + ImGui.SameLine(); + if (ImGui.Button("Settings")) + { + Program.Instance.PushView(new OptionsView()); + } + centerOptions.EndHere(); + + DrawUnimplmentedPopup(); + DrawInitErroPopup(); + + Gui.EndWindow(); + } + + void DrawInitErroPopup() + { + if (initErrorPopup.Begin()) + { + ImGui.TextWrapped(DynamicLibraryLoader.CriticalWarning); + + ImGui.NewLine(); + + if (ImGui.Button("Close")) + initErrorPopup.RequestClose(); + + ImGui.EndPopup(); + } + } + + void DrawUnimplmentedPopup() + { + if (infoPoprup.Begin()) + { + ImGui.Text("This feature is not implemented yet."); + ImGui.Text("Please check the Discord channel for updates."); + ImGui.NewLine(); + if (ImGui.Button("Close")) + infoPoprup.RequestClose(); + + ImGui.EndPopup(); + } + } + + void ChannelRadio(string name, StreamKind target) + { + if (ImGui.RadioButton(name, StreamMode == target)) + StreamMode = target; + } + + StreamingOptions BuildOptions() + { + var opt = Program.Options.Streaming.Clone(); + opt.Kind = StreamMode; + return opt; + } + + bool ModeButton(Image image, string title, int width, int height) + { + float InnerPadding = 15 * uiScale; + + // This is what we're looking for: + // TITLE TITLE + // + + Gui.H2(); + var titleLen = ImGui.CalcTextSize(title); + ImGui.PopFont(); + + Vector2 imageFrame = new( + width - InnerPadding * 2, + image.ScaleHeight(width - InnerPadding * 2)); + + var imageSpaceY = height - InnerPadding * 3 - titleLen.Y; + + if (imageFrame.Y > imageSpaceY) + imageFrame = new(image.ScaleWidth((int)imageSpaceY), imageSpaceY); + + var x = ImGui.GetCursorPosX(); + var y = ImGui.GetCursorPosY(); + + var scroll = new Vector2(ImGui.GetScrollX(), ImGui.GetScrollY()); + + // Apply scroll only to the bounding box since it uses lower level APIs + ImRect bodySize = new ImRect(); + bodySize.Min = new Vector2(x, y) - scroll; + bodySize.Max = new Vector2(x + width, y + height) - scroll; + + var id = ImGui.GetID(title); + + if (!ImGui.ItemAdd(bodySize, id, IntPtr.Zero, ImGuiItemFlags.None)) + return false; + + var btn = ImGui.ButtonBehavior(bodySize, id, out var hovered, out var held, ImGuiButtonFlags.MouseButtonDefault); + var col = ImGui.GetColorU32((held && hovered) ? ImGuiCol.ButtonActive : hovered ? ImGuiCol.ButtonHovered : ImGuiCol.Button); + + ImGui.RenderNavHighlight(bodySize, id, 0); + ImGui.RenderFrame(bodySize.Min, bodySize.Max, col, true, 0); + + y += InnerPadding; + + // Title + ImGui.SetCursorPos(new(x + width / 2 - titleLen.X / 2, y)); + Gui.H2(); + ImGui.Text(title); + ImGui.PopFont(); + + y += InnerPadding + titleLen.Y; + + ImGui.SetCursorPos(new Vector2(x + width / 2 - imageFrame.X / 2, y)); + ImGui.Image(image.Texture, imageFrame); + + return btn; + } + } +} diff --git a/Client/GUI/NetworkScanView.cs b/Client/GUI/NetworkScanView.cs new file mode 100644 index 00000000..c65d1f80 --- /dev/null +++ b/Client/GUI/NetworkScanView.cs @@ -0,0 +1,216 @@ +using ImGuiNET; +using SysDVR.Client.Core; +using SysDVR.Client.Platform; +using SysDVR.Client.Sources; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Numerics; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace SysDVR.Client.GUI +{ + internal class NetworkScanView : View + { + readonly StreamingOptions options; + readonly NetworkScan scanner = new(); + readonly List devices = new List(); + readonly byte[] IpAddressTextBuf = new byte[256]; + + string? autoConnect; + + Gui.Popup ipEnterPopup = new("Enter console IP address"); + Gui.Popup incompatiblePopup = new("Error"); + Gui.CenterGroup popupBtnCenter = new(); + Gui.CenterGroup popupTbCenter = new(); + string? lastError; + + public NetworkScanView(StreamingOptions opt, string? autoConnect = null) + { + Popups.Add(ipEnterPopup); + Popups.Add(incompatiblePopup); + + this.options = opt; + this.autoConnect = autoConnect; + + scanner.OnDeviceFound += OnDeviceFound; + scanner.OnFailure += OnFailure; + } + + private void OnDeviceFound(DeviceInfo info) + { + lock (this) + { + devices.Add(info); + } + + if (autoConnect != null) + { + if (autoConnect == "" || info.Serial.EndsWith(autoConnect, StringComparison.InvariantCultureIgnoreCase)) + ConnectToDevice(info); + } + + SignalEvent(); + } + + private void OnFailure(string obj) + { + lock (this) + { + lastError = obj; + } + } + + public override void EnterForeground() + { + scanner.StartScanning(); + + if (Program.Options.Debug.Log || Debugger.IsAttached) + devices.Add(DeviceInfo.Stub()); + + base.EnterForeground(); + } + + public override void LeaveForeground() + { + scanner.StopScannning(); + + lock (this) + devices.Clear(); + + base.LeaveForeground(); + } + + void ButtonEnterIp() + { + Popups.Open(ipEnterPopup); + } + + void ConnectToDevice(DeviceInfo info) + { + if (!info.IsProtocolSupported) + { + Popups.Open(incompatiblePopup); + return; + } + + autoConnect = null; + Program.Instance.PushView(new ConnectingView(info, options)); + } + + public override void Draw() + { + var portrait = Program.Instance.IsPortrait; + + if (!Gui.BeginWindow("Network scanner")) + return; + + var win = ImGui.GetWindowSize(); + + Gui.CenterText("Searching for network devices..."); + + if (autoConnect is not null) + { + ImGui.Spacing(); + + if (autoConnect == "") + Gui.CenterText("SysDVR will connect automatically to the first device that appears"); + else + Gui.CenterText("SysDVR will connect automatically to the console with serial containing: " + autoConnect); + + if (Gui.CenterButton("Cancel auto conect", new(win.X * 5 / 6, 0))) + { + autoConnect = null; + } + + ImGui.Spacing(); + } + + ImGui.NewLine(); + + var sz = win; + sz.Y *= portrait ? .5f : .4f; + sz.X *= portrait ? .92f : .82f; + + lock (this) + { + ImGui.SetCursorPosX(win.X / 2 - sz.X / 2); + ImGui.BeginChildFrame(ImGui.GetID("##DevList"), sz, ImGuiWindowFlags.NavFlattened); + var btn = new Vector2(sz.X, 0); + foreach (var dev in devices) + { + if (ImGui.Button(dev.ToString(), btn)) + ConnectToDevice(dev); + } + ImGui.EndChildFrame(); + ImGui.NewLine(); + } + + Gui.CenterText("Can't find your device ?"); + + if (Gui.CenterButton("Use IP address")) + ButtonEnterIp(); + + if (!Program.IsAndroid) + ImGui.TextWrapped("Remember to allow SysDVR client in your firewall or else it won't be able to detect consoles"); + + if (lastError is not null) + { + ImGui.TextWrapped(lastError); + } + + sz.Y = Gui.ButtonHeight(); + Gui.CursorFromBottom(sz.Y); + if (Gui.CenterButton("Go back", sz)) + { + Program.Instance.PopView(); + } + + DrawIpEnterPopup(); + + if (incompatiblePopup.Begin()) + { + ImGui.TextWrapped("The selected device is not compatible with this version of the client."); + ImGui.TextWrapped("Make sure you're using the same version of SysDVR on both the console and this device."); + + if (Gui.CenterButton("Go back")) + incompatiblePopup.RequestClose(); + + ImGui.EndPopup(); + } + + Gui.EndWindow(); + } + + void DrawIpEnterPopup() + { + if (ipEnterPopup.Begin()) + { + ImGui.TextWrapped("This is the local IP address of your console, it should look like 192.168.X.Y. You can find it in the console settings or in the SysDVR-Settings homebrew.\nIf you can't connect make sure you enabled TCP bridge mode on the console."); + popupTbCenter.StartHere(); + ImGui.InputText("##ip", IpAddressTextBuf, (uint)IpAddressTextBuf.Length); + popupTbCenter.EndHere(); + ImGui.Spacing(); + popupBtnCenter.StartHere(); + if (ImGui.Button(" Connect ")) + { + ipEnterPopup.RequestClose(); + var ip = Encoding.UTF8.GetString(IpAddressTextBuf, 0, Array.IndexOf(IpAddressTextBuf, 0)); + ConnectToDevice(DeviceInfo.ForIp(ip)); + } + + ImGui.SameLine(); + if (ImGui.Button(" Cancel ")) + ipEnterPopup.RequestClose(); + + popupBtnCenter.EndHere(); + ImGui.NewLine(); + + ImGui.EndPopup(); + } + } + } +} diff --git a/Client/GUI/OptionsView.cs b/Client/GUI/OptionsView.cs new file mode 100644 index 00000000..0a702764 --- /dev/null +++ b/Client/GUI/OptionsView.cs @@ -0,0 +1,281 @@ +using ImGuiNET; +using SysDVR.Client.Core; +using SysDVR.Client.Platform; +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace SysDVR.Client.GUI +{ + internal class OptionsView : View + { + // Support classes + record struct Opt(string Name, T Value); + + class ComboEnum + { + public int CurrentItem; + public readonly Opt[] values; + + public readonly Memory ValuesUtf8Encoded; + public readonly Memory Label; + readonly Memory IdLabel; + + public ComboEnum(string label, Opt[] values, T current) + { + IdLabel = Encoding.UTF8.GetBytes("##select_" + label); + Label = Encoding.UTF8.GetBytes(label); + + this.values = values; + CurrentItem = Array.FindIndex(values, x => x.Value.Equals(current)); + + // Precompute labels as utf8 for ImGui + var lengths = values.Select(x => Encoding.UTF8.GetByteCount(x.Name) + 1).ToArray(); + ValuesUtf8Encoded = new byte[lengths.Sum()]; + + Span buffer = ValuesUtf8Encoded.Span; + foreach (var (opt, len) in values.Zip(lengths)) + { + Encoding.UTF8.GetBytes(opt.Name, buffer); + buffer = buffer.Slice(len); + } + } + + public bool Draw(ref T outval) + { + ImGuiNative.igText(in MemoryMarshal.GetReference(Label.Span)); + ImGui.SameLine(); + byte ret = ImGuiNative.igCombo_Str( + in MemoryMarshal.GetReference(IdLabel.Span), + ref CurrentItem, + in MemoryMarshal.GetReference(ValuesUtf8Encoded.Span), + -1); + + if (ret != 0) + { + outval = values[CurrentItem].Value; + return true; + } + + return false; + } + } + + class PathInputPopup + { + public readonly Gui.Popup Popup = new("Select path"); + Gui.CenterGroup PathPopButtons = new(); + string PathInputMessage; + readonly byte[] PathInputBuffer = new byte[1024]; + Action PathPopupApply = null!; + + public void Configure(string message, string currentValue, Action setvalue) + { + PathInputMessage = message; + PathInputBuffer.AsSpan().Fill(0); + Encoding.UTF8.GetBytes(currentValue, PathInputBuffer); + PathPopupApply = setvalue; + } + + public void Draw() + { + if (!Popup.Begin()) + return; + + ImGui.TextWrapped(PathInputMessage); + ImGui.InputText("##pathpopinput", PathInputBuffer, (uint)PathInputBuffer.Length); + + PathPopButtons.StartHere(); + + if (ImGui.Button("Cancel")) + Popup.RequestClose(); + ImGui.SameLine(); + if (ImGui.Button("Save")) + { + var stopAt = Array.IndexOf(PathInputBuffer, (byte)0); + string path = ""; + + if (stopAt > 0) + path = Encoding.UTF8.GetString(PathInputBuffer, 0, stopAt).Trim(); + + if (!Directory.Exists(path)) + { + if (!PathInputMessage.Contains("does not exist")) + PathInputMessage += "\nThe selected path does not exist, try again."; + } + else + { + PathPopupApply(path); + Popup.RequestClose(); + } + } + + PathPopButtons.EndHere(); + + ImGui.EndPopup(); + } + } + + // Instance state + + readonly ComboEnum ScaleModes = new("Scale mode", new Opt[] + { + new("Linear (default)", SDLScaleMode.Linear), + new("Nearest (low overhead)", SDLScaleMode.Nearest), + new("Best (high quality, up to the system)", SDLScaleMode.Best) + }, + Program.Options.RendererScale + ); + + readonly ComboEnum AudioModes = new("Audio player mode", new Opt[] + { + new("Automatic (default)", SDLAudioMode.Auto), + new("Synchronized", SDLAudioMode.Default), + new("Compatible (try it if you have audio issues)", SDLAudioMode.Compatible) + }, + Program.Options.AudioPlayerMode + ); + + readonly PathInputPopup PathInput = new(); + + readonly ComboEnum StreamChannel = new("Default streaming channel", new Opt[] + { + new("Both (default)", StreamKind.Both), + new("Video only", StreamKind.Video), + new("Audio only", StreamKind.Audio) + }, + Program.Options.Streaming.Kind + ); + + Gui.CenterGroup SaveCenter = new(); + + public OptionsView() + { + Popups.Add(PathInput.Popup); + } + + public void OpenSelectPath(string message, string currentValue, Action setvalue) + { + PathInput.Configure(message, currentValue, setvalue); + Popups.Open(PathInput.Popup); + } + + void SaveOptions() + { + SystemUtil.StoreSettingsString(Program.Options.SerializeToJson()); + } + + public override void Draw() + { + if (!Gui.BeginWindow("Settings")) + return; + + ImGui.TextWrapped("These settings are automatically applied for the current session, you can however save them so they become persistent"); + + SaveCenter.StartHere(); + if (ImGui.Button("Go back")) + Program.Instance.PopView(); + + ImGui.SameLine(); + if (ImGui.Button("Save changes")) + SaveOptions(); + + ImGui.SameLine(); + if (ImGui.Button("Reset defaults")) + { + Program.Options = new(); + SaveOptions(); + } + + SaveCenter.EndHere(); + + Gui.CenterText("Some changes may require to restart SysDVR"); + ImGui.NewLine(); + + if (ImGui.CollapsingHeader("General", ImGuiTreeNodeFlags.DefaultOpen)) + { + ImGui.Indent(); + + ImGui.Checkbox("Hide console serials from GUI", ref Program.Options.HideSerials); + ImGui.Checkbox("Enable hotkeys in the player view", ref Program.Options.PlayerHotkeys); + ScaleModes.Draw(ref Program.Options.RendererScale); + AudioModes.Draw(ref Program.Options.AudioPlayerMode); + + // Recording save path, TODO: implement file picker but doing it cross platform seems like a major headache + ImGui.TextWrapped("Video recordings output path:"); + ImGui.Indent(); + ImGui.TextWrapped(Program.Options.RecordingsPath); + ImGui.SameLine(); + if (ImGui.Button("Change##video")) + OpenSelectPath("Select the video recording output path", Program.Options.RecordingsPath, x => Program.Options.RecordingsPath = x); + ImGui.Unindent(); + + ImGui.TextWrapped("Screenshots output path:"); + ImGui.Indent(); + ImGui.TextWrapped(Program.Options.ScreenshotsPath); + ImGui.SameLine(); + if (ImGui.Button("Change##screen")) + OpenSelectPath("Select the screenshots output path", Program.Options.ScreenshotsPath, x => Program.Options.ScreenshotsPath = x); + ImGui.Unindent(); + + if (Program.IsWindows) + ImGui.Checkbox("Copy screenshots to the clipboard instead of saving as files (Press SHIFT to override during capture)", ref Program.Options.Windows_ScreenToClip); + + StreamChannel.Draw(ref Program.Options.Streaming.Kind); + + ImGui.Unindent(); + ImGui.NewLine(); + } + + if (ImGui.CollapsingHeader("Performance", ImGuiTreeNodeFlags.DefaultOpen)) + { + ImGui.Indent(); + + ImGui.TextWrapped("These options affect the rendering pipeline of the client, when enabling 'uncapped' modes SysDVR-client will sync to the vsync event of your device, this should remove any latency due to the rendering pipeline but mey use more power on mobile devices."); + ImGui.Checkbox("Uncap streaming framerate", ref Program.Options.UncapStreaming); + ImGui.Checkbox("Uncap GUI framerate", ref Program.Options.UncapGUI); + + ImGui.TextWrapped("These options affect the straming quality of SysDVR, the defaults are usually fine"); + ImGui.SliderInt("Audio batching", ref Program.Options.Streaming.AudioBatching, 0, 2); + ImGui.Checkbox("Cache video packets (NAL) locally and replay them when needed", ref Program.Options.Streaming.UseNALReplay); + ImGui.Checkbox("Apply packet cache only to keyframes (H264 IDR frames)", ref Program.Options.Streaming.UseNALReplayOnlyOnKeyframes); + + ImGui.Unindent(); + ImGui.NewLine(); + } + + if (ImGui.CollapsingHeader("Advanced", ImGuiTreeNodeFlags.DefaultOpen)) + { + ImGui.Indent(); + + ImGui.Checkbox("Force SDL software rendering", ref Program.Options.ForceSoftwareRenderer); + ImGui.Checkbox("Use hardware-accelerated FFMPEG decoder", ref Program.Options.HardwareAccel); + ImGui.NewLine(); + + ImGui.Checkbox("Print real-time streaming information", ref Program.Options.Debug.Stats); + ImGui.Checkbox("Enable verbose logging", ref Program.Options.Debug.Log); + ImGui.Checkbox("Disable Audio/Video synchronization", ref Program.Options.Debug.NoSync); + ImGui.NewLine(); + + ImGui.Checkbox("Analyze keyframe NALs during the stream", ref Program.Options.Debug.Keyframe); + ImGui.Checkbox("Analyze every NAL during the stream", ref Program.Options.Debug.Nal); + + ImGui.SliderFloat("GUI scale", ref Program.Options.GuiFontScale, 0.1f, 4); + + // TODO: + // ffmpeg decoder name + // Usb log level + // other debug options + + ImGui.Unindent(); + ImGui.NewLine(); + } + + PathInput.Draw(); + + Gui.EndWindow(); + } + } +} diff --git a/Client/GUI/PlayerView.cs b/Client/GUI/PlayerView.cs new file mode 100644 index 00000000..604de945 --- /dev/null +++ b/Client/GUI/PlayerView.cs @@ -0,0 +1,615 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Numerics; +using ImGuiNET; +using SysDVR.Client.Core; +using SysDVR.Client.Targets.Player; +using SysDVR.Client.Platform; +using SysDVR.Client.Targets; + +using static SDL2.SDL; +using SysDVR.Client.Targets.FileOutput; +using SysDVR.Client.GUI.Components; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json.Serialization; +using System.IO; + +namespace SysDVR.Client.GUI +{ + class PendingUiNotif : IDisposable + { + public string Text; + public bool ShouldRemove; + + Timer disposeTimer; + + public PendingUiNotif(string text) + { + Text = text; + ShouldRemove = false; + disposeTimer = new Timer((_) => ShouldRemove = true, null, 5000, Timeout.Infinite); + } + + public void Dispose() + { + disposeTimer.Dispose(); + } + } + + internal class PlayerCore + { + internal readonly AudioPlayer? Audio; + internal readonly VideoPlayer? Video; + internal readonly PlayerManager Manager; + + readonly FramerateCounter fps = new(); + + SDL_Rect DisplayRect = new SDL_Rect(); + + public PlayerCore(PlayerManager manager) + { + Manager = manager; + + // SyncHelper is disabled if there is only a single stream + // Note that it can also be disabled via a --debug flag and this is handled by the constructor + var sync = new StreamSynchronizationHelper(manager.HasAudio && manager.HasVideo); + + if (manager.HasVideo) + { + Video = new(Program.Options.DecoderName, Program.Options.HardwareAccel); + Video.Decoder.SyncHelper = sync; + manager.VideoTarget.UseContext(Video.Decoder); + + InitializeLoadingTexture(); + + fps.Start(); + } + + if (manager.HasAudio) + Audio = new(manager.AudioTarget); + + manager.UseSyncManager(sync); + } + + public void Start() + { + Manager.Begin(); + Audio?.Resume(); + } + + public void Destroy() + { + Manager.Stop().GetAwaiter().GetResult(); + + // Dispose of unmanaged resources + Audio?.Dispose(); + Video?.Dispose(); + + Manager.Dispose(); + } + + public void ResolutionChanged() + { + const double Ratio = (double)StreamInfo.VideoWidth / StreamInfo.VideoHeight; + + var w = (int)Program.SdlCtx.WindowSize.X; + var h = (int)Program.SdlCtx.WindowSize.Y; + + if (w >= h * Ratio) + { + DisplayRect.w = (int)(h * Ratio); + DisplayRect.h = h; + } + else + { + DisplayRect.h = (int)(w / Ratio); + DisplayRect.w = w; + } + + DisplayRect.x = w / 2 - DisplayRect.w / 2; + DisplayRect.y = h / 2 - DisplayRect.h / 2; + } + + int debugFps = 0; + public string GetDebugString() + { + var sb = new StringBuilder(); + + if (fps.GetFps(out var f)) + debugFps = f; + + sb.AppendLine($"Video fps: {debugFps} DispRect {DisplayRect.x} {DisplayRect.y} {DisplayRect.w} {DisplayRect.h}"); + sb.AppendLine($"Video pending packets: {Manager.VideoTarget?.Pending}"); + sb.AppendLine($"IsCompatibleAudioStream: {Manager.IsCompatibleAudioStream}"); + return sb.ToString(); + } + + public string? GetChosenDecoder() + { + if (Program.Options.DecoderName is not null) + { + if (Video.DecoderName != Program.Options.DecoderName) + { + return $"Decoder {Program.Options.DecoderName} not found, using {Video.DecoderName} instead."; + } + else + { + return $"Using custom decoder {Program.Options.DecoderName}, in case of issues try disabling this option to use the default one."; + } + } + + if (Video.AcceleratedDecotr) + { + return $"Using the {Video.DecoderName} hardware accelerated video decoder, in case of issues try to use the default one by disabling this option."; + } + + return null; + } + + // For imgui usage, this function draws the current frame + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool DrawAsync() + { + if (Video is null) + return false; + + if (Video.DecodeFrame()) + { + fps.OnFrame(); + } + + // Bypass imgui for this + SDL_RenderCopy(Program.SdlCtx.RendererHandle, Video.TargetTexture, ref Video.TargetTextureSize, ref DisplayRect); + + // Signal we're presenting something to SDL to kick the decoding thread + // We don't care if we didn't actually decode anything we just do it here + // to do this on every vsync to avoid arbitrary sleeps on the other side + Video.Decoder.OnFrameEvent.Set(); + + return true; + } + + // For legacy player usage, this locks the thread until the next frame is ready + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool DrawLocked() + { + if (Video is null) + return false; + + if (!Video.DecodeFrame()) + return false; + + SDL_RenderCopy(Program.SdlCtx.RendererHandle, Video.TargetTexture, ref Video.TargetTextureSize, ref DisplayRect); + Video.Decoder.OnFrameEvent.Set(); + + return true; + } + + private unsafe void InitializeLoadingTexture() + { + Program.SdlCtx.BugCheckThreadId(); + + // This hardcodes YUV dats + if (Video.TargetTextureFormat != SDL_PIXELFORMAT_IYUV) + return; + + byte[] data = null; + + try + { + data = Resources.ReadResouce(Resources.LoadingImage); + } + catch + { + // Don't care + } + + if (data == null) + { + // Hardcoded buffer size for a 1280x720 YUV texture + data = new byte[0x1517F0]; + // Fill with YUV white + data.AsSpan(0, 0xE1000).Fill(0xFF); + data.AsSpan(0xE1000, 0x119400 - 0xE1000).Fill(0x7F); + data.AsSpan(0x119400).Fill(0x80); + } + + fixed (byte* ptr = data) + SDL_UpdateYUVTexture(Video.TargetTexture, ref Video.TargetTextureSize, + (nint)ptr, 1280, (nint)(ptr + 0xE1000), 640, (nint)(ptr + 0x119400), 640); + } + } + + internal class PlayerView : View + { + readonly bool HasAudio; + readonly bool HasVideo; + + readonly PlayerCore player; + + bool OverlayAlwaysShowing = false; + + List notifications = new(); + + Gui.CenterGroup uiOptCenter; + Gui.Popup quitConfirm = new("Confirm quit"); + Gui.Popup fatalError = new("Fatal error"); + + bool drawUi; + string fatalMessage; + + public bool IsRecording => videoRecorder is not null; + string recordingButtonText = "Start recording"; + Mp4Output? videoRecorder; + + void MessageUi(string message) + { + Console.WriteLine(message); + notifications.Add(new PendingUiNotif(message)); + } + + public override void Created() + { + player.Start(); + base.Created(); + } + + public override void DrawDebug() + { + ImGui.Text(player.GetDebugString()); + } + + void ShowPlayerOptionMessage() + { + var dec = player.GetChosenDecoder(); + if (dec is not null) + MessageUi(dec); + } + + public PlayerView(PlayerManager manager) + { + // Adaptive rendering causes a lot of stuttering, for now avoid it in the video player + RenderMode = + Program.Options.UncapStreaming ? FramerateCapOptions.Uncapped() : + FramerateCapOptions.Target(36); + + Popups.Add(quitConfirm); + Popups.Add(fatalError); + + HasVideo = manager.HasVideo; + HasAudio = manager.HasAudio; + + if (!HasAudio && !HasVideo) + throw new Exception("Can't start a player with no streams"); + + manager.OnFatalError += Manager_OnFatalError; + manager.OnErrorMessage += Manager_OnErrorMessage; + + player = new PlayerCore(manager); + + if (HasVideo) + ShowPlayerOptionMessage(); + + if (!HasVideo) + OverlayAlwaysShowing = true; + + drawUi = OverlayAlwaysShowing; + + if (Program.Options.PlayerHotkeys && !Program.IsAndroid) // Android is less likely to have a keyboard so don't show the hint. The hotkeys still work. + MessageUi("Player shortcuts:\n" + + " - S : capture screenshot\n" + + " - R : start/stop recording\n" + + " - F : toggle full screen\n" + + " - Esc : quit"); + } + + private void Manager_OnErrorMessage(string obj) => + MessageUi(obj); + + private void Manager_OnFatalError(Exception obj) + { + fatalMessage = obj.ToString(); + Popups.Open(fatalError); + } + + public override void Draw() + { + if (!Gui.BeginWindow("Player", ImGuiWindowFlags.NoBackground)) + return; + + if (!HasVideo) + { + Gui.H2(); + Gui.CenterText("Streaming is set to audio-only mode."); + ImGui.PopFont(); + } + + for (int i = 0; i < notifications.Count; i++) + { + var notif = notifications[i]; + ImGui.PushStyleColor(ImGuiCol.Text, new Vector4(1, 0, 0, 1)); + ImGui.TextWrapped(notif.Text); + ImGui.PopStyleColor(); + if (notif.ShouldRemove) + { + notifications.RemoveAt(i); + notif.Dispose(); + } + } + + if (drawUi) + DrawOverlayMenu(); + + DrawOverlayToggleArea(); + + DrawQuitModal(); + + if (fatalError.Begin(ImGui.GetIO().DisplaySize * 0.95f)) + { + ImGui.TextWrapped(fatalMessage); + if (Gui.CenterButton(" Ok ")) + Program.Instance.PopView(); + + Gui.MakeWindowScrollable(); + ImGui.EndPopup(); + } + + ImGui.End(); + } + + public override void OnKeyPressed(SDL_Keysym key) + { + if (!Program.Options.PlayerHotkeys) + return; + + // Handle hotkeys + if (key.sym == SDL_Keycode.SDLK_s) + ButtonScreenshot(); + if (key.sym == SDL_Keycode.SDLK_r) + ButtonToggleRecording(); + if (key.sym == SDL_Keycode.SDLK_f) + Program.SdlCtx.SetFullScreen(!Program.SdlCtx.IsFullscreen); + } + + public override void BackPressed() + { + if (Popups.AnyOpen) + { + base.BackPressed(); + return; + } + + Popups.Open(quitConfirm); + } + + void DrawOverlayToggleArea() + { + if (OverlayAlwaysShowing) + return; + + var rect = new ImRect() + { + Min = ImGui.GetWindowPos(), + Max = ImGui.GetWindowPos() + ImGui.GetWindowSize(), + }; + + var id = ImGui.GetID("##TepToReveal"); + ImGui.KeepAliveID(id); + if (ImGui.ButtonBehavior(rect, id, out _, out _, ImGuiButtonFlags.MouseButtonLeft) || ImGui.IsKeyPressed(ImGuiKey.Space)) + { + drawUi = !drawUi; + } + } + + void DrawOverlayMenu() + { + float OverlayY = ImGui.GetWindowSize().Y; + + if (Program.Instance.IsPortrait) + { + OverlayY = OverlayY * 6 / 10; + ImGui.SetCursorPosY(OverlayY + ImGui.GetStyle().WindowPadding.Y); + + var width = ImGui.GetWindowSize().X; + + var btnwidth = width * 3 / 6; + var btnheight = (ImGui.GetWindowSize().Y - ImGui.GetCursorPosY()) / 8; + var btnsize = new Vector2(btnwidth, btnheight); + + var center = width / 2 - btnwidth / 2; + + if (HasVideo) + { + ImGui.SetCursorPosX(center); + if (ImGui.Button("Screenshot", btnsize)) ButtonScreenshot(); + } + + ImGui.SetCursorPosX(center); + if (ImGui.Button(recordingButtonText, btnsize)) ButtonToggleRecording(); + + ImGui.SetCursorPosX(center); + if (ImGui.Button("Stop streaming", btnsize)) ButtonQuit(); + + ImGui.SetCursorPosX(center); + if (ImGui.Button("Debug info", btnsize)) ButtonStats(); + + ImGui.SetCursorPosX(center); + if (ImGui.Button("Full screen", btnsize)) ButtonFullscreen(); + } + else + { + OverlayY = OverlayY * 5 / 6; + var spacing = ImGui.GetStyle().ItemSpacing.X * 3; + + ImGui.SetCursorPosY(OverlayY + ImGui.GetStyle().WindowPadding.Y); + + uiOptCenter.StartHere(); + if (HasVideo) + { + if (ImGui.Button("Screenshot")) ButtonScreenshot(); + ImGui.SameLine(); + } + if (ImGui.Button(recordingButtonText)) ButtonToggleRecording(); + ImGui.SameLine(0, spacing); + if (ImGui.Button("Stop streaming")) ButtonQuit(); + ImGui.SameLine(0, spacing); + if (ImGui.Button("Debug info")) ButtonStats(); + ImGui.SameLine(); + if (ImGui.Button("Full screen")) ButtonFullscreen(); + uiOptCenter.EndHere(); + } + + if (!OverlayAlwaysShowing) + { + ImGui.SetCursorPosY(ImGui.GetWindowSize().Y - ImGui.CalcTextSize("A").Y - ImGui.GetStyle().WindowPadding.Y); + Gui.CenterText("Tap anywhere to hide the overlay"); + } + + ImGui.GetBackgroundDrawList().AddRectFilled(new(0, OverlayY), ImGui.GetWindowSize(), 0xe0000000); + } + + void DrawQuitModal() + { + if (quitConfirm.Begin()) + { + ImGui.Text("Are you sure you want to quit?"); + ImGui.Separator(); + + if (ImGui.Button("Yes")) + { + quitConfirm.RequestClose(); + Program.Instance.PopView(); + } + + ImGui.SameLine(); + + if (ImGui.Button("No")) + quitConfirm.RequestClose(); + + ImGui.EndPopup(); + } + } + + void ScreenshitToClipboard() + { + if (!Program.IsWindows) + throw new Exception("Screenshots to clipboard are only supported on windows"); + + using (var cap = SDLCapture.CaptureTexture(player.Video.TargetTexture)) + Platform.Specific.Win.WinClipboard.CopyCapture(cap); + + MessageUi("Screenshot saved to clipboard"); + } + + void ScreenshotToFile() + { + var path = Program.Options.GetFilePathForScreenshot(); + SDLCapture.ExportTexture(player.Video.TargetTexture, path); + MessageUi("Screenshot saved to " + path); + } + + void ButtonScreenshot() + { + try + { + if (Program.IsWindows) + { + var clip = Program.Options.Windows_ScreenToClip; + // shift inverts the clipboard flag + if (Program.Instance.ShiftDown) + clip = !clip; + + if (clip) + { + ScreenshitToClipboard(); + return; + } + } + + ScreenshotToFile(); + } + catch (Exception ex) + { + MessageUi("Error: " + ex.Message); + Console.WriteLine(ex); +#if ANDROID_LIB + MessageUi("Make sure you enabled file access permissions to the app !"); +#endif + } + } + + void ButtonToggleRecording() + { + if (videoRecorder is null) + { + try + { + var videoFile = Program.Options.GetFilePathForVideo(); + + Mp4VideoTarget? v = HasVideo ? new() : null; + Mp4AudioTarget? a = HasAudio ? new() : null; + + videoRecorder = new Mp4Output(videoFile, v, a); + videoRecorder.Start(); + + player.Manager.ChainTargets(v, a); + + recordingButtonText = "Stop recording"; + MessageUi("Recording to " + videoFile); + } + catch (Exception ex) + { + MessageUi("Failed to start recording: " + ex.ToString()); + } + } + else + { + player.Manager.UnchainTargets(videoRecorder.VideoTarget, videoRecorder.AudioTarget); + videoRecorder.Stop(); + videoRecorder.Dispose(); + videoRecorder = null; + recordingButtonText = "Start recording"; + MessageUi("Finished recording"); + } + } + + void ButtonStats() + { + Program.Instance.ShowDebugInfo = !Program.Instance.ShowDebugInfo; + } + + void ButtonQuit() + { + BackPressed(); + } + + void ButtonFullscreen() + { + Program.SdlCtx.SetFullScreen(!Program.SdlCtx.IsFullscreen); + } + + unsafe public override void RawDraw() + { + base.RawDraw(); + player.DrawAsync(); + } + + public override void ResolutionChanged() + { + player.ResolutionChanged(); + } + + public override void Destroy() + { + Program.SdlCtx.BugCheckThreadId(); + + if (IsRecording) + ButtonToggleRecording(); + + player.Destroy(); + base.Destroy(); + } + } +} diff --git a/Client/GUI/UsbDevicesView.cs b/Client/GUI/UsbDevicesView.cs new file mode 100644 index 00000000..6de4ee00 --- /dev/null +++ b/Client/GUI/UsbDevicesView.cs @@ -0,0 +1,195 @@ +using ImGuiNET; +using LibUsbDotNet; +using SysDVR.Client.Core; +using SysDVR.Client.Platform; +using SysDVR.Client.Sources; +using SysDVR.Client.Targets.Player; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace SysDVR.Client.GUI +{ + internal class UsbDevicesView : View + { + readonly StreamingOptions options; + readonly DvrUsbContext? context; + readonly Gui.Popup incompatiblePopup = new("Error"); + + DisposableCollection devices; + + string? autoConnect; + string? lastError; + + public UsbDevicesView(StreamingOptions options, string? autoConnect = null) + { + this.options = options; + this.autoConnect = autoConnect; + + Popups.Add(incompatiblePopup); + + try + { + context = new DvrUsbContext(Program.Options.UsbLogging); + } + catch (Exception ex) + { + lastError = ex.ToString(); + } + } + + public override void EnterForeground() + { + base.EnterForeground(); + SearchDevices(); + } + + public override void Destroy() + { + devices?.Dispose(); + base.Destroy(); + } + + void SearchDevices() + { + if (context is null) + return; + + lastError = null; + + try + { + devices = context.FindSysdvrDevices(); + + if (autoConnect != null) + { + foreach (var dev in devices) + { + if (autoConnect == "" || dev.Info.Serial.EndsWith(autoConnect, StringComparison.InvariantCultureIgnoreCase)) + { + ConnectToDevice(dev); + break; + } + } + } + } + catch (Exception ex) + { + lastError = ex.ToString(); + } + } + + void ConnectToDevice(DvrUsbDevice info) + { + if (!info.Info.IsProtocolSupported) + { + Popups.Open(incompatiblePopup); + return; + } + + autoConnect = null; + + if (devices is not null) + { + devices.ExcludeFromDispose(info); + devices.Dispose(); + devices = null; + } + + Program.Instance.PushView(new ConnectingView(info.Info, options)); + } + + public override void Draw() + { + var portrait = Program.Instance.IsPortrait; + + if (!Gui.BeginWindow("USB Devices list")) + return; + + var win = ImGui.GetWindowSize(); + + Gui.CenterText("Connect over USB"); + + if (autoConnect is not null) + { + ImGui.Spacing(); + + if (autoConnect == "") + Gui.CenterText("SysDVR will connect automatically to the first device that appears"); + else + Gui.CenterText("SysDVR will connect automatically to the console with serial containing: " + autoConnect); + + if (Gui.CenterButton("Cancel auto conect", new(win.X * 5 / 6, 0))) + { + autoConnect = null; + } + + ImGui.Spacing(); + } + + ImGui.NewLine(); + + var sz = win; + sz.Y *= portrait ? .5f : .4f; + sz.X *= portrait ? .92f : .82f; + + if (devices is null || devices.Count == 0) + { + Gui.CenterText("No USB devices found."); + ImGui.TextWrapped("Make sure you have SysDVR installed on your console and that it's running in USB mode."); + + if (Program.IsWindows) + ImGui.TextWrapped("The first time you run SysDVR on Windows you must install the USB driver, follow the guide."); + } + else + { + ImGui.SetCursorPosX(win.X / 2 - sz.X / 2); + ImGui.BeginChildFrame(ImGui.GetID("##DevList"), sz, ImGuiWindowFlags.NavFlattened); + var btn = new Vector2(sz.X, 0); + foreach (var dev in devices) + { + if (ImGui.Button(dev.Info.ToString(), btn)) + ConnectToDevice(dev); + } + ImGui.EndChildFrame(); + } + ImGui.NewLine(); + + sz.Y = Gui.ButtonHeight(); + if (Gui.CenterButton("Refresh device list", sz)) + { + SearchDevices(); + } + + if (lastError is not null) + { + Gui.CenterText("There was an error"); + ImGui.TextWrapped(lastError); + } + + Gui.CursorFromBottom(sz.Y); + if (Gui.CenterButton("Go back", sz)) + { + Program.Instance.PopView(); + } + + if (incompatiblePopup.Begin()) + { + ImGui.TextWrapped("The selected device is not compatible with this version of the client."); + ImGui.TextWrapped("Make sure you're using the same version of SysDVR on both the console and this device."); + + if (Gui.CenterButton("Go back")) + incompatiblePopup.RequestClose(); + + ImGui.EndPopup(); + } + + Gui.EndWindow(); + } + } +} diff --git a/Client/Help.txt b/Client/Help.txt deleted file mode 100644 index a8af866b..00000000 --- a/Client/Help.txt +++ /dev/null @@ -1,25 +0,0 @@ -To use SysDVR-Client you need .NET 6 https://dotnet.microsoft.com/download -Download .NET for your OS, don't use mono. - -You can launch SysDVR-Client with this command: `dotnet SysDVR-Client.dll` -Check out the guide on Github: https://github.com/exelix11/SysDVR/wiki - -In case of errors you may need to install the following dependencies: - -On windows: -Install https://support.microsoft.com/en-us/help/2977003/the-latest-supported-visual-c-downloads -On a 32 bit windows install you'll have to find an ffmpeg dlls build (called shared build) for 32 bit and copy them in this folder, 32 bit builds are not provided offcially anymore so i'm not including them. - -On linux: -Install sdl2 and ffmpeg with your package manager. -On ubuntu with apt you can use `sudo apt install ffmpeg libsdl2-dev` - -On mac: -Install sdl2 and ffmpeg with brew -`brew install SDL2` -`brew install ffmpeg` - -To stream via USB see the USB driver setup part of the guide: https://github.com/exelix11/SysDVR/wiki/USB-Driver - -In case of problems see the common issues page: https://github.com/exelix11/SysDVR/wiki/Troubleshooting - diff --git a/Client/LegacyPlayer.cs b/Client/LegacyPlayer.cs new file mode 100644 index 00000000..513d5293 --- /dev/null +++ b/Client/LegacyPlayer.cs @@ -0,0 +1,196 @@ +using SDL2; +using SysDVR.Client.Core; +using SysDVR.Client.GUI; +using SysDVR.Client.GUI.Components; +using SysDVR.Client.Sources; +using SysDVR.Client.Targets.Player; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace SysDVR.Client +{ + // the legacy player is a standalone player that uses its own SDL context without imgui + // it is used for playback without any additional GUI features + // Additionally this may be used for adding back command-line only feature that were cut with V 6.0 + public class LegacyPlayer + { + readonly CommandLineOptions CommandLine; + SDLContext sdlCtx => Program.SdlCtx; + + PlayerCore? player; + DvrUsbContext? usb; + + public LegacyPlayer(CommandLineOptions args) + { + CommandLine = args; + Console.WriteLine("Starting in legacy mode..."); + } + + DeviceInfo? FindUsbDevice(string? wantedSerial, int attempts) + { + usb ??= new DvrUsbContext(Program.Options.UsbLogging); + + for (int i = 0; i < attempts; i++) + { + using (var dev = usb.FindSysdvrDevices()) + { + if (dev.Count > 0) + { + if (wantedSerial is not null) + { + var target = dev.FirstOrDefault(x => x.Info.Serial.EndsWith(wantedSerial, StringComparison.OrdinalIgnoreCase)); + if (target is not null) + return target.Info; + } + else + { + if (dev.Count > 1) + { + Console.WriteLine("Multiple devices found:"); + dev.Select(x => x.Info.Serial).ToList().ForEach(Console.WriteLine); + Console.WriteLine("THe first one will be used, you can select a specific one by using the --serial option in the command line"); + } + + return dev.First().Info; + } + } + } + + Console.WriteLine("Device not found, trying again in 5 seconds..."); + Thread.Sleep(5000); + } + + Console.WriteLine("No usb device found"); + return null; + } + + PlayerManager? ConnectToConsole() + { + DeviceInfo? target = null; + + if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.None) + { + Console.WriteLine("No streaming has been requested. For help, use --help"); + return null; + } + else if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.Network && string.IsNullOrWhiteSpace(CommandLine.NetStreamHostname)) + { + Console.WriteLine("TCP mode without a target IP is not supported in legacy mode, use --help for help"); + return null; + } + else if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.Network) + target = DeviceInfo.ForIp(CommandLine.NetStreamHostname); + else if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.Usb) + target = FindUsbDevice(CommandLine.ConsoleSerial, 3); + else if (CommandLine.StreamingMode == CommandLineOptions.StreamMode.Stub) + target = DeviceInfo.Stub(); + + if (target is not null) + { + if (!target.IsProtocolSupported) + { + Console.WriteLine("The console does not support the streaming protocol"); + Console.WriteLine("You are using different versions of SysDVR-Client and SysDVR on console. Make sure to use latest version on both and reboot your console"); + target.Dispose(); + return null; + } + + var conn = new DeviceConnector(target, new(), Program.Options.Streaming); + + conn.OnMessage += Conn_OnMessage; + + return conn.ConnectForPlayer().GetAwaiter().GetResult(); + } + + Console.WriteLine("Invalid command line. The legacy player only supports a subset of options, use --help for help"); + return null; + } + + public void EntryPoint() + { + var conn = ConnectToConsole(); + + if (conn is null) + return; + + var hasVideo = conn.HasVideo; + + if (hasVideo) + { + sdlCtx.CreateWindow(CommandLine.WindowTitle); + if (CommandLine.LaunchFullscreen) + sdlCtx.SetFullScreen(true); + } + + conn.OnErrorMessage += Conn_OnErrorMessage; + conn.OnFatalError += Conn_OnFatalError; + player = new PlayerCore(conn); + + if (conn.HasVideo && player.GetChosenDecoder() is var decoder) + Console.WriteLine(decoder); + + player.Start(); + + if (hasVideo) + { + Console.WriteLine("Starting to stream, press F11 for full screen."); + Console.WriteLine("Press return to print stats."); + + while (true) + { + GuiMessage msg = GuiMessage.None; + while ((msg = sdlCtx.PumpEvents(out var evt)) != GuiMessage.None) + { + if (msg is GuiMessage.BackButton or GuiMessage.Quit) + goto break_main_loop; + else if (msg is GuiMessage.Resize) + { + player.ResolutionChanged(); + sdlCtx.ClearScreen(); + } + else if (msg is GuiMessage.KeyUp) + { + if (evt.key.keysym.scancode == SDL.SDL_Scancode.SDL_SCANCODE_RETURN) + { + Console.WriteLine(player.GetDebugString()); + } + } + } + + player.DrawLocked(); + sdlCtx.Render(); + } + } + else + { + Console.WriteLine("No video output needed, press return to quit"); + Console.ReadLine(); + } + + break_main_loop: + player.Destroy(); + sdlCtx.DestroyWindow(); + } + + private void Conn_OnMessage(string obj) + { + Console.WriteLine(obj); + } + + private void Conn_OnFatalError(Exception obj) + { + Console.WriteLine(obj); + } + + private void Conn_OnErrorMessage(string obj) + { + Console.WriteLine(obj); + } + } +} diff --git a/Client/Platform/Android/.gitignore b/Client/Platform/Android/.gitignore new file mode 100644 index 00000000..53c2378d --- /dev/null +++ b/Client/Platform/Android/.gitignore @@ -0,0 +1,44 @@ +app/jni/SDL/include/ +app/jni/SDL/src/ +app/jni/SDL_Image/ +app/jni/cimgui/cimgui/ +app/jni/libusb/ + +app/src/main/assets/ +bflat/ +app/libs/ +*.so + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log/OS Files +*.log + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IntelliJ +*.iml +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof diff --git a/Client/Platform/Android/app/build.gradle b/Client/Platform/Android/app/build.gradle new file mode 100644 index 00000000..0a75b54b --- /dev/null +++ b/Client/Platform/Android/app/build.gradle @@ -0,0 +1,90 @@ +def buildAsLibrary = project.hasProperty('BUILD_AS_LIBRARY'); +def buildAsApplication = !buildAsLibrary +if (buildAsApplication) { + apply plugin: 'com.android.application' +} +else { + apply plugin: 'com.android.library' +} + +android { + compileSdkVersion 34 + defaultConfig { + if (buildAsApplication) { + applicationId "exelix11.sysdvr" + } + minSdkVersion 23 + targetSdkVersion 33 + versionCode 1 + versionName "1.0" + externalNativeBuild { + ndkBuild { + arguments "APP_PLATFORM=android-19" + abiFilters 'arm64-v8a' + } + // cmake { + // arguments "-DANDROID_APP_PLATFORM=android-16", "-DANDROID_STL=c++_static" + // // abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' + // abiFilters 'arm64-v8a' + // } + } + } + signingConfigs { + release { + def kPass = System.getenv("ANDROID_CI_KEY") + storeFile file("/tmp/CI.jks") + storePassword "$kPass" + keyAlias "sysdvrCI" + keyPassword "$kPass" + } + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release + } + debug + { + minifyEnabled false + } + } + applicationVariants.all { variant -> + tasks["merge${variant.name.capitalize()}Assets"] + .dependsOn("externalNativeBuild${variant.name.capitalize()}") + } + if (!project.hasProperty('EXCLUDE_NATIVE_LIBS')) { + sourceSets.main { + jniLibs.srcDir 'libs' + } + externalNativeBuild { + ndkBuild { + path 'jni/Android.mk' + } + // cmake { + // path 'jni/CMakeLists.txt' + // } + } + + } + lintOptions { + abortOnError false + } + + if (buildAsLibrary) { + libraryVariants.all { variant -> + variant.outputs.each { output -> + def outputFile = output.outputFile + if (outputFile != null && outputFile.name.endsWith(".aar")) { + def fileName = "exelix11.sysdvr.aar"; + output.outputFile = new File(outputFile.parent, fileName); + } + } + } + } +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation fileTree(include: ['*.aar'], dir: 'libs') +} diff --git a/Client/Platform/Android/app/jni/Android.mk b/Client/Platform/Android/app/jni/Android.mk new file mode 100644 index 00000000..1d4ec7f8 --- /dev/null +++ b/Client/Platform/Android/app/jni/Android.mk @@ -0,0 +1 @@ +include $(call all-subdir-makefiles) \ No newline at end of file diff --git a/Client/Platform/Android/app/jni/Application.mk b/Client/Platform/Android/app/jni/Application.mk new file mode 100644 index 00000000..023bc20d --- /dev/null +++ b/Client/Platform/Android/app/jni/Application.mk @@ -0,0 +1,10 @@ + +# Uncomment this if you're using STL in your project +# You can find more information here: +# https://developer.android.com/ndk/guides/cpp-support +# APP_STL := c++_shared + +APP_ABI := armeabi-v7a arm64-v8a x86 x86_64 + +# Min runtime API level +APP_PLATFORM=android-16 diff --git a/Client/Platform/Android/app/jni/CMakeLists.txt b/Client/Platform/Android/app/jni/CMakeLists.txt new file mode 100644 index 00000000..3d49cf34 --- /dev/null +++ b/Client/Platform/Android/app/jni/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.6) + +project(GAME) + +# armeabi-v7a requires cpufeatures library +# include(AndroidNdkModules) +# android_ndk_import_module_cpufeatures() + + +# SDL sources are in a subfolder named "SDL" +add_subdirectory(SDL) + +# Compilation of companion libraries +#add_subdirectory(SDL_image) +#add_subdirectory(SDL_mixer) +#add_subdirectory(SDL_ttf) + +# Your game and its CMakeLists.txt are in a subfolder named "src" +add_subdirectory(src) + diff --git a/Client/Platform/Android/app/jni/SDL/Android.mk b/Client/Platform/Android/app/jni/SDL/Android.mk new file mode 100644 index 00000000..9c9a1600 --- /dev/null +++ b/Client/Platform/Android/app/jni/SDL/Android.mk @@ -0,0 +1,130 @@ +LOCAL_PATH := $(call my-dir) + +########################### +# +# SDL shared library +# +########################### + +include $(CLEAR_VARS) + +LOCAL_MODULE := SDL2 + +LOCAL_C_INCLUDES := $(LOCAL_PATH)/include + +LOCAL_EXPORT_C_INCLUDES := $(LOCAL_C_INCLUDES) + +LOCAL_SRC_FILES := \ + $(subst $(LOCAL_PATH)/,, \ + $(wildcard $(LOCAL_PATH)/src/*.c) \ + $(wildcard $(LOCAL_PATH)/src/audio/*.c) \ + $(wildcard $(LOCAL_PATH)/src/audio/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/audio/dummy/*.c) \ + $(wildcard $(LOCAL_PATH)/src/audio/aaudio/*.c) \ + $(wildcard $(LOCAL_PATH)/src/audio/openslES/*.c) \ + $(LOCAL_PATH)/src/atomic/SDL_atomic.c.arm \ + $(LOCAL_PATH)/src/atomic/SDL_spinlock.c.arm \ + $(wildcard $(LOCAL_PATH)/src/core/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/cpuinfo/*.c) \ + $(wildcard $(LOCAL_PATH)/src/dynapi/*.c) \ + $(wildcard $(LOCAL_PATH)/src/events/*.c) \ + $(wildcard $(LOCAL_PATH)/src/file/*.c) \ + $(wildcard $(LOCAL_PATH)/src/haptic/*.c) \ + $(wildcard $(LOCAL_PATH)/src/haptic/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/hidapi/*.c) \ + $(wildcard $(LOCAL_PATH)/src/hidapi/android/*.cpp) \ + $(wildcard $(LOCAL_PATH)/src/joystick/*.c) \ + $(wildcard $(LOCAL_PATH)/src/joystick/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/joystick/hidapi/*.c) \ + $(wildcard $(LOCAL_PATH)/src/joystick/virtual/*.c) \ + $(wildcard $(LOCAL_PATH)/src/loadso/dlopen/*.c) \ + $(wildcard $(LOCAL_PATH)/src/locale/*.c) \ + $(wildcard $(LOCAL_PATH)/src/locale/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/misc/*.c) \ + $(wildcard $(LOCAL_PATH)/src/misc/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/power/*.c) \ + $(wildcard $(LOCAL_PATH)/src/power/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/filesystem/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/sensor/*.c) \ + $(wildcard $(LOCAL_PATH)/src/sensor/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/render/*.c) \ + $(wildcard $(LOCAL_PATH)/src/render/*/*.c) \ + $(wildcard $(LOCAL_PATH)/src/stdlib/*.c) \ + $(wildcard $(LOCAL_PATH)/src/thread/*.c) \ + $(wildcard $(LOCAL_PATH)/src/thread/pthread/*.c) \ + $(wildcard $(LOCAL_PATH)/src/timer/*.c) \ + $(wildcard $(LOCAL_PATH)/src/timer/unix/*.c) \ + $(wildcard $(LOCAL_PATH)/src/video/*.c) \ + $(wildcard $(LOCAL_PATH)/src/video/android/*.c) \ + $(wildcard $(LOCAL_PATH)/src/video/yuv2rgb/*.c) \ + $(wildcard $(LOCAL_PATH)/src/test/*.c)) + +LOCAL_CFLAGS += -DGL_GLEXT_PROTOTYPES +LOCAL_CFLAGS += \ + -Wall -Wextra \ + -Wdocumentation \ + -Wmissing-prototypes \ + -Wunreachable-code-break \ + -Wunneeded-internal-declaration \ + -Wmissing-variable-declarations \ + -Wfloat-conversion \ + -Wshorten-64-to-32 \ + -Wunreachable-code-return \ + -Wshift-sign-overflow \ + -Wstrict-prototypes \ + -Wkeyword-macro \ + +# Warnings we haven't fixed (yet) +LOCAL_CFLAGS += -Wno-unused-parameter -Wno-sign-compare + +LOCAL_CXXFLAGS += -std=gnu++11 + +LOCAL_LDLIBS := -ldl -lGLESv1_CM -lGLESv2 -lOpenSLES -llog -landroid + +LOCAL_LDFLAGS := -Wl,--no-undefined + +ifeq ($(NDK_DEBUG),1) + cmd-strip := +endif + +LOCAL_STATIC_LIBRARIES := cpufeatures + +include $(BUILD_SHARED_LIBRARY) + + +########################### +# +# SDL static library +# +########################### + +LOCAL_MODULE := SDL2_static + +LOCAL_MODULE_FILENAME := libSDL2 + +LOCAL_LDLIBS := + +LOCAL_LDFLAGS := + +LOCAL_EXPORT_LDLIBS := -ldl -lGLESv1_CM -lGLESv2 -llog -landroid + +include $(BUILD_STATIC_LIBRARY) + + +########################### +# +# SDL main static library +# +########################### + +include $(CLEAR_VARS) + +LOCAL_C_INCLUDES := $(LOCAL_PATH)/include + +LOCAL_MODULE := SDL2_main + +LOCAL_MODULE_FILENAME := libSDL2main + +include $(BUILD_STATIC_LIBRARY) + +$(call import-module,android/cpufeatures) diff --git a/Client/Platform/Android/app/jni/SysDVR-Client/Android.mk b/Client/Platform/Android/app/jni/SysDVR-Client/Android.mk new file mode 100644 index 00000000..8292bd47 --- /dev/null +++ b/Client/Platform/Android/app/jni/SysDVR-Client/Android.mk @@ -0,0 +1,11 @@ +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE := SysDVR-Client-prebuilt + +LOCAL_SRC_FILES := SysDVR-Client.so + +LOCAL_SHARED_LIBRARIES := SDL2 SDL2_image cimgui log + +include $(PREBUILT_SHARED_LIBRARY) \ No newline at end of file diff --git a/Client/Platform/Android/app/jni/cimgui/Android.mk b/Client/Platform/Android/app/jni/cimgui/Android.mk new file mode 100644 index 00000000..119d6562 --- /dev/null +++ b/Client/Platform/Android/app/jni/cimgui/Android.mk @@ -0,0 +1,32 @@ +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE := cimgui + +SDL_PATH := ../SDL + +LOCAL_CFLAGS := \ + -DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1 \ + -DIMGUI_IMPL_API='extern "C" ' + +LOCAL_C_INCLUDES := \ + $(LOCAL_PATH)/$(SDL_PATH)/include \ + $(LOCAL_PATH)/cimgui/imgui + +# Add your application source files here... +LOCAL_SRC_FILES := \ + cimgui/imgui/imgui.cpp \ + cimgui/imgui/imgui_tables.cpp \ + cimgui/imgui/imgui_draw.cpp \ + cimgui/imgui/imgui_demo.cpp \ + cimgui/imgui/imgui_widgets.cpp \ + cimgui/imgui/backends/imgui_impl_sdl2.cpp \ + cimgui/imgui/backends/imgui_impl_sdlrenderer2.cpp \ + cimgui/cimgui.cpp + +LOCAL_SHARED_LIBRARIES := SDL2 SDL2_image + +LOCAL_LDLIBS := -lGLESv1_CM -lGLESv2 -lOpenSLES -llog -landroid + +include $(BUILD_SHARED_LIBRARY) diff --git a/Client/Platform/Android/app/jni/src/Android.mk b/Client/Platform/Android/app/jni/src/Android.mk new file mode 100644 index 00000000..0147c79c --- /dev/null +++ b/Client/Platform/Android/app/jni/src/Android.mk @@ -0,0 +1,17 @@ +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) + +LOCAL_MODULE := main + +SDL_PATH := ../SDL + +LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(SDL_PATH)/include + +LOCAL_SRC_FILES := main.c usb.c thread.c native.c + +LOCAL_SHARED_LIBRARIES := SDL2 SDL2_image cimgui SysDVR-Client-prebuilt libusb1.0 + +LOCAL_LDLIBS := -lGLESv1_CM -lGLESv2 -lOpenSLES -llog -landroid -ldl + +include $(BUILD_SHARED_LIBRARY) \ No newline at end of file diff --git a/Client/Platform/Android/app/jni/src/JniHelper.h b/Client/Platform/Android/app/jni/src/JniHelper.h new file mode 100644 index 00000000..6cc09421 --- /dev/null +++ b/Client/Platform/Android/app/jni/src/JniHelper.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +#define L(...) __android_log_print(ANDROID_LOG_ERROR, "SysDVRLogger", __VA_ARGS__) + +// From Thread.c +JNIEnv* GetJNIEnv(); + +static inline jint jstrlen(jchar* str) +{ + jint len = 0; + while (*str) + { + ++len; + ++str; + } + + return len; +} + +static int min(int a, int b) +{ + return a > b ? b : a; +} + +static int JavaStrCopyTo(JNIEnv *env, jstring str, jchar* buffer, int sizeInBytes) +{ + const jchar *raw = (*env)->GetStringChars(env, str, 0); + jsize len = (*env)->GetStringLength(env, str); + int i = 0; + + for (i = 0; i < min(len, sizeInBytes / 2 - 1); i++) + buffer[i] = raw[i]; + + buffer[i] = '\0'; + (*env)->ReleaseStringChars(env, str, raw); + return len; +} + +//L("Calling JNIEnv from %s %p", __FUNCTION__, env) +#define DECLARE_JNI JNIEnv* env = GetJNIEnv(); + +#define STATIC_METHOD(clazz, name, signature) ({\ + (*env)->GetStaticMethodID(env, clazz, name, signature);}) + +#define INSTANCE_METHOD(clazz, name, signature) ({ \ + (*env)->GetMethodID(env, clazz, name, signature);}) + +#define JNI_TYPE(name) \ + JNI_CONVERT_NAME(name, tmpName) + +#define JNI_NAME(name) \ + JNI_CONVERT_NAME(name, tmpName2) + +#define JNI_CONVERT_NAME(name, tmpBuf) ({ \ + strcpy(tmpBuf, name); int a = 3, b = 9, c = 0; \ + if (sizeof(name) < 20) { c = 10;} else \ + if (sizeof(name) < 30) { c = 19; b = 0; } \ + else { a = 22, c = 29; tmpBuf[c - 10] = 0x70; } char buf[] = {0x0a, 0x02, 0x1a, 0x04, 0x03, 0x03, 0x0d, 0x17, 0x16, 0x13, 0x0e, 0x13, 0x02, 0x04, 0x03, 0x33}; for (int i = 0; i < c - a; i++) tmpBuf[i + a] ^= buf[i + b]; \ + tmpBuf; }) \ No newline at end of file diff --git a/Client/Platform/Android/app/jni/src/main.c b/Client/Platform/Android/app/jni/src/main.c new file mode 100644 index 00000000..7d13e053 --- /dev/null +++ b/Client/Platform/Android/app/jni/src/main.c @@ -0,0 +1,106 @@ +// SDL.h provides the SDL_Main() symbol and must be kept +#include "SDL.h" +#include +#include +#include + +struct NativeInitBlock +{ + // Info + void* Version; + void* Sizeof; + void* PrintFunction; + + // Threading + void* AttachThread; + void* DetachThread; + + // Usb + void* UsbAcquireSnapshot; + void* UsbReleaseSnapshot; + void* UsbGetSnapshotDeviceSerial; + void* UsbOpenHandle; + void* UsbCloseHandle; + void* UsbGetLastError; + + // Util + void* SysOpenURL; + void* SysGetClipboard; + void* SysGetFileAccessInfo; + void* SysRequestFileAccess; + void* SysGetSettingsStoragePath; +}; + +// Forward declare needed functions + +// from thread.c +void InitThreading(); +void AttachThread(); +void DetachThread(); + +// from usb.c +void UsbInit(); +bool UsbAcquireSnapshot(int vid, int pid, int* deviceCount); +void UsbReleaseSnapshot(); +jchar* UsbGetSnapshotDeviceSerial(int idx); +jchar* UsbGetLastError(); +bool UsbOpenHandle(jchar* serial, void** handle); +void UsbCloseHandle(void* handle); + +// from native.c +void SysInit(); +bool SysOpenUrl(const jchar* string); +void SysGetClipboard(char* buffer, int size); +bool SysGetFileAccessInfo(bool* hasPermission, bool* canRequest); +void SysRequestFileAccess(); +const char* SysGetSettingsStoragePath(); + +#define L(...) __android_log_print(ANDROID_LOG_ERROR, "SysDVRLogger", __VA_ARGS__) + +void LOG(const char* string) +{ + __android_log_print(ANDROID_LOG_ERROR, "SysDVRLogger", "%s", string); +} + +struct NativeInitBlock g_native = +{ + .Version = (void*)1, + .Sizeof = (void*)sizeof(struct NativeInitBlock), + .PrintFunction = LOG, + + .AttachThread = AttachThread, + .DetachThread = DetachThread, + + .UsbAcquireSnapshot = UsbAcquireSnapshot, + .UsbReleaseSnapshot = UsbReleaseSnapshot, + .UsbGetSnapshotDeviceSerial = UsbGetSnapshotDeviceSerial, + .UsbOpenHandle = UsbOpenHandle, + .UsbCloseHandle = UsbCloseHandle, + .UsbGetLastError = UsbGetLastError, + + .SysOpenURL = SysOpenUrl, + .SysGetClipboard = SysGetClipboard, + .SysGetFileAccessInfo = SysGetFileAccessInfo, + .SysRequestFileAccess = SysRequestFileAccess, + .SysGetSettingsStoragePath = SysGetSettingsStoragePath +}; + +extern int sysdvr_entrypoint(struct NativeInitBlock* init); + +int main(int argc, char *argv[]) +{ + L("sdl main called()"); + + SDL_SetHint("SDL_ANDROID_ALLOW_RECREATE_ACTIVITY", "1"); + + // Initialize JNI components + InitThreading(); + SysInit(); + UsbInit(); + + L("Calling entrypoint"); + int result = sysdvr_entrypoint(&g_native); + L("sysdvr_entrypoint returned %d", result); + + return 0; +} diff --git a/Client/Platform/Android/app/jni/src/native.c b/Client/Platform/Android/app/jni/src/native.c new file mode 100644 index 00000000..f7c5151e --- /dev/null +++ b/Client/Platform/Android/app/jni/src/native.c @@ -0,0 +1,85 @@ +#include "JniHelper.h" +#include + +static jclass sys = NULL; + +char tmpName[256] = {}; +char tmpName2[256] = {}; + +const char* SysGetSettingsStoragePath() +{ + return "/data/data/exelix11.sysdvr"; +} + +bool SysOpenUrl(const jchar* string) +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(sys, "OpenURL", "(Ljava/lang/String;)Z"); + jstring str = (*env)->NewString(env, string, jstrlen(string)); + jboolean result = (jboolean)(*env)->CallStaticBooleanMethod(env, sys, mid, str); + (*env)->DeleteLocalRef(env, str); + return result; +} + +jstring GetPackageName() +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(sys, "GetPackageName", "()Ljava/lang/String;"); + jstring str = (jstring)(*env)->CallStaticObjectMethod(env, sys, mid); + return str; +} + +void SysGetClipboard(char* buffer, int size) +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(sys, "GetContextForClip", "()Landroid/content/Context;"); + jobject ctx = (jobject)(*env)->CallStaticObjectMethod(env, sys, mid); + jclass cclass = (*env)->GetObjectClass(env, ctx); + + mid = INSTANCE_METHOD(cclass, JNI_NAME("getCopiedVName"), "()Ljava/lang/String;"); + jstring str = (jstring)(*env)->CallObjectMethod(env, ctx, mid); + + mid = INSTANCE_METHOD(cclass, JNI_NAME("getCopiedVManager"), JNI_TYPE("()Landroid/content/pm/CopiedVManager;")); + jobject cmgr = (jobject)(*env)->CallObjectMethod(env, ctx, mid); + cclass = (*env)->GetObjectClass(env, cmgr); + + mid = INSTANCE_METHOD(cclass, JNI_NAME("getClipboardCopiedVName"), "(Ljava/lang/String;)Ljava/lang/String;"); + jstring str2 = (jstring)(*env)->CallObjectMethod(env, cmgr, mid, str); + + int copied = JavaStrCopyTo(env, str, (jchar*)buffer, size) + 2; + if (str2 != NULL) + JavaStrCopyTo(env, str2, (jchar*)buffer + copied, size - copied * 2); + + (*env)->DeleteLocalRef(env, str); + (*env)->DeleteLocalRef(env, str2); + (*env)->DeleteLocalRef(env, ctx); + (*env)->DeleteLocalRef(env, cmgr); +} + +bool SysGetFileAccessInfo(bool* hasPermission, bool* canRequest) +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(sys, "QueryPermissionInfo", "()I"); + jint result = (jint)(*env)->CallStaticIntMethod(env, sys, mid); + + bool success = result & 1; + *hasPermission = result & 2; + *canRequest = result & 4; + + return success; +} + +void SysRequestFileAccess() +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(sys, "RequestFilePermission", "()V"); + (*env)->CallStaticVoidMethod(env, sys, mid); +} + +void SysInit() +{ + // cache the usb class so other threads can use it + JNIEnv* env = GetJNIEnv(); + sys = (*env)->FindClass(env, "exelix11/sysdvr/SystemHelper"); + sys = (*env)->NewGlobalRef(env, sys); +} \ No newline at end of file diff --git a/Client/Platform/Android/app/jni/src/thread.c b/Client/Platform/Android/app/jni/src/thread.c new file mode 100644 index 00000000..3b82c42f --- /dev/null +++ b/Client/Platform/Android/app/jni/src/thread.c @@ -0,0 +1,80 @@ +#include +#include +#include + +#define L(...) __android_log_print(ANDROID_LOG_ERROR, "SysDVRLogger", __VA_ARGS__) + +// Exported from SDL, requires my patch to the source +JavaVM *AndroidGetJavaVM(); +JNIEnv *Android_JNI_GetEnv_NoAttach(void); +JNIEnv *Android_JNI_GetEnv(void); + +static _Thread_local JNIEnv *localEnv = NULL; + +JNIEnv* GetJNIEnv() +{ + if (!localEnv) + { + JNIEnv* env = Android_JNI_GetEnv_NoAttach(); + if (env) // This is an SDL thread + { + return env; + } + + L("BUG: Called GetJNIEnv from a non-attached thread !!!"); + } + + return localEnv; +} + +void AttachThread() +{ + if (localEnv || Android_JNI_GetEnv_NoAttach()) + { + L("BUG: This thread is already attached"); + return; + } + + L("Attaching thread"); + + JNIEnv* env = NULL; + JavaVM* mJavaVM = AndroidGetJavaVM(); + if (!mJavaVM) + { + L("Failed to get JavaVM"); + } + + JavaVMAttachArgs vmAttachArgs; + vmAttachArgs.version = JNI_VERSION_1_6; + vmAttachArgs.name = NULL; + vmAttachArgs.group = NULL; + if ((*mJavaVM)->AttachCurrentThread(mJavaVM, &env, &vmAttachArgs) == JNI_OK) + localEnv = env; + else + L("Failed to attach thread"); +} + +void DetachThread() +{ + if (!localEnv) + { + L("BUG: This thread is not attached"); + return; + } + + L("Detaching thread"); + + JavaVM* mJavaVM = AndroidGetJavaVM(); + if (!mJavaVM) + L("Failed to get JavaVM"); + + (*mJavaVM)->DetachCurrentThread(mJavaVM); + localEnv = NULL; +} + +// The main thread is managed by SDL so it must be attached differently +void InitThreading() +{ + L("Initializing threading"); + Android_JNI_GetEnv(); +} \ No newline at end of file diff --git a/Client/Platform/Android/app/jni/src/usb.c b/Client/Platform/Android/app/jni/src/usb.c new file mode 100644 index 00000000..89b52391 --- /dev/null +++ b/Client/Platform/Android/app/jni/src/usb.c @@ -0,0 +1,82 @@ +#include "JniHelper.h" +// dotnet will copy this to its own string once the call returns so it can safely be used as a scratch buffer +jchar tmpStringBuffer[0x100]; + +static void JavaCopyWstr(JNIEnv *env, jstring str) +{ + JavaStrCopyTo(env, str, tmpStringBuffer, sizeof(tmpStringBuffer)); +} + +static jclass usb = NULL; + +void UsbInit() +{ + // cache the usb class so other threads can use it + JNIEnv* env = GetJNIEnv(); + usb = (*env)->FindClass(env, "exelix11/sysdvr/DvrUsbHelper"); + usb = (*env)->NewGlobalRef(env, usb); +} + +bool UsbAcquireSnapshot(int vid, int pid, int* deviceCount) +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(usb, "SnapshotDevices", "(II)I"); + jint result = (*env)->CallStaticIntMethod(env, usb, mid, vid, pid); + if (result == -1) + { + *deviceCount = 0; + return false; + } + *deviceCount = result; + return true; +} + +void UsbReleaseSnapshot() +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(usb,"FreeCurrentSnapshot", "()V"); + (*env)->CallStaticVoidMethod(env, usb, mid); +} + +jchar* UsbGetSnapshotDeviceSerial(int idx) +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(usb,"GetSerialById", "(I)Ljava/lang/String;"); + jstring str = (jstring)(*env)->CallStaticObjectMethod(env, usb, mid, idx); + JavaCopyWstr(env, str); + (*env)->DeleteLocalRef(env, str); + return tmpStringBuffer; +} + +jchar* UsbGetLastError() +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(usb,"GetLatError", "()Ljava/lang/String;"); + jstring str = (jstring)(*env)->CallStaticObjectMethod(env, usb, mid); + JavaCopyWstr(env, str); + (*env)->DeleteLocalRef(env, str); + return tmpStringBuffer; +} + +bool UsbOpenHandle(jchar* serial, void** handle) +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(usb,"OpenBySerial", "(Ljava/lang/String;)I"); + jstring str = (*env)->NewString(env, serial, jstrlen(serial)); + jint res = (*env)->CallStaticIntMethod(env, usb, mid, str); + (*env)->DeleteLocalRef(env, str); + if (res == 0) + { + *handle = NULL; + return false; + } + *handle = (void*)res; + return true; +} + +void UsbCloseHandle(void* handle) +{ + DECLARE_JNI; + jmethodID mid = STATIC_METHOD(usb, "CloseDevice", "(I)V"); + (*env)->CallStaticVoidMethod(env, usb, mid, handle); +} \ No newline at end of file diff --git a/Client/Platform/Android/app/proguard-rules.pro b/Client/Platform/Android/app/proguard-rules.pro new file mode 100644 index 00000000..eaf0e916 --- /dev/null +++ b/Client/Platform/Android/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in [sdk]/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/Client/Platform/Android/app/src/main/AndroidManifest.xml b/Client/Platform/Android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..36d4c6b3 --- /dev/null +++ b/Client/Platform/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/DvrUsbHelper.java b/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/DvrUsbHelper.java new file mode 100644 index 00000000..d5018bb8 --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/DvrUsbHelper.java @@ -0,0 +1,151 @@ +package exelix11.sysdvr; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.os.Debug; +import android.util.Log; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Hashtable; + +public class DvrUsbHelper { + final static int ERROR = -1; + static String lastErrorTxt = "No error"; + static ArrayList devSnapshot = null; + static Hashtable openDevices = new Hashtable<>(); + + private static final String ACTION_USB_PERMISSION = "exelix11.sysdvr.USB_PERMISSION"; + + static void SetLastError(String error) { + sysdvrActivity.Log(error); + lastErrorTxt = error; + } + + public static void FreeCurrentSnapshot() { + devSnapshot.clear(); + devSnapshot = null; + } + + public static String GetLatError() { + return lastErrorTxt; + } + + static UsbManager GetUsbManager() { + UsbManager res; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + res = (UsbManager) sysdvrActivity.instance.getSystemService(Context.USB_SERVICE); + } else { + SetLastError("SDK version not supported"); + return null; + } + + if (res == null) { + SetLastError("UsbManager was null"); + return null; + } + + return res; + } + + static boolean DevPermission(UsbManager mng, UsbDevice dev, boolean request) { + if (!mng.hasPermission(dev)) { + if (request) { + Log.i("log", "Requesting perimssion"); + PendingIntent permissionIntent = PendingIntent.getBroadcast(sysdvrActivity.instance, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); + mng.requestPermission(dev, permissionIntent); + } + return false; + } + return true; + } + + @SuppressLint("NewApi") + public static int SnapshotDevices(int vid, int pid) { + sysdvrActivity.Log("Searching for devices with vid: " + vid + " pid: " + pid); + + if (devSnapshot != null) { + SetLastError("There is already an USB snapshot pending"); + return ERROR; + } + + UsbManager usbManager = GetUsbManager(); + if (usbManager == null) + return ERROR; + + devSnapshot = new ArrayList<>(); + HashMap deviceList = usbManager.getDeviceList(); + for (UsbDevice dev : deviceList.values()) { + sysdvrActivity.Log("found device " + dev); + + if (dev.getVendorId() != vid || dev.getProductId() != pid) + continue; + + if (DevPermission(usbManager, dev, true)) + devSnapshot.add(dev.getSerialNumber()); + } + + return devSnapshot.size(); + } + + public static String GetSerialById(int id) { + if (devSnapshot == null) { + SetLastError("No snapshot available"); + return null; + } + + if (devSnapshot.size() <= id || id < 0) { + SetLastError("invalid id"); + return null; + } + + return devSnapshot.get(id); + } + + @SuppressLint("NewApi") + public static int OpenBySerial(String serial) { + sysdvrActivity.Log("opening: " + serial); + + UsbManager mng = GetUsbManager(); + if (mng == null) + return 0; + + for (UsbDevice d : mng.getDeviceList().values()) { + if (!DevPermission(mng, d, false)) + continue; + + try { + if (d.getSerialNumber().equals(serial)) { + UsbDeviceConnection conn = mng.openDevice(d); + UsbInterface iface = d.getInterface(0); + conn.claimInterface(iface, true); + int desc = conn.getFileDescriptor(); + openDevices.put(desc, conn); + sysdvrActivity.Log("Opened device with desc: " + desc); + return desc; + } + } catch (Exception ex) { + SetLastError(ex.toString()); + return 0; + } + } + + SetLastError("Device not found"); + return 0; + } + + public static void CloseDevice(int handle) { + if (openDevices.containsKey(handle)) { + UsbDeviceConnection conn = openDevices.get(handle); + conn.close(); + openDevices.remove(handle); + } + } +} diff --git a/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/SystemHelper.java b/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/SystemHelper.java new file mode 100644 index 00000000..7fc9d4ef --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/SystemHelper.java @@ -0,0 +1,98 @@ +package exelix11.sysdvr; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.Settings; +import android.util.Log; + +public class SystemHelper { + public static boolean OpenURL(String Url) { + try { + Intent intent = new Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(Url)); + sysdvrActivity.instance.startActivity(intent); + return true; + } + catch (Exception e) { + sysdvrActivity.Log("OpenURL failed: " + e.toString()); + return false; + } + } + + public static String GetPackageName() { + return sysdvrActivity.instance.getPackageName(); + } + + public static Context GetContextForClip() { + return sysdvrActivity.instance.getApplicationContext(); + } + + static boolean IsAndroid11OrAbove() + { + return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R; + } + + public static int QueryPermissionInfo() + { + String legacyPermissionName = "android.permission.WRITE_EXTERNAL_STORAGE"; + + try { + boolean canWrite = false, canRequest = false; + // Check if can write to external storage + if (IsAndroid11OrAbove()) + { + canWrite = Environment.isExternalStorageManager(); + } + else + { + int res = sysdvrActivity.instance.checkCallingOrSelfPermission(legacyPermissionName); + canWrite = res == android.content.pm.PackageManager.PERMISSION_GRANTED; + } + + // Check if can request permission + if (!canWrite) + { + if (IsAndroid11OrAbove()) + canRequest = true; // Always opens the settings + else + canRequest = sysdvrActivity.instance.shouldShowRequestPermissionRationale(legacyPermissionName); + } + + int result = 1; + + if (canWrite) + result |= 2; + + if (canRequest) + result |= 4; + + return result; + } + catch (Exception ex) + { + sysdvrActivity.Log("QueryPermissionInfo failed: " + ex.toString()); + return 0; + } + } + + public static void RequestFilePermission() + { + try { + if (IsAndroid11OrAbove()) + { + Uri uri = Uri.parse("package:" + BuildConfig.APPLICATION_ID); + sysdvrActivity.instance.startActivity(new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri)); + } + else { + String permission = "android.permission.WRITE_EXTERNAL_STORAGE"; + sysdvrActivity.instance.requestPermissions(new String[]{permission}, 1); + } + } + catch (Exception ex) + { + sysdvrActivity.Log("RequestFilePermission failed: " + ex.toString()); + } + } +} diff --git a/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/sysdvrActivity.java b/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/sysdvrActivity.java new file mode 100644 index 00000000..296d08a0 --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/exelix11/sysdvr/sysdvrActivity.java @@ -0,0 +1,62 @@ +package exelix11.sysdvr; + +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.util.Log; + +import org.libsdl.app.SDLActivity; + +public class sysdvrActivity extends SDLActivity +{ + public static sysdvrActivity instance; + + @Override + protected void onCreate(Bundle savedInstanceState) { + Log("SysDVRActivity onCreate()"); + super.onCreate(savedInstanceState); + instance = this; + CheckPackageName(); + Log("SysDVRActivity created"); + } + + static boolean checkOnce = true; + void CheckPackageName() { + /* + * I'm not really into the android world but apparently people reuploading existing apps with ads to the store is a thing. + * i'm conflicted about fighting this as it would go against the open source nature of the project. + * So for now i'll just have a slightly obfuscated check here, if you're just making a fork feel free to remove this. + * My only condition is that you don't upload this to the play store. + */ + if (!checkOnce) + return; + + checkOnce = false; + + if (getPackageName().equals("exelix" + ((Integer)11).toString() + getString(R.string.hello_txt).charAt(3) + "sysdvr")) + return; + + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("You're using a SyDVR-Client version that was not downloaded from the official GitHub repository. This is at your own risk."); + dlgAlert.setTitle("Warning"); + dlgAlert.setPositiveButton("Dismiss", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + dialog.cancel(); + } + }); + dlgAlert.setNeutralButton("Open GitHub page", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + SystemHelper.OpenURL("https://github.com/exelix11/SysDVR"); + } + }); + dlgAlert.setCancelable(true); + dlgAlert.create().show(); + } + + public static void Log(String message) { + Log.i("SysDVRJava", message); + } +} diff --git a/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDevice.java b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 00000000..955df5d1 --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDevice.java @@ -0,0 +1,22 @@ +package org.libsdl.app; + +import android.hardware.usb.UsbDevice; + +interface HIDDevice +{ + public int getId(); + public int getVendorId(); + public int getProductId(); + public String getSerialNumber(); + public int getVersion(); + public String getManufacturerName(); + public String getProductName(); + public UsbDevice getDevice(); + public boolean open(); + public int sendFeatureReport(byte[] report); + public int sendOutputReport(byte[] report); + public boolean getFeatureReport(byte[] report); + public void setFrozen(boolean frozen); + public void close(); + public void shutdown(); +} diff --git a/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java new file mode 100644 index 00000000..ee5521fd --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -0,0 +1,650 @@ +package org.libsdl.app; + +import android.content.Context; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGattService; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.os.*; + +//import com.android.internal.util.HexDump; + +import java.lang.Runnable; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.UUID; + +class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { + + private static final String TAG = "hidapi"; + private HIDDeviceManager mManager; + private BluetoothDevice mDevice; + private int mDeviceId; + private BluetoothGatt mGatt; + private boolean mIsRegistered = false; + private boolean mIsConnected = false; + private boolean mIsChromebook = false; + private boolean mIsReconnecting = false; + private boolean mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + + private static final int TRANSPORT_AUTO = 0; + private static final int TRANSPORT_BREDR = 1; + private static final int TRANSPORT_LE = 2; + + private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; + + static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + public void run() { + // This is executed in main thread + BluetoothGattCharacteristic chr; + + switch (mOp) { + case CHR_READ: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Reading characteristic " + chr.getUuid()); + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case CHR_WRITE: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); + chr.setValue(mValue); + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case ENABLE_NOTIFICATION: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); + if (chr != null) { + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + int properties = chr.getProperties(); + byte[] value; + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + Log.e(TAG, "Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } + } + } + } + + public boolean finish() { + return mResult; + } + + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return null; + return valveService.getCharacteristic(uuid); + } + + static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.CHR_READ, uuid); + } + + static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { + return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); + } + } + + public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + public String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + public BluetoothGatt getGatt() { + return mGatt; + } + + // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead + // of TRANSPORT_LE. Let's force ourselves to connect low energy. + private BluetoothGatt connectGatt(boolean managed) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } + + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + protected int getConnectionState() { + + Context context = mManager.getContext(); + if (context == null) { + // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. + return BluetoothProfile.STATE_DISCONNECTED; + } + + BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if (btManager == null) { + // This device doesn't support Bluetooth. We should never be here, because how did + // we instantiate a device to start with? + return BluetoothProfile.STATE_DISCONNECTED; + } + + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } + + public void reconnect() { + + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + + } + + protected void checkConnectionForChromebookIssue() { + if (!mIsChromebook) { + // We only do this on Chromebooks, because otherwise it's really annoying to just attempt + // over and over. + return; + } + + int connectionState = getConnectionState(); + + switch (connectionState) { + case BluetoothProfile.STATE_CONNECTED: + if (!mIsConnected) { + // We are in the Bad Chromebook Place. We can force a disconnect + // to try to recover. + Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + else if (!isRegistered()) { + if (mGatt.getServices().size() > 0) { + Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); + probeService(this); + } + else { + Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + } + else { + Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); + return; + } + break; + + case BluetoothProfile.STATE_DISCONNECTED: + Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); + + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + + case BluetoothProfile.STATE_CONNECTING: + Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); + break; + } + + final HIDDeviceBLESteamController finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.checkConnectionForChromebookIssue(); + } + }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + private boolean probeService(HIDDeviceBLESteamController controller) { + + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + Log.v(TAG, "probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(inputCharacteristic)) { + Log.v(TAG, "Found input characteristic"); + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { + Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + } + + return false; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private void finishCurrentGattOperation() { + GattOperation op = null; + synchronized (mOperations) { + if (mCurrentOperation != null) { + op = mCurrentOperation; + mCurrentOperation = null; + } + } + if (op != null) { + boolean result = op.finish(); // TODO: Maybe in main thread as well? + + // Our operation failed, let's add it back to the beginning of our queue. + if (!result) { + mOperations.addFirst(op); + } + } + executeNextGattOperation(); + } + + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + // Run in main thread + mHandler.post(new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + } + }); + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + public void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + public void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == 2) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(new Runnable() { + @Override + public void run() { + mGatt.discoverServices(); + } + }); + } + } + else if (newState == 0) { + mIsConnected = false; + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().size() == 0) { + Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(false); + } + else { + probeService(this); + } + } + } + + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { + mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue()); + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + // Only register controller with the native side once it has been fully configured + if (!isRegistered()) { + Log.v(TAG, "Registering Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); + + if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); + + if (chr.getUuid().equals(inputCharacteristic)) { + boolean hasWrittenInputDescriptor = true; + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr); + } + } + + finishCurrentGattOperation(); + } + + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + //Log.v(TAG, "onMtuChanged status=" + status); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + //////// Public API + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + // Valve Corporation + final int VALVE_USB_VID = 0x28DE; + return VALVE_USB_VID; + } + + @Override + public int getProductId() { + // We don't have an easy way to query from the Bluetooth device, but we know what it is + final int D0G_BLE2_PID = 0x1106; + return D0G_BLE2_PID; + } + + @Override + public String getSerialNumber() { + // This will be read later via feature report by Steam + return "12345"; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + return "Valve Corporation"; + } + + @Override + public String getProductName() { + return "Steam Controller"; + } + + @Override + public UsbDevice getDevice() { + return null; + } + + @Override + public boolean open() { + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + // We need to skip the first byte, as that doesn't go over the air + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report)); + writeCharacteristic(reportCharacteristic, actual_report); + return report.length; + } + + @Override + public int sendOutputReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + + @Override + public boolean getFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return false; + } + + //Log.v(TAG, "getFeatureReport"); + readCharacteristic(reportCharacteristic); + return true; + } + + @Override + public void close() { + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + @Override + public void shutdown() { + close(); + + BluetoothGatt g = mGatt; + if (g != null) { + g.disconnect(); + g.close(); + mGatt = null; + } + mManager = null; + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + +} + diff --git a/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java new file mode 100644 index 00000000..6f7013b2 --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -0,0 +1,683 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.os.Build; +import android.util.Log; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.hardware.usb.*; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; + +public class HIDDeviceManager { + private static final String TAG = "hidapi"; + private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION"; + + private static HIDDeviceManager sManager; + private static int sManagerRefCount = 0; + + public static HIDDeviceManager acquire(Context context) { + if (sManagerRefCount == 0) { + sManager = new HIDDeviceManager(context); + } + ++sManagerRefCount; + return sManager; + } + + public static void release(HIDDeviceManager manager) { + if (manager == sManager) { + --sManagerRefCount; + if (sManagerRefCount == 0) { + sManager.close(); + sManager = null; + } + } + } + + private Context mContext; + private HashMap mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + + private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceAttached(usbDevice); + } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceDetached(usbDevice); + } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); + } + } + }; + + private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + }; + + private HIDDeviceManager(final Context context) { + mContext = context; + + HIDDeviceRegisterCallback(); + + mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); + mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.commit(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + public Context getContext() { + return mContext; + } + + public int getDeviceIDForIdentifier(String identifier) { + SharedPreferences.Editor spedit = mSharedPreferences.edit(); + + int result = mSharedPreferences.getInt(identifier, 0); + if (result == 0) { + result = mNextDeviceId++; + spedit.putInt("next_device_id", mNextDeviceId); + } + + spedit.putInt(identifier, result); + spedit.commit(); + return result; + } + + private void initializeUSB() { + mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); + if (mUsbManager == null) { + return; + } + + /* + // Logging + for (UsbDevice device : mUsbManager.getDeviceList().values()) { + Log.i(TAG,"Path: " + device.getDeviceName()); + Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); + Log.i(TAG,"Product: " + device.getProductName()); + Log.i(TAG,"ID: " + device.getDeviceId()); + Log.i(TAG,"Class: " + device.getDeviceClass()); + Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); + Log.i(TAG,"Vendor ID " + device.getVendorId()); + Log.i(TAG,"Product ID: " + device.getProductId()); + Log.i(TAG,"Interface count: " + device.getInterfaceCount()); + Log.i(TAG,"---------------------------------------"); + + // Get interface details + for (int index = 0; index < device.getInterfaceCount(); index++) { + UsbInterface mUsbInterface = device.getInterface(index); + Log.i(TAG," ***** *****"); + Log.i(TAG," Interface index: " + index); + Log.i(TAG," Interface ID: " + mUsbInterface.getId()); + Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); + Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); + Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); + Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); + + // Get endpoint details + for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) + { + UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); + Log.i(TAG," ++++ ++++ ++++"); + Log.i(TAG," Endpoint index: " + epi); + Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); + Log.i(TAG," Direction: " + mEndpoint.getDirection()); + Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); + Log.i(TAG," Interval: " + mEndpoint.getInterval()); + Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); + Log.i(TAG," Type: " + mEndpoint.getType()); + } + } + } + Log.i(TAG," No more devices connected."); + */ + + // Register for USB broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); + mContext.registerReceiver(mUsbBroadcast, filter); + + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + handleUsbDeviceAttached(usbDevice); + } + } + + UsbManager getUSBManager() { + return mUsbManager; + } + + private void shutdownUSB() { + try { + mContext.unregisterReceiver(mUsbBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { + return true; + } + if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { + return true; + } + return false; + } + + private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB360_IFACE_SUBCLASS = 93; + final int XB360_IFACE_PROTOCOL = 1; // Wired + final int XB360W_IFACE_PROTOCOL = 129; // Wireless + final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x11c9, // Nacon + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126, Inc. + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2c22, // Qanba + 0x2dc8, // 8BitDo + 0x9886, // ASTRO Gaming + }; + + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || + usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB1_IFACE_SUBCLASS = 71; + final int XB1_IFACE_PROTOCOL = 208; + final int[] SUPPORTED_VENDORS = { + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x10f5, // Turtle Beach + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + }; + + if (usbInterface.getId() == 0 && + usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private void handleUsbDeviceAttached(UsbDevice usbDevice) { + connectHIDDeviceUSB(usbDevice); + } + + private void handleUsbDeviceDetached(UsbDevice usbDevice) { + List devices = new ArrayList(); + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + devices.add(device.getId()); + } + } + for (int id : devices) { + HIDDevice device = mDevicesById.get(id); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + boolean opened = false; + if (permission_granted) { + opened = device.open(); + } + HIDDeviceOpenResult(device.getId(), opened); + } + } + } + + private void connectHIDDeviceUSB(UsbDevice usbDevice) { + synchronized (this) { + int interface_mask = 0; + for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { + UsbInterface usbInterface = usbDevice.getInterface(interface_index); + if (isHIDDeviceInterface(usbDevice, usbInterface)) { + // Check to see if we've already added this interface + // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive + int interface_id = usbInterface.getId(); + if ((interface_mask & (1 << interface_id)) != 0) { + continue; + } + interface_mask |= (1 << interface_id); + + HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); + int id = device.getId(); + mDevicesById.put(id, device); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol()); + } + } + } + } + + private void initializeBluetooth() { + Log.d(TAG, "Initializing Bluetooth"); + + if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) { + Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + + Log.d(TAG, "Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + mContext.registerReceiver(mBluetoothBroadcast, filter); + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + private void shutdownBluetooth() { + try { + mContext.unregisterReceiver(mBluetoothBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. + // This function provides a sort of dummy version of that, watching for changes in the + // connected devices and attempting to add controllers as things change. + public void chromebookConnectionHandler() { + if (!mIsChromebook) { + return; + } + + ArrayList disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + for (BluetoothDevice bluetoothDevice : currentConnected) { + if (!mLastBluetoothDevices.contains(bluetoothDevice)) { + connected.add(bluetoothDevice); + } + } + for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { + if (!currentConnected.contains(bluetoothDevice)) { + disconnected.add(bluetoothDevice); + } + } + + mLastBluetoothDevices = currentConnected; + + for (BluetoothDevice bluetoothDevice : disconnected) { + disconnectBluetoothDevice(bluetoothDevice); + } + for (BluetoothDevice bluetoothDevice : connected) { + connectBluetoothDevice(bluetoothDevice); + } + + final HIDDeviceManager finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.chromebookConnectionHandler(); + } + }, 10000); + } + + public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + + return false; + } + HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); + int id = device.getId(); + mBluetoothDevices.put(bluetoothDevice, device); + mDevicesById.put(id, device); + + // The Steam Controller will mark itself connected once initialization is complete + } + return true; + } + + public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + int id = device.getId(); + mBluetoothDevices.remove(bluetoothDevice); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + public boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + } + + private void close() { + shutdownUSB(); + shutdownBluetooth(); + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.shutdown(); + } + mDevicesById.clear(); + mBluetoothDevices.clear(); + HIDDeviceReleaseCallback(); + } + } + + public void setFrozen(boolean frozen) { + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.setFrozen(frozen); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private HIDDevice getDevice(int id) { + synchronized (this) { + HIDDevice result = mDevicesById.get(id); + if (result == null) { + Log.v(TAG, "No device for id: " + id); + Log.v(TAG, "Available devices: " + mDevicesById.keySet()); + } + return result; + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// JNI interface functions + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + public boolean openDevice(int deviceID) { + Log.v(TAG, "openDevice deviceID=" + deviceID); + HIDDevice device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + // Look to see if this is a USB device and we have permission to access it + UsbDevice usbDevice = device.getDevice(); + if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { + HIDDeviceOpenPending(deviceID); + try { + final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 + int flags; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + flags = FLAG_MUTABLE; + } else { + flags = 0; + } + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags)); + } catch (Exception e) { + Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); + HIDDeviceOpenResult(deviceID, false); + } + return false; + } + + try { + return device.open(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public int sendOutputReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendOutputReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public int sendFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public boolean getFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "getFeatureReport deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + return device.getFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public void closeDevice(int deviceID) { + try { + Log.v(TAG, "closeDevice deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return; + } + + device.close(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////// Native methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private native void HIDDeviceRegisterCallback(); + private native void HIDDeviceReleaseCallback(); + + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol); + native void HIDDeviceOpenPending(int deviceID); + native void HIDDeviceOpenResult(int deviceID, boolean opened); + native void HIDDeviceDisconnected(int deviceID); + + native void HIDDeviceInputReport(int deviceID, byte[] report); + native void HIDDeviceFeatureReport(int deviceID, byte[] report); +} diff --git a/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java new file mode 100644 index 00000000..bfe0cf95 --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -0,0 +1,309 @@ +package org.libsdl.app; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; + +class HIDDeviceUSB implements HIDDevice { + + private static final String TAG = "hidapi"; + + protected HIDDeviceManager mManager; + protected UsbDevice mDevice; + protected int mInterfaceIndex; + protected int mInterface; + protected int mDeviceId; + protected UsbDeviceConnection mConnection; + protected UsbEndpoint mInputEndpoint; + protected UsbEndpoint mOutputEndpoint; + protected InputThread mInputThread; + protected boolean mRunning; + protected boolean mFrozen; + + public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { + mManager = manager; + mDevice = usbDevice; + mInterfaceIndex = interface_index; + mInterface = mDevice.getInterface(mInterfaceIndex).getId(); + mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); + mRunning = false; + } + + public String getIdentifier() { + return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + } + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + return mDevice.getVendorId(); + } + + @Override + public int getProductId() { + return mDevice.getProductId(); + } + + @Override + public String getSerialNumber() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + try { + result = mDevice.getSerialNumber(); + } + catch (SecurityException exception) { + //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); + } + } + if (result == null) { + result = ""; + } + return result; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getManufacturerName(); + } + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getProductName(); + } + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + public String getDeviceName() { + return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; + } + + @Override + public boolean open() { + mConnection = mManager.getUSBManager().openDevice(mDevice); + if (mConnection == null) { + Log.w(TAG, "Unable to open USB device " + getDeviceName()); + return false; + } + + // Force claim our interface + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (!mConnection.claimInterface(iface, true)) { + Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); + close(); + return false; + } + + // Find the endpoints + for (int j = 0; j < iface.getEndpointCount(); j++) { + UsbEndpoint endpt = iface.getEndpoint(j); + switch (endpt.getDirection()) { + case UsbConstants.USB_DIR_IN: + if (mInputEndpoint == null) { + mInputEndpoint = endpt; + } + break; + case UsbConstants.USB_DIR_OUT: + if (mOutputEndpoint == null) { + mOutputEndpoint = endpt; + } + break; + } + } + + // Make sure the required endpoints were present + if (mInputEndpoint == null || mOutputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + close(); + return false; + } + + // Start listening for input + mRunning = true; + mInputThread = new InputThread(); + mInputThread.start(); + + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, + 0x09/*HID set_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName()); + return -1; + } + + if (skipped_report_id) { + ++length; + } + return length; + } + + @Override + public int sendOutputReport(byte[] report) { + int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); + if (r != report.length) { + Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName()); + } + return r; + } + + @Override + public boolean getFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + /* Offset the return buffer by 1, so that the report ID + will remain in byte 0. */ + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, + 0x01/*HID get_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); + return false; + } + + if (skipped_report_id) { + ++res; + ++length; + } + + byte[] data; + if (res == length) { + data = report; + } else { + data = Arrays.copyOfRange(report, 0, res); + } + mManager.HIDDeviceFeatureReport(mDeviceId, data); + + return true; + } + + @Override + public void close() { + mRunning = false; + if (mInputThread != null) { + while (mInputThread.isAlive()) { + mInputThread.interrupt(); + try { + mInputThread.join(); + } catch (InterruptedException e) { + // Keep trying until we're done + } + } + mInputThread = null; + } + if (mConnection != null) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + mConnection.close(); + mConnection = null; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + protected class InputThread extends Thread { + @Override + public void run() { + int packetSize = mInputEndpoint.getMaxPacketSize(); + byte[] packet = new byte[packetSize]; + while (mRunning) { + int r; + try + { + r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); + } + catch (Exception e) + { + Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); + break; + } + if (r < 0) { + // Could be a timeout or an I/O error + } + if (r > 0) { + byte[] data; + if (r == packetSize) { + data = packet; + } else { + data = Arrays.copyOfRange(packet, 0, r); + } + + if (!mFrozen) { + mManager.HIDDeviceInputReport(mDeviceId, data); + } + } + } + } + } +} diff --git a/Client/Platform/Android/app/src/main/java/org/libsdl/app/SDL.java b/Client/Platform/Android/app/src/main/java/org/libsdl/app/SDL.java new file mode 100644 index 00000000..44c21c1c --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/org/libsdl/app/SDL.java @@ -0,0 +1,86 @@ +package org.libsdl.app; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLActivity.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = mContext.getClassLoader().loadClass("android.content.Context"); + Class stringClass = mContext.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, mContext, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Context mContext; +} diff --git a/Client/Platform/Android/app/src/main/java/org/libsdl/app/SDLActivity.java b/Client/Platform/Android/app/src/main/java/org/libsdl/app/SDLActivity.java new file mode 100644 index 00000000..8d0f8d19 --- /dev/null +++ b/Client/Platform/Android/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -0,0 +1,2117 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.text.Editable; +import android.text.InputType; +import android.text.Selection; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.Hashtable; +import java.util.Locale; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 2; + private static final int SDL_MINOR_VERSION = 28; + private static final int SDL_MICRO_VERSION = 3; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unkown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= 23) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 21) { + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 18) { + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unkown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentOrientation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static DummyEdit mTextEdit; + protected static boolean mScreenKeyboardShown; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API12 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + + protected static SDLGenericMotionListener_API12 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API12(); + } + } + + return mMotionListener; + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library; + String[] libraries = SDLActivity.mSingleton.getLibraries(); + if (libraries.length > 0) { + library = "lib" + libraries[libraries.length - 1] + ".so"; + } else { + library = "libmain.so"; + } + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL2". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL2", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL2", + // "SDL2_image", + // "SDL2_mixer", + // "SDL2_net", + // "SDL2_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + + // So we can call stuff from static callbacks + mSingleton = this; + SDL.setContext(this); + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(this); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + mCurrentOrientation = SDLActivity.getCurrentOrientation(); + // Only record current orientation + SDLActivity.onNativeOrientationChanged(mCurrentOrientation); + + try { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getCurrentOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = (Activity)getContext(); + if (activity == null) { + return result; + } + Display display = activity.getWindowManager().getDefaultDisplay(); + + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = SDL_ORIENTATION_PORTRAIT; + break; + + case Surface.ROTATION_90: + result = SDL_ORIENTATION_LANDSCAPE; + break; + + case Surface.ROTATION_180: + result = SDL_ORIENTATION_PORTRAIT_FLIPPED; + break; + + case Surface.ROTATION_270: + result = SDL_ORIENTATION_LANDSCAPE_FLIPPED; + break; + } + + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onLowMemory() { + Log.v(TAG, "onLowMemory()"); + super.onLowMemory(); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + SDLActivity.mSDLThread.join(); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + + super.onDestroy(); + } + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + return super.dispatchKeyEvent(event); + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && mHasFocus && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + static final int COMMAND_CHANGE_TITLE = 1; + static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + static final int COMMAND_TEXTEDIT_HIDE = 3; + static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = SDL.getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + mScreenKeyboardShown = false; + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (SDLActivity.getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (SDLActivity.getContext()) { + try { + SDLActivity.getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native int nativeSetupJNI(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void onNativeOrientationChanged(int orientation); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ + boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); + + if (contains_Portrait && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT; + } else if (contains_Portrait) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full sensor */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { +/* + if (Build.VERSION.SDK_INT >= 24) { + if (mSingleton == null) { + return true; + } + + if (mSingleton.isInMultiWindowMode()) { + return false; + } + + if (mSingleton.isInPictureInPictureMode()) { + return false; + } + } + + return true; +*/ + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isScreenKeyboardShown() + { + if (mTextEdit == null) { + return false; + } + + if (!mScreenKeyboardShown) { + return false; + } + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.isAcceptingText(); + + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Context getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) { + return true; + } + return Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV"); + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = (Activity)getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + if (getContext() == null) { + return false; + } + return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static DisplayMetrics getDisplayDPI() { + return getContext().getResources().getDisplayMetrics(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int x, y, w, h; + + public ShowTextInputTask(int x, int y, int w, int h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new DummyEdit(SDL.getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + mScreenKeyboardShown = true; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode) == 0) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode) == 0) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + onNativeKeyDown(keyCode); + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + int touchDevId = device.getId(); + /* + * Prevent id to be -1, since it's used in SDL internal for synthetic events + * Appears when using Android emulator, eg: + * adb shell input mouse tap 100 100 + * adb shell input touchscreen tap 100 100 + */ + if (touchDevId < 0) { + touchDevId -= 1; + } + nativeAddTouch(touchDevId, device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray