diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ee9f76c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: build + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + - uses: android-actions/setup-android@v3 + with: + packages: platforms;android-36 build-tools;36.0.0 + - name: Server + run: server/build.sh + - name: Mac tests + working-directory: mac + run: swift test + - name: Mac app + working-directory: mac + run: scripts/build-app.sh && ditto -c -k --keepParent dist/DroidBridge.app dist/DroidBridge.zip + - uses: actions/upload-artifact@v4 + with: + name: DroidBridge + path: | + mac/dist/DroidBridge.zip + server/build/droidbridge-server.jar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e14a6e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +.build/ +*.app +dist/ +.DS_Store +.swiftpm/ diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..2cb83af --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +DroidBridge +Copyright 2026 Levi Nóbrega and contributors + +This product includes software from scrcpy (https://github.com/Genymobile/scrcpy), +Copyright (C) 2018 Genymobile, Copyright (C) 2018-2026 Romain Vimont, +licensed under the Apache License, Version 2.0. The unmodified scrcpy 4.1 server +sources are in server/third_party/ (license: server/third_party/SCRCPY-LICENSE). diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6f82f8 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +

DroidBridge

+ +# DroidBridge + +Use your Mac's mouse and keyboard on an Android phone or tablet. Push the pointer past the edge +of your Mac's screen and it shows up on the Android device; push it back and you're on the Mac. +The text clipboard is shared both ways. An open-source take on DeskDock, for macOS. + +- Real Android pointer and keyboard: the device sees a USB mouse and keyboard (UHID). +- Nothing to install on the device: the Mac starts a small server over adb. +- Text clipboard sync in both directions. +- Works with several displays: only edges that don't lead to another display count. +- English and Brazilian Portuguese. + +Status: early (0.1). USB only for now; Wi-Fi and file drag-and-drop are planned. + +## Requirements + +- macOS 13 or later. +- Android 8.1 or later with **USB debugging** on (Settings > About phone > tap Build number 7 times, + then Settings > Developer options > USB debugging). +- `adb` (Android platform-tools): `brew install android-platform-tools`. + +## Use + +1. Connect the device by USB and accept the debugging prompt on it. +2. Open DroidBridge. It lives in the menu bar. +3. Allow it in System Settings > Privacy & Security > Accessibility. +4. In the menu, open **Arrange Screens…** and drag the Android device against the edge of a display. + The pointer passes only through the highlighted stretch; the size slider makes it longer or shorter. +5. Push the pointer through that stretch. + +Back to the Mac: push the pointer against the device edge facing the Mac, or press **⌃⌥⌘B**. + +By default Command acts as Ctrl on Android, so ⌘C / ⌘V / ⌘A work as expected (Control becomes Meta). +Turn it off in the menu to keep Command as Meta. + +The Android keyboard layout follows the Mac's (for example US International - PC becomes +"English (US), International style"), so dead keys and accents type the same. This needs Android 15 or later. + +## Build + +```sh +# Android server (needs ANDROID_HOME with platforms/android-36 and build-tools/36.0.0, JDK 17+) +server/build.sh +# Mac app (Xcode 15+), tests, and the .app bundle in mac/dist/ +cd mac && swift test && scripts/build-app.sh +``` + +Ad-hoc signed builds lose the Accessibility permission each time they are rebuilt. +`mac/scripts/create-signing-identity.sh` creates a local signing identity that keeps it. + +`tools/devtest.py` drives the server on a connected device without the Mac app. + +## How it works + +See [docs/architecture.md](docs/architecture.md) and the [wire protocol](protocol/README.md). + +## License + +Apache License 2.0. Includes the scrcpy server sources (Apache 2.0), see [NOTICE](NOTICE). diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..2771e38 Binary files /dev/null and b/assets/logo.png differ diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..7309a82 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,52 @@ +# How DroidBridge works + +## The device side + +There is no app to install on the phone. The Mac pushes `droidbridge-server.jar` to +`/data/local/tmp` and runs it with `app_process` as the adb shell user, like scrcpy does. +The shell user may create input devices through `/dev/uhid` (Android 8.1 and later), so the +server registers a real USB-HID mouse and keyboard. Android then draws its own pointer, +accelerates it with its own settings, and treats keys exactly like a physical keyboard +(all characters, the IME, the hardware keyboard layout). + +The shell user can also read and write the clipboard in the background, which apps can't do +since Android 10. + +## Coming back to the Mac + +The mouse is relative and Android accelerates it, so the Mac can't compute where the pointer +is. The shell user can't monitor input events, and the system's cursor-position API is internal +to `system_server`. What the shell user can do is ask the input service for a dump: it lists each +device's hovering pointer in display coordinates, e.g. +`DeviceId(15):[... hoveringPointers=[Pointer(id=0, MOUSE) at (0, 1835.57)]]`. A dump takes +about 20 ms on a recent phone, so the server only takes one (at most every 30 ms) while the pointer +moves toward the edge that leads back to the Mac. Once the pointer touches that edge and keeps +being pushed against it, the server tells the Mac, with the position along the edge. + +Measured on a Galaxy Z Fold (Android 17), 2026-10-07: + +- UHID relative mouse: real pointer, class `CURSOR`. +- UHID absolute (X/Y) mouse: Android ignores the device. Not usable. +- Injected events (`InputManager.injectInputEvent`, as scrcpy's "SDK" mouse): exact position but + no pointer is drawn. + +## Entering the device + +When the Mac cursor is pushed out of a free screen edge, the Mac freezes its cursor +(`CGAssociateMouseAndMouseCursorPosition(false)`), hides it and sends `ENTER` with the position +along the edge. The server pushes the pointer into the corner of the entry edge, then walks it along +the edge, measuring with the dump, until it is within about 12 px of the matching position +(0.3-0.5 s). From then on the Mac forwards raw mouse deltas (times the speed setting), buttons, +scrolling and keys, and swallows them locally through a `CGEventTap`. + +## The Mac side + +A menu-bar app (Swift, no Xcode project: Swift Package Manager plus `mac/scripts/build-app.sh`). + +- `DroidBridgeCore`: wire protocol, screen-edge geometry (multiple displays: only edges that don't + lead to another display count), macOS key code to HID usage table. Unit-tested. +- `DroidBridge`: event tap, cursor hiding, adb link with reconnection, clipboard polling, menu. + +Permissions: Accessibility (to swallow events). Hiding the cursor from a background app is not +possible on recent macOS, so the app comes to the front while the pointer is on the device and gives +the focus back to the previous app on return. diff --git a/mac/Package.swift b/mac/Package.swift new file mode 100644 index 0000000..e3c8971 --- /dev/null +++ b/mac/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "DroidBridge", + platforms: [.macOS(.v13)], + targets: [ + .target(name: "DroidBridgeCore"), + .executableTarget(name: "DroidBridge", dependencies: ["DroidBridgeCore"]), + .testTarget(name: "DroidBridgeCoreTests", dependencies: ["DroidBridgeCore"]), + ] +) diff --git a/mac/Resources/AppIcon-1024.png b/mac/Resources/AppIcon-1024.png new file mode 100644 index 0000000..a447359 Binary files /dev/null and b/mac/Resources/AppIcon-1024.png differ diff --git a/mac/Resources/MenuBarIcon.png b/mac/Resources/MenuBarIcon.png new file mode 100644 index 0000000..a1992b8 Binary files /dev/null and b/mac/Resources/MenuBarIcon.png differ diff --git a/mac/Resources/MenuBarIcon@2x.png b/mac/Resources/MenuBarIcon@2x.png new file mode 100644 index 0000000..e263cef Binary files /dev/null and b/mac/Resources/MenuBarIcon@2x.png differ diff --git a/mac/Resources/en.lproj/Localizable.strings b/mac/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..72a3bcf --- /dev/null +++ b/mac/Resources/en.lproj/Localizable.strings @@ -0,0 +1,33 @@ +"status.noAdb" = "adb not found: install Android platform-tools"; +"status.waiting" = "Waiting for an Android device (USB debugging)"; +"status.connecting" = "Connecting…"; +"status.connected" = "Connected: %@"; +"status.onDevice" = "Controlling %@"; +"status.failed" = "Could not connect: %@"; +"menu.grantAccessibility" = "Allow Accessibility access…"; +"menu.share" = "Share mouse and keyboard"; +"menu.speed" = "Pointer speed on Android"; +"speed.multiplier" = "%d×"; +"menu.commandAsControl" = "Command acts as Ctrl"; +"menu.invertScroll" = "Invert scrolling on Android"; +"menu.hotkey" = "Back to the Mac: ⌃⌥⌘B"; +"menu.quit" = "Quit DroidBridge"; +"menu.arrange" = "Arrange Screens…"; +"arrangement.title" = "Arrange Screens"; +"arrangement.hint" = "Drag the Android device against one or two displays, as in System Settings > Displays. The pointer only passes through the highlighted stretches; along the rest of the edges it stops."; +"arrangement.passage" = "Passage"; +"arrangement.size" = "Size of the Android device in the arrangement"; +"arrangement.sizeHint" = "Larger means a longer passage."; +"arrangement.reset" = "Restore Default"; +"arrangement.done" = "Done"; +"arrangement.device" = "Android"; +"edge.right" = "right"; +"edge.left" = "left"; +"edge.top" = "top"; +"edge.bottom" = "bottom"; +"axis.height" = "height"; +"axis.width" = "width"; +"passage.text" = "%1$@: %2$@ edge, from %3$d%% to %4$d%% of its %5$@."; +"passage.drop" = "Drop to attach to %1$@: %2$@ edge, from %3$d%% to %4$d%% of its %5$@."; +"passage.enters" = "The pointer enters the Android device through the side that touches the display, at the matching position."; +"passage.dropNone" = "Not touching any display: dropping it here puts it back where it was."; diff --git a/mac/Resources/pt-BR.lproj/Localizable.strings b/mac/Resources/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000..f2cf71c --- /dev/null +++ b/mac/Resources/pt-BR.lproj/Localizable.strings @@ -0,0 +1,33 @@ +"status.noAdb" = "adb não encontrado: instale o Android platform-tools"; +"status.waiting" = "Aguardando um Android (depuração USB)"; +"status.connecting" = "Conectando…"; +"status.connected" = "Conectado: %@"; +"status.onDevice" = "Controlando %@"; +"status.failed" = "Não foi possível conectar: %@"; +"menu.grantAccessibility" = "Permitir acesso de Acessibilidade…"; +"menu.share" = "Compartilhar mouse e teclado"; +"menu.speed" = "Velocidade do ponteiro no Android"; +"speed.multiplier" = "%d×"; +"menu.commandAsControl" = "Command funciona como Ctrl"; +"menu.invertScroll" = "Inverter rolagem no Android"; +"menu.hotkey" = "Voltar ao Mac: ⌃⌥⌘B"; +"menu.quit" = "Sair do DroidBridge"; +"menu.arrange" = "Organizar telas…"; +"arrangement.title" = "Organizar telas"; +"arrangement.hint" = "Arraste o Android até encostar em um ou dois monitores, como em Ajustes do Sistema > Monitores. O ponteiro passa só pelos trechos destacados; no resto das bordas ele para."; +"arrangement.passage" = "Passagem"; +"arrangement.size" = "Tamanho do Android na organização"; +"arrangement.sizeHint" = "Maior = trecho de passagem mais longo."; +"arrangement.reset" = "Restaurar padrão"; +"arrangement.done" = "Concluir"; +"arrangement.device" = "Android"; +"edge.right" = "direita"; +"edge.left" = "esquerda"; +"edge.top" = "de cima"; +"edge.bottom" = "de baixo"; +"axis.height" = "altura"; +"axis.width" = "largura"; +"passage.text" = "%1$@: borda %2$@, de %3$d%% a %4$d%% da %5$@."; +"passage.drop" = "Solte para encostar em %1$@: borda %2$@, de %3$d%% a %4$d%% da %5$@."; +"passage.enters" = "O ponteiro entra no Android pelo lado que encosta no monitor, na posição correspondente."; +"passage.dropNone" = "Longe dos monitores: soltando aqui, ele volta para onde estava."; diff --git a/mac/Sources/DroidBridge/AppDelegate.swift b/mac/Sources/DroidBridge/AppDelegate.swift new file mode 100644 index 0000000..8cc5e4a --- /dev/null +++ b/mac/Sources/DroidBridge/AppDelegate.swift @@ -0,0 +1,229 @@ +import AppKit +import ApplicationServices +import Carbon +import DroidBridgeCore +import os + +func L(_ key: String) -> String { NSLocalizedString(key, comment: "") } + +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + private let link = DeviceLink() + private let capture = InputCapture() + private let clipboard = ClipboardSync() + private var statusItem: NSStatusItem! + private var tapTimer: Timer? + private let arrangementWindow = ArrangementWindowController() + private var passages: [Passage] = [] + private let log = Logger(subsystem: "dev.droidbridge", category: "app") + + func applicationDidFinishLaunching(_ notification: Notification) { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.menu = NSMenu() + statusItem.menu?.delegate = self + updateIcon() + + applySettings() + capture.send = { [weak self] in self?.link.send($0) } + capture.canCross = { [weak self] in + guard let self, Settings.enabled, case .connected = self.link.state else { return false } + return true + } + capture.onRemoteChanged = { [weak self] _ in self?.updateIcon() } + capture.passages = { [weak self] in self?.passages ?? [] } + refreshPassage() + arrangementWindow.model.onChange = { [weak self] in self?.refreshPassage() } + NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification, object: nil, + queue: .main) { [weak self] _ in + self?.refreshPassage() + if self?.capture.isRemote == false { self?.arrangementWindow.model.reload() } + } + DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name(kTISNotifySelectedKeyboardInputSourceChanged as String), object: nil, queue: .main + ) { [weak self] _ in self?.sendKeyboardLayout() } + clipboard.send = { [weak self] text in + guard let self, case .connected = self.link.state else { return } + self.link.send(Wire.clipboard(text)) + } + link.onState = { [weak self] state in + guard let self else { return } + if case let .connected(model, width, height) = state { + self.clipboard.pushCurrent() + self.sendKeyboardLayout() + self.arrangementWindow.model.deviceName = model + self.arrangementWindow.model.deviceSize = CGSize(width: width, height: height) + self.refreshPassage() + } else { + self.capture.returnToMac() + } + self.updateIcon() + } + link.onMessage = { [weak self] message in + switch message { + case let .edge(side, ratio): self?.capture.returnToMac(side: side, ratio: ratio) + case let .clipboard(text): self?.clipboard.receivedFromDevice(text) + default: break + } + } + + let trusted = AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary) + log.info("accessibility trusted: \(trusted)") + startTapWhenAllowed() + clipboard.start() + link.start() + } + + func applicationWillTerminate(_ notification: Notification) { + capture.returnToMac() + link.stop() + } + + private func refreshPassage() { + passages = Arrangements.passages(for: DisplayInfo.all(), aspect: arrangementWindow.model.aspect) + } + + /// Makes the device's keyboard layout match the Mac's current one, so dead keys work the same. + private func sendKeyboardLayout() { + guard case .connected = link.state, + let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let raw = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return } + let id = Unmanaged.fromOpaque(raw).takeUnretainedValue() as String + let layout = KeyboardLayoutMap.androidLayout(forInputSource: id) + capture.cedilla.enabled = layout == "english_us_intl" + if let layout { + log.info("keyboard layout \(id, privacy: .public) -> \(layout, privacy: .public)") + link.send(Wire.layout(layout)) + } else { + log.info("no Android layout for \(id, privacy: .public)") + } + } + + private func startTapWhenAllowed() { + if capture.start() { return } + tapTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] t in + if self?.capture.start() == true { + t.invalidate() + self?.updateIcon() + } + } + } + + private func applySettings() { + capture.options.speed = Settings.speed + capture.options.commandAsControl = Settings.commandAsControl + capture.options.invertScroll = Settings.invertScroll + } + + private lazy var menuBarIcon: NSImage? = { + let image = Bundle.main.image(forResource: "MenuBarIcon") + image?.isTemplate = true + image?.size = NSSize(width: 18, height: 18) + return image + }() + + /// The bridge glyph; dimmed while no device is connected. + private func updateIcon() { + statusItem.button?.image = menuBarIcon ?? NSImage(systemSymbolName: "keyboard", accessibilityDescription: "DroidBridge") + if case .connected = link.state { + statusItem.button?.appearsDisabled = false + } else { + statusItem.button?.appearsDisabled = true + } + statusItem.button?.toolTip = statusText() + } + + // MARK: - Menu + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + menu.addItem(disabled(statusText())) + if !AXIsProcessTrusted() { + menu.addItem(item(L("menu.grantAccessibility"), #selector(openAccessibility))) + } + menu.addItem(.separator()) + + let share = item(L("menu.share"), #selector(toggleShare)) + share.state = Settings.enabled ? .on : .off + menu.addItem(share) + + menu.addItem(item(L("menu.arrange"), #selector(openArrangement))) + + let speed = NSMenuItem(title: L("menu.speed"), action: nil, keyEquivalent: "") + let speedMenu = NSMenu() + for value in [1.0, 2.0, 3.0, 4.0, 6.0] { + let i = item(String(format: L("speed.multiplier"), Int(value)), #selector(chooseSpeed(_:))) + i.representedObject = value + i.state = Settings.speed == value ? .on : .off + speedMenu.addItem(i) + } + speed.submenu = speedMenu + menu.addItem(speed) + + let cmd = item(L("menu.commandAsControl"), #selector(toggleCommand)) + cmd.state = Settings.commandAsControl ? .on : .off + menu.addItem(cmd) + let scroll = item(L("menu.invertScroll"), #selector(toggleScroll)) + scroll.state = Settings.invertScroll ? .on : .off + menu.addItem(scroll) + + menu.addItem(.separator()) + menu.addItem(disabled(L("menu.hotkey"))) + menu.addItem(.separator()) + menu.addItem(item(L("menu.quit"), #selector(quit))) + } + + private func statusText() -> String { + switch link.state { + case .noAdb: return L("status.noAdb") + case .waiting: return L("status.waiting") + case .connecting: return L("status.connecting") + case let .connected(model, _, _): + return String(format: L(capture.isRemote ? "status.onDevice" : "status.connected"), model) + case let .failed(reason): return String(format: L("status.failed"), reason) + } + } + + private func item(_ title: String, _ action: Selector) -> NSMenuItem { + let i = NSMenuItem(title: title, action: action, keyEquivalent: "") + i.target = self + return i + } + + private func disabled(_ title: String) -> NSMenuItem { + let i = NSMenuItem(title: title, action: nil, keyEquivalent: "") + i.isEnabled = false + return i + } + + @objc private func toggleShare() { + Settings.enabled.toggle() + if !Settings.enabled { capture.returnToMac() } + } + + @objc private func openArrangement() { + arrangementWindow.show() + } + + @objc private func chooseSpeed(_ sender: NSMenuItem) { + guard let v = sender.representedObject as? Double else { return } + Settings.speed = v + applySettings() + } + + @objc private func toggleCommand() { + Settings.commandAsControl.toggle() + applySettings() + } + + @objc private func toggleScroll() { + Settings.invertScroll.toggle() + applySettings() + } + + @objc private func openAccessibility() { + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) + } + + @objc private func quit() { + NSApp.terminate(nil) + } +} diff --git a/mac/Sources/DroidBridge/ArrangementWindow.swift b/mac/Sources/DroidBridge/ArrangementWindow.swift new file mode 100644 index 0000000..166a7e3 --- /dev/null +++ b/mac/Sources/DroidBridge/ArrangementWindow.swift @@ -0,0 +1,310 @@ +import AppKit +import DroidBridgeCore +import SwiftUI + +/// "Arrange screens": drag the Android device against the edges of the Mac displays, like in the +/// macOS Displays settings. Every stretch of a display edge it touches is a passage; it can touch two +/// displays at once (in a corner between them). The size slider scales it. +final class ArrangementModel: ObservableObject { + @Published var displays: [DisplayInfo] = [] + @Published var device: CGRect? + @Published var deviceName: String? + @Published var deviceSize: CGSize? { + didSet { if oldValue != deviceSize { reload() } } + } + /// Drag offset of the device, in global points. + @Published var drag: CGSize? + + var onChange: (() -> Void)? + + static let snapDistance: CGFloat = 24 + + func reload() { + displays = DisplayInfo.all() + device = Arrangements.deviceRect(for: displays, aspect: aspect) + } + + /// Device width / height as it is held now. + var aspect: CGFloat { + guard let s = deviceSize, s.width > 0, s.height > 0 else { return Arrangements.defaultAspect } + return s.width / s.height + } + + var bounds: [CGRect] { displays.map(\.bounds) } + + var passages: [Passage] { + guard let device else { return [] } + return EdgeGeometry.passages(device: device, displays: bounds) + } + + func candidate(for ghost: CGRect, scale: CGFloat) -> CGRect? { + EdgeGeometry.snap(device: ghost, displays: bounds, reach: Self.snapDistance / scale) + } + + func commit(_ r: CGRect) { + device = r + Arrangements.save(r, displays: displays) + onChange?() + } + + /// The slider: the device height relative to the display it touches first. + var size: Double { + guard let device, let d = passages.first?.display else { return 0.6 } + return Double(device.height / d.height) + } + + func setSize(_ value: Double) { + let ps = passages + guard let r = device, let d = ps.first?.display else { return } + let h = CGFloat(value) * d.height + let w = h * aspect + var x = r.midX - w / 2 + var y = r.midY - h / 2 + // Keep the sides that touch a display where they are. + for p in ps { + switch p.edge { + case .right: x = r.minX + case .left: x = r.maxX - w + case .top: y = r.maxY - h + case .bottom: y = r.minY + } + } + let next = CGRect(x: x, y: y, width: w, height: h) + let overlaps = bounds.contains { $0.intersection(next).width > 0.5 && $0.intersection(next).height > 0.5 } + guard !overlaps, !EdgeGeometry.passages(device: next, displays: bounds).isEmpty else { return } + commit(next) + } + + func reset() { + Settings.arrangement = nil + reload() + onChange?() + } + + func describe(_ p: Passage, format: String = "passage.text") -> String { + let name = displays.first { $0.bounds == p.display }?.name ?? "?" + return String(format: L(format), name, L("edge.\(p.edge.rawValue)"), Int((p.start * 100).rounded()), + Int((p.end * 100).rounded()), L(p.edge.isVertical ? "axis.height" : "axis.width")) + } +} + +struct ArrangementView: View { + @ObservedObject var model: ArrangementModel + var onDone: () -> Void + static let accent = Color(red: 0x2F / 255, green: 0xBF / 255, blue: 0x71 / 255) + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(L("arrangement.hint")) + .font(.system(size: 13)) + .foregroundColor(Color(white: 0.28)) + .fixedSize(horizontal: false, vertical: true) + GeometryReader { geo in + ArrangementCanvas(model: model, size: geo.size) + } + .frame(height: 320) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(red: 0.914, green: 0.914, blue: 0.929))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color(white: 0.855))) + HStack(alignment: .top, spacing: 16) { + passageCard + sizeCard + } + HStack { + Button(L("arrangement.reset")) { model.reset() } + Spacer() + Button(L("arrangement.done"), action: onDone).keyboardShortcut(.defaultAction) + } + } + .padding(EdgeInsets(top: 20, leading: 24, bottom: 20, trailing: 24)) + .frame(width: 880) + } + + private var passageCard: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle().fill(Self.accent).frame(width: 10, height: 10) + Text(L("arrangement.passage")).font(.system(size: 13, weight: .semibold)) + } + ForEach(Array(model.passages.enumerated()), id: \.offset) { _, p in + Text(model.describe(p)).font(.system(size: 13)).fixedSize(horizontal: false, vertical: true) + } + Text(L("passage.enters")).font(.system(size: 12)).foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.white)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color(white: 0.878))) + } + + private var sizeCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text(L("arrangement.size")).font(.system(size: 13, weight: .semibold)) + Slider(value: Binding(get: { model.size }, set: { model.setSize($0) }), in: 0.2...1) + Text(L("arrangement.sizeHint")).font(.system(size: 12)).foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: 14, leading: 16, bottom: 14, trailing: 16)) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.white)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color(white: 0.878))) + } +} + +/// The displays and the device, drawn to scale. +private struct ArrangementCanvas: View { + @ObservedObject var model: ArrangementModel + let size: CGSize + + var body: some View { + let t = transform() + let dragging = model.drag != nil + let candidate = model.drag.flatMap { drag in + model.device.flatMap { model.candidate(for: $0.offsetBy(dx: drag.width, dy: drag.height), scale: t.scale) } + } + let candidatePassages = candidate.map { EdgeGeometry.passages(device: $0, displays: model.bounds) } ?? [] + ZStack(alignment: .topLeading) { + ForEach(model.displays) { d in + displayView(d).frame(width: d.bounds.width * t.scale, height: d.bounds.height * t.scale) + .offset(t.point(d.bounds.origin)) + } + if let rect = model.device { + ForEach(Array(model.passages.enumerated()), id: \.offset) { _, p in + passageBar(p, t: t, color: dragging ? Color(white: 0.78) : ArrangementView.accent) + } + if let c = candidate { + ForEach(Array(candidatePassages.enumerated()), id: \.offset) { _, p in + passageBar(p, t: t, color: ArrangementView.accent) + } + RoundedRectangle(cornerRadius: 10) + .stroke(ArrangementView.accent, style: StrokeStyle(lineWidth: 2, dash: [6, 4])) + .frame(width: c.width * t.scale, height: c.height * t.scale) + .offset(t.point(c.origin)) + } + // One view for both states, so the drag gesture survives the switch. + let shown = model.drag.map { rect.offsetBy(dx: $0.width, dy: $0.height) } ?? rect + deviceView(ghost: dragging).frame(width: shown.width * t.scale, height: shown.height * t.scale) + .offset(t.point(shown.origin)) + .gesture(dragGesture(rect: rect, t: t)) + if dragging { + VStack(alignment: .leading, spacing: 2) { + if candidatePassages.isEmpty { + Text(L("passage.dropNone")) + } else { + ForEach(Array(candidatePassages.enumerated()), id: \.offset) { _, p in + Text(model.describe(p, format: "passage.drop")) + } + } + } + .font(.system(size: 12, weight: .medium)) + .padding(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.92))) + .offset(x: 10, y: 10) + } + } + } + .frame(width: size.width, height: size.height, alignment: .topLeading) + .clipped() + } + + private func dragGesture(rect: CGRect, t: Transform) -> some Gesture { + DragGesture(minimumDistance: 2) + .onChanged { v in + model.drag = CGSize(width: v.translation.width / t.scale, height: v.translation.height / t.scale) + } + .onEnded { v in + let ghost = rect.offsetBy(dx: v.translation.width / t.scale, dy: v.translation.height / t.scale) + if let c = model.candidate(for: ghost, scale: t.scale) { model.commit(c) } + model.drag = nil + } + } + + private func displayView(_ d: DisplayInfo) -> some View { + VStack(spacing: 0) { + if d.isMain { + Rectangle().fill(Color(white: 0.96)).frame(height: 8) + } + Text(d.name) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.center) + .padding(4) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color(red: 0.31, green: 0.43, blue: 0.56)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.24, green: 0.34, blue: 0.45), lineWidth: 2)) + } + + private func deviceView(ghost: Bool) -> some View { + VStack(spacing: 4) { + Image(systemName: "iphone").font(.system(size: 16)).foregroundColor(.white) + Text(model.deviceName ?? L("arrangement.device")) + .font(.system(size: 12, weight: .semibold)).foregroundColor(.white).lineLimit(1) + if !ghost, let s = model.deviceSize { + Text("\(Int(s.width)) × \(Int(s.height))").font(.system(size: 11)).foregroundColor(Color(white: 0.72)) + } + } + .padding(4) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(RoundedRectangle(cornerRadius: 10).fill(Color(red: 0.125, green: 0.129, blue: 0.141).opacity(ghost ? 0.82 : 1))) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(ghost ? Color.white : ArrangementView.accent, lineWidth: 2)) + .shadow(color: .black.opacity(ghost ? 0.28 : 0), radius: 12, y: 6) + .contentShape(Rectangle()) + } + + private func passageBar(_ p: Passage, t: Transform, color: Color) -> some View { + let d = p.display + let r: CGRect + switch p.edge { + case .right: r = CGRect(x: d.maxX, y: d.minY + CGFloat(p.start) * d.height, width: 0, height: CGFloat(p.end - p.start) * d.height) + case .left: r = CGRect(x: d.minX, y: d.minY + CGFloat(p.start) * d.height, width: 0, height: CGFloat(p.end - p.start) * d.height) + case .top: r = CGRect(x: d.minX + CGFloat(p.start) * d.width, y: d.minY, width: CGFloat(p.end - p.start) * d.width, height: 0) + case .bottom: r = CGRect(x: d.minX + CGFloat(p.start) * d.width, y: d.maxY, width: CGFloat(p.end - p.start) * d.width, height: 0) + } + let o = t.point(r.origin) + let w = max(r.width * t.scale, 6) + let h = max(r.height * t.scale, 6) + return RoundedRectangle(cornerRadius: 3).fill(color) + .frame(width: w, height: h) + .offset(x: o.width - (r.width == 0 ? 3 : 0), y: o.height - (r.height == 0 ? 3 : 0)) + } + + struct Transform { + let scale: CGFloat + let origin: CGPoint + func point(_ p: CGPoint) -> CGSize { CGSize(width: (p.x - origin.x) * scale, height: (p.y - origin.y) * scale) } + } + + /// Fits the displays, with room around them for the device, into the view. + private func transform() -> Transform { + let union = model.displays.map(\.bounds).reduce(CGRect.null) { $0.union($1) } + guard !union.isNull, size.width > 0 else { return Transform(scale: 1, origin: .zero) } + let margin = max(union.width, union.height) * 0.28 + let area = union.insetBy(dx: -margin, dy: -margin) + let scale = min(size.width / area.width, size.height / area.height) + let origin = CGPoint(x: union.midX - size.width / scale / 2, y: union.midY - size.height / scale / 2) + return Transform(scale: scale, origin: origin) + } +} + +/// Hosts the view in a window; one at a time. +final class ArrangementWindowController { + private var window: NSWindow? + let model = ArrangementModel() + + func show() { + model.reload() + if window == nil { + let host = NSHostingController(rootView: ArrangementView(model: model, onDone: { [weak self] in self?.window?.close() })) + let w = NSWindow(contentViewController: host) + w.title = L("arrangement.title") + w.styleMask = [.titled, .closable] + w.isReleasedWhenClosed = false + w.center() + window = w + } + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } +} diff --git a/mac/Sources/DroidBridge/ClipboardSync.swift b/mac/Sources/DroidBridge/ClipboardSync.swift new file mode 100644 index 0000000..44ba457 --- /dev/null +++ b/mac/Sources/DroidBridge/ClipboardSync.swift @@ -0,0 +1,35 @@ +import AppKit + +/// Keeps the text clipboard the same on both sides. +final class ClipboardSync { + var send: ((String) -> Void)? + private var timer: Timer? + private var lastChangeCount = NSPasteboard.general.changeCount + private var lastFromDevice: String? + + func start() { + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in self?.poll() } + } + + /// Sends the current Mac clipboard (on connect). + func pushCurrent() { + lastChangeCount = NSPasteboard.general.changeCount + if let text = NSPasteboard.general.string(forType: .string), text != lastFromDevice { send?(text) } + } + + func receivedFromDevice(_ text: String) { + lastFromDevice = text + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + lastChangeCount = pb.changeCount + } + + private func poll() { + let pb = NSPasteboard.general + guard pb.changeCount != lastChangeCount else { return } + lastChangeCount = pb.changeCount + guard let text = pb.string(forType: .string), text != lastFromDevice else { return } + send?(text) + } +} diff --git a/mac/Sources/DroidBridge/DeviceLink.swift b/mac/Sources/DroidBridge/DeviceLink.swift new file mode 100644 index 0000000..ac33b05 --- /dev/null +++ b/mac/Sources/DroidBridge/DeviceLink.swift @@ -0,0 +1,223 @@ +import DroidBridgeCore +import Foundation +import os + +/// Finds an Android device over adb, starts the server on it and keeps one connection open. +/// All callbacks run on the main queue. +final class DeviceLink { + enum State: Equatable { + case noAdb + case waiting + case connecting(String) + case connected(model: String, width: Int, height: Int) + case failed(String) + } + + static let serverVersion = "0.1.0" + private static let remoteJar = "/data/local/tmp/droidbridge-server.jar" + private let log = Logger(subsystem: "dev.droidbridge", category: "link") + + var onState: ((State) -> Void)? + var onMessage: ((Wire.Message) -> Void)? + + private(set) var state: State = .waiting { + didSet { if state != oldValue { let s = state; DispatchQueue.main.async { self.onState?(s) } } } + } + + private let queue = DispatchQueue(label: "dev.droidbridge.link") + private let writeQueue = DispatchQueue(label: "dev.droidbridge.write") + private var adb: String? + private var server: Process? + private var socket: Int32 = -1 + private var forwardPort: Int? + private var serial: String? + private var stopped = false + + func start() { + queue.async { self.loop() } + } + + func stop() { + stopped = true + queue.async { self.disconnect() } + } + + func send(_ data: Data) { + writeQueue.async { [weak self] in + guard let self, self.socket >= 0 else { return } + data.withUnsafeBytes { raw in + var offset = 0 + while offset < raw.count { + let n = Darwin.send(self.socket, raw.baseAddress! + offset, raw.count - offset, 0) + if n <= 0 { return } + offset += n + } + } + } + } + + // MARK: - Connection loop (runs on `queue`) + + private func loop() { + while !stopped { + if adb == nil { adb = Self.findAdb() } + guard let adb else { + state = .noAdb + Thread.sleep(forTimeInterval: 3) + continue + } + guard let device = firstDevice(adb) else { + state = .waiting + Thread.sleep(forTimeInterval: 2) + continue + } + do { + try connect(adb: adb, serial: device) + readUntilClosed() + } catch { + log.error("connect failed: \(error.localizedDescription, privacy: .public)") + state = .failed(error.localizedDescription) + } + disconnect() + Thread.sleep(forTimeInterval: 2) + } + } + + private func connect(adb: String, serial: String) throws { + self.serial = serial + state = .connecting(serial) + guard let jar = Bundle.main.url(forResource: "droidbridge-server", withExtension: "jar") else { + throw LinkError("droidbridge-server.jar is missing from the app") + } + try run(adb, ["-s", serial, "push", jar.path, Self.remoteJar]) + let port = try run(adb, ["-s", serial, "forward", "tcp:0", "localabstract:droidbridge"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let p = Int(port) else { throw LinkError("adb forward returned \(port)") } + forwardPort = p + + let process = Process() + process.executableURL = URL(fileURLWithPath: adb) + process.arguments = ["-s", serial, "shell", "CLASSPATH=\(Self.remoteJar)", "app_process", "/", + "dev.droidbridge.Main", Self.serverVersion] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + let ready = DispatchSemaphore(value: 0) + var buffer = "" + pipe.fileHandleForReading.readabilityHandler = { [weak self] h in + let chunk = String(decoding: h.availableData, as: UTF8.self) + if chunk.isEmpty { h.readabilityHandler = nil; ready.signal(); return } + buffer += chunk + while let nl = buffer.firstIndex(of: "\n") { + let line = String(buffer[..= 0 { close(socket); socket = -1 } + } + if let server, server.isRunning { server.terminate() } + server = nil + if let adb, let serial, let forwardPort { + _ = try? run(adb, ["-s", serial, "forward", "--remove", "tcp:\(forwardPort)"]) + } + forwardPort = nil + if !stopped, state != .noAdb { state = .waiting } + } + + // MARK: - Helpers + + private func firstDevice(_ adb: String) -> String? { + guard let out = try? run(adb, ["devices"]) else { return nil } + for line in out.split(separator: "\n").dropFirst() { + let parts = line.split(whereSeparator: { $0 == "\t" || $0 == " " }) + if parts.count >= 2, parts[1] == "device" { return String(parts[0]) } + } + return nil + } + + @discardableResult + private func run(_ tool: String, _ args: [String]) throws -> String { + let p = Process() + p.executableURL = URL(fileURLWithPath: tool) + p.arguments = args + let out = Pipe() + p.standardOutput = out + p.standardError = out + try p.run() + let data = out.fileHandleForReading.readDataToEndOfFile() + p.waitUntilExit() + let text = String(decoding: data, as: UTF8.self) + guard p.terminationStatus == 0 else { throw LinkError("adb \(args.joined(separator: " ")): \(text)") } + return text + } + + static func findAdb() -> String? { + var candidates = ["/opt/homebrew/bin/adb", "/usr/local/bin/adb", + NSHomeDirectory() + "/Library/Android/sdk/platform-tools/adb"] + if let sdk = ProcessInfo.processInfo.environment["ANDROID_HOME"] { + candidates.insert(sdk + "/platform-tools/adb", at: 0) + } + if let bundled = Bundle.main.url(forResource: "adb", withExtension: nil)?.path { + candidates.insert(bundled, at: 0) + } + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + private static func connectLocal(port: Int) throws -> Int32 { + let fd = Darwin.socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw LinkError("socket() failed") } + var one: Int32 = 1 + setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(UInt16(port).bigEndian) + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let r = withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard r == 0 else { close(fd); throw LinkError("cannot connect to the device") } + return fd + } +} + +struct LinkError: LocalizedError { + let message: String + init(_ message: String) { self.message = message } + var errorDescription: String? { message } +} diff --git a/mac/Sources/DroidBridge/Displays.swift b/mac/Sources/DroidBridge/Displays.swift new file mode 100644 index 0000000..db2cd72 --- /dev/null +++ b/mac/Sources/DroidBridge/Displays.swift @@ -0,0 +1,56 @@ +import AppKit +import DroidBridgeCore + +/// A Mac display, with a key that stays the same across reconnections and reboots. +struct DisplayInfo: Equatable, Identifiable { + let id: CGDirectDisplayID + let key: String + let name: String + let bounds: CGRect + let isMain: Bool + + static func all() -> [DisplayInfo] { + var count: UInt32 = 0 + CGGetActiveDisplayList(0, nil, &count) + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + CGGetActiveDisplayList(count, &ids, &count) + let names = Dictionary(NSScreen.screens.compactMap { s -> (CGDirectDisplayID, String)? in + guard let n = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { return nil } + return (CGDirectDisplayID(n.uint32Value), s.localizedName) + }, uniquingKeysWith: { a, _ in a }) + return ids.prefix(Int(count)).map { id in + let key = CGDisplayIsBuiltin(id) != 0 ? "builtin" + : "\(CGDisplayVendorNumber(id))-\(CGDisplayModelNumber(id))-\(CGDisplaySerialNumber(id))" + return DisplayInfo(id: id, key: key, name: names[id] ?? "Display \(id)", bounds: CGDisplayBounds(id), + isMain: CGDisplayIsMain(id) != 0) + } + } +} + +/// Resolves the saved arrangement against the displays connected now. +enum Arrangements { + /// Device width / height while the size is unknown (a portrait phone). + static let defaultAspect: CGFloat = 1080.0 / 2340.0 + + /// The device rectangle in global points: the saved one when its anchor display is connected, + /// otherwise the default. + static func deviceRect(for displays: [DisplayInfo], aspect: CGFloat) -> CGRect? { + if let a = Settings.arrangement, let anchor = displays.first(where: { $0.key == a.anchorKey }) { + let r = a.rect(anchor: anchor.bounds) + if !EdgeGeometry.passages(device: r, displays: displays.map(\.bounds)).isEmpty { return r } + } + return EdgeGeometry.defaultDevice(displays: displays.map(\.bounds), aspect: aspect) + } + + static func passages(for displays: [DisplayInfo], aspect: CGFloat) -> [Passage] { + guard let r = deviceRect(for: displays, aspect: aspect) else { return [] } + return EdgeGeometry.passages(device: r, displays: displays.map(\.bounds)) + } + + /// Saves a device rectangle, anchored to the first display it touches. + static func save(_ r: CGRect, displays: [DisplayInfo]) { + let ps = EdgeGeometry.passages(device: r, displays: displays.map(\.bounds)) + guard let first = ps.first, let anchor = displays.first(where: { $0.bounds == first.display }) else { return } + Settings.arrangement = Arrangement(anchorKey: anchor.key, rect: r, anchor: anchor.bounds) + } +} diff --git a/mac/Sources/DroidBridge/InputCapture.swift b/mac/Sources/DroidBridge/InputCapture.swift new file mode 100644 index 0000000..836b7b4 --- /dev/null +++ b/mac/Sources/DroidBridge/InputCapture.swift @@ -0,0 +1,292 @@ +import AppKit +import CoreGraphics +import DroidBridgeCore +import os + +/// Watches global mouse and keyboard events. While the pointer is "on Android", it swallows them and +/// forwards them to the device instead. +final class InputCapture { + struct Options { + var speed: Double = 1.0 + var commandAsControl = true + var invertScroll = false + } + + var options = Options() + /// Sends a frame to the device. + var send: ((Data) -> Void)? + /// Called when control moves to the device (true) or back to the Mac (false). + var onRemoteChanged: ((Bool) -> Void)? + /// Whether crossing to the device is allowed (a device is connected and sharing is on). + var canCross: () -> Bool = { false } + /// The passages to the device for the current displays (see Arrangements). + var passages: () -> [Passage] = { [] } + /// Fixes ' + c for layouts where Android composes it differently (see CedillaFix). + var cedilla = CedillaFix() + + private(set) var isRemote = false + private let log = Logger(subsystem: "dev.droidbridge", category: "input") + private var tap: CFMachPort? + private var source: CFRunLoopSource? + private var crossed: Passage? + private var activePassages: [Passage] = [] + /// After coming back, the cursor must move away from the edge before it can cross again. + private var armed = true + private var buttons: UInt8 = 0 + private var keyboard = KeyboardState() + private var scrollAccumulator = CGVector.zero + private let cursor = CursorVisibility() + private var loggedTapFailure = false + + /// Starts the event tap. Fails until the app has the Accessibility permission. + @discardableResult + func start() -> Bool { + guard tap == nil else { return true } + let types: [CGEventType] = [.mouseMoved, .leftMouseDown, .leftMouseUp, .leftMouseDragged, .rightMouseDown, + .rightMouseUp, .rightMouseDragged, .otherMouseDown, .otherMouseUp, + .otherMouseDragged, .scrollWheel, .keyDown, .keyUp, .flagsChanged] + let mask = types.reduce(CGEventMask(0)) { $0 | (CGEventMask(1) << $1.rawValue) } + let me = Unmanaged.passUnretained(self).toOpaque() + guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, + eventsOfInterest: mask, callback: { _, type, event, info in + let capture = Unmanaged.fromOpaque(info!).takeUnretainedValue() + return capture.handle(type, event) + }, userInfo: me) else { + if !loggedTapFailure { + loggedTapFailure = true + log.error("event tap not created (Accessibility permission missing?)") + } + return false + } + self.tap = tap + source = CFMachPortCreateRunLoopSource(nil, tap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + log.info("event tap started") + return true + } + + /// Brings control back to the Mac, e.g. when the device disconnects or with the hotkey. + func returnToMac(side: Side? = nil, ratio: Double? = nil) { + guard isRemote else { return } + isRemote = false + send?(Wire.leave()) + keyboard.reset() + cedilla.reset() + buttons = 0 + // The passage leading out of that side of the device, at that position. + var passage = crossed + if let side, let ratio { + let candidates = activePassages.filter { $0.edge.androidSide == side } + passage = candidates.first { ratio >= $0.deviceStart - 0.01 && ratio <= $0.deviceEnd + 0.01 } + ?? candidates.min { abs(($0.deviceStart + $0.deviceEnd) / 2 - ratio) < abs(($1.deviceStart + $1.deviceEnd) / 2 - ratio) } + ?? crossed + } + if let passage { + let p = EdgeGeometry.returnPoint(passage: passage, androidRatio: ratio ?? 0.5) + CGWarpMouseCursorPosition(p) + log.info("back on the Mac at \(p.x), \(p.y)") + } + CGAssociateMouseAndMouseCursorPosition(1) + cursor.show() + armed = false + onRemoteChanged?(false) + } + + // MARK: - Event handling + + private func handle(_ type: CGEventType, _ event: CGEvent) -> Unmanaged? { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + return Unmanaged.passUnretained(event) + } + if isRemote { + forward(type, event) + return nil + } + if type == .mouseMoved { + checkCrossing(event) + } + return Unmanaged.passUnretained(event) + } + + private func checkCrossing(_ event: CGEvent) { + let p = event.location + let delta = CGVector(dx: event.getDoubleValueField(.mouseEventDeltaX), dy: event.getDoubleValueField(.mouseEventDeltaY)) + if !armed { + if let last = crossed, !last.display.contains(p) || EdgeGeometry.isClear(of: last, at: p) { + armed = true + } else if crossed == nil { + armed = true + } + return + } + guard canCross() else { return } + let all = passages() + let displays = Self.displays() + guard let (passage, ratio) = all.lazy.compactMap({ ps in + EdgeGeometry.crossing(at: p, delta: delta, passage: ps, displays: displays).map { (ps, $0) } + }).first else { return } + isRemote = true + crossed = passage + activePassages = all + CGAssociateMouseAndMouseCursorPosition(0) + cursor.hide() + let returns = all.map { (side: $0.edge.androidSide, start: $0.deviceStart, end: $0.deviceEnd) } + send?(Wire.enter(side: passage.edge.androidSide, ratio: ratio, returns: returns)) + log.info("to Android, ratio \(ratio)") + onRemoteChanged?(true) + } + + private func forward(_ type: CGEventType, _ event: CGEvent) { + switch type { + case .mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + let dx = Int((event.getDoubleValueField(.mouseEventDeltaX) * options.speed).rounded()) + let dy = Int((event.getDoubleValueField(.mouseEventDeltaY) * options.speed).rounded()) + if dx != 0 || dy != 0 { send?(Wire.mouse(buttons: buttons, dx: dx, dy: dy)) } + case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, .otherMouseDown, .otherMouseUp: + let number = event.getIntegerValueField(.mouseEventButtonNumber) + // HID bits: 0 left, 1 right, 2 middle, 3 back, 4 forward. macOS numbers: 0 left, 1 right, 2 middle, 3 back, 4 forward. + guard number >= 0, number < 5 else { return } + let bit = UInt8(1) << UInt8(number) + let down = type == .leftMouseDown || type == .rightMouseDown || type == .otherMouseDown + buttons = down ? buttons | bit : buttons & ~bit + send?(Wire.mouse(buttons: buttons, dx: 0, dy: 0)) + case .scrollWheel: + scroll(event) + case .keyDown, .keyUp: + key(type, event) + case .flagsChanged: + flags(event) + default: + break + } + } + + private func scroll(_ event: CGEvent) { + let sign: Double = options.invertScroll ? -1 : 1 + var wheel = 0 + var hwheel = 0 + if event.getIntegerValueField(.scrollWheelEventIsContinuous) != 0 { + // Trackpad / Magic Mouse: pixels. About 24 px per wheel notch. + scrollAccumulator.dy += event.getDoubleValueField(.scrollWheelEventPointDeltaAxis1) * sign + scrollAccumulator.dx += event.getDoubleValueField(.scrollWheelEventPointDeltaAxis2) * sign + wheel = Int(scrollAccumulator.dy / 24) + hwheel = Int(scrollAccumulator.dx / 24) + scrollAccumulator.dy -= Double(wheel) * 24 + scrollAccumulator.dx -= Double(hwheel) * 24 + } else { + wheel = Int(Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis1)) * sign) + hwheel = Int(Double(event.getIntegerValueField(.scrollWheelEventDeltaAxis2)) * sign) + } + // HID AC Pan is positive to the right; macOS axis 2 is positive to the left. + if wheel != 0 || hwheel != 0 { send?(Wire.mouse(buttons: buttons, dx: 0, dy: 0, wheel: wheel, hwheel: -hwheel)) } + } + + private func key(_ type: CGEventType, _ event: CGEvent) { + let code = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + if type == .keyDown, isReturnHotkey(code, event.flags) { + returnToMac() + return + } + guard let usage = KeyMap.usage(forKeyCode: code) else { return } + var changed = keyboard.setModifiers(HIDModifiers.from(flags: event.flags, commandAsControl: options.commandAsControl)) + let actions: [CedillaFix.Action] + if type == .keyDown { + // Android repeats held keys itself; ignore macOS autorepeat. + if event.getIntegerValueField(.keyboardEventAutorepeat) != 0 { return } + actions = cedilla.keyDown(usage, modifiers: keyboard.modifiers) + } else { + actions = cedilla.keyUp(usage) + } + for action in actions { + switch action { + case let .press(u): + changed = keyboard.press(u) || changed + case let .release(u): + changed = keyboard.release(u) || changed + case let .tap(u, mods): + send?(Wire.keys([mods.rawValue, 0, u, 0, 0, 0, 0, 0])) + send?(Wire.keys([mods.rawValue, 0, 0, 0, 0, 0, 0, 0])) + changed = true + } + } + if changed { send?(Wire.keys(keyboard.report)) } + } + + private func flags(_ event: CGEvent) { + let code = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + if code == KeyMap.capsLockKeyCode { + // Caps Lock arrives as a flag change; tap it on the device. + keyboard.press(KeyMap.capsLockUsage) + send?(Wire.keys(keyboard.report)) + keyboard.release(KeyMap.capsLockUsage) + send?(Wire.keys(keyboard.report)) + return + } + if keyboard.setModifiers(HIDModifiers.from(flags: event.flags, commandAsControl: options.commandAsControl)) { + send?(Wire.keys(keyboard.report)) + } + } + + /// Control + Option + Command + B brings the pointer back to the Mac. + private func isReturnHotkey(_ code: UInt16, _ flags: CGEventFlags) -> Bool { + code == 0x0B && flags.contains([.maskControl, .maskAlternate, .maskCommand]) + } + + static func displays() -> [CGRect] { + var count: UInt32 = 0 + CGGetActiveDisplayList(0, nil, &count) + var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) + CGGetActiveDisplayList(count, &ids, &count) + return ids.prefix(Int(count)).map { CGDisplayBounds($0) } + } +} + +/// Hides the cursor while the pointer is on the device. A background app can only hide the cursor +/// after setting the window server property "SetsCursorInBackground" (what Synergy/Barrier do); as +/// that alone does not hide it on recent macOS, the app also comes to the front while the pointer is +/// away, hides its cursor there, and gives the focus back to the previous app on return. +final class CursorVisibility { + private let log = Logger(subsystem: "dev.droidbridge", category: "cursor") + private var hidden = false + private var backgroundEnabled = false + private var previousApp: NSRunningApplication? + + func hide() { + guard !hidden else { return } + hidden = true + enableBackgroundCursorControl() + previousApp = NSWorkspace.shared.frontmostApplication + NSApp.activate(ignoringOtherApps: true) + NSCursor.hide() + let err = CGDisplayHideCursor(CGMainDisplayID()) + if err != .success { log.error("CGDisplayHideCursor: \(err.rawValue)") } + } + + func show() { + guard hidden else { return } + hidden = false + CGDisplayShowCursor(CGMainDisplayID()) + NSCursor.unhide() + if let app = previousApp, app != NSRunningApplication.current { + app.activate() + } + previousApp = nil + } + + private func enableBackgroundCursorControl() { + guard !backgroundEnabled else { return } + backgroundEnabled = true + typealias DefaultConnection = @convention(c) () -> Int32 + typealias SetProperty = @convention(c) (Int32, Int32, CFString, CFTypeRef) -> Int32 + let handle = dlopen(nil, RTLD_NOW) + guard let c = dlsym(handle, "_CGSDefaultConnection"), let s = dlsym(handle, "CGSSetConnectionProperty") else { + log.error("window server functions not found") + return + } + let connection = unsafeBitCast(c, to: DefaultConnection.self)() + let r = unsafeBitCast(s, to: SetProperty.self)(connection, connection, "SetsCursorInBackground" as CFString, kCFBooleanTrue) + log.info("SetsCursorInBackground: \(r)") + } +} diff --git a/mac/Sources/DroidBridge/Settings.swift b/mac/Sources/DroidBridge/Settings.swift new file mode 100644 index 0000000..978e377 --- /dev/null +++ b/mac/Sources/DroidBridge/Settings.swift @@ -0,0 +1,39 @@ +import DroidBridgeCore +import Foundation + +/// User preferences, stored in UserDefaults. +struct Settings { + private static let d = UserDefaults.standard + + static var enabled: Bool { + get { d.object(forKey: "enabled") as? Bool ?? true } + set { d.set(newValue, forKey: "enabled") } + } + + /// Where the device sits; nil means the default (against the outermost right edge). + static var arrangement: Arrangement? { + get { d.data(forKey: "deviceArrangement").flatMap { try? JSONDecoder().decode(Arrangement.self, from: $0) } } + set { + if let newValue, let data = try? JSONEncoder().encode(newValue) { + d.set(data, forKey: "deviceArrangement") + } else { + d.removeObject(forKey: "deviceArrangement") + } + } + } + + static var speed: Double { + get { d.object(forKey: "speed") as? Double ?? 3.0 } + set { d.set(newValue, forKey: "speed") } + } + + static var commandAsControl: Bool { + get { d.object(forKey: "commandAsControl") as? Bool ?? true } + set { d.set(newValue, forKey: "commandAsControl") } + } + + static var invertScroll: Bool { + get { d.object(forKey: "invertScroll") as? Bool ?? false } + set { d.set(newValue, forKey: "invertScroll") } + } +} diff --git a/mac/Sources/DroidBridge/main.swift b/mac/Sources/DroidBridge/main.swift new file mode 100644 index 0000000..c39deb8 --- /dev/null +++ b/mac/Sources/DroidBridge/main.swift @@ -0,0 +1,7 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/mac/Sources/DroidBridgeCore/CedillaFix.swift b/mac/Sources/DroidBridgeCore/CedillaFix.swift new file mode 100644 index 0000000..4529e8c --- /dev/null +++ b/mac/Sources/DroidBridgeCore/CedillaFix.swift @@ -0,0 +1,52 @@ +/// macOS "US International - PC" types ç for ' then c; Android's "English (US), International style" +/// composes ć instead and has ç on AltGr + comma. This holds the dead ' back and, when c follows, +/// sends AltGr + comma. +public struct CedillaFix { + public enum Action: Equatable { + case press(UInt8) + case release(UInt8) + /// Press and release `usage` with exactly these modifiers, then restore the held state. + case tap(UInt8, HIDModifiers) + } + + static let quote: UInt8 = 0x34 + static let c: UInt8 = 0x06 + static let comma: UInt8 = 0x36 + + public var enabled = false + private var pending = false + private var swallow: UInt8? + + public init() {} + + public mutating func keyDown(_ usage: UInt8, modifiers: HIDModifiers) -> [Action] { + guard enabled else { return [.press(usage)] } + if pending { + pending = false + if usage == Self.c, modifiers.isSubset(of: [.leftShift, .rightShift]) { + swallow = usage + return [.tap(Self.comma, modifiers.union(.rightAlt))] + } + return [.tap(Self.quote, []), .press(usage)] + } + if usage == Self.quote, modifiers.isEmpty { + pending = true + swallow = usage + return [] + } + return [.press(usage)] + } + + public mutating func keyUp(_ usage: UInt8) -> [Action] { + if swallow == usage { + swallow = nil + return [] + } + return [.release(usage)] + } + + public mutating func reset() { + pending = false + swallow = nil + } +} diff --git a/mac/Sources/DroidBridgeCore/EdgeGeometry.swift b/mac/Sources/DroidBridgeCore/EdgeGeometry.swift new file mode 100644 index 0000000..339b92a --- /dev/null +++ b/mac/Sources/DroidBridgeCore/EdgeGeometry.swift @@ -0,0 +1,270 @@ +import CoreGraphics + +/// An edge of a Mac display. +public enum Edge: String, Codable, CaseIterable { + case right, left, top, bottom + + /// The Android screen edge the pointer enters through (and leaves through). + public var androidSide: Side { + switch self { + case .right: return .left + case .left: return .right + case .top: return .bottom + case .bottom: return .top + } + } + + /// Left and right edges run vertically; positions along them are heights. + public var isVertical: Bool { self == .left || self == .right } +} + +/// Where the pointer passes between the Mac and the device: a stretch of one display's edge. +/// `start` and `end` are fractions along the display edge, from the top (vertical edges) or the left; +/// `deviceStart` and `deviceEnd` are the same stretch as fractions along the device's facing edge. +public struct Passage: Equatable { + public var display: CGRect + public var edge: Edge + public var start: Double + public var end: Double + public var deviceStart: Double + public var deviceEnd: Double + + public init(display: CGRect, edge: Edge, start: Double, end: Double, deviceStart: Double = 0, deviceEnd: Double = 1) { + self.display = display + self.edge = edge + self.start = start + self.end = end + self.deviceStart = deviceStart + self.deviceEnd = deviceEnd + } + + /// The edge length in points. + public var edgeLength: CGFloat { edge.isVertical ? display.height : display.width } +} + +/// The saved arrangement: the device rectangle, relative to the origin of an anchor display (by a +/// stable key), in global points. Passages are wherever it touches display edges. +public struct Arrangement: Codable, Equatable { + public var anchorKey: String + public var x: Double + public var y: Double + public var width: Double + public var height: Double + + public init(anchorKey: String, rect: CGRect, anchor: CGRect) { + self.anchorKey = anchorKey + x = Double(rect.minX - anchor.minX) + y = Double(rect.minY - anchor.minY) + width = Double(rect.width) + height = Double(rect.height) + } + + public func rect(anchor: CGRect) -> CGRect { + CGRect(x: anchor.minX + CGFloat(x), y: anchor.minY + CGFloat(y), width: CGFloat(width), height: CGFloat(height)) + } +} + +/// Screen-edge math in global display coordinates (origin at the top left of the main display, y down). +public enum EdgeGeometry { + /// Distance from the edge (px) that counts as touching it: the cursor stops at maxX - 1. + static let slop: CGFloat = 1.5 + + /// When the cursor is pushed out through the passage, returns the matching position along the + /// device's edge (0...1). + public static func crossing(at p: CGPoint, delta: CGVector, passage: Passage, displays: [CGRect]) -> Double? { + let d = passage.display + let touching: Bool + let outward: Bool + let along: CGFloat + let beyond: CGPoint + switch passage.edge { + case .right: + touching = p.x >= d.maxX - slop && p.x < d.maxX + slop + outward = delta.dx > 0 + along = (p.y - d.minY) / d.height + beyond = CGPoint(x: d.maxX + 1, y: p.y) + case .left: + touching = p.x <= d.minX + slop - 1 && p.x >= d.minX - slop + outward = delta.dx < 0 + along = (p.y - d.minY) / d.height + beyond = CGPoint(x: d.minX - 1, y: p.y) + case .top: + touching = p.y <= d.minY + slop - 1 && p.y >= d.minY - slop + outward = delta.dy < 0 + along = (p.x - d.minX) / d.width + beyond = CGPoint(x: p.x, y: d.minY - 1) + case .bottom: + touching = p.y >= d.maxY - slop && p.y < d.maxY + slop + outward = delta.dy > 0 + along = (p.x - d.minX) / d.width + beyond = CGPoint(x: p.x, y: d.maxY + 1) + } + let r = Double(along) + guard touching, outward, r >= 0, r <= 1, r >= passage.start, r <= passage.end, + passage.end > passage.start, !covered(beyond, displays) else { return nil } + let t = (r - passage.start) / (passage.end - passage.start) + return passage.deviceStart + t * (passage.deviceEnd - passage.deviceStart) + } + + /// Where to put the Mac cursor when the pointer comes back at `androidRatio` along the device edge: + /// just inside the passage. + public static func returnPoint(passage: Passage, androidRatio: Double, inset: CGFloat = 3) -> CGPoint { + let d = passage.display + let span = passage.deviceEnd - passage.deviceStart + let t = span > 0 ? min(max((androidRatio - passage.deviceStart) / span, 0), 1) : 0.5 + let r = CGFloat(passage.start + t * (passage.end - passage.start)) + switch passage.edge { + case .right: return CGPoint(x: d.maxX - inset, y: min(d.minY + r * d.height, d.maxY - 1)) + case .left: return CGPoint(x: d.minX + inset - 1, y: min(d.minY + r * d.height, d.maxY - 1)) + case .top: return CGPoint(x: min(d.minX + r * d.width, d.maxX - 1), y: d.minY + inset - 1) + case .bottom: return CGPoint(x: min(d.minX + r * d.width, d.maxX - 1), y: d.maxY - inset) + } + } + + /// Whether the cursor has moved far enough from the edge to cross again (avoids bouncing back). + public static func isClear(of passage: Passage, at p: CGPoint, distance: CGFloat = 12) -> Bool { + let d = passage.display + switch passage.edge { + case .right: return p.x < d.maxX - distance + case .left: return p.x > d.minX + distance + case .top: return p.y > d.minY + distance + case .bottom: return p.y < d.maxY - distance + } + } + + /// The stretches of an edge (fractions along it) that don't touch another display. + public static func freeIntervals(of d: CGRect, edge: Edge, displays: [CGRect]) -> [ClosedRange] { + let length = edge.isVertical ? d.height : d.width + var blocked: [ClosedRange] = [] + for o in displays where o != d { + let touches: Bool + let lo: CGFloat + let hi: CGFloat + switch edge { + case .right: touches = abs(o.minX - d.maxX) < 1; lo = o.minY - d.minY; hi = o.maxY - d.minY + case .left: touches = abs(o.maxX - d.minX) < 1; lo = o.minY - d.minY; hi = o.maxY - d.minY + case .top: touches = abs(o.maxY - d.minY) < 1; lo = o.minX - d.minX; hi = o.maxX - d.minX + case .bottom: touches = abs(o.minY - d.maxY) < 1; lo = o.minX - d.minX; hi = o.maxX - d.minX + } + let a = Double(max(lo, 0) / length) + let b = Double(min(hi, length) / length) + if touches, b > a { blocked.append(a...b) } + } + var free: [ClosedRange] = [] + var cursor = 0.0 + for b in blocked.sorted(by: { $0.lowerBound < $1.lowerBound }) { + if b.lowerBound > cursor { free.append(cursor...b.lowerBound) } + cursor = max(cursor, b.upperBound) + } + if cursor < 1 { free.append(cursor...1) } + return free.filter { $0.upperBound - $0.lowerBound > 0.02 } + } + + /// Fits a stretch of `length` (fraction of the edge) centered on `center` into the free interval + /// nearest to `center`. Returns nil when the edge has no free stretch. + public static func snap(center: Double, length: Double, free: [ClosedRange]) -> ClosedRange? { + func distance(_ i: ClosedRange) -> Double { + i.contains(center) ? 0 : min(abs(i.lowerBound - center), abs(i.upperBound - center)) + } + guard let interval = free.min(by: { distance($0) < distance($1) }) else { return nil } + let l = min(length, interval.upperBound - interval.lowerBound) + var start = center - l / 2 + start = min(max(start, interval.lowerBound), interval.upperBound - l) + return start...(start + l) + } + + /// The default passage: the whole free part of the outermost display's edge on that side. + public static func defaultPassage(edge: Edge = .right, displays: [CGRect]) -> Passage? { + let ordered: [CGRect] + switch edge { + case .right: ordered = displays.sorted { $0.maxX > $1.maxX } + case .left: ordered = displays.sorted { $0.minX < $1.minX } + case .top: ordered = displays.sorted { $0.minY < $1.minY } + case .bottom: ordered = displays.sorted { $0.maxY > $1.maxY } + } + for d in ordered { + if let i = freeIntervals(of: d, edge: edge, displays: displays).max(by: { + ($0.upperBound - $0.lowerBound) < ($1.upperBound - $1.lowerBound) + }) { + return Passage(display: d, edge: edge, start: i.lowerBound, end: i.upperBound) + } + } + return nil + } + + /// The passages of a device rectangle: every stretch of a display edge it touches. + public static func passages(device r: CGRect, displays: [CGRect], tolerance: CGFloat = 0.5) -> [Passage] { + var out: [Passage] = [] + for d in displays { + for edge in Edge.allCases { + let flush: Bool + let lo: CGFloat + let hi: CGFloat + switch edge { + case .right: flush = abs(r.minX - d.maxX) <= tolerance; lo = max(r.minY, d.minY); hi = min(r.maxY, d.maxY) + case .left: flush = abs(r.maxX - d.minX) <= tolerance; lo = max(r.minY, d.minY); hi = min(r.maxY, d.maxY) + case .top: flush = abs(r.maxY - d.minY) <= tolerance; lo = max(r.minX, d.minX); hi = min(r.maxX, d.maxX) + case .bottom: flush = abs(r.minY - d.maxY) <= tolerance; lo = max(r.minX, d.minX); hi = min(r.maxX, d.maxX) + } + guard flush, hi - lo > 1 else { continue } + let edgeOrigin = edge.isVertical ? d.minY : d.minX + let edgeLength = edge.isVertical ? d.height : d.width + let deviceOrigin = edge.isVertical ? r.minY : r.minX + let deviceLength = edge.isVertical ? r.height : r.width + let start = Double((lo - edgeOrigin) / edgeLength) + let end = Double((hi - edgeOrigin) / edgeLength) + for f in freeIntervals(of: d, edge: edge, displays: displays) { + let s = max(start, f.lowerBound) + let e = min(end, f.upperBound) + guard e - s > 0.005 else { continue } + func device(_ v: Double) -> Double { + Double((edgeOrigin + CGFloat(v) * edgeLength - deviceOrigin) / deviceLength) + } + out.append(Passage(display: d, edge: edge, start: s, end: e, deviceStart: device(s), deviceEnd: device(e))) + } + } + } + return out + } + + /// Snaps a dragged device rectangle against the nearest display edges (each axis on its own, so it + /// can sit in a corner between two displays). Returns nil when it would touch nothing or overlap a display. + public static func snap(device g: CGRect, displays: [CGRect], reach: CGFloat) -> CGRect? { + var dx: (CGFloat, CGFloat)? // (distance, shift) + var dy: (CGFloat, CGFloat)? + for d in displays { + if g.maxY > d.minY - reach, g.minY < d.maxY + reach { + for shift in [d.maxX - g.minX, d.minX - g.maxX] where abs(shift) <= reach { + if dx == nil || abs(shift) < dx!.0 { dx = (abs(shift), shift) } + } + } + if g.maxX > d.minX - reach, g.minX < d.maxX + reach { + for shift in [d.maxY - g.minY, d.minY - g.maxY] where abs(shift) <= reach { + if dy == nil || abs(shift) < dy!.0 { dy = (abs(shift), shift) } + } + } + } + var options: [CGRect] = [] + if let dx, let dy { options.append(g.offsetBy(dx: dx.1, dy: dy.1)) } + if let dx { options.append(g.offsetBy(dx: dx.1, dy: 0)) } + if let dy { options.append(g.offsetBy(dx: 0, dy: dy.1)) } + return options.first { r in + !displays.contains { $0.intersection(r).width > 0.5 && $0.intersection(r).height > 0.5 } + && !passages(device: r, displays: displays).isEmpty + } + } + + /// The default device rectangle: against the outermost display's free right edge, centered, + /// `size` times that display's height. + public static func defaultDevice(displays: [CGRect], aspect: CGFloat, size: CGFloat = 0.6) -> CGRect? { + guard let p = defaultPassage(edge: .right, displays: displays) else { return nil } + let d = p.display + let height = min(size * d.height, CGFloat(p.end - p.start) * d.height) + let center = d.minY + CGFloat(p.start + p.end) / 2 * d.height + return CGRect(x: d.maxX, y: center - height / 2, width: height * aspect, height: height) + } + + static func covered(_ p: CGPoint, _ displays: [CGRect]) -> Bool { + displays.contains { $0.contains(p) } + } +} diff --git a/mac/Sources/DroidBridgeCore/Keyboard.swift b/mac/Sources/DroidBridgeCore/Keyboard.swift new file mode 100644 index 0000000..c0f6efd --- /dev/null +++ b/mac/Sources/DroidBridgeCore/Keyboard.swift @@ -0,0 +1,118 @@ +import CoreGraphics + +/// macOS virtual key codes (Carbon kVK_*) to USB HID keyboard usages. +public enum KeyMap { + public static let capsLockKeyCode: UInt16 = 0x39 + public static let capsLockUsage: UInt8 = 0x39 + + static let table: [UInt16: UInt8] = [ + 0x00: 0x04, 0x0B: 0x05, 0x08: 0x06, 0x02: 0x07, 0x0E: 0x08, 0x03: 0x09, 0x05: 0x0A, 0x04: 0x0B, // a b c d e f g h + 0x22: 0x0C, 0x26: 0x0D, 0x28: 0x0E, 0x25: 0x0F, 0x2E: 0x10, 0x2D: 0x11, 0x1F: 0x12, 0x23: 0x13, // i j k l m n o p + 0x0C: 0x14, 0x0F: 0x15, 0x01: 0x16, 0x11: 0x17, 0x20: 0x18, 0x09: 0x19, 0x0D: 0x1A, 0x07: 0x1B, // q r s t u v w x + 0x10: 0x1C, 0x06: 0x1D, // y z + 0x12: 0x1E, 0x13: 0x1F, 0x14: 0x20, 0x15: 0x21, 0x17: 0x22, 0x16: 0x23, 0x1A: 0x24, 0x1C: 0x25, // 1-8 + 0x19: 0x26, 0x1D: 0x27, // 9 0 + 0x24: 0x28, 0x35: 0x29, 0x33: 0x2A, 0x30: 0x2B, 0x31: 0x2C, // return esc backspace tab space + 0x1B: 0x2D, 0x18: 0x2E, 0x21: 0x2F, 0x1E: 0x30, 0x2A: 0x31, // - = [ ] backslash + 0x29: 0x33, 0x27: 0x34, 0x32: 0x35, 0x2B: 0x36, 0x2F: 0x37, 0x2C: 0x38, // ; ' ` , . / + 0x0A: 0x64, // ISO section (non-US \ |) + 0x7A: 0x3A, 0x78: 0x3B, 0x63: 0x3C, 0x76: 0x3D, 0x60: 0x3E, 0x61: 0x3F, // F1-F6 + 0x62: 0x40, 0x64: 0x41, 0x65: 0x42, 0x6D: 0x43, 0x67: 0x44, 0x6F: 0x45, // F7-F12 + 0x72: 0x49, 0x73: 0x4A, 0x74: 0x4B, 0x75: 0x4C, 0x77: 0x4D, 0x79: 0x4E, // help(insert) home pgup del end pgdn + 0x7C: 0x4F, 0x7B: 0x50, 0x7D: 0x51, 0x7E: 0x52, // right left down up + 0x47: 0x53, 0x4B: 0x54, 0x43: 0x55, 0x4E: 0x56, 0x45: 0x57, 0x4C: 0x58, // clear / * - + enter + 0x53: 0x59, 0x54: 0x5A, 0x55: 0x5B, 0x56: 0x5C, 0x57: 0x5D, 0x58: 0x5E, // keypad 1-6 + 0x59: 0x5F, 0x5B: 0x60, 0x5C: 0x61, 0x52: 0x62, 0x41: 0x63, 0x51: 0x67, // keypad 7 8 9 0 . = + ] + + public static func usage(forKeyCode code: UInt16) -> UInt8? { table[code] } +} + +/// HID modifier byte bits. +public struct HIDModifiers: OptionSet, Equatable { + public let rawValue: UInt8 + public init(rawValue: UInt8) { self.rawValue = rawValue } + + public static let leftControl = HIDModifiers(rawValue: 0x01) + public static let leftShift = HIDModifiers(rawValue: 0x02) + public static let leftAlt = HIDModifiers(rawValue: 0x04) + public static let leftMeta = HIDModifiers(rawValue: 0x08) + public static let rightControl = HIDModifiers(rawValue: 0x10) + public static let rightShift = HIDModifiers(rawValue: 0x20) + public static let rightAlt = HIDModifiers(rawValue: 0x40) + public static let rightMeta = HIDModifiers(rawValue: 0x80) + + // Device-dependent bits of CGEventFlags (IOKit NX_DEVICE*KEYMASK). + static let nxLeftControl: UInt64 = 0x0001 + static let nxLeftShift: UInt64 = 0x0002 + static let nxRightShift: UInt64 = 0x0004 + static let nxLeftCommand: UInt64 = 0x0008 + static let nxRightCommand: UInt64 = 0x0010 + static let nxLeftAlt: UInt64 = 0x0020 + static let nxRightAlt: UInt64 = 0x0040 + static let nxRightControl: UInt64 = 0x2000 + + /// Converts macOS modifier flags. With `commandAsControl`, Command becomes Ctrl (so Cmd+C copies on + /// Android) and Control becomes Meta; otherwise Command is Meta and Control is Ctrl. + public static func from(flags: CGEventFlags, commandAsControl: Bool) -> HIDModifiers { + let raw = flags.rawValue + var m: HIDModifiers = [] + func side(_ generic: CGEventFlags, _ left: UInt64, _ right: UInt64, _ l: HIDModifiers, _ r: HIDModifiers) { + guard flags.contains(generic) else { return } + let hasLeft = raw & left != 0 + let hasRight = raw & right != 0 + if hasRight { m.insert(r) } + if hasLeft || !hasRight { m.insert(l) } + } + side(.maskShift, nxLeftShift, nxRightShift, .leftShift, .rightShift) + side(.maskAlternate, nxLeftAlt, nxRightAlt, .leftAlt, .rightAlt) + if commandAsControl { + side(.maskCommand, nxLeftCommand, nxRightCommand, .leftControl, .rightControl) + side(.maskControl, nxLeftControl, nxRightControl, .leftMeta, .rightMeta) + } else { + side(.maskCommand, nxLeftCommand, nxRightCommand, .leftMeta, .rightMeta) + side(.maskControl, nxLeftControl, nxRightControl, .leftControl, .rightControl) + } + return m + } +} + +/// Keys held down on the Android keyboard, as an 8-byte HID boot report. +public struct KeyboardState: Equatable { + public private(set) var modifiers: HIDModifiers = [] + public private(set) var pressed: [UInt8] = [] + + public init() {} + + /// Returns true when the report changed. + @discardableResult + public mutating func press(_ usage: UInt8) -> Bool { + guard !pressed.contains(usage) else { return false } + if pressed.count == 6 { pressed.removeFirst() } + pressed.append(usage) + return true + } + + @discardableResult + public mutating func release(_ usage: UInt8) -> Bool { + guard let i = pressed.firstIndex(of: usage) else { return false } + pressed.remove(at: i) + return true + } + + @discardableResult + public mutating func setModifiers(_ m: HIDModifiers) -> Bool { + guard m != modifiers else { return false } + modifiers = m + return true + } + + public mutating func reset() { + modifiers = [] + pressed = [] + } + + public var report: [UInt8] { + [modifiers.rawValue, 0] + pressed + [UInt8](repeating: 0, count: 6 - pressed.count) + } +} diff --git a/mac/Sources/DroidBridgeCore/KeyboardLayoutMap.swift b/mac/Sources/DroidBridgeCore/KeyboardLayoutMap.swift new file mode 100644 index 0000000..b3fbc28 --- /dev/null +++ b/mac/Sources/DroidBridgeCore/KeyboardLayoutMap.swift @@ -0,0 +1,32 @@ +/// macOS keyboard layouts to the matching Android keyboard layout (the suffix of +/// "com.android.inputdevices/.../keyboard_layout_"), so dead keys and symbols type the same. +public enum KeyboardLayoutMap { + static let table: [String: String] = [ + "US": "english_us", + "ABC": "english_us", + "USInternational-PC": "english_us_intl", + "ABC-India": "english_india", + "British": "english_uk", + "British-PC": "english_uk", + "Canadian": "english_ca", + "Dvorak": "english_us_dvorak", + "Colemak": "english_us_colemak", + "Brazilian": "brazilian", + "Brazilian-ABNT2": "brazilian", + "Brazilian-Pro": "brazilian", + "Portuguese": "portuguese", + "German": "german", + "French": "french", + "Spanish": "spanish", + "Spanish-ISO": "spanish", + "Italian": "italian", + "Italian-Pro": "italian", + ] + + /// `sourceID` is a Text Input Source id such as "com.apple.keylayout.USInternational-PC". + public static func androidLayout(forInputSource sourceID: String) -> String? { + let prefix = "com.apple.keylayout." + guard sourceID.hasPrefix(prefix) else { return nil } + return table[String(sourceID.dropFirst(prefix.count))] + } +} diff --git a/mac/Sources/DroidBridgeCore/Wire.swift b/mac/Sources/DroidBridgeCore/Wire.swift new file mode 100644 index 0000000..bd1149e --- /dev/null +++ b/mac/Sources/DroidBridgeCore/Wire.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Wire protocol shared with the Android server (server/src/dev/droidbridge/Protocol.java). +/// Every message is: u8 type, u32 payload length (big-endian), payload. +public enum Wire { + public static let version: UInt16 = 1 + public static let maxPayload = 1 << 20 + + public enum Out: UInt8 { + case hello = 0x01, enter = 0x02, leave = 0x03, mouse = 0x04, keys = 0x05, clipboard = 0x06, ping = 0x07, layout = 0x08 + } + + public enum In: UInt8 { + case device = 0x81, edge = 0x82, clipboard = 0x83, pong = 0x84 + } + + public enum Message: Equatable { + case device(protocolVersion: UInt16, width: Int, height: Int, model: String) + case edge(side: Side, ratio: Double) + case clipboard(String) + case pong + case unknown(UInt8) + } + + public static func frame(_ type: Out, _ payload: [UInt8] = []) -> Data { + var d = Data([type.rawValue]) + d.append(contentsOf: be32(UInt32(payload.count))) + d.append(contentsOf: payload) + return d + } + + public static func hello() -> Data { frame(.hello, be16(version)) } + + /// Puts the pointer on `side` at `ratio`; `returns` are the stretches of the device edges that + /// lead back to the Mac. + public static func enter(side: Side, ratio: Double, returns: [(side: Side, start: Double, end: Double)] = []) -> Data { + var p = [side.rawValue] + be16(ratio16(ratio)) + [UInt8(min(returns.count, 255))] + for r in returns.prefix(255) { p += [r.side.rawValue] + be16(ratio16(r.start)) + be16(ratio16(r.end)) } + return frame(.enter, p) + } + + public static func leave() -> Data { frame(.leave) } + + public static func mouse(buttons: UInt8, dx: Int, dy: Int, wheel: Int = 0, hwheel: Int = 0) -> Data { + frame(.mouse, [buttons & 0x1F] + be16(UInt16(bitPattern: Int16(clamping: dx))) + be16(UInt16(bitPattern: Int16(clamping: dy))) + + [UInt8(bitPattern: Int8(clamping: wheel)), UInt8(bitPattern: Int8(clamping: hwheel))]) + } + + public static func keys(_ report: [UInt8]) -> Data { + precondition(report.count == 8) + return frame(.keys, report) + } + + public static func clipboard(_ text: String) -> Data { frame(.clipboard, Array(text.utf8)) } + + public static func ping() -> Data { frame(.ping) } + + /// The Android keyboard layout to use (see KeyboardLayoutMap). + public static func layout(_ name: String) -> Data { frame(.layout, Array(name.utf8)) } + + public static func decode(type: UInt8, payload: [UInt8]) -> Message { + switch In(rawValue: type) { + case .device where payload.count >= 6: + return .device(protocolVersion: u16(payload, 0), width: Int(u16(payload, 2)), height: Int(u16(payload, 4)), + model: String(decoding: payload[6...], as: UTF8.self)) + case .edge where payload.count >= 3: + return .edge(side: Side(rawValue: payload[0]) ?? .left, ratio: Double(u16(payload, 1)) / 65535) + case .clipboard: + return .clipboard(String(decoding: payload, as: UTF8.self)) + case .pong: + return .pong + default: + return .unknown(type) + } + } + + static func ratio16(_ r: Double) -> UInt16 { UInt16((min(max(r, 0), 1) * 65535).rounded()) } + static func be16(_ v: UInt16) -> [UInt8] { [UInt8(v >> 8), UInt8(v & 0xFF)] } + static func be32(_ v: UInt32) -> [UInt8] { [UInt8(v >> 24), UInt8((v >> 16) & 0xFF), UInt8((v >> 8) & 0xFF), UInt8(v & 0xFF)] } + static func u16(_ b: [UInt8], _ i: Int) -> UInt16 { UInt16(b[i]) << 8 | UInt16(b[i + 1]) } +} + +/// A side of the Android screen. +public enum Side: UInt8 { + case left = 0, right = 1, top = 2, bottom = 3 +} + +/// Splits a byte stream into messages. +public struct FrameReader { + private var buffer: [UInt8] = [] + + public init() {} + + public mutating func feed(_ bytes: [UInt8]) throws -> [Wire.Message] { + buffer.append(contentsOf: bytes) + var out: [Wire.Message] = [] + while buffer.count >= 5 { + let length = Int(buffer[1]) << 24 | Int(buffer[2]) << 16 | Int(buffer[3]) << 8 | Int(buffer[4]) + guard length <= Wire.maxPayload else { throw FrameError.tooLarge(length) } + guard buffer.count >= 5 + length else { break } + out.append(Wire.decode(type: buffer[0], payload: Array(buffer[5..<(5 + length)]))) + buffer.removeFirst(5 + length) + } + return out + } + + public enum FrameError: Error { case tooLarge(Int) } +} diff --git a/mac/Tests/DroidBridgeCoreTests/CoreTests.swift b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift new file mode 100644 index 0000000..f3ed5fb --- /dev/null +++ b/mac/Tests/DroidBridgeCoreTests/CoreTests.swift @@ -0,0 +1,244 @@ +import CoreGraphics +import XCTest +@testable import DroidBridgeCore + +final class WireTests: XCTestCase { + func testMouseFrame() { + let d = Wire.mouse(buttons: 1, dx: -2, dy: 300, wheel: -1) + XCTAssertEqual([UInt8](d), [0x04, 0, 0, 0, 7, 1, 0xFF, 0xFE, 0x01, 0x2C, 0xFF, 0]) + } + + func testEnterFrame() { + XCTAssertEqual([UInt8](Wire.enter(side: .left, ratio: 1)), [0x02, 0, 0, 0, 4, 0, 0xFF, 0xFF, 0]) + XCTAssertEqual([UInt8](Wire.enter(side: .left, ratio: 0, returns: [(side: .bottom, start: 0, end: 1)])), + [0x02, 0, 0, 0, 9, 0, 0, 0, 1, 3, 0, 0, 0xFF, 0xFF]) + } + + func testReaderSplitsAndJoins() throws { + var r = FrameReader() + let edge: [UInt8] = [0x82, 0, 0, 0, 3, 1, 0x80, 0x00] + let clip: [UInt8] = [0x83, 0, 0, 0, 2] + Array("oi".utf8) + XCTAssertEqual(try r.feed(Array(edge[0..<4])), []) + let msgs = try r.feed(Array(edge[4...]) + clip) + XCTAssertEqual(msgs.count, 2) + if case let .edge(side, ratio) = msgs[0] { + XCTAssertEqual(side, .right) + XCTAssertEqual(ratio, 32768.0 / 65535, accuracy: 1e-9) + } else { XCTFail() } + XCTAssertEqual(msgs[1], .clipboard("oi")) + } + + func testDeviceMessage() throws { + var r = FrameReader() + let p: [UInt8] = [0, 1, 0x09, 0x90, 0x07, 0x38] + Array("samsung X".utf8) + let msgs = try r.feed([0x81, 0, 0, 0, UInt8(p.count)] + p) + XCTAssertEqual(msgs, [.device(protocolVersion: 1, width: 2448, height: 1848, model: "samsung X")]) + } +} + +final class EdgeTests: XCTestCase { + // Levi's setup: a 1080p monitor as the main display, the MacBook to its right and lower. + let external = CGRect(x: 0, y: 0, width: 1920, height: 1080) + let builtin = CGRect(x: 1920, y: 494, width: 1920, height: 1243) + var displays: [CGRect] { [external, builtin] } + + func testFullRightEdge() { + let p = Passage(display: builtin, edge: .right, start: 0, end: 1) + let r = EdgeGeometry.crossing(at: CGPoint(x: 3839.5, y: 494 + 1243 / 2), delta: CGVector(dx: 3, dy: 0), passage: p, displays: displays) + XCTAssertEqual(r ?? -1, 0.5, accuracy: 0.001) + } + + func testNoCrossingWhenMovingInward() { + let p = Passage(display: builtin, edge: .right, start: 0, end: 1) + XCTAssertNil(EdgeGeometry.crossing(at: CGPoint(x: 3839.5, y: 900), delta: CGVector(dx: -3, dy: 0), passage: p, displays: displays)) + } + + func testOnlyInsideThePassage() { + let p = Passage(display: builtin, edge: .right, start: 0.2, end: 0.6) + XCTAssertNil(EdgeGeometry.crossing(at: CGPoint(x: 3839.5, y: 494 + 0.1 * 1243), delta: CGVector(dx: 2, dy: 0), passage: p, displays: displays)) + let r = EdgeGeometry.crossing(at: CGPoint(x: 3839.5, y: 494 + 0.5 * 1243), delta: CGVector(dx: 2, dy: 0), passage: p, displays: displays) + XCTAssertEqual(r ?? -1, 0.75, accuracy: 0.001) + } + + func testReturnPointMapsBackIntoThePassage() { + let p = Passage(display: builtin, edge: .right, start: 0.2, end: 0.6) + let point = EdgeGeometry.returnPoint(passage: p, androidRatio: 0.75) + XCTAssertTrue(builtin.contains(point)) + XCTAssertEqual(point.y, 494 + 0.5 * 1243, accuracy: 0.01) + XCTAssertNil(EdgeGeometry.crossing(at: point, delta: CGVector(dx: 1, dy: 0), passage: p, displays: displays)) + XCTAssertFalse(EdgeGeometry.isClear(of: p, at: point)) + } + + func testFreeIntervals() { + // The external display's right edge touches the MacBook from y 494 to 1080. + let free = EdgeGeometry.freeIntervals(of: external, edge: .right, displays: displays) + XCTAssertEqual(free.count, 1) + XCTAssertEqual(free[0].upperBound, 494.0 / 1080, accuracy: 0.001) + // The MacBook's left edge touches the external display from its top to 586 points down. + let left = EdgeGeometry.freeIntervals(of: builtin, edge: .left, displays: displays) + XCTAssertEqual(left.count, 1) + XCTAssertEqual(left[0].lowerBound, 586.0 / 1243, accuracy: 0.001) + XCTAssertEqual(EdgeGeometry.freeIntervals(of: builtin, edge: .right, displays: displays), [0...1]) + } + + func testSharedEdgeNeverCrosses() { + let p = Passage(display: external, edge: .right, start: 0, end: 1) + XCTAssertNil(EdgeGeometry.crossing(at: CGPoint(x: 1919.5, y: 800), delta: CGVector(dx: 2, dy: 0), passage: p, displays: displays)) + XCTAssertNotNil(EdgeGeometry.crossing(at: CGPoint(x: 1919.5, y: 200), delta: CGVector(dx: 2, dy: 0), passage: p, displays: displays)) + } + + func testSnapClampsIntoFreeStretch() { + let s = EdgeGeometry.snap(center: 0.4, length: 0.6, free: [0...0.457]) + XCTAssertEqual(s?.lowerBound ?? -1, 0, accuracy: 0.001) + XCTAssertEqual(s?.upperBound ?? -1, 0.457, accuracy: 0.001) + let t = EdgeGeometry.snap(center: 0.9, length: 0.4, free: [0...1]) + XCTAssertEqual(t?.lowerBound ?? -1, 0.6, accuracy: 0.001) + XCTAssertNil(EdgeGeometry.snap(center: 0.5, length: 0.5, free: [])) + } + + func testDefaultPassage() { + let p = EdgeGeometry.defaultPassage(edge: .right, displays: displays) + XCTAssertEqual(p, Passage(display: builtin, edge: .right, start: 0, end: 1)) + XCTAssertEqual(EdgeGeometry.defaultPassage(edge: .top, displays: displays)?.display, external) + } + + func testTopAndBottom() { + let top = Passage(display: external, edge: .top, start: 0, end: 1) + XCTAssertNotNil(EdgeGeometry.crossing(at: CGPoint(x: 700, y: 0), delta: CGVector(dx: 0, dy: -2), passage: top, displays: displays)) + let bottom = Passage(display: external, edge: .bottom, start: 0, end: 1) + XCTAssertNotNil(EdgeGeometry.crossing(at: CGPoint(x: 700, y: 1079.2), delta: CGVector(dx: 0, dy: 2), passage: bottom, displays: displays)) + } + + func testAndroidSides() { + XCTAssertEqual(Edge.right.androidSide, .left) + XCTAssertEqual(Edge.bottom.androidSide, .top) + } +} + +final class DeviceRectTests: XCTestCase { + let external = CGRect(x: 0, y: 0, width: 1920, height: 1080) + let builtin = CGRect(x: 1920, y: 494, width: 1920, height: 1243) + var displays: [CGRect] { [external, builtin] } + + func testCornerTouchesBothDisplays() { + // In the corner right of the external display and above the MacBook. + let r = CGRect(x: 1920, y: 94, width: 530, height: 400) + let ps = EdgeGeometry.passages(device: r, displays: displays) + XCTAssertEqual(ps.count, 2) + let right = ps.first { $0.edge == .right } + XCTAssertEqual(right?.display, external) + XCTAssertEqual(right?.start ?? -1, 94.0 / 1080, accuracy: 0.001) + XCTAssertEqual(right?.end ?? -1, 494.0 / 1080, accuracy: 0.001) + XCTAssertEqual(right?.deviceStart ?? -1, 0, accuracy: 0.001) + XCTAssertEqual(right?.deviceEnd ?? -1, 1, accuracy: 0.001) + let top = ps.first { $0.edge == .top } + XCTAssertEqual(top?.display, builtin) + XCTAssertEqual(top?.end ?? -1, 530.0 / 1920, accuracy: 0.001) + XCTAssertEqual(top?.edge.androidSide, .bottom) + } + + func testPartialPassageMapsToDevicePart() { + // Taller than the free stretch: only its lower part touches the MacBook's right edge. + let r = CGRect(x: 3840, y: 1437, width: 300, height: 600) + let ps = EdgeGeometry.passages(device: r, displays: displays) + XCTAssertEqual(ps.count, 1) + XCTAssertEqual(ps[0].deviceStart, 0, accuracy: 0.001) + XCTAssertEqual(ps[0].deviceEnd, 0.5, accuracy: 0.001) + let ratio = EdgeGeometry.crossing(at: CGPoint(x: 3839.5, y: 1587), delta: CGVector(dx: 1, dy: 0), passage: ps[0], displays: displays) + XCTAssertEqual(ratio ?? -1, 0.25, accuracy: 0.001) + let back = EdgeGeometry.returnPoint(passage: ps[0], androidRatio: 0.25) + XCTAssertEqual(back.y, 1587, accuracy: 0.5) + } + + func testSnapIntoCorner() { + let ghost = CGRect(x: 1930, y: 80, width: 530, height: 400) + let r = EdgeGeometry.snap(device: ghost, displays: displays, reach: 24) + XCTAssertEqual(r, CGRect(x: 1920, y: 94, width: 530, height: 400)) + } + + func testSnapRejectsOverlapAndFarAway() { + XCTAssertNil(EdgeGeometry.snap(device: CGRect(x: 100, y: 100, width: 300, height: 300), displays: displays, reach: 24)) + XCTAssertNil(EdgeGeometry.snap(device: CGRect(x: 5000, y: 100, width: 300, height: 300), displays: displays, reach: 24)) + } + + func testDefaultDevice() { + let r = EdgeGeometry.defaultDevice(displays: displays, aspect: 0.5) + XCTAssertEqual(r?.minX, 3840) + XCTAssertEqual(r?.height ?? 0, 0.6 * 1243, accuracy: 0.01) + XCTAssertEqual(EdgeGeometry.passages(device: r!, displays: displays).count, 1) + } +} + +final class CedillaTests: XCTestCase { + func testQuoteCBecomesCedilla() { + var f = CedillaFix() + f.enabled = true + XCTAssertEqual(f.keyDown(0x34, modifiers: []), []) + XCTAssertEqual(f.keyUp(0x34), []) + XCTAssertEqual(f.keyDown(0x06, modifiers: []), [.tap(0x36, .rightAlt)]) + XCTAssertEqual(f.keyUp(0x06), []) + XCTAssertEqual(f.keyDown(0x34, modifiers: []), []) + XCTAssertEqual(f.keyDown(0x06, modifiers: .leftShift), [.tap(0x36, [.leftShift, .rightAlt])]) + } + + func testOtherKeysKeepTheDeadKey() { + var f = CedillaFix() + f.enabled = true + _ = f.keyDown(0x34, modifiers: []) + XCTAssertEqual(f.keyDown(0x04, modifiers: []), [.tap(0x34, []), .press(0x04)]) + XCTAssertEqual(f.keyUp(0x04), [.release(0x04)]) + } + + func testDisabled() { + var f = CedillaFix() + XCTAssertEqual(f.keyDown(0x34, modifiers: []), [.press(0x34)]) + } +} + +final class LayoutMapTests: XCTestCase { + func testKnownLayouts() { + XCTAssertEqual(KeyboardLayoutMap.androidLayout(forInputSource: "com.apple.keylayout.USInternational-PC"), "english_us_intl") + XCTAssertEqual(KeyboardLayoutMap.androidLayout(forInputSource: "com.apple.keylayout.Brazilian-ABNT2"), "brazilian") + XCTAssertNil(KeyboardLayoutMap.androidLayout(forInputSource: "com.apple.keylayout.Klingon")) + XCTAssertNil(KeyboardLayoutMap.androidLayout(forInputSource: "com.apple.inputmethod.Kotoeri")) + } +} + +final class KeyboardTests: XCTestCase { + func testLetters() { + XCTAssertEqual(KeyMap.usage(forKeyCode: 0x00), 0x04) // a + XCTAssertEqual(KeyMap.usage(forKeyCode: 0x06), 0x1D) // z + XCTAssertEqual(KeyMap.usage(forKeyCode: 0x24), 0x28) // return + XCTAssertNil(KeyMap.usage(forKeyCode: 0x3F)) // fn + } + + func testTableHasNoDuplicateUsages() { + let usages = KeyMap.table.values + XCTAssertEqual(Set(usages).count, usages.count) + } + + func testCommandAsControl() { + let flags = CGEventFlags(rawValue: CGEventFlags.maskCommand.rawValue | HIDModifiers.nxLeftCommand) + XCTAssertEqual(HIDModifiers.from(flags: flags, commandAsControl: true), .leftControl) + XCTAssertEqual(HIDModifiers.from(flags: flags, commandAsControl: false), .leftMeta) + } + + func testRightShiftAndGenericFlag() { + let right = CGEventFlags(rawValue: CGEventFlags.maskShift.rawValue | HIDModifiers.nxRightShift) + XCTAssertEqual(HIDModifiers.from(flags: right, commandAsControl: true), .rightShift) + XCTAssertEqual(HIDModifiers.from(flags: .maskAlternate, commandAsControl: true), .leftAlt) + } + + func testReport() { + var k = KeyboardState() + k.setModifiers(.leftShift) + k.press(0x04) + k.press(0x05) + XCTAssertEqual(k.report, [0x02, 0, 0x04, 0x05, 0, 0, 0, 0]) + XCTAssertFalse(k.press(0x04)) + k.release(0x04) + XCTAssertEqual(k.report, [0x02, 0, 0x05, 0, 0, 0, 0, 0]) + for u: UInt8 in 10...16 { k.press(u) } + XCTAssertEqual(k.pressed.count, 6) + } +} diff --git a/mac/scripts/build-app.sh b/mac/scripts/build-app.sh new file mode 100755 index 0000000..af76e0e --- /dev/null +++ b/mac/scripts/build-app.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Builds dist/DroidBridge.app: the Swift menu-bar app plus the Android server jar. +# Env: DROIDBRIDGE_VERSION (default 0.1.0), SIGN_IDENTITY (default "-", ad-hoc), SERVER_JAR (prebuilt jar). +set -euo pipefail +cd "$(dirname "$0")/.." +VERSION=${DROIDBRIDGE_VERSION:-0.1.0} +IDENTITY=${SIGN_IDENTITY:--} +APP=dist/DroidBridge.app + +swift build -c release --arch arm64 --arch x86_64 +BIN=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)/DroidBridge + +JAR=${SERVER_JAR:-../server/build/droidbridge-server.jar} +if [[ ! -f "$JAR" ]]; then + DROIDBRIDGE_VERSION=$VERSION ../server/build.sh +fi + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/DroidBridge" +cp "$JAR" "$APP/Contents/Resources/droidbridge-server.jar" +cp -R Resources/*.lproj "$APP/Contents/Resources/" +cp Resources/MenuBarIcon.png Resources/MenuBarIcon@2x.png "$APP/Contents/Resources/" +# App icon: all the sizes macOS asks for, from the 1024 px master. +ICONSET=$(mktemp -d)/AppIcon.iconset +mkdir -p "$ICONSET" +for s in 16 32 128 256 512; do + sips -z $s $s Resources/AppIcon-1024.png --out "$ICONSET/icon_${s}x${s}.png" >/dev/null + sips -z $((s * 2)) $((s * 2)) Resources/AppIcon-1024.png --out "$ICONSET/icon_${s}x${s}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" +cat > "$APP/Contents/Info.plist" < + + + + CFBundleIdentifierdev.droidbridge.mac + CFBundleNameDroidBridge + CFBundleDisplayNameDroidBridge + CFBundleExecutableDroidBridge + CFBundlePackageTypeAPPL + CFBundleIconFileAppIcon + CFBundleShortVersionString$VERSION + CFBundleVersion$VERSION + CFBundleDevelopmentRegionen + CFBundleLocalizationsenpt-BR + LSMinimumSystemVersion13.0 + LSUIElement + NSHumanReadableCopyrightApache License 2.0 + + +PLIST +# A stable identity keeps the Accessibility permission across rebuilds (ad-hoc signatures lose it). +# scripts/create-signing-identity.sh makes one in its own keychain. +if [[ -n "${SIGN_KEYCHAIN:-}" ]]; then + security unlock-keychain -p "$(cat "${SIGN_KEYCHAIN_PASSWORD_FILE:?}")" "$SIGN_KEYCHAIN" +fi +codesign --force --sign "$IDENTITY" --identifier dev.droidbridge.mac "$APP" +echo "$APP" diff --git a/mac/scripts/create-signing-identity.sh b/mac/scripts/create-signing-identity.sh new file mode 100755 index 0000000..2123134 --- /dev/null +++ b/mac/scripts/create-signing-identity.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Creates a self-signed code-signing identity ("DroidBridge Local Signing") in its own keychain, so +# local builds keep the same signature and macOS keeps the Accessibility permission across rebuilds. +# Then build with: +# SIGN_IDENTITY="DroidBridge Local Signing" SIGN_KEYCHAIN=~/Library/Keychains/droidbridge-signing.keychain-db \ +# SIGN_KEYCHAIN_PASSWORD_FILE=~/.config/droidbridge/keychain-password scripts/build-app.sh +set -euo pipefail +KC=$HOME/Library/Keychains/droidbridge-signing.keychain-db +PWFILE=$HOME/.config/droidbridge/keychain-password +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +cat > "$TMP/cert.cnf" </dev/null +P12PASS=$(openssl rand -hex 16) +openssl pkcs12 -export -legacy -inkey "$TMP/key.pem" -in "$TMP/cert.pem" -out "$TMP/id.p12" -passout "pass:$P12PASS" 2>/dev/null \ + || openssl pkcs12 -export -inkey "$TMP/key.pem" -in "$TMP/cert.pem" -out "$TMP/id.p12" -passout "pass:$P12PASS" + +mkdir -p "$(dirname "$PWFILE")" +openssl rand -hex 16 > "$PWFILE" +chmod 600 "$PWFILE" +security create-keychain -p "$(cat "$PWFILE")" "$KC" +security set-keychain-settings "$KC" +security unlock-keychain -p "$(cat "$PWFILE")" "$KC" +security import "$TMP/id.p12" -k "$KC" -P "$P12PASS" -T /usr/bin/codesign +security set-key-partition-list -S apple-tool:,apple: -s -k "$(cat "$PWFILE")" "$KC" >/dev/null +# codesign only finds identities in keychains on the search list. +security list-keychains -d user -s $(security list-keychains -d user | tr -d '"') "$KC" +security find-identity -p codesigning "$KC" diff --git a/protocol/README.md b/protocol/README.md new file mode 100644 index 0000000..ce079fb --- /dev/null +++ b/protocol/README.md @@ -0,0 +1,37 @@ +# DroidBridge wire protocol (version 1) + +The Mac app starts the server on the device over adb and connects to it through +`adb forward tcp: localabstract:droidbridge`. Every message, in both +directions, is: + +| Field | Size | +|---|---| +| type | u8 | +| payload length | u32, big-endian | +| payload | length bytes | + +All integers are big-endian. `side` is a side of the Android screen: +0 left, 1 right, 2 top, 3 bottom. `ratio` is a position along that edge, +0 at the top/left end and 65535 at the other. + +## Mac to Android + +| Type | Name | Payload | +|---|---|---| +| 0x01 | HELLO | u16 protocol version | +| 0x02 | ENTER | u8 side, u16 ratio: put the pointer on this edge; then u8 n and n × (u8 side, u16 start, u16 end): the stretches of the device edges that lead back to the Mac (without them: the whole entry side) | +| 0x03 | LEAVE | empty: release keys and buttons, stop watching the edge | +| 0x04 | MOUSE | u8 buttons (bit 0 left, 1 right, 2 middle, 3 back, 4 forward), i16 dx, i16 dy, i8 wheel, i8 horizontal wheel | +| 0x05 | KEYS | 8-byte HID boot keyboard report (modifiers, reserved, 6 usages) | +| 0x06 | CLIPBOARD | UTF-8 text to put on the device clipboard | +| 0x07 | PING | empty | +| 0x08 | LAYOUT | UTF-8 Android keyboard layout name, e.g. `english_us_intl` (Android 15+) | + +## Android to Mac + +| Type | Name | Payload | +|---|---|---| +| 0x81 | DEVICE | u16 protocol version, u16 width, u16 height, UTF-8 model; sent first | +| 0x82 | EDGE | u8 side, u16 ratio: the pointer was pushed out through a return stretch | +| 0x83 | CLIPBOARD | UTF-8 text copied on the device | +| 0x84 | PONG | empty | diff --git a/server/build.sh b/server/build.sh new file mode 100755 index 0000000..b20c88b --- /dev/null +++ b/server/build.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Builds droidbridge-server.jar (a dex in a jar) without Gradle, like scrcpy's +# build_without_gradle.sh. Needs ANDROID_HOME with platforms/android-36 and build-tools/36.0.0. +set -euo pipefail + +VERSION=${DROIDBRIDGE_VERSION:-0.1.0} +PLATFORM=${ANDROID_PLATFORM:-36} +BUILD_TOOLS=${ANDROID_BUILD_TOOLS:-36.0.0} +: "${ANDROID_HOME:?set ANDROID_HOME}" + +HERE=$(cd "$(dirname "$0")" && pwd) +OUT=${BUILD_DIR:-$HERE/build} +ANDROID_JAR="$ANDROID_HOME/platforms/android-$PLATFORM/android.jar" +ANDROID_AIDL="$ANDROID_HOME/platforms/android-$PLATFORM/framework.aidl" +TOOLS="$ANDROID_HOME/build-tools/$BUILD_TOOLS" + +rm -rf "$OUT" && mkdir -p "$OUT/classes" "$OUT/gen/com/genymobile/scrcpy" "$OUT/gen/dev/droidbridge" + +cat > "$OUT/gen/com/genymobile/scrcpy/BuildConfig.java" < "$OUT/gen/dev/droidbridge/BuildInfo.java" < "$OUT/sources.txt" +javac -encoding UTF-8 -nowarn -bootclasspath "$ANDROID_JAR" -cp "$TOOLS/core-lambda-stubs.jar" \ + -d "$OUT/classes" -source 1.8 -target 1.8 @"$OUT/sources.txt" 2>&1 | grep -v "^warning\|^Note:\|^1 warning\|^[0-9]* warnings" || true +test -f "$OUT/classes/dev/droidbridge/Main.class" + +# Like scrcpy, the generated/stub android.* classes go into the dex too. +(cd "$OUT/classes" && "$TOOLS/d8" --classpath "$ANDROID_JAR" --output "$OUT/droidbridge-server.jar" --min-api 26 \ + $(find . -name '*.class')) +echo "$OUT/droidbridge-server.jar" diff --git a/server/src/dev/droidbridge/KeyboardLayouts.java b/server/src/dev/droidbridge/KeyboardLayouts.java new file mode 100644 index 0000000..4e108e9 --- /dev/null +++ b/server/src/dev/droidbridge/KeyboardLayouts.java @@ -0,0 +1,50 @@ +package dev.droidbridge; + +import android.os.IBinder; +import android.view.InputDevice; + +import java.lang.reflect.Method; + +/** + * Sets the Android keyboard layout of our UHID keyboard, so dead keys and symbols match the Mac's + * layout. Uses IInputManager.setKeyboardLayoutOverrideForInputDevice (Android 15+, allowed to the + * shell user through SET_KEYBOARD_LAYOUT). + */ +final class KeyboardLayouts { + private static final String PREFIX = "com.android.inputdevices/com.android.inputdevices.InputDeviceReceiver/keyboard_layout_"; + + private KeyboardLayouts() { + } + + /** {@code layout} is the suffix of an Android layout descriptor, e.g. "english_us_intl". */ + static boolean apply(String deviceName, String layout) { + if (!layout.matches("[a-z_]+")) { + Log.w("bad layout name " + layout); + return false; + } + try { + InputDevice device = null; + for (int id : InputDevice.getDeviceIds()) { + InputDevice d = InputDevice.getDevice(id); + if (d != null && deviceName.equals(d.getName())) { + device = d; + } + } + if (device == null) { + Log.w("keyboard not found for layout"); + return false; + } + Object identifier = InputDevice.class.getMethod("getIdentifier").invoke(device); + Class sm = Class.forName("android.os.ServiceManager"); + IBinder binder = (IBinder) sm.getMethod("getService", String.class).invoke(null, "input"); + Object im = Class.forName("android.hardware.input.IInputManager$Stub").getMethod("asInterface", IBinder.class).invoke(null, binder); + Method set = im.getClass().getMethod("setKeyboardLayoutOverrideForInputDevice", identifier.getClass(), String.class); + set.invoke(im, identifier, PREFIX + layout); + Log.i("keyboard layout " + layout); + return true; + } catch (ReflectiveOperationException | RuntimeException e) { + Log.e("cannot set keyboard layout " + layout, e); + return false; + } + } +} diff --git a/server/src/dev/droidbridge/Log.java b/server/src/dev/droidbridge/Log.java new file mode 100644 index 0000000..5f6d6ae --- /dev/null +++ b/server/src/dev/droidbridge/Log.java @@ -0,0 +1,24 @@ +package dev.droidbridge; + +/** Logs to logcat (tag "droidbridge") and to stdout, which the Mac app keeps. */ +final class Log { + private static final String TAG = "droidbridge"; + + private Log() { + } + + static void i(String msg) { + android.util.Log.i(TAG, msg); + System.out.println("I " + msg); + } + + static void w(String msg) { + android.util.Log.w(TAG, msg); + System.out.println("W " + msg); + } + + static void e(String msg, Throwable t) { + android.util.Log.e(TAG, msg, t); + System.out.println("E " + msg + (t != null ? ": " + t : "")); + } +} diff --git a/server/src/dev/droidbridge/Main.java b/server/src/dev/droidbridge/Main.java new file mode 100644 index 0000000..46ef8ac --- /dev/null +++ b/server/src/dev/droidbridge/Main.java @@ -0,0 +1,78 @@ +package dev.droidbridge; + +import android.annotation.SuppressLint; +import android.net.LocalServerSocket; +import android.net.LocalSocket; +import android.os.Looper; +import android.system.Os; + +import com.genymobile.scrcpy.Workarounds; + +import java.lang.reflect.Field; + +/** + * Entry point, started by the Mac app as the shell user: + * {@code CLASSPATH=/data/local/tmp/droidbridge-server.jar app_process / dev.droidbridge.Main } + * Listens on the abstract socket "droidbridge" (reached through {@code adb forward}) for one connection. + */ +public final class Main { + public static final String VERSION = BuildInfo.VERSION; + + private Main() { + } + + public static void main(String... args) { + int status = 0; + try { + run(args); + } catch (Throwable t) { + Log.e("fatal", t); + status = 1; + } finally { + // The Android runtime may leave non-daemon threads behind. + System.exit(status); + } + } + + private static void run(String... args) throws Exception { + if (args.length < 1 || !VERSION.equals(args[0])) { + throw new IllegalArgumentException("server is " + VERSION + ", client asked for " + (args.length > 0 ? args[0] : "nothing")); + } + if (Os.getuid() == 0) { + Os.setuid(2000); // the clipboard does not work as root + } + prepareMainLooper(); + Workarounds.apply(); + + LocalSocket socket; + try (LocalServerSocket server = new LocalServerSocket("droidbridge")) { + Log.i("droidbridge " + VERSION + " waiting for the Mac"); + System.out.println("READY"); + socket = server.accept(); + } + Session session = new Session(socket.getInputStream(), socket.getOutputStream()); + Thread worker = new Thread(() -> { + try { + session.run(); + } catch (Throwable t) { + Log.e("session ended", t); + } finally { + Looper.getMainLooper().quitSafely(); + } + }, "session"); + worker.start(); + Looper.loop(); + socket.close(); + } + + private static void prepareMainLooper() throws ReflectiveOperationException { + // Like Looper.prepareMainLooper(), but the loop may quit. + Looper.prepare(); + synchronized (Looper.class) { + @SuppressLint("DiscouragedPrivateApi") + Field field = Looper.class.getDeclaredField("sMainLooper"); + field.setAccessible(true); + field.set(null, Looper.myLooper()); + } + } +} diff --git a/server/src/dev/droidbridge/PointerProbe.java b/server/src/dev/droidbridge/PointerProbe.java new file mode 100644 index 0000000..39d9f66 --- /dev/null +++ b/server/src/dev/droidbridge/PointerProbe.java @@ -0,0 +1,124 @@ +package dev.droidbridge; + +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.view.InputDevice; + +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Reads where the mouse pointer is. The shell user cannot monitor input, and the system's + * cursor position API is internal to system_server, but the input service's dump lists each + * device's hovering pointer in display coordinates, e.g. + * {@code DeviceId(15):[... hoveringPointers=[Pointer(id=0, MOUSE) at (0, 1835.57)] ...]}. + * A dump takes about 20 ms on a recent phone, so it is only taken when the pointer moves + * toward the edge that leads back to the Mac. + */ +final class PointerProbe { + static final class Sample { + final float x; + final float y; + final int width; + final int height; + + Sample(float x, float y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } + + @Override + public String toString() { + return "(" + x + ", " + y + ") in " + width + "x" + height; + } + } + + private static final Pattern FRAME = Pattern.compile("logicalFrame=\\[0, 0, (\\d+), (\\d+)\\]"); + + private final IBinder input; + private int deviceId = -1; + + PointerProbe() throws ReflectiveOperationException { + Class serviceManager = Class.forName("android.os.ServiceManager"); + Method getService = serviceManager.getMethod("getService", String.class); + input = (IBinder) getService.invoke(null, "input"); + if (input == null) { + throw new IllegalStateException("input service not found"); + } + } + + /** Finds the input device id the system gave to our UHID mouse. */ + boolean findDevice(String name, long timeoutMs) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + do { + for (int id : InputDevice.getDeviceIds()) { + InputDevice device = InputDevice.getDevice(id); + if (device != null && name.equals(device.getName())) { + deviceId = id; + Log.i("mouse is input device " + id); + return true; + } + } + Thread.sleep(50); + } while (System.currentTimeMillis() < deadline); + return false; + } + + /** Returns the pointer position, or null if the pointer has not moved yet. */ + Sample sample() throws IOException { + String dump = dump(); + Matcher pointer = Pattern.compile("DeviceId\\(" + deviceId + + "\\):\\[.*?hoveringPointers=\\[Pointer\\(id=\\d+, MOUSE\\) at \\(([-\\d.]+), ([-\\d.]+)\\)").matcher(dump); + if (!pointer.find()) { + return null; + } + int width = 0; + int height = 0; + int cursor = dump.indexOf("MouseCursorController"); + Matcher frame = FRAME.matcher(dump); + if (cursor >= 0 && frame.find(cursor) || frame.find(0)) { + width = Integer.parseInt(frame.group(1)); + height = Integer.parseInt(frame.group(2)); + } + return new Sample(Float.parseFloat(pointer.group(1)), Float.parseFloat(pointer.group(2)), width, height); + } + + private String dump() throws IOException { + ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(256 * 1024); + Thread reader = new Thread(() -> { + try (InputStream in = new FileInputStream(pipe[0].getFileDescriptor())) { + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) { + bytes.write(buf, 0, n); + } + } catch (IOException e) { + Log.w("dump read: " + e.getMessage()); + } + }, "probe-reader"); + reader.start(); + try { + input.dump(pipe[1].getFileDescriptor(), new String[0]); + } catch (Exception e) { + throw new IOException("input dump failed", e); + } finally { + pipe[1].close(); + } + try { + reader.join(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + pipe[0].close(); + return new String(bytes.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/server/src/dev/droidbridge/Protocol.java b/server/src/dev/droidbridge/Protocol.java new file mode 100644 index 0000000..f4e3e0b --- /dev/null +++ b/server/src/dev/droidbridge/Protocol.java @@ -0,0 +1,37 @@ +package dev.droidbridge; + +/** + * Wire protocol between the Mac app and this server (see protocol/README.md). + * Every message is: u8 type, u32 payload length (big-endian), payload. + */ +public final class Protocol { + public static final int VERSION = 1; + + // Mac -> Android + public static final int HELLO = 0x01; // u16 protocol version + public static final int ENTER = 0x02; // u8 side, u16 ratio, u8 n, n x {u8 side, u16 start, u16 end} + public static final int LEAVE = 0x03; // empty + public static final int MOUSE = 0x04; // u8 buttons, i16 dx, i16 dy, i8 wheel, i8 hwheel + public static final int KEYS = 0x05; // 8-byte HID boot keyboard report + public static final int CLIPBOARD = 0x06; // utf-8 text + public static final int PING = 0x07; // empty + public static final int LAYOUT = 0x08; // utf-8 Android keyboard layout name, e.g. "english_us_intl" + + // Android -> Mac + public static final int DEVICE = 0x81; // u16 protocol version, u16 width, u16 height, utf-8 model + public static final int EDGE = 0x82; // u8 side, u16 ratio along the edge + public static final int DEVICE_CLIPBOARD = 0x83; // utf-8 text + public static final int PONG = 0x84; // empty + + // Sides of the Android screen + public static final int SIDE_NONE = -1; + public static final int SIDE_LEFT = 0; + public static final int SIDE_RIGHT = 1; + public static final int SIDE_TOP = 2; + public static final int SIDE_BOTTOM = 3; + + public static final int MAX_PAYLOAD = 1 << 20; + + private Protocol() { + } +} diff --git a/server/src/dev/droidbridge/Session.java b/server/src/dev/droidbridge/Session.java new file mode 100644 index 0000000..3b63021 --- /dev/null +++ b/server/src/dev/droidbridge/Session.java @@ -0,0 +1,386 @@ +package dev.droidbridge; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import com.genymobile.scrcpy.wrappers.ClipboardManager; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** One connection from the Mac: a mouse and a keyboard over UHID, plus the clipboard. */ +final class Session { + static final String MOUSE_NAME = "droidbridge mouse"; + static final String KEYBOARD_NAME = "droidbridge keyboard"; + private static final int VENDOR_ID = 0x18D1; + + /** Counts pushed against the return edge, after it is reached, before control goes back. */ + private static final int PUSH_TO_LEAVE = 24; + /** Minimum time between two pointer probes. */ + private static final long PROBE_INTERVAL_MS = 30; + /** How close to the edge (px) counts as touching it. */ + private static final float EDGE_SLOP = 1.5f; + + private final DataInputStream in; + private final DataOutputStream out; + private final Uhid mouse; + private final Uhid keyboard; + private final PointerProbe probe; + private final ClipboardManager clipboard; + private final ExecutorService prober = Executors.newSingleThreadExecutor(); + + /** Stretches of the device edges that lead back to the Mac: {side, start, end}; null = none. */ + private volatile float[][] returns; + /** The side the pointer is touching inside a return stretch, or SIDE_NONE. */ + private volatile int edgeSide = Protocol.SIDE_NONE; + private volatile float edgeRatio; + private volatile boolean probing; + private volatile long lastProbe; + private int pushed; + private boolean edgeSent; + private int buttons; + + private final Object clipLock = new Object(); + private String lastClipFromMac; + private String lastClipToMac; + + Session(InputStream input, OutputStream output) throws Exception { + in = new DataInputStream(new BufferedInputStream(input)); + out = new DataOutputStream(new BufferedOutputStream(output)); + mouse = new Uhid(MOUSE_NAME, VENDOR_ID, 0x4D42, Uhid.MOUSE_DESCRIPTOR); + keyboard = new Uhid(KEYBOARD_NAME, VENDOR_ID, 0x4B42, Uhid.KEYBOARD_DESCRIPTOR); + probe = new PointerProbe(); + if (!probe.findDevice(MOUSE_NAME, 3000)) { + throw new IOException("the system did not add the UHID mouse"); + } + clipboard = ServiceManager.getClipboardManager(); + } + + void run() throws IOException { + int[] size = displaySize(); + sendDevice(size[0], size[1]); + if (clipboard != null) { + // Clipboard callbacks need a looper; the main thread runs one. + new Handler(Looper.getMainLooper()).post(() -> clipboard.addPrimaryClipChangedListener(this::onDeviceClipboard)); + } else { + Log.w("no clipboard manager on this device"); + } + try { + while (true) { + int type = in.readUnsignedByte(); + int length = in.readInt(); + if (length < 0 || length > Protocol.MAX_PAYLOAD) { + throw new IOException("bad payload length " + length); + } + byte[] payload = new byte[length]; + in.readFully(payload); + handle(type, payload); + } + } catch (EOFException e) { + Log.i("Mac disconnected"); + } finally { + prober.shutdownNow(); + mouse.close(); + keyboard.close(); + } + } + + private void handle(int type, byte[] p) throws IOException { + switch (type) { + case Protocol.HELLO: + int version = ((p[0] & 0xFF) << 8) | (p[1] & 0xFF); + Log.i("Mac protocol " + version); + break; + case Protocol.MOUSE: + buttons = p[0] & 0x1F; + int dx = (short) (((p[1] & 0xFF) << 8) | (p[2] & 0xFF)); + int dy = (short) (((p[3] & 0xFF) << 8) | (p[4] & 0xFF)); + moveMouse(dx, dy, p[5], p[6]); + onMotion(dx, dy); + break; + case Protocol.KEYS: + if (p.length == 8) { + keyboard.input(p); + } + break; + case Protocol.ENTER: + enter(p[0], u16(p, 1) / 65535f, parseReturns(p)); + break; + case Protocol.LEAVE: + returns = null; + buttons = 0; + mouse.input(new byte[5]); + keyboard.input(new byte[8]); + break; + case Protocol.CLIPBOARD: + setDeviceClipboard(new String(p, StandardCharsets.UTF_8)); + break; + case Protocol.LAYOUT: + KeyboardLayouts.apply(KEYBOARD_NAME, new String(p, StandardCharsets.UTF_8)); + break; + case Protocol.PING: + send(Protocol.PONG, new byte[0]); + break; + default: + Log.w("unknown message type " + type); + } + } + + private static int u16(byte[] p, int i) { + return ((p[i] & 0xFF) << 8) | (p[i + 1] & 0xFF); + } + + /** ENTER: u8 side, u16 ratio, then (protocol 1.1) u8 count and count x {u8 side, u16 start, u16 end}. */ + private static float[][] parseReturns(byte[] p) { + if (p.length < 4) { + return new float[][] {{p[0], 0, 1}}; + } + int count = p[3] & 0xFF; + float[][] out = new float[count][]; + for (int i = 0; i < count && 4 + i * 5 + 5 <= p.length; i++) { + int o = 4 + i * 5; + out[i] = new float[] {p[o], u16(p, o + 1) / 65535f, u16(p, o + 3) / 65535f}; + } + return out; + } + + /** Sends a relative move as UHID reports, each axis limited to -127..127 per report. */ + private void moveMouse(int dx, int dy, int wheel, int hwheel) throws IOException { + do { + int sx = clamp(dx); + int sy = clamp(dy); + mouse.input(new byte[] {(byte) buttons, (byte) sx, (byte) sy, (byte) wheel, (byte) hwheel}); + dx -= sx; + dy -= sy; + wheel = 0; + hwheel = 0; + } while (dx != 0 || dy != 0); + } + + private static int clamp(int v) { + return Math.max(-127, Math.min(127, v)); + } + + /** + * Puts the pointer on the entry edge at the given ratio. The mouse is relative and Android + * accelerates it, so: push into the corner, then walk along the edge and measure. + */ + private void enter(int side, float ratio, float[][] spans) throws IOException { + Log.i("enter side=" + side + " ratio=" + ratio + " returns=" + spans.length); + returns = null; + boolean horizontalEdge = side == Protocol.SIDE_TOP || side == Protocol.SIDE_BOTTOM; + int intoX = side == Protocol.SIDE_RIGHT ? 1 : -1; + int intoY = side == Protocol.SIDE_BOTTOM ? 1 : -1; + // Into the corner: the entry edge, at the start of the axis along it. + for (int i = 0; i < 40; i++) { + mouse.input(new byte[] {0, (byte) (horizontalEdge ? -127 : 127 * intoX), (byte) (horizontalEdge ? 127 * intoY : -127), 0, 0}); + } + sleep(15); + PointerProbe.Sample s = probe.sample(); + if (s != null && s.width > 0) { + float length = horizontalEdge ? s.width - 1 : s.height - 1; + float target = ratio * length; + float gain = 0; + for (int attempt = 0; attempt < 4; attempt++) { + float now = horizontalEdge ? s.x : s.y; + float error = target - now; + if (Math.abs(error) < 12) { + break; + } + int counts = gain > 0 ? Math.round(error / gain) : (int) Math.signum(error) * 60; + walk(horizontalEdge, counts); + sleep(15); + PointerProbe.Sample next = probe.sample(); + if (next == null) { + break; + } + float moved = (horizontalEdge ? next.x : next.y) - now; + if (counts != 0 && moved * counts > 0) { + gain = moved / counts; + } + s = next; + } + Log.i("entered at " + s + ", target " + target); + } + edgeSide = side; + edgeRatio = ratio; + pushed = 0; + edgeSent = false; + returns = spans; + } + + /** Moves along one axis in small steps, so acceleration stays predictable. */ + private void walk(boolean horizontal, int counts) throws IOException { + int step = counts > 0 ? 10 : -10; + while (counts != 0) { + int s = Math.abs(counts) < Math.abs(step) ? counts : step; + mouse.input(new byte[] {0, (byte) (horizontal ? s : 0), (byte) (horizontal ? 0 : s), 0, 0}); + counts -= s; + sleep(2); + } + } + + private static int toward(int side, int dx, int dy) { + switch (side) { + case Protocol.SIDE_LEFT: return -dx; + case Protocol.SIDE_RIGHT: return dx; + case Protocol.SIDE_TOP: return -dy; + default: return dy; + } + } + + /** Watches for the pointer being pushed out through a stretch of edge that leads back to the Mac. */ + private void onMotion(int dx, int dy) throws IOException { + float[][] spans = returns; + if (spans == null || spans.length == 0 || buttons != 0) { + return; + } + boolean anyToward = false; + for (float[] span : spans) { + if (span != null && toward((int) span[0], dx, dy) > 0) { + anyToward = true; + } + } + if (!anyToward) { + if (dx != 0 || dy != 0) { + edgeSide = Protocol.SIDE_NONE; + pushed = 0; + } + return; + } + int side = edgeSide; + if (side != Protocol.SIDE_NONE && toward(side, dx, dy) > 0) { + pushed += toward(side, dx, dy); + if (pushed >= PUSH_TO_LEAVE && !edgeSent) { + edgeSent = true; + int r = Math.round(Math.max(0, Math.min(1, edgeRatio)) * 65535); + send(Protocol.EDGE, new byte[] {(byte) side, (byte) (r >> 8), (byte) r}); + Log.i("edge reached on side " + side + ", back to the Mac (ratio " + edgeRatio + ")"); + } + } + // Keep probing while pushing: the pointer may have slid along the edge or out of the stretch. + long now = System.currentTimeMillis(); + if (!probing && now - lastProbe >= PROBE_INTERVAL_MS) { + probing = true; + lastProbe = now; + prober.execute(() -> { + try { + PointerProbe.Sample s = probe.sample(); + if (s != null && s.width > 0) { + int found = Protocol.SIDE_NONE; + float foundRatio = 0; + for (float[] span : spans) { + if (span == null) { + continue; + } + int sd = (int) span[0]; + boolean touching; + float ratio; + switch (sd) { + case Protocol.SIDE_LEFT: touching = s.x <= EDGE_SLOP; ratio = s.y / (s.height - 1); break; + case Protocol.SIDE_RIGHT: touching = s.x >= s.width - 1 - EDGE_SLOP; ratio = s.y / (s.height - 1); break; + case Protocol.SIDE_TOP: touching = s.y <= EDGE_SLOP; ratio = s.x / (s.width - 1); break; + default: touching = s.y >= s.height - 1 - EDGE_SLOP; ratio = s.x / (s.width - 1); break; + } + if (touching && ratio >= span[1] - 0.01f && ratio <= span[2] + 0.01f) { + found = sd; + foundRatio = ratio; + break; + } + } + if (found != edgeSide) { + pushed = 0; + } + edgeRatio = foundRatio; + edgeSide = found; + } + } catch (IOException e) { + Log.w("probe: " + e.getMessage()); + } finally { + probing = false; + } + }); + } + } + + private void setDeviceClipboard(String text) { + if (clipboard == null) { + return; + } + synchronized (clipLock) { + lastClipFromMac = text; + } + clipboard.setText(text); + } + + private void onDeviceClipboard() { + CharSequence cs = clipboard.getText(); + if (cs == null) { + return; + } + String text = cs.toString(); + synchronized (clipLock) { + if (text.equals(lastClipFromMac) || text.equals(lastClipToMac)) { + return; + } + lastClipToMac = text; + } + try { + send(Protocol.DEVICE_CLIPBOARD, text.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + Log.w("clipboard send: " + e.getMessage()); + } + } + + private void sendDevice(int width, int height) throws IOException { + byte[] model = (Build.MANUFACTURER + " " + Build.MODEL).getBytes(StandardCharsets.UTF_8); + byte[] p = new byte[6 + model.length]; + p[0] = (byte) (Protocol.VERSION >> 8); + p[1] = (byte) Protocol.VERSION; + p[2] = (byte) (width >> 8); + p[3] = (byte) width; + p[4] = (byte) (height >> 8); + p[5] = (byte) height; + System.arraycopy(model, 0, p, 6, model.length); + send(Protocol.DEVICE, p); + } + + private synchronized void send(int type, byte[] payload) throws IOException { + out.writeByte(type); + out.writeInt(payload.length); + out.write(payload); + out.flush(); + } + + private int[] displaySize() { + android.util.DisplayMetrics m = new android.util.DisplayMetrics(); + try { + android.view.Display d = ((android.hardware.display.DisplayManager) com.genymobile.scrcpy.FakeContext.get() + .getSystemService(android.content.Context.DISPLAY_SERVICE)).getDisplay(0); + d.getRealMetrics(m); + return new int[] {m.widthPixels, m.heightPixels}; + } catch (RuntimeException e) { + Log.w("display size: " + e.getMessage()); + return new int[] {0, 0}; + } + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/server/src/dev/droidbridge/Uhid.java b/server/src/dev/droidbridge/Uhid.java new file mode 100644 index 0000000..a3a5908 --- /dev/null +++ b/server/src/dev/droidbridge/Uhid.java @@ -0,0 +1,94 @@ +package dev.droidbridge; + +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +/** A virtual HID device backed by /dev/uhid (Linux Documentation/hid/uhid.rst). */ +final class Uhid { + private static final int UHID_CREATE2 = 11; + private static final int UHID_INPUT2 = 12; + private static final short BUS_VIRTUAL = 0x06; + + static final byte[] MOUSE_DESCRIPTOR = { + 0x05, 0x01, 0x09, 0x02, (byte) 0xA1, 0x01, 0x09, 0x01, (byte) 0xA1, 0x00, + // 5 buttons + 0x05, 0x09, 0x19, 0x01, 0x29, 0x05, 0x15, 0x00, 0x25, 0x01, (byte) 0x95, 0x05, 0x75, 0x01, (byte) 0x81, 0x02, + (byte) 0x95, 0x01, 0x75, 0x03, (byte) 0x81, 0x01, + // X, Y, wheel: relative, -127..127 + 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38, 0x15, (byte) 0x81, 0x25, 0x7F, 0x75, 0x08, (byte) 0x95, 0x03, + (byte) 0x81, 0x06, + // AC Pan (horizontal wheel) + 0x05, 0x0C, 0x0A, 0x38, 0x02, 0x15, (byte) 0x81, 0x25, 0x7F, 0x75, 0x08, (byte) 0x95, 0x01, (byte) 0x81, 0x06, + (byte) 0xC0, (byte) 0xC0, + }; + + static final byte[] KEYBOARD_DESCRIPTOR = { + 0x05, 0x01, 0x09, 0x06, (byte) 0xA1, 0x01, + // modifiers + 0x05, 0x07, 0x19, (byte) 0xE0, 0x29, (byte) 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, (byte) 0x95, 0x08, + (byte) 0x81, 0x02, + // reserved byte + 0x75, 0x08, (byte) 0x95, 0x01, (byte) 0x81, 0x01, + // LEDs (output, ignored) + 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x75, 0x01, (byte) 0x95, 0x05, (byte) 0x91, 0x02, 0x75, 0x03, (byte) 0x95, + 0x01, (byte) 0x91, 0x01, + // 6 keys + 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, (byte) 0x95, 0x06, (byte) 0x81, 0x00, + (byte) 0xC0, + }; + + private final FileDescriptor fd; + + Uhid(String name, int vendorId, int productId, byte[] descriptor) throws IOException { + try { + fd = Os.open("/dev/uhid", OsConstants.O_RDWR, 0); + byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8); + ByteBuffer buf = ByteBuffer.allocate(280 + descriptor.length).order(ByteOrder.nativeOrder()); + buf.putInt(UHID_CREATE2); + buf.put(nameBytes, 0, Math.min(nameBytes.length, 127)); + buf.position(4 + 256); + buf.putShort((short) descriptor.length); + buf.putShort(BUS_VIRTUAL); + buf.putInt(vendorId); + buf.putInt(productId); + buf.putInt(0); // version + buf.putInt(0); // country + buf.put(descriptor); + write(buf.array()); + } catch (ErrnoException e) { + throw new IOException("cannot open /dev/uhid", e); + } + } + + void input(byte[] report) throws IOException { + ByteBuffer buf = ByteBuffer.allocate(6 + report.length).order(ByteOrder.nativeOrder()); + buf.putInt(UHID_INPUT2); + buf.putShort((short) report.length); + buf.put(report); + write(buf.array()); + } + + private void write(byte[] data) throws IOException { + try { + Os.write(fd, data, 0, data.length); + } catch (ErrnoException e) { + throw new IOException(e); + } + } + + /** Closing the fd destroys the device. */ + void close() { + try { + Os.close(fd); + } catch (ErrnoException e) { + Log.w("uhid close: " + e.getMessage()); + } + } +} diff --git a/server/third_party/SCRCPY-LICENSE b/server/third_party/SCRCPY-LICENSE new file mode 100644 index 0000000..3325e9f --- /dev/null +++ b/server/third_party/SCRCPY-LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (C) 2018 Genymobile + Copyright (C) 2018-2026 Romain Vimont + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/server/third_party/scrcpy-aidl/android/content/IOnPrimaryClipChangedListener.aidl b/server/third_party/scrcpy-aidl/android/content/IOnPrimaryClipChangedListener.aidl new file mode 100644 index 0000000..46d7f7c --- /dev/null +++ b/server/third_party/scrcpy-aidl/android/content/IOnPrimaryClipChangedListener.aidl @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2008, The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.content; + +/** + * {@hide} + */ +oneway interface IOnPrimaryClipChangedListener { + void dispatchPrimaryClipChanged(); +} diff --git a/server/third_party/scrcpy-aidl/android/view/IDisplayWindowListener.aidl b/server/third_party/scrcpy-aidl/android/view/IDisplayWindowListener.aidl new file mode 100644 index 0000000..0d1f197 --- /dev/null +++ b/server/third_party/scrcpy-aidl/android/view/IDisplayWindowListener.aidl @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.view; + +import android.graphics.Rect; +import android.content.res.Configuration; + +import java.util.List; + +/** + * Interface to listen for changes to display window-containers. + * + * This differs from DisplayManager's DisplayListener in a couple ways: + * - onDisplayAdded is always called after the display is actually added to the WM hierarchy. + * This corresponds to the DisplayContent and not the raw Dislay from DisplayManager. + * - onDisplayConfigurationChanged is called for all configuration changes, not just changes + * to displayinfo (eg. windowing-mode). + * + */ +oneway interface IDisplayWindowListener { + + /** + * Called when a new display is added to the WM hierarchy. The existing display ids are returned + * when this listener is registered with WM via {@link #registerDisplayWindowListener}. + */ + void onDisplayAdded(int displayId); + + /** + * Called when a display's window-container configuration has changed. + */ + void onDisplayConfigurationChanged(int displayId, in Configuration newConfig); + + /** + * Called when a display is removed from the hierarchy. + */ + void onDisplayRemoved(int displayId); +} diff --git a/server/third_party/scrcpy-java/android/content/IContentProvider.java b/server/third_party/scrcpy-java/android/content/IContentProvider.java new file mode 100644 index 0000000..bb907dd --- /dev/null +++ b/server/third_party/scrcpy-java/android/content/IContentProvider.java @@ -0,0 +1,5 @@ +package android.content; + +public interface IContentProvider { + // android.content.IContentProvider is hidden, this is a fake one to expose the type to the project +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/AndroidVersions.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AndroidVersions.java new file mode 100644 index 0000000..5303924 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AndroidVersions.java @@ -0,0 +1,32 @@ +package com.genymobile.scrcpy; + +import android.os.Build; + +/** + * Android version code constants, done right. + *

+ * API levels + */ +public final class AndroidVersions { + + private AndroidVersions() { + // not instantiable + } + + public static final int API_21_ANDROID_5_0 = Build.VERSION_CODES.LOLLIPOP; + public static final int API_22_ANDROID_5_1 = Build.VERSION_CODES.LOLLIPOP_MR1; + public static final int API_23_ANDROID_6_0 = Build.VERSION_CODES.M; + public static final int API_24_ANDROID_7_0 = Build.VERSION_CODES.N; + public static final int API_25_ANDROID_7_1 = Build.VERSION_CODES.N_MR1; + public static final int API_26_ANDROID_8_0 = Build.VERSION_CODES.O; + public static final int API_27_ANDROID_8_1 = Build.VERSION_CODES.O_MR1; + public static final int API_28_ANDROID_9 = Build.VERSION_CODES.P; + public static final int API_29_ANDROID_10 = Build.VERSION_CODES.Q; + public static final int API_30_ANDROID_11 = Build.VERSION_CODES.R; + public static final int API_31_ANDROID_12 = Build.VERSION_CODES.S; + public static final int API_32_ANDROID_12L = Build.VERSION_CODES.S_V2; + public static final int API_33_ANDROID_13 = Build.VERSION_CODES.TIRAMISU; + public static final int API_34_ANDROID_14 = Build.VERSION_CODES.UPSIDE_DOWN_CAKE; + public static final int API_35_ANDROID_15 = Build.VERSION_CODES.VANILLA_ICE_CREAM; + +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/AsyncProcessor.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AsyncProcessor.java new file mode 100644 index 0000000..d5da6a9 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AsyncProcessor.java @@ -0,0 +1,18 @@ +package com.genymobile.scrcpy; + +public interface AsyncProcessor { + interface TerminationListener { + /** + * Notify processor termination + * + * @param fatalError {@code true} if this must cause the termination of the whole scrcpy-server. + */ + void onTerminated(boolean fatalError); + } + + void start(TerminationListener listener); + + void stop(); + + void join() throws InterruptedException; +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/CleanUp.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/CleanUp.java new file mode 100644 index 0000000..a5816c3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/CleanUp.java @@ -0,0 +1,271 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.Settings; +import com.genymobile.scrcpy.util.SettingsException; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.os.BatteryManager; +import android.os.Looper; +import android.system.ErrnoException; +import android.system.Os; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; + +/** + * Handle the cleanup of scrcpy, even if the main process is killed. + *

+ * This is useful to restore some state when scrcpy is closed, even on device disconnection (which kills the scrcpy process). + */ +public final class CleanUp { + + // Dynamic options + private static final int PENDING_CHANGE_DISPLAY_POWER = 1 << 0; + private int pendingChanges; + private boolean pendingRestoreDisplayPower; + + private Thread thread; + private boolean interrupted; + + private CleanUp(Options options) { + thread = new Thread(() -> runCleanUp(options), "cleanup"); + thread.start(); + } + + public static CleanUp start(Options options) { + return new CleanUp(options); + } + + public synchronized void interrupt() { + // Do not use thread.interrupt() because only the wait() call must be interrupted, not Command.exec() + interrupted = true; + notify(); + } + + public void join() throws InterruptedException { + thread.join(); + } + + private void runCleanUp(Options options) { + boolean disableShowTouches = false; + if (options.getShowTouches()) { + try { + String oldValue = Settings.getAndPutValue(Settings.TABLE_SYSTEM, "show_touches", "1"); + // If "show touches" was disabled, it must be disabled back on clean up + disableShowTouches = !"1".equals(oldValue); + } catch (SettingsException e) { + Ln.e("Could not change \"show_touches\"", e); + } + } + + int restoreStayOn = -1; + if (options.getStayAwake()) { + int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS; + try { + String oldValue = Settings.getAndPutValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn)); + try { + int currentStayOn = Integer.parseInt(oldValue); + // Restore only if the current value is different + if (currentStayOn != stayOn) { + restoreStayOn = currentStayOn; + } + } catch (NumberFormatException e) { + // ignore + } + } catch (SettingsException e) { + Ln.e("Could not change \"stay_on_while_plugged_in\"", e); + } + } + + int restoreScreenOffTimeout = -1; + int screenOffTimeout = options.getScreenOffTimeout(); + if (screenOffTimeout != -1) { + try { + String oldValue = Settings.getAndPutValue(Settings.TABLE_SYSTEM, "screen_off_timeout", String.valueOf(screenOffTimeout)); + try { + int currentScreenOffTimeout = Integer.parseInt(oldValue); + // Restore only if the current value is different + if (currentScreenOffTimeout != screenOffTimeout) { + restoreScreenOffTimeout = currentScreenOffTimeout; + } + } catch (NumberFormatException e) { + // ignore + } + } catch (SettingsException e) { + Ln.e("Could not change \"screen_off_timeout\"", e); + } + } + + int displayId = options.getDisplayId(); + + int restoreDisplayImePolicy = -1; + if (displayId > 0) { + int displayImePolicy = options.getDisplayImePolicy(); + if (displayImePolicy != -1) { + int currentDisplayImePolicy = ServiceManager.getWindowManager().getDisplayImePolicy(displayId); + if (currentDisplayImePolicy != displayImePolicy) { + ServiceManager.getWindowManager().setDisplayImePolicy(displayId, displayImePolicy); + restoreDisplayImePolicy = currentDisplayImePolicy; + } + } + } + + boolean powerOffScreen = options.getPowerOffScreenOnClose(); + + try { + run(displayId, restoreStayOn, disableShowTouches, powerOffScreen, restoreScreenOffTimeout, restoreDisplayImePolicy); + } catch (IOException e) { + Ln.e("Clean up I/O exception", e); + } + } + + private void run(int displayId, int restoreStayOn, boolean disableShowTouches, boolean powerOffScreen, int restoreScreenOffTimeout, + int restoreDisplayImePolicy) throws IOException { + String[] cmd = { + "app_process", + "/", + CleanUp.class.getName(), + String.valueOf(displayId), + String.valueOf(restoreStayOn), + String.valueOf(disableShowTouches), + String.valueOf(powerOffScreen), + String.valueOf(restoreScreenOffTimeout), + String.valueOf(restoreDisplayImePolicy), + }; + + ProcessBuilder builder = new ProcessBuilder(cmd); + builder.environment().put("CLASSPATH", Server.SERVER_PATH); + Process process = builder.start(); + OutputStream out = process.getOutputStream(); + + while (true) { + int localPendingChanges; + boolean localPendingRestoreDisplayPower; + synchronized (this) { + while (!interrupted && pendingChanges == 0) { + try { + wait(); + } catch (InterruptedException e) { + throw new AssertionError("Clean up thread MUST NOT be interrupted"); + } + } + if (interrupted) { + break; + } + localPendingChanges = pendingChanges; + localPendingRestoreDisplayPower = pendingRestoreDisplayPower; + pendingChanges = 0; + } + if ((localPendingChanges & PENDING_CHANGE_DISPLAY_POWER) != 0) { + out.write(localPendingRestoreDisplayPower ? 1 : 0); + out.flush(); + } + } + } + + public synchronized void setRestoreDisplayPower(boolean restoreDisplayPower) { + pendingRestoreDisplayPower = restoreDisplayPower; + pendingChanges |= PENDING_CHANGE_DISPLAY_POWER; + notify(); + } + + public static void unlinkSelf() { + try { + new File(Server.SERVER_PATH).delete(); + } catch (Exception e) { + Ln.e("Could not unlink server", e); + } + } + + @SuppressWarnings("deprecation") + private static void prepareMainLooper() { + Looper.prepareMainLooper(); + } + + public static void main(String... args) { + try { + // Start a new session to avoid being terminated along with the server process on some devices + Os.setsid(); + } catch (ErrnoException e) { + Ln.e("setsid() failed", e); + } + unlinkSelf(); + + // Needed for workarounds + prepareMainLooper(); + Workarounds.apply(); + + int displayId = Integer.parseInt(args[0]); + int restoreStayOn = Integer.parseInt(args[1]); + boolean disableShowTouches = Boolean.parseBoolean(args[2]); + boolean powerOffScreen = Boolean.parseBoolean(args[3]); + int restoreScreenOffTimeout = Integer.parseInt(args[4]); + int restoreDisplayImePolicy = Integer.parseInt(args[5]); + + // Dynamic option + boolean restoreDisplayPower = false; + + try { + // Wait for the server to die + int msg; + while ((msg = System.in.read()) != -1) { + // Only restore display power + assert msg == 0 || msg == 1; + restoreDisplayPower = msg != 0; + } + } catch (IOException e) { + // Expected when the server is dead + } + + Ln.i("Cleaning up"); + + if (disableShowTouches) { + Ln.i("Disabling \"show touches\""); + try { + Settings.putValue(Settings.TABLE_SYSTEM, "show_touches", "0"); + } catch (SettingsException e) { + Ln.e("Could not restore \"show_touches\"", e); + } + } + + if (restoreStayOn != -1) { + Ln.i("Restoring \"stay awake\""); + try { + Settings.putValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(restoreStayOn)); + } catch (SettingsException e) { + Ln.e("Could not restore \"stay_on_while_plugged_in\"", e); + } + } + + if (restoreScreenOffTimeout != -1) { + Ln.i("Restoring \"screen off timeout\""); + try { + Settings.putValue(Settings.TABLE_SYSTEM, "screen_off_timeout", String.valueOf(restoreScreenOffTimeout)); + } catch (SettingsException e) { + Ln.e("Could not restore \"screen_off_timeout\"", e); + } + } + + if (restoreDisplayImePolicy != -1) { + Ln.i("Restoring \"display IME policy\""); + ServiceManager.getWindowManager().setDisplayImePolicy(displayId, restoreDisplayImePolicy); + } + + // Change the power of the main display when mirroring a virtual display + int targetDisplayId = displayId != Device.DISPLAY_ID_NONE ? displayId : 0; + if (Device.isScreenOn(targetDisplayId)) { + if (powerOffScreen) { + Ln.i("Power off screen"); + Device.powerOffScreen(targetDisplayId); + } else if (restoreDisplayPower) { + Ln.i("Restoring display power"); + Device.setDisplayPower(targetDisplayId, true); + } + } + + System.exit(0); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/FakeContext.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/FakeContext.java new file mode 100644 index 0000000..5d41a8f --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/FakeContext.java @@ -0,0 +1,127 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.AttributionSource; +import android.content.ContentResolver; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.IContentProvider; +import android.os.Binder; +import android.os.Process; + +import java.lang.reflect.Field; + +public final class FakeContext extends ContextWrapper { + + public static final String PACKAGE_NAME = "com.android.shell"; + public static final int ROOT_UID = 0; // Like android.os.Process.ROOT_UID, but before API 29 + + private static final FakeContext INSTANCE = new FakeContext(); + + public static FakeContext get() { + return INSTANCE; + } + + private final ContentResolver contentResolver = new ContentResolver(this) { + @SuppressWarnings({"unused", "ProtectedMemberInFinalClass"}) + // @Override (but super-class method not visible) + protected IContentProvider acquireProvider(Context c, String name) { + return ServiceManager.getActivityManager().getContentProviderExternal(name, new Binder()); + } + + @SuppressWarnings("unused") + // @Override (but super-class method not visible) + public boolean releaseProvider(IContentProvider icp) { + return false; + } + + @SuppressWarnings({"unused", "ProtectedMemberInFinalClass"}) + // @Override (but super-class method not visible) + protected IContentProvider acquireUnstableProvider(Context c, String name) { + return null; + } + + @SuppressWarnings("unused") + // @Override (but super-class method not visible) + public boolean releaseUnstableProvider(IContentProvider icp) { + return false; + } + + @SuppressWarnings("unused") + // @Override (but super-class method not visible) + public void unstableProviderDied(IContentProvider icp) { + // ignore + } + }; + + private FakeContext() { + super(Workarounds.getSystemContext()); + } + + @Override + public String getPackageName() { + return PACKAGE_NAME; + } + + @Override + public String getOpPackageName() { + return PACKAGE_NAME; + } + + @TargetApi(AndroidVersions.API_31_ANDROID_12) + @Override + public AttributionSource getAttributionSource() { + AttributionSource.Builder builder = new AttributionSource.Builder(Process.SHELL_UID); + builder.setPackageName(PACKAGE_NAME); + return builder.build(); + } + + // @Override to be added on SDK upgrade for Android 14 + @SuppressWarnings("unused") + public int getDeviceId() { + return 0; + } + + @Override + public Context getApplicationContext() { + return this; + } + + @Override + public Context createPackageContext(String packageName, int flags) { + return this; + } + + @Override + public ContentResolver getContentResolver() { + return contentResolver; + } + + @SuppressLint("SoonBlockedPrivateApi") + @Override + public Object getSystemService(String name) { + Object service = super.getSystemService(name); + if (service == null) { + return null; + } + + // "semclipboard" is a Samsung-internal service + // See: + // - + // - + if (Context.CLIPBOARD_SERVICE.equals(name) || "semclipboard".equals(name) || Context.ACTIVITY_SERVICE.equals(name)) { + try { + Field field = service.getClass().getDeclaredField("mContext"); + field.setAccessible(true); + field.set(service, this); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + return service; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/Options.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Options.java new file mode 100644 index 0000000..26a6ba9 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Options.java @@ -0,0 +1,707 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.audio.AudioCodec; +import com.genymobile.scrcpy.audio.AudioSource; +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.model.CodecOption; +import com.genymobile.scrcpy.model.NewDisplay; +import com.genymobile.scrcpy.model.Orientation; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.video.CameraAspectRatio; +import com.genymobile.scrcpy.video.CameraFacing; +import com.genymobile.scrcpy.video.VideoCodec; +import com.genymobile.scrcpy.video.VideoSource; +import com.genymobile.scrcpy.wrappers.WindowManager; + +import android.graphics.Rect; +import android.util.Pair; + +import java.util.List; +import java.util.Locale; + +public class Options { + + private Ln.Level logLevel = Ln.Level.DEBUG; + private int scid = -1; // 31-bit non-negative value, or -1 + private boolean video = true; + private boolean audio = true; + private int maxSize; + private int minSizeAlignment = 1; + private VideoCodec videoCodec = VideoCodec.H264; + private AudioCodec audioCodec = AudioCodec.OPUS; + private VideoSource videoSource = VideoSource.DISPLAY; + private AudioSource audioSource = AudioSource.OUTPUT; + private boolean audioDup; + private int videoBitRate = 8000000; + private int audioBitRate = 128000; + private float maxFps; + private float angle; + private boolean tunnelForward; + private Rect crop; + private boolean control = true; + private int displayId; + private String cameraId; + private Size cameraSize; + private CameraFacing cameraFacing; + private CameraAspectRatio cameraAspectRatio; + private float cameraZoom = 1; + private int cameraFps; + private boolean cameraHighSpeed; + private boolean cameraTorch; + private boolean showTouches; + private boolean stayAwake; + private int screenOffTimeout = -1; + private int displayImePolicy = -1; + private List videoCodecOptions; + private List audioCodecOptions; + + private String videoEncoder; + private String audioEncoder; + private boolean powerOffScreenOnClose; + private boolean clipboardAutosync = true; + private boolean downsizeOnError = true; + private boolean cleanup = true; + private boolean powerOn = true; + + private NewDisplay newDisplay; + private boolean vdDestroyContent = true; + private boolean vdSystemDecorations = true; + private boolean flexDisplay; + + private boolean keepActive; + private boolean ignoreVideoEncoderConstraints; + + private Orientation.Lock captureOrientationLock = Orientation.Lock.Unlocked; + private Orientation captureOrientation = Orientation.Orient0; + + private boolean listEncoders; + private boolean listDisplays; + private boolean listCameras; + private boolean listCameraSizes; + private boolean listApps; + + // Options not used by the scrcpy client, but useful to use scrcpy-server directly + private boolean sendDeviceMeta = true; // send device name and size + private boolean sendFrameMeta = true; // send PTS so that the client may record properly + private boolean sendDummyByte = true; // write a byte on start to detect connection issues + private boolean sendStreamMeta = true; // write the stream metadata (codec and session) + + public Ln.Level getLogLevel() { + return logLevel; + } + + public int getScid() { + return scid; + } + + public boolean getVideo() { + return video; + } + + public boolean getAudio() { + return audio; + } + + public int getMaxSize() { + return maxSize; + } + + public int getMinSizeAlignment() { + return minSizeAlignment; + } + + public VideoCodec getVideoCodec() { + return videoCodec; + } + + public AudioCodec getAudioCodec() { + return audioCodec; + } + + public VideoSource getVideoSource() { + return videoSource; + } + + public AudioSource getAudioSource() { + return audioSource; + } + + public boolean getAudioDup() { + return audioDup; + } + + public int getVideoBitRate() { + return videoBitRate; + } + + public int getAudioBitRate() { + return audioBitRate; + } + + public float getMaxFps() { + return maxFps; + } + + public float getAngle() { + return angle; + } + + public boolean isTunnelForward() { + return tunnelForward; + } + + public Rect getCrop() { + return crop; + } + + public boolean getControl() { + return control; + } + + public int getDisplayId() { + return displayId; + } + + public String getCameraId() { + return cameraId; + } + + public Size getCameraSize() { + return cameraSize; + } + + public CameraFacing getCameraFacing() { + return cameraFacing; + } + + public CameraAspectRatio getCameraAspectRatio() { + return cameraAspectRatio; + } + + public float getCameraZoom() { + return cameraZoom; + } + + public int getCameraFps() { + return cameraFps; + } + + public boolean getCameraHighSpeed() { + return cameraHighSpeed; + } + + public boolean getCameraTorch() { + return cameraTorch; + } + + public boolean getShowTouches() { + return showTouches; + } + + public boolean getStayAwake() { + return stayAwake; + } + + public int getScreenOffTimeout() { + return screenOffTimeout; + } + + public int getDisplayImePolicy() { + return displayImePolicy; + } + + public List getVideoCodecOptions() { + return videoCodecOptions; + } + + public List getAudioCodecOptions() { + return audioCodecOptions; + } + + public String getVideoEncoder() { + return videoEncoder; + } + + public String getAudioEncoder() { + return audioEncoder; + } + + public boolean getPowerOffScreenOnClose() { + return this.powerOffScreenOnClose; + } + + public boolean getClipboardAutosync() { + return clipboardAutosync; + } + + public boolean getDownsizeOnError() { + return downsizeOnError; + } + + public boolean getCleanup() { + return cleanup; + } + + public boolean getPowerOn() { + return powerOn; + } + + public NewDisplay getNewDisplay() { + return newDisplay; + } + + public Orientation getCaptureOrientation() { + return captureOrientation; + } + + public Orientation.Lock getCaptureOrientationLock() { + return captureOrientationLock; + } + + public boolean getVDDestroyContent() { + return vdDestroyContent; + } + + public boolean getVDSystemDecorations() { + return vdSystemDecorations; + } + + public boolean getKeepActive() { + return keepActive; + } + + public boolean getFlexDisplay() { + return flexDisplay; + } + + public boolean getIgnoreVideoEncoderConstraints() { + return ignoreVideoEncoderConstraints; + } + + public boolean getList() { + return listEncoders || listDisplays || listCameras || listCameraSizes || listApps; + } + + public boolean getListEncoders() { + return listEncoders; + } + + public boolean getListDisplays() { + return listDisplays; + } + + public boolean getListCameras() { + return listCameras; + } + + public boolean getListCameraSizes() { + return listCameraSizes; + } + + public boolean getListApps() { + return listApps; + } + + public boolean getSendDeviceMeta() { + return sendDeviceMeta; + } + + public boolean getSendFrameMeta() { + return sendFrameMeta; + } + + public boolean getSendDummyByte() { + return sendDummyByte; + } + + public boolean getSendStreamMeta() { + return sendStreamMeta; + } + + @SuppressWarnings("MethodLength") + public static Options parse(String... args) { + if (args.length < 1) { + throw new IllegalArgumentException("Missing client version"); + } + + String clientVersion = args[0]; + if (!clientVersion.equals(BuildConfig.VERSION_NAME)) { + throw new IllegalArgumentException( + "The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")"); + } + + Options options = new Options(); + + for (int i = 1; i < args.length; ++i) { + String arg = args[i]; + int equalIndex = arg.indexOf('='); + if (equalIndex == -1) { + throw new IllegalArgumentException("Invalid key=value pair: \"" + arg + "\""); + } + String key = arg.substring(0, equalIndex); + String value = arg.substring(equalIndex + 1); + switch (key) { + case "scid": + int scid = Integer.parseInt(value, 0x10); + if (scid < -1) { + throw new IllegalArgumentException("scid may not be negative (except -1 for 'none'): " + scid); + } + options.scid = scid; + break; + case "log_level": + options.logLevel = Ln.Level.valueOf(value.toUpperCase(Locale.ENGLISH)); + break; + case "video": + options.video = Boolean.parseBoolean(value); + break; + case "audio": + options.audio = Boolean.parseBoolean(value); + break; + case "video_codec": + VideoCodec videoCodec = VideoCodec.findByName(value); + if (videoCodec == null) { + throw new IllegalArgumentException("Video codec " + value + " not supported"); + } + options.videoCodec = videoCodec; + break; + case "audio_codec": + AudioCodec audioCodec = AudioCodec.findByName(value); + if (audioCodec == null) { + throw new IllegalArgumentException("Audio codec " + value + " not supported"); + } + options.audioCodec = audioCodec; + break; + case "video_source": + VideoSource videoSource = VideoSource.findByName(value); + if (videoSource == null) { + throw new IllegalArgumentException("Video source " + value + " not supported"); + } + options.videoSource = videoSource; + break; + case "audio_source": + AudioSource audioSource = AudioSource.findByName(value); + if (audioSource == null) { + throw new IllegalArgumentException("Audio source " + value + " not supported"); + } + options.audioSource = audioSource; + break; + case "audio_dup": + options.audioDup = Boolean.parseBoolean(value); + break; + case "max_size": + options.maxSize = Integer.parseInt(value); + break; + case "min_size_alignment": + int align = Integer.parseInt(value); + if (align < 1 || align > 16 || (align & (align - 1)) != 0) { + throw new IllegalArgumentException("min_size_alignment (" + align + ") must be 1, 2, 4, 8 or 16"); + } + options.minSizeAlignment = align; + break; + case "video_bit_rate": + options.videoBitRate = Integer.parseInt(value); + break; + case "audio_bit_rate": + options.audioBitRate = Integer.parseInt(value); + break; + case "max_fps": + options.maxFps = parseFloat("max_fps", value); + break; + case "angle": + options.angle = parseFloat("angle", value); + break; + case "tunnel_forward": + options.tunnelForward = Boolean.parseBoolean(value); + break; + case "crop": + if (!value.isEmpty()) { + options.crop = parseCrop(value); + } + break; + case "control": + options.control = Boolean.parseBoolean(value); + break; + case "display_id": + options.displayId = Integer.parseInt(value); + break; + case "show_touches": + options.showTouches = Boolean.parseBoolean(value); + break; + case "stay_awake": + options.stayAwake = Boolean.parseBoolean(value); + break; + case "screen_off_timeout": + options.screenOffTimeout = Integer.parseInt(value); + if (options.screenOffTimeout < -1) { + throw new IllegalArgumentException("Invalid screen off timeout: " + options.screenOffTimeout); + } + break; + case "video_codec_options": + options.videoCodecOptions = CodecOption.parse(value); + break; + case "audio_codec_options": + options.audioCodecOptions = CodecOption.parse(value); + break; + case "video_encoder": + if (!value.isEmpty()) { + options.videoEncoder = value; + } + break; + case "audio_encoder": + if (!value.isEmpty()) { + options.audioEncoder = value; + } + break; + case "power_off_on_close": + options.powerOffScreenOnClose = Boolean.parseBoolean(value); + break; + case "clipboard_autosync": + options.clipboardAutosync = Boolean.parseBoolean(value); + break; + case "downsize_on_error": + options.downsizeOnError = Boolean.parseBoolean(value); + break; + case "cleanup": + options.cleanup = Boolean.parseBoolean(value); + break; + case "power_on": + options.powerOn = Boolean.parseBoolean(value); + break; + case "list_encoders": + options.listEncoders = Boolean.parseBoolean(value); + break; + case "list_displays": + options.listDisplays = Boolean.parseBoolean(value); + break; + case "list_cameras": + options.listCameras = Boolean.parseBoolean(value); + break; + case "list_camera_sizes": + options.listCameraSizes = Boolean.parseBoolean(value); + break; + case "list_apps": + options.listApps = Boolean.parseBoolean(value); + break; + case "camera_id": + if (!value.isEmpty()) { + options.cameraId = value; + } + break; + case "camera_size": + if (!value.isEmpty()) { + options.cameraSize = parseSize(value); + } + break; + case "camera_facing": + if (!value.isEmpty()) { + CameraFacing facing = CameraFacing.findByName(value); + if (facing == null) { + throw new IllegalArgumentException("Camera facing " + value + " not supported"); + } + options.cameraFacing = facing; + } + break; + case "camera_ar": + if (!value.isEmpty()) { + options.cameraAspectRatio = parseCameraAspectRatio(value); + } + break; + case "camera_zoom": + if (!value.isEmpty()) { + options.cameraZoom = Float.parseFloat(value); + } + break; + case "camera_fps": + options.cameraFps = Integer.parseInt(value); + break; + case "camera_high_speed": + options.cameraHighSpeed = Boolean.parseBoolean(value); + break; + case "camera_torch": + options.cameraTorch = Boolean.parseBoolean(value); + break; + case "new_display": + options.newDisplay = parseNewDisplay(value); + break; + case "vd_destroy_content": + options.vdDestroyContent = Boolean.parseBoolean(value); + break; + case "vd_system_decorations": + options.vdSystemDecorations = Boolean.parseBoolean(value); + break; + case "flex_display": + options.flexDisplay = Boolean.parseBoolean(value); + break; + case "capture_orientation": + Pair pair = parseCaptureOrientation(value); + options.captureOrientationLock = pair.first; + options.captureOrientation = pair.second; + break; + case "display_ime_policy": + options.displayImePolicy = parseDisplayImePolicy(value); + break; + case "keep_active": + options.keepActive = Boolean.parseBoolean(value); + break; + case "ignore_video_encoder_constraints": + options.ignoreVideoEncoderConstraints = Boolean.parseBoolean(value); + break; + case "send_device_meta": + options.sendDeviceMeta = Boolean.parseBoolean(value); + break; + case "send_frame_meta": + options.sendFrameMeta = Boolean.parseBoolean(value); + break; + case "send_dummy_byte": + options.sendDummyByte = Boolean.parseBoolean(value); + break; + case "send_stream_meta": + options.sendStreamMeta = Boolean.parseBoolean(value); + break; + case "raw_stream": + boolean rawStream = Boolean.parseBoolean(value); + if (rawStream) { + options.sendDeviceMeta = false; + options.sendFrameMeta = false; + options.sendDummyByte = false; + options.sendStreamMeta = false; + } + break; + default: + Ln.w("Unknown server option: " + key); + break; + } + } + + if (options.newDisplay != null) { + assert options.displayId == 0 : "Must not set both displayId and newDisplay"; + options.displayId = Device.DISPLAY_ID_NONE; + } + + return options; + } + + private static Rect parseCrop(String crop) { + // input format: "width:height:x:y" + String[] tokens = crop.split(":"); + if (tokens.length != 4) { + throw new IllegalArgumentException("Crop must contains 4 values separated by colons: \"" + crop + "\""); + } + int width = Integer.parseInt(tokens[0]); + int height = Integer.parseInt(tokens[1]); + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("Invalid crop size: " + width + "x" + height); + } + int x = Integer.parseInt(tokens[2]); + int y = Integer.parseInt(tokens[3]); + if (x < 0 || y < 0) { + throw new IllegalArgumentException("Invalid crop offset: " + x + ":" + y); + } + return new Rect(x, y, x + width, y + height); + } + + private static Size parseSize(String size) { + // input format: "x" + String[] tokens = size.split("x"); + if (tokens.length != 2) { + throw new IllegalArgumentException("Invalid size format (expected x): \"" + size + "\""); + } + int width = Integer.parseInt(tokens[0]); + int height = Integer.parseInt(tokens[1]); + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("Invalid non-positive size dimension: \"" + size + "\""); + } + return new Size(width, height); + } + + private static CameraAspectRatio parseCameraAspectRatio(String ar) { + if ("sensor".equals(ar)) { + return CameraAspectRatio.sensorAspectRatio(); + } + + String[] tokens = ar.split(":"); + if (tokens.length == 2) { + int w = Integer.parseInt(tokens[0]); + int h = Integer.parseInt(tokens[1]); + return CameraAspectRatio.fromFraction(w, h); + } + + float floatAr = Float.parseFloat(tokens[0]); + return CameraAspectRatio.fromFloat(floatAr); + } + + private static float parseFloat(String key, String value) { + try { + return Float.parseFloat(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid float value for " + key + ": \"" + value + "\""); + } + } + + private static NewDisplay parseNewDisplay(String newDisplay) { + // Possible inputs: + // - "" (empty string) + // - "x/" + // - "x" + // - "/" + if (newDisplay.isEmpty()) { + return new NewDisplay(); + } + + String[] tokens = newDisplay.split("/"); + + Size size; + if (!tokens[0].isEmpty()) { + size = parseSize(tokens[0]); + } else { + size = null; + } + + int dpi; + if (tokens.length >= 2) { + dpi = Integer.parseInt(tokens[1]); + if (dpi <= 0) { + throw new IllegalArgumentException("Invalid non-positive dpi: " + tokens[1]); + } + } else { + dpi = 0; + } + + return new NewDisplay(size, dpi); + } + + private static Pair parseCaptureOrientation(String value) { + if (value.isEmpty()) { + throw new IllegalArgumentException("Empty capture orientation string"); + } + + Orientation.Lock lock; + if (value.charAt(0) == '@') { + // Consume '@' + value = value.substring(1); + if (value.isEmpty()) { + // Only '@': lock to the initial orientation (orientation is unused) + return Pair.create(Orientation.Lock.LockedInitial, Orientation.Orient0); + } + lock = Orientation.Lock.LockedValue; + } else { + lock = Orientation.Lock.Unlocked; + } + + return Pair.create(lock, Orientation.getByName(value)); + } + + private static int parseDisplayImePolicy(String value) { + switch (value) { + case "local": + return WindowManager.DISPLAY_IME_POLICY_LOCAL; + case "fallback": + return WindowManager.DISPLAY_IME_POLICY_FALLBACK_DISPLAY; + case "hide": + return WindowManager.DISPLAY_IME_POLICY_HIDE; + default: + throw new IllegalArgumentException("Invalid display IME policy: " + value); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/Server.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Server.java new file mode 100644 index 0000000..d7d1a76 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Server.java @@ -0,0 +1,291 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.audio.AudioCapture; +import com.genymobile.scrcpy.audio.AudioCodec; +import com.genymobile.scrcpy.audio.AudioDirectCapture; +import com.genymobile.scrcpy.audio.AudioEncoder; +import com.genymobile.scrcpy.audio.AudioPlaybackCapture; +import com.genymobile.scrcpy.audio.AudioRawRecorder; +import com.genymobile.scrcpy.audio.AudioSource; +import com.genymobile.scrcpy.control.ControlChannel; +import com.genymobile.scrcpy.control.Controller; +import com.genymobile.scrcpy.device.DesktopConnection; +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.device.Streamer; +import com.genymobile.scrcpy.model.ConfigurationException; +import com.genymobile.scrcpy.model.NewDisplay; +import com.genymobile.scrcpy.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; +import com.genymobile.scrcpy.video.CameraCapture; +import com.genymobile.scrcpy.video.NewDisplayCapture; +import com.genymobile.scrcpy.video.ScreenCapture; +import com.genymobile.scrcpy.video.SurfaceCapture; +import com.genymobile.scrcpy.video.SurfaceEncoder; +import com.genymobile.scrcpy.video.VideoSource; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.os.Looper; +import android.system.Os; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +public final class Server { + + public static final String SERVER_PATH; + + static { + String[] classPaths = System.getProperty("java.class.path").split(File.pathSeparator); + // By convention, scrcpy is always executed with the absolute path of scrcpy-server.jar as the first item in the classpath + SERVER_PATH = classPaths[0]; + } + + private static class Completion { + private int running; + private boolean fatalError; + + Completion(int running) { + this.running = running; + } + + synchronized void addCompleted(boolean fatalError) { + --running; + if (fatalError) { + this.fatalError = true; + } + if (running == 0 || this.fatalError) { + Looper.getMainLooper().quitSafely(); + } + } + } + + private Server() { + // not instantiable + } + + private static void scrcpy(Options options) throws IOException, ConfigurationException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12 && options.getVideoSource() == VideoSource.CAMERA) { + Ln.e("Camera mirroring is not supported before Android 12"); + throw new ConfigurationException("Camera mirroring is not supported"); + } + + if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) { + if (options.getNewDisplay() != null) { + Ln.e("New virtual display is not supported before Android 10"); + throw new ConfigurationException("New virtual display is not supported"); + } + if (options.getDisplayImePolicy() != -1) { + Ln.e("Display IME policy is not supported before Android 10"); + throw new ConfigurationException("Display IME policy is not supported"); + } + } + + CleanUp cleanUp = null; + + if (options.getCleanup()) { + cleanUp = CleanUp.start(options); + } + + int scid = options.getScid(); + boolean tunnelForward = options.isTunnelForward(); + boolean control = options.getControl(); + boolean video = options.getVideo(); + boolean audio = options.getAudio(); + boolean sendDummyByte = options.getSendDummyByte(); + + Workarounds.apply(); + + List asyncProcessors = new ArrayList<>(); + + DesktopConnection connection = DesktopConnection.open(scid, tunnelForward, video, audio, control, sendDummyByte); + try { + if (options.getSendDeviceMeta()) { + connection.sendDeviceMeta(Device.getDeviceName()); + } + + Controller controller = null; + + if (control) { + ControlChannel controlChannel = connection.getControlChannel(); + controller = new Controller(controlChannel, cleanUp, options); + asyncProcessors.add(controller); + } + + if (audio) { + AudioCodec audioCodec = options.getAudioCodec(); + AudioSource audioSource = options.getAudioSource(); + AudioCapture audioCapture; + if (audioSource.isDirect()) { + audioCapture = new AudioDirectCapture(audioSource); + } else { + audioCapture = new AudioPlaybackCapture(options.getAudioDup()); + } + + Streamer audioStreamer = new Streamer(connection.getAudioFd(), audioCodec, options.getSendStreamMeta(), options.getSendFrameMeta()); + AsyncProcessor audioRecorder; + if (audioCodec == AudioCodec.RAW) { + audioRecorder = new AudioRawRecorder(audioCapture, audioStreamer); + } else { + audioRecorder = new AudioEncoder(audioCapture, audioStreamer, options); + } + asyncProcessors.add(audioRecorder); + } + + if (video) { + Streamer videoStreamer = new Streamer(connection.getVideoFd(), options.getVideoCodec(), options.getSendStreamMeta(), + options.getSendFrameMeta()); + SurfaceCapture surfaceCapture; + if (options.getVideoSource() == VideoSource.DISPLAY) { + NewDisplay newDisplay = options.getNewDisplay(); + if (newDisplay != null) { + surfaceCapture = new NewDisplayCapture(controller, options); + } else { + assert options.getDisplayId() != Device.DISPLAY_ID_NONE; + surfaceCapture = new ScreenCapture(controller, options); + } + } else { + surfaceCapture = new CameraCapture(options); + } + SurfaceEncoder surfaceEncoder = new SurfaceEncoder(surfaceCapture, videoStreamer, options); + asyncProcessors.add(surfaceEncoder); + + if (controller != null) { + controller.setSurfaceCapture(surfaceCapture); + } + } + + Completion completion = new Completion(asyncProcessors.size()); + for (AsyncProcessor asyncProcessor : asyncProcessors) { + asyncProcessor.start((fatalError) -> { + completion.addCompleted(fatalError); + }); + } + + Looper.loop(); // interrupted by the Completion implementation + } finally { + if (cleanUp != null) { + cleanUp.interrupt(); + } + for (AsyncProcessor asyncProcessor : asyncProcessors) { + asyncProcessor.stop(); + } + + connection.shutdown(); + + try { + if (cleanUp != null) { + cleanUp.join(); + } + for (AsyncProcessor asyncProcessor : asyncProcessors) { + asyncProcessor.join(); + } + + OpenGLRunner.shutdown(); + } catch (InterruptedException e) { + // ignore + } + + connection.close(); + } + } + + private static void prepareMainLooper() { + // Like Looper.prepareMainLooper(), but with quitAllowed set to true + Looper.prepare(); + synchronized (Looper.class) { + try { + @SuppressLint("DiscouragedPrivateApi") + Field field = Looper.class.getDeclaredField("sMainLooper"); + field.setAccessible(true); + field.set(null, Looper.myLooper()); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + } + + public static void main(String... args) { + int status = 0; + try { + internalMain(args); + } catch (Throwable t) { + Ln.e(t.getMessage(), t); + status = 1; + } finally { + // By default, the Java process exits when all non-daemon threads are terminated. + // The Android SDK might start some non-daemon threads internally, preventing the scrcpy server to exit. + // So force the process to exit explicitly. + System.exit(status); + } + } + + private static void internalMain(String... args) throws Exception { + Thread.UncaughtExceptionHandler defaultHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler((t, e) -> { + Ln.e("Exception on thread " + t, e); + if (defaultHandler != null) { + defaultHandler.uncaughtException(t, e); + } + }); + + dropRootPrivileges(); + + prepareMainLooper(); + + Options options = Options.parse(args); + + Ln.disableSystemStreams(); + Ln.initLogLevel(options.getLogLevel()); + + Ln.i("Device: [" + Build.MANUFACTURER + "] " + Build.BRAND + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")"); + + if (options.getList()) { + if (options.getCleanup()) { + CleanUp.unlinkSelf(); + } + + if (options.getListEncoders()) { + Ln.i(LogUtils.buildVideoEncoderListMessage()); + Ln.i(LogUtils.buildAudioEncoderListMessage()); + } + if (options.getListDisplays()) { + Ln.i(LogUtils.buildDisplayListMessage()); + } + if (options.getListCameras() || options.getListCameraSizes()) { + Workarounds.apply(); + Ln.i(LogUtils.buildCameraListMessage(options.getListCameraSizes())); + } + if (options.getListApps()) { + Workarounds.apply(); + Ln.i("Processing Android apps... (this may take some time)"); + Ln.i(LogUtils.buildAppListMessage()); + } + // Just print the requested data, do not mirror + return; + } + + try { + scrcpy(options); + } catch (ConfigurationException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + } + } + + @SuppressWarnings("deprecation") + private static void dropRootPrivileges() { + try { + if (Os.getuid() == 0) { + // Copy-paste does not work with root user + // + Os.setuid(2000); + } + } catch (Exception e) { + Ln.w("Cannot set UID", e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/Workarounds.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Workarounds.java new file mode 100644 index 0000000..1e012de --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Workarounds.java @@ -0,0 +1,296 @@ +package com.genymobile.scrcpy; + +import com.genymobile.scrcpy.audio.AudioCaptureException; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Application; +import android.app.Instrumentation; +import android.content.AttributionSource; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.media.AudioAttributes; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.os.Build; +import android.os.Looper; +import android.os.Parcel; + +import java.lang.ref.WeakReference; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +@SuppressLint("PrivateApi,BlockedPrivateApi,SoonBlockedPrivateApi,DiscouragedPrivateApi") +public final class Workarounds { + + private static final Class ACTIVITY_THREAD_CLASS; + private static final Object ACTIVITY_THREAD; + + static { + try { + // ActivityThread activityThread = new ActivityThread(); + ACTIVITY_THREAD_CLASS = Class.forName("android.app.ActivityThread"); + Constructor activityThreadConstructor = ACTIVITY_THREAD_CLASS.getDeclaredConstructor(); + activityThreadConstructor.setAccessible(true); + ACTIVITY_THREAD = activityThreadConstructor.newInstance(); + + // ActivityThread.sCurrentActivityThread = activityThread; + Field sCurrentActivityThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("sCurrentActivityThread"); + sCurrentActivityThreadField.setAccessible(true); + sCurrentActivityThreadField.set(null, ACTIVITY_THREAD); + + // activityThread.mSystemThread = true; + Field mSystemThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("mSystemThread"); + mSystemThreadField.setAccessible(true); + mSystemThreadField.setBoolean(ACTIVITY_THREAD, true); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private Workarounds() { + // not instantiable + } + + public static void apply() { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + // On some Samsung devices, DisplayManagerGlobal.getDisplayInfoLocked() calls ActivityThread.currentActivityThread().getConfiguration(), + // which requires a non-null ConfigurationController. + // ConfigurationController was introduced in Android 12, so do not attempt to set it on lower versions. + // + // Must be called before fillAppContext() because it is necessary to get a valid system context. + fillConfigurationController(); + } + + // On ONYX devices, fillAppInfo() breaks video mirroring: + // + boolean mustFillAppInfo = !Build.BRAND.equalsIgnoreCase("ONYX"); + + if (mustFillAppInfo) { + fillAppInfo(); + } + + fillAppContext(); + } + + private static void fillAppInfo() { + try { + // ActivityThread.AppBindData appBindData = new ActivityThread.AppBindData(); + Class appBindDataClass = Class.forName("android.app.ActivityThread$AppBindData"); + Constructor appBindDataConstructor = appBindDataClass.getDeclaredConstructor(); + appBindDataConstructor.setAccessible(true); + Object appBindData = appBindDataConstructor.newInstance(); + + ApplicationInfo applicationInfo = new ApplicationInfo(); + applicationInfo.packageName = FakeContext.PACKAGE_NAME; + + // appBindData.appInfo = applicationInfo; + Field appInfoField = appBindDataClass.getDeclaredField("appInfo"); + appInfoField.setAccessible(true); + appInfoField.set(appBindData, applicationInfo); + + // activityThread.mBoundApplication = appBindData; + Field mBoundApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mBoundApplication"); + mBoundApplicationField.setAccessible(true); + mBoundApplicationField.set(ACTIVITY_THREAD, appBindData); + } catch (Throwable throwable) { + // this is a workaround, so failing is not an error + Ln.d("Could not fill app info: " + throwable.getMessage()); + } + } + + private static void fillAppContext() { + try { + Application app = Instrumentation.newApplication(Application.class, FakeContext.get()); + + // activityThread.mInitialApplication = app; + Field mInitialApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mInitialApplication"); + mInitialApplicationField.setAccessible(true); + mInitialApplicationField.set(ACTIVITY_THREAD, app); + } catch (Throwable throwable) { + // this is a workaround, so failing is not an error + Ln.d("Could not fill app context: " + throwable.getMessage()); + } + } + + private static void fillConfigurationController() { + try { + Class configurationControllerClass = Class.forName("android.app.ConfigurationController"); + Class activityThreadInternalClass = Class.forName("android.app.ActivityThreadInternal"); + + // configurationController = new ConfigurationController(ACTIVITY_THREAD); + Constructor configurationControllerConstructor = configurationControllerClass.getDeclaredConstructor(activityThreadInternalClass); + configurationControllerConstructor.setAccessible(true); + Object configurationController = configurationControllerConstructor.newInstance(ACTIVITY_THREAD); + + // ACTIVITY_THREAD.mConfigurationController = configurationController; + Field configurationControllerField = ACTIVITY_THREAD_CLASS.getDeclaredField("mConfigurationController"); + configurationControllerField.setAccessible(true); + configurationControllerField.set(ACTIVITY_THREAD, configurationController); + } catch (Throwable throwable) { + Ln.d("Could not fill configuration: " + throwable.getMessage()); + } + } + + static Context getSystemContext() { + try { + Method getSystemContextMethod = ACTIVITY_THREAD_CLASS.getDeclaredMethod("getSystemContext"); + return (Context) getSystemContextMethod.invoke(ACTIVITY_THREAD); + } catch (Throwable throwable) { + // this is a workaround, so failing is not an error + Ln.d("Could not get system context: " + throwable.getMessage()); + return null; + } + } + + @TargetApi(AndroidVersions.API_30_ANDROID_11) + @SuppressLint("WrongConstant,MissingPermission") + public static AudioRecord createAudioRecord(int source, int sampleRate, int channelConfig, int channels, int channelMask, int encoding) throws + AudioCaptureException { + // Vivo (and maybe some other third-party ROMs) modified `AudioRecord`'s constructor, requiring `Context`s from real App environment. + // + // This method invokes the `AudioRecord(long nativeRecordInJavaObj)` constructor to create an empty `AudioRecord` instance, then uses + // reflections to initialize it like the normal constructor do (or the `AudioRecord.Builder.build()` method do). + // As a result, the modified code was not executed. + try { + // AudioRecord audioRecord = new AudioRecord(0L); + Constructor audioRecordConstructor = AudioRecord.class.getDeclaredConstructor(long.class); + audioRecordConstructor.setAccessible(true); + AudioRecord audioRecord = audioRecordConstructor.newInstance(0L); + + // audioRecord.mRecordingState = RECORDSTATE_STOPPED; + Field mRecordingStateField = AudioRecord.class.getDeclaredField("mRecordingState"); + mRecordingStateField.setAccessible(true); + mRecordingStateField.set(audioRecord, AudioRecord.RECORDSTATE_STOPPED); + + Looper looper = Looper.myLooper(); + if (looper == null) { + looper = Looper.getMainLooper(); + } + + // audioRecord.mInitializationLooper = looper; + Field mInitializationLooperField = AudioRecord.class.getDeclaredField("mInitializationLooper"); + mInitializationLooperField.setAccessible(true); + mInitializationLooperField.set(audioRecord, looper); + + // Create `AudioAttributes` with fixed capture preset + int capturePreset = source; + AudioAttributes.Builder audioAttributesBuilder = new AudioAttributes.Builder(); + Method setInternalCapturePresetMethod = AudioAttributes.Builder.class.getMethod("setInternalCapturePreset", int.class); + setInternalCapturePresetMethod.invoke(audioAttributesBuilder, capturePreset); + AudioAttributes attributes = audioAttributesBuilder.build(); + + // audioRecord.mAudioAttributes = attributes; + Field mAudioAttributesField = AudioRecord.class.getDeclaredField("mAudioAttributes"); + mAudioAttributesField.setAccessible(true); + mAudioAttributesField.set(audioRecord, attributes); + + // audioRecord.audioParamCheck(capturePreset, sampleRate, encoding); + Method audioParamCheckMethod = AudioRecord.class.getDeclaredMethod("audioParamCheck", int.class, int.class, int.class); + audioParamCheckMethod.setAccessible(true); + audioParamCheckMethod.invoke(audioRecord, capturePreset, sampleRate, encoding); + + // audioRecord.mChannelCount = channels + Field mChannelCountField = AudioRecord.class.getDeclaredField("mChannelCount"); + mChannelCountField.setAccessible(true); + mChannelCountField.set(audioRecord, channels); + + // audioRecord.mChannelMask = channelMask + Field mChannelMaskField = AudioRecord.class.getDeclaredField("mChannelMask"); + mChannelMaskField.setAccessible(true); + mChannelMaskField.set(audioRecord, channelMask); + + int minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding); + int bufferSizeInBytes = minBufferSize * 8; + + // audioRecord.audioBuffSizeCheck(bufferSizeInBytes) + Method audioBuffSizeCheckMethod = AudioRecord.class.getDeclaredMethod("audioBuffSizeCheck", int.class); + audioBuffSizeCheckMethod.setAccessible(true); + audioBuffSizeCheckMethod.invoke(audioRecord, bufferSizeInBytes); + + final int channelIndexMask = 0; + + int[] sampleRateArray = new int[]{sampleRate}; + int[] session = new int[]{AudioManager.AUDIO_SESSION_ID_GENERATE}; + + int initResult; + if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12) { + // private native final int native_setup(Object audiorecord_this, + // Object /*AudioAttributes*/ attributes, + // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat, + // int buffSizeInBytes, int[] sessionId, String opPackageName, + // long nativeRecordInJavaObj); + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, int.class, + int.class, int.class, int.class, int[].class, String.class, long.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, sampleRateArray, + channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, FakeContext.get().getOpPackageName(), + 0L); + } else { + // Assume `context` is never `null` + AttributionSource attributionSource = FakeContext.get().getAttributionSource(); + + // Assume `attributionSource.getPackageName()` is never null + + // ScopedParcelState attributionSourceState = attributionSource.asScopedParcelState() + Method asScopedParcelStateMethod = AttributionSource.class.getDeclaredMethod("asScopedParcelState"); + asScopedParcelStateMethod.setAccessible(true); + + try (AutoCloseable attributionSourceState = (AutoCloseable) asScopedParcelStateMethod.invoke(attributionSource)) { + Method getParcelMethod = attributionSourceState.getClass().getDeclaredMethod("getParcel"); + Parcel attributionSourceParcel = (Parcel) getParcelMethod.invoke(attributionSourceState); + + if (Build.VERSION.SDK_INT < AndroidVersions.API_34_ANDROID_14) { + // private native int native_setup(Object audiorecordThis, + // Object /*AudioAttributes*/ attributes, + // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat, + // int buffSizeInBytes, int[] sessionId, @NonNull Parcel attributionSource, + // long nativeRecordInJavaObj, int maxSharedAudioHistoryMs); + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, + int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, + sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, + attributionSourceParcel, 0L, 0); + } else { + // Android 14 added a new int parameter "halInputFlags" + // + Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, + int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class, int.class); + nativeSetupMethod.setAccessible(true); + initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, + sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, + attributionSourceParcel, 0L, 0, 0); + } + } + } + + if (initResult != AudioRecord.SUCCESS) { + Ln.e("Error code " + initResult + " when initializing native AudioRecord object."); + throw new RuntimeException("Cannot create AudioRecord"); + } + + // mSampleRate = sampleRate[0] + Field mSampleRateField = AudioRecord.class.getDeclaredField("mSampleRate"); + mSampleRateField.setAccessible(true); + mSampleRateField.set(audioRecord, sampleRateArray[0]); + + // audioRecord.mSessionId = session[0] + Field mSessionIdField = AudioRecord.class.getDeclaredField("mSessionId"); + mSessionIdField.setAccessible(true); + mSessionIdField.set(audioRecord, session[0]); + + // audioRecord.mState = AudioRecord.STATE_INITIALIZED + Field mStateField = AudioRecord.class.getDeclaredField("mState"); + mStateField.setAccessible(true); + mStateField.set(audioRecord, AudioRecord.STATE_INITIALIZED); + + return audioRecord; + } catch (Exception e) { + Ln.e("Cannot create AudioRecord", e); + throw new AudioCaptureException(); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCapture.java new file mode 100644 index 0000000..62903f8 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCapture.java @@ -0,0 +1,20 @@ +package com.genymobile.scrcpy.audio; + +import android.media.MediaCodec; + +import java.nio.ByteBuffer; + +public interface AudioCapture { + void checkCompatibility() throws AudioCaptureException; + void start() throws AudioCaptureException; + void stop(); + + /** + * Read a chunk of {@link AudioConfig#MAX_READ_SIZE} samples. + * + * @param outDirectBuffer The target buffer + * @param outBufferInfo The info to provide to MediaCodec + * @return the number of bytes actually read. + */ + int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo); +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCaptureException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCaptureException.java new file mode 100644 index 0000000..4b0b7e8 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCaptureException.java @@ -0,0 +1,12 @@ +package com.genymobile.scrcpy.audio; + +/** + * Exception for any audio capture issue. + *

+ * This includes the case where audio capture failed on Android 11 specifically because the running App (Shell) was not in foreground. + *

+ * Its purpose is to disable audio without errors (that's why the exception is empty, any error message must be printed by the caller before + * throwing the exception). + */ +public class AudioCaptureException extends Exception { +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCodec.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCodec.java new file mode 100644 index 0000000..5e20a2a --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCodec.java @@ -0,0 +1,51 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.model.Codec; + +import android.media.MediaFormat; + +public enum AudioCodec implements Codec { + OPUS(0x6f_70_75_73, "opus", MediaFormat.MIMETYPE_AUDIO_OPUS), + AAC(0x00_61_61_63, "aac", MediaFormat.MIMETYPE_AUDIO_AAC), + FLAC(0x66_6c_61_63, "flac", MediaFormat.MIMETYPE_AUDIO_FLAC), + RAW(0x00_72_61_77, "raw", MediaFormat.MIMETYPE_AUDIO_RAW); + + private final int id; // 4-byte ASCII representation of the name + private final String name; + private final String mimeType; + + AudioCodec(int id, String name, String mimeType) { + this.id = id; + this.name = name; + this.mimeType = mimeType; + } + + @Override + public Type getType() { + return Type.AUDIO; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getMimeType() { + return mimeType; + } + + public static AudioCodec findByName(String name) { + for (AudioCodec codec : values()) { + if (codec.name.equals(name)) { + return codec; + } + } + return null; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioConfig.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioConfig.java new file mode 100644 index 0000000..c77165a --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioConfig.java @@ -0,0 +1,29 @@ +package com.genymobile.scrcpy.audio; + +import android.media.AudioFormat; + +public final class AudioConfig { + public static final int SAMPLE_RATE = 48000; + public static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO; + public static final int CHANNELS = 2; + public static final int CHANNEL_MASK = AudioFormat.CHANNEL_IN_LEFT | AudioFormat.CHANNEL_IN_RIGHT; + public static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; + public static final int BYTES_PER_SAMPLE = 2; + + // Never read more than 1024 samples, even if the buffer is bigger (that would increase latency). + // A lower value is useless, since the system captures audio samples by blocks of 1024 (so for example if we read by blocks of 256 samples, we + // receive 4 successive blocks without waiting, then we wait for the 4 next ones). + public static final int MAX_READ_SIZE = 1024 * CHANNELS * BYTES_PER_SAMPLE; + + private AudioConfig() { + // Not instantiable + } + + public static AudioFormat createAudioFormat() { + AudioFormat.Builder builder = new AudioFormat.Builder(); + builder.setEncoding(ENCODING); + builder.setSampleRate(SAMPLE_RATE); + builder.setChannelMask(CHANNEL_CONFIG); + return builder.build(); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioDirectCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioDirectCapture.java new file mode 100644 index 0000000..bf870be --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioDirectCapture.java @@ -0,0 +1,142 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.Workarounds; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.Intent; +import android.media.AudioRecord; +import android.media.MediaCodec; +import android.os.Build; +import android.os.SystemClock; + +import java.nio.ByteBuffer; + +public class AudioDirectCapture implements AudioCapture { + + private static final int SAMPLE_RATE = AudioConfig.SAMPLE_RATE; + private static final int CHANNEL_CONFIG = AudioConfig.CHANNEL_CONFIG; + private static final int CHANNELS = AudioConfig.CHANNELS; + private static final int CHANNEL_MASK = AudioConfig.CHANNEL_MASK; + private static final int ENCODING = AudioConfig.ENCODING; + + private final int audioSource; + + private AudioRecord recorder; + private AudioRecordReader reader; + + public AudioDirectCapture(AudioSource audioSource) { + this.audioSource = audioSource.getDirectAudioSource(); + } + + @TargetApi(AndroidVersions.API_23_ANDROID_6_0) + @SuppressLint({"WrongConstant", "MissingPermission"}) + private static AudioRecord createAudioRecord(int audioSource) { + AudioRecord.Builder builder = new AudioRecord.Builder(); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + // On older APIs, Workarounds.fillAppInfo() must be called beforehand + builder.setContext(FakeContext.get()); + } + builder.setAudioSource(audioSource); + builder.setAudioFormat(AudioConfig.createAudioFormat()); + int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, ENCODING); + if (minBufferSize > 0) { + // This buffer size does not impact latency + builder.setBufferSizeInBytes(8 * minBufferSize); + } + + return builder.build(); + } + + private static void startWorkaroundAndroid11() { + // Android 11 requires Apps to be at foreground to record audio. + // Normally, each App has its own user ID, so Android checks whether the requesting App has the user ID that's at the foreground. + // But scrcpy server is NOT an App, it's a Java application started from Android shell, so it has the same user ID (2000) with Android + // shell ("com.android.shell"). + // If there is an Activity from Android shell running at foreground, then the permission system will believe scrcpy is also in the + // foreground. + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + intent.setComponent(new ComponentName(FakeContext.PACKAGE_NAME, "com.android.shell.HeapDumpActivity")); + ServiceManager.getActivityManager().startActivity(intent); + } + + private static void stopWorkaroundAndroid11() { + ServiceManager.getActivityManager().forceStopPackage(FakeContext.PACKAGE_NAME); + } + + private void tryStartRecording(int attempts, int delayMs) throws AudioCaptureException { + while (attempts-- > 0) { + // Wait for activity to start + SystemClock.sleep(delayMs); + try { + startRecording(); + return; // it worked + } catch (UnsupportedOperationException e) { + if (attempts == 0) { + Ln.e("Failed to start audio capture"); + Ln.e("On Android 11, audio capture must be started in the foreground, make sure that the device is unlocked when starting " + + "scrcpy."); + throw new AudioCaptureException(); + } else { + Ln.d("Failed to start audio capture, retrying..."); + } + } + } + } + + private void startRecording() throws AudioCaptureException { + try { + recorder = createAudioRecord(audioSource); + } catch (NullPointerException e) { + // Creating an AudioRecord using an AudioRecord.Builder does not work on Vivo phones: + // - + // - + recorder = Workarounds.createAudioRecord(audioSource, SAMPLE_RATE, CHANNEL_CONFIG, CHANNELS, CHANNEL_MASK, ENCODING); + } + recorder.startRecording(); + reader = new AudioRecordReader(recorder); + } + + @Override + public void checkCompatibility() throws AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) { + Ln.w("Audio disabled: it is not supported before Android 11"); + throw new AudioCaptureException(); + } + } + + @Override + public void start() throws AudioCaptureException { + if (Build.VERSION.SDK_INT == AndroidVersions.API_30_ANDROID_11) { + startWorkaroundAndroid11(); + try { + tryStartRecording(5, 100); + } finally { + stopWorkaroundAndroid11(); + } + } else { + startRecording(); + } + } + + @Override + public void stop() { + if (recorder != null) { + // Will call .stop() if necessary, without throwing an IllegalStateException + recorder.release(); + } + } + + @Override + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) { + return reader.read(outDirectBuffer, outBufferInfo); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioEncoder.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioEncoder.java new file mode 100644 index 0000000..1be21eb --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioEncoder.java @@ -0,0 +1,380 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.AsyncProcessor; +import com.genymobile.scrcpy.Options; +import com.genymobile.scrcpy.device.Streamer; +import com.genymobile.scrcpy.model.Codec; +import com.genymobile.scrcpy.model.CodecOption; +import com.genymobile.scrcpy.model.ConfigurationException; +import com.genymobile.scrcpy.util.CodecUtils; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +public final class AudioEncoder implements AsyncProcessor { + + private static class InputTask { + private final int index; + + InputTask(int index) { + this.index = index; + } + } + + private static class OutputTask { + private final int index; + private final MediaCodec.BufferInfo bufferInfo; + + OutputTask(int index, MediaCodec.BufferInfo bufferInfo) { + this.index = index; + this.bufferInfo = bufferInfo; + } + } + + private static final int SAMPLE_RATE = AudioConfig.SAMPLE_RATE; + private static final int CHANNELS = AudioConfig.CHANNELS; + + private final AudioCapture capture; + private final Streamer streamer; + private final int bitRate; + private final List codecOptions; + private final String encoderName; + + private boolean recreatePts; + private long previousPts; + + // Capacity of 64 is in practice "infinite" (it is limited by the number of available MediaCodec buffers, typically 4). + // So many pending tasks would lead to an unacceptable delay anyway. + private final BlockingQueue inputTasks = new ArrayBlockingQueue<>(64); + private final BlockingQueue outputTasks = new ArrayBlockingQueue<>(64); + + private Thread thread; + private HandlerThread mediaCodecThread; + + private Thread inputThread; + private Thread outputThread; + + private boolean ended; + + public AudioEncoder(AudioCapture capture, Streamer streamer, Options options) { + this.capture = capture; + this.streamer = streamer; + this.bitRate = options.getAudioBitRate(); + this.codecOptions = options.getAudioCodecOptions(); + this.encoderName = options.getAudioEncoder(); + } + + private static MediaFormat createFormat(String mimeType, int bitRate, List codecOptions) { + MediaFormat format = new MediaFormat(); + format.setString(MediaFormat.KEY_MIME, mimeType); + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); + format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS); + format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE); + + if (codecOptions != null) { + for (CodecOption option : codecOptions) { + String key = option.getKey(); + Object value = option.getValue(); + CodecUtils.setCodecOption(format, key, value); + Ln.d("Audio codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value); + } + } + + return format; + } + + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + private void inputThread(MediaCodec mediaCodec, AudioCapture capture) throws IOException, InterruptedException { + final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + while (!Thread.currentThread().isInterrupted()) { + InputTask task = inputTasks.take(); + ByteBuffer buffer = mediaCodec.getInputBuffer(task.index); + int r = capture.read(buffer, bufferInfo); + if (r <= 0) { + throw new IOException("Could not read audio: " + r); + } + + mediaCodec.queueInputBuffer(task.index, bufferInfo.offset, bufferInfo.size, bufferInfo.presentationTimeUs, bufferInfo.flags); + } + } + + private void outputThread(MediaCodec mediaCodec) throws IOException, InterruptedException { + streamer.writeAudioHeader(); + + while (!Thread.currentThread().isInterrupted()) { + OutputTask task = outputTasks.take(); + ByteBuffer buffer = mediaCodec.getOutputBuffer(task.index); + try { + if (recreatePts) { + fixTimestamp(task.bufferInfo); + } + streamer.writePacket(buffer, task.bufferInfo); + } finally { + mediaCodec.releaseOutputBuffer(task.index, false); + } + } + } + + private void fixTimestamp(MediaCodec.BufferInfo bufferInfo) { + assert recreatePts; + + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + // Config packet, nothing to fix + return; + } + + long pts = bufferInfo.presentationTimeUs; + if (previousPts != 0) { + long now = System.nanoTime() / 1000; + // This specific encoder produces PTS matching the exact number of samples + long duration = pts - previousPts; + bufferInfo.presentationTimeUs = now - duration; + } + + previousPts = pts; + } + + @Override + public void start(TerminationListener listener) { + thread = new Thread(() -> { + boolean fatalError = false; + try { + encode(); + } catch (ConfigurationException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + fatalError = true; + } catch (AudioCaptureException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + } catch (IOException e) { + Ln.e("Audio encoding error", e); + fatalError = true; + } finally { + Ln.d("Audio encoder stopped"); + listener.onTerminated(fatalError); + } + }, "audio-encoder"); + thread.start(); + } + + @Override + public void stop() { + if (thread != null) { + // Just wake up the blocking wait from the thread, so that it properly releases all its resources and terminates + end(); + } + } + + @Override + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + } + + private synchronized void end() { + ended = true; + notify(); + } + + private synchronized void waitEnded() { + try { + while (!ended) { + wait(); + } + } catch (InterruptedException e) { + // ignore + } + } + + @TargetApi(AndroidVersions.API_23_ANDROID_6_0) + private void encode() throws IOException, ConfigurationException, AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) { + Ln.w("Audio disabled: it is not supported before Android 11"); + streamer.writeDisableStream(false); + return; + } + + MediaCodec mediaCodec = null; + + boolean mediaCodecStarted = false; + try { + capture.checkCompatibility(); // throws an AudioCaptureException on error + + Codec codec = streamer.getCodec(); + mediaCodec = createMediaCodec(codec, encoderName); + + // The default OPUS and FLAC encoders overwrite the input PTS with a value that matches the number of samples. This is not the behavior + // we want: it ignores any audio clock drift and hard silences (packets not produced on silence). To work around this behavior, + // regenerate PTS based on the current time and the packet duration. + String codecName = mediaCodec.getCanonicalName(); + recreatePts = "c2.android.opus.encoder".equals(codecName) || "c2.android.flac.encoder".equals(codecName); + + mediaCodecThread = new HandlerThread("media-codec"); + mediaCodecThread.start(); + + MediaFormat format = createFormat(codec.getMimeType(), bitRate, codecOptions); + mediaCodec.setCallback(new EncoderCallback(), new Handler(mediaCodecThread.getLooper())); + mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + + capture.start(); + + final MediaCodec mediaCodecRef = mediaCodec; + inputThread = new Thread(() -> { + try { + inputThread(mediaCodecRef, capture); + } catch (IOException | InterruptedException e) { + Ln.e("Audio capture error", e); + } finally { + end(); + } + }, "audio-in"); + + outputThread = new Thread(() -> { + try { + outputThread(mediaCodecRef); + } catch (InterruptedException e) { + // this is expected on close + } catch (IOException e) { + // Broken pipe is expected on close, because the socket is closed by the client + if (!IO.isBrokenPipe(e)) { + Ln.e("Audio encoding error", e); + } + } finally { + end(); + } + }, "audio-out"); + + mediaCodec.start(); + mediaCodecStarted = true; + inputThread.start(); + outputThread.start(); + + waitEnded(); + } catch (AudioCaptureException e) { + // Notify the client that the audio could not be captured + streamer.writeDisableStream(false); + throw e; + } catch (Throwable e) { + // Notify the error to make scrcpy exit + streamer.writeDisableStream(true); + throw e; + } finally { + // Cleanup everything (either at the end or on error at any step of the initialization) + if (mediaCodecThread != null) { + Looper looper = mediaCodecThread.getLooper(); + if (looper != null) { + looper.quitSafely(); + } + } + if (inputThread != null) { + inputThread.interrupt(); + } + if (outputThread != null) { + outputThread.interrupt(); + } + + try { + if (mediaCodecThread != null) { + mediaCodecThread.join(); + } + if (inputThread != null) { + inputThread.join(); + } + if (outputThread != null) { + outputThread.join(); + } + } catch (InterruptedException e) { + // Should never happen + throw new AssertionError(e); + } + + if (mediaCodec != null) { + if (mediaCodecStarted) { + mediaCodec.stop(); + } + mediaCodec.release(); + } + if (capture != null) { + capture.stop(); + } + } + } + + private static MediaCodec createMediaCodec(Codec codec, String encoderName) throws IOException, ConfigurationException { + if (encoderName != null) { + Ln.d("Creating audio encoder by name: '" + encoderName + "'"); + try { + MediaCodec mediaCodec = MediaCodec.createByCodecName(encoderName); + String mimeType = Codec.getMimeType(mediaCodec); + if (!codec.getMimeType().equals(mimeType)) { + Ln.e("Audio encoder type for \"" + encoderName + "\" (" + mimeType + ") does not match codec type (" + codec.getMimeType() + ")"); + throw new ConfigurationException("Incorrect encoder type: " + encoderName); + } + return mediaCodec; + } catch (IllegalArgumentException e) { + Ln.e("Audio encoder '" + encoderName + "' for " + codec.getName() + " not found\n" + LogUtils.buildAudioEncoderListMessage()); + throw new ConfigurationException("Unknown encoder: " + encoderName); + } catch (IOException e) { + Ln.e("Could not create audio encoder '" + encoderName + "' for " + codec.getName() + "\n" + LogUtils.buildAudioEncoderListMessage()); + throw e; + } + } + + try { + MediaCodec mediaCodec = MediaCodec.createEncoderByType(codec.getMimeType()); + Ln.d("Using audio encoder: '" + mediaCodec.getName() + "'"); + return mediaCodec; + } catch (IOException | IllegalArgumentException e) { + Ln.e("Could not create default audio encoder for " + codec.getName() + "\n" + LogUtils.buildAudioEncoderListMessage()); + throw e; + } + } + + private final class EncoderCallback extends MediaCodec.Callback { + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + @Override + public void onInputBufferAvailable(MediaCodec codec, int index) { + try { + inputTasks.put(new InputTask(index)); + } catch (InterruptedException e) { + end(); + } + } + + @Override + public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo bufferInfo) { + try { + outputTasks.put(new OutputTask(index, bufferInfo)); + } catch (InterruptedException e) { + end(); + } + } + + @Override + public void onError(MediaCodec codec, MediaCodec.CodecException e) { + Ln.e("MediaCodec error", e); + end(); + } + + @Override + public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) { + // ignore + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java new file mode 100644 index 0000000..009a239 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java @@ -0,0 +1,138 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioRecord; +import android.media.MediaCodec; +import android.os.Build; + +import java.lang.reflect.Method; +import java.nio.ByteBuffer; + +public final class AudioPlaybackCapture implements AudioCapture { + + private final boolean keepPlayingOnDevice; + + private AudioRecord recorder; + private AudioRecordReader reader; + + public AudioPlaybackCapture(boolean keepPlayingOnDevice) { + this.keepPlayingOnDevice = keepPlayingOnDevice; + } + + @SuppressLint("PrivateApi") + private AudioRecord createAudioRecord() throws AudioCaptureException { + // See + try { + Class audioMixingRuleClass = Class.forName("android.media.audiopolicy.AudioMixingRule"); + Class audioMixingRuleBuilderClass = Class.forName("android.media.audiopolicy.AudioMixingRule$Builder"); + + // AudioMixingRule.Builder audioMixingRuleBuilder = new AudioMixingRule.Builder(); + Object audioMixingRuleBuilder = audioMixingRuleBuilderClass.getConstructor().newInstance(); + + // audioMixingRuleBuilder.setTargetMixRole(AudioMixingRule.MIX_ROLE_PLAYERS); + int mixRolePlayersConstant = audioMixingRuleClass.getField("MIX_ROLE_PLAYERS").getInt(null); + Method setTargetMixRoleMethod = audioMixingRuleBuilderClass.getMethod("setTargetMixRole", int.class); + setTargetMixRoleMethod.invoke(audioMixingRuleBuilder, mixRolePlayersConstant); + + AudioAttributes attributes = new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build(); + + // audioMixingRuleBuilder.addMixRule(AudioMixingRule.RULE_MATCH_ATTRIBUTE_USAGE, attributes); + int ruleMatchAttributeUsageConstant = audioMixingRuleClass.getField("RULE_MATCH_ATTRIBUTE_USAGE").getInt(null); + Method addMixRuleMethod = audioMixingRuleBuilderClass.getMethod("addMixRule", int.class, Object.class); + addMixRuleMethod.invoke(audioMixingRuleBuilder, ruleMatchAttributeUsageConstant, attributes); + + // AudioMixingRule audioMixingRule = builder.build(); + Object audioMixingRule = audioMixingRuleBuilderClass.getMethod("build").invoke(audioMixingRuleBuilder); + + // audioMixingRuleBuilder.voiceCommunicationCaptureAllowed(true); + Method voiceCommunicationCaptureAllowedMethod = audioMixingRuleBuilderClass.getMethod("voiceCommunicationCaptureAllowed", boolean.class); + voiceCommunicationCaptureAllowedMethod.invoke(audioMixingRuleBuilder, true); + + Class audioMixClass = Class.forName("android.media.audiopolicy.AudioMix"); + Class audioMixBuilderClass = Class.forName("android.media.audiopolicy.AudioMix$Builder"); + + // AudioMix.Builder audioMixBuilder = new AudioMix.Builder(audioMixingRule); + Object audioMixBuilder = audioMixBuilderClass.getConstructor(audioMixingRuleClass).newInstance(audioMixingRule); + + // audioMixBuilder.setFormat(createAudioFormat()); + Method setFormat = audioMixBuilder.getClass().getMethod("setFormat", AudioFormat.class); + setFormat.invoke(audioMixBuilder, AudioConfig.createAudioFormat()); + + String routeFlagName = keepPlayingOnDevice ? "ROUTE_FLAG_LOOP_BACK_RENDER" : "ROUTE_FLAG_LOOP_BACK"; + int routeFlags = audioMixClass.getField(routeFlagName).getInt(null); + + // audioMixBuilder.setRouteFlags(routeFlag); + Method setRouteFlags = audioMixBuilder.getClass().getMethod("setRouteFlags", int.class); + setRouteFlags.invoke(audioMixBuilder, routeFlags); + + // AudioMix audioMix = audioMixBuilder.build(); + Object audioMix = audioMixBuilderClass.getMethod("build").invoke(audioMixBuilder); + + Class audioPolicyClass = Class.forName("android.media.audiopolicy.AudioPolicy"); + Class audioPolicyBuilderClass = Class.forName("android.media.audiopolicy.AudioPolicy$Builder"); + + // AudioPolicy.Builder audioPolicyBuilder = new AudioPolicy.Builder(); + Object audioPolicyBuilder = audioPolicyBuilderClass.getConstructor(Context.class).newInstance(FakeContext.get()); + + // audioPolicyBuilder.addMix(audioMix); + Method addMixMethod = audioPolicyBuilderClass.getMethod("addMix", audioMixClass); + addMixMethod.invoke(audioPolicyBuilder, audioMix); + + // AudioPolicy audioPolicy = audioPolicyBuilder.build(); + Object audioPolicy = audioPolicyBuilderClass.getMethod("build").invoke(audioPolicyBuilder); + + // AudioManager.registerAudioPolicyStatic(audioPolicy); + Method registerAudioPolicyStaticMethod = AudioManager.class.getDeclaredMethod("registerAudioPolicyStatic", audioPolicyClass); + registerAudioPolicyStaticMethod.setAccessible(true); + int result = (int) registerAudioPolicyStaticMethod.invoke(null, audioPolicy); + if (result != 0) { + throw new RuntimeException("registerAudioPolicy() returned " + result); + } + + // audioPolicy.createAudioRecordSink(audioPolicy); + Method createAudioRecordSinkClass = audioPolicyClass.getMethod("createAudioRecordSink", audioMixClass); + return (AudioRecord) createAudioRecordSinkClass.invoke(audioPolicy, audioMix); + } catch (Exception e) { + Ln.e("Could not capture audio playback", e); + throw new AudioCaptureException(); + } + } + + @Override + public void checkCompatibility() throws AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_33_ANDROID_13) { + Ln.w("Audio disabled: audio playback capture source not supported before Android 13"); + throw new AudioCaptureException(); + } + } + + @Override + public void start() throws AudioCaptureException { + recorder = createAudioRecord(); + recorder.startRecording(); + reader = new AudioRecordReader(recorder); + } + + @Override + public void stop() { + if (recorder != null) { + // Will call .stop() if necessary, without throwing an IllegalStateException + recorder.release(); + } + } + + @Override + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) { + return reader.read(outDirectBuffer, outBufferInfo); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRawRecorder.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRawRecorder.java new file mode 100644 index 0000000..9645bbb --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRawRecorder.java @@ -0,0 +1,99 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.AsyncProcessor; +import com.genymobile.scrcpy.device.Streamer; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.Ln; + +import android.media.MediaCodec; +import android.os.Build; + +import java.io.IOException; +import java.nio.ByteBuffer; + +public final class AudioRawRecorder implements AsyncProcessor { + + private final AudioCapture capture; + private final Streamer streamer; + + private Thread thread; + + public AudioRawRecorder(AudioCapture capture, Streamer streamer) { + this.capture = capture; + this.streamer = streamer; + } + + private void record() throws IOException, AudioCaptureException { + if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) { + Ln.w("Audio disabled: it is not supported before Android 11"); + streamer.writeDisableStream(false); + return; + } + + final ByteBuffer buffer = ByteBuffer.allocateDirect(AudioConfig.MAX_READ_SIZE); + final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + try { + try { + capture.start(); + } catch (Throwable t) { + // Notify the client that the audio could not be captured + streamer.writeDisableStream(false); + throw t; + } + + streamer.writeAudioHeader(); + while (!Thread.currentThread().isInterrupted()) { + buffer.position(0); + int r = capture.read(buffer, bufferInfo); + if (r < 0) { + throw new IOException("Could not read audio: " + r); + } + buffer.limit(r); + + streamer.writePacket(buffer, bufferInfo); + } + } catch (IOException e) { + // Broken pipe is expected on close, because the socket is closed by the client + if (!IO.isBrokenPipe(e)) { + Ln.e("Audio capture error", e); + } + } finally { + capture.stop(); + } + } + + @Override + public void start(TerminationListener listener) { + thread = new Thread(() -> { + boolean fatalError = false; + try { + record(); + } catch (AudioCaptureException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + } catch (Throwable t) { + Ln.e("Audio recording error", t); + fatalError = true; + } finally { + Ln.d("Audio recorder stopped"); + listener.onTerminated(fatalError); + } + }, "audio-raw"); + thread.start(); + } + + @Override + public void stop() { + if (thread != null) { + thread.interrupt(); + } + } + + @Override + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRecordReader.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRecordReader.java new file mode 100644 index 0000000..32b4225 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRecordReader.java @@ -0,0 +1,67 @@ +package com.genymobile.scrcpy.audio; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.TargetApi; +import android.media.AudioRecord; +import android.media.AudioTimestamp; +import android.media.MediaCodec; + +import java.nio.ByteBuffer; + +public class AudioRecordReader { + + private static final long ONE_SAMPLE_US = + (1000000 + AudioConfig.SAMPLE_RATE - 1) / AudioConfig.SAMPLE_RATE; // 1 sample in microseconds (used for fixing PTS) + + private final AudioRecord recorder; + + private final AudioTimestamp timestamp = new AudioTimestamp(); + private long previousRecorderTimestamp = -1; + private long previousPts = 0; + private long nextPts = 0; + + public AudioRecordReader(AudioRecord recorder) { + this.recorder = recorder; + } + + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) { + int r = recorder.read(outDirectBuffer, AudioConfig.MAX_READ_SIZE); + if (r <= 0) { + return r; + } + + long pts; + + int ret = recorder.getTimestamp(timestamp, AudioTimestamp.TIMEBASE_MONOTONIC); + if (ret == AudioRecord.SUCCESS && timestamp.nanoTime != previousRecorderTimestamp) { + pts = timestamp.nanoTime / 1000; + previousRecorderTimestamp = timestamp.nanoTime; + } else { + if (nextPts == 0) { + Ln.w("Could not get initial audio timestamp"); + nextPts = System.nanoTime() / 1000; + } + // compute from previous timestamp and packet size + pts = nextPts; + } + + long durationUs = r * 1000000L / (AudioConfig.CHANNELS * AudioConfig.BYTES_PER_SAMPLE * AudioConfig.SAMPLE_RATE); + nextPts = pts + durationUs; + + if (previousPts != 0 && pts < previousPts + ONE_SAMPLE_US) { + // Audio PTS may come from two sources: + // - recorder.getTimestamp() if the call works; + // - an estimation from the previous PTS and the packet size as a fallback. + // + // Therefore, the property that PTS are monotonically increasing is no guaranteed in corner cases, so enforce it. + pts = previousPts + ONE_SAMPLE_US; + } + previousPts = pts; + + outBufferInfo.set(0, r, pts, 0); + return r; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioSource.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioSource.java new file mode 100644 index 0000000..d16b5e3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioSource.java @@ -0,0 +1,45 @@ +package com.genymobile.scrcpy.audio; + +import android.annotation.SuppressLint; +import android.media.MediaRecorder; + +@SuppressLint("InlinedApi") +public enum AudioSource { + OUTPUT("output", MediaRecorder.AudioSource.REMOTE_SUBMIX), + MIC("mic", MediaRecorder.AudioSource.MIC), + PLAYBACK("playback", -1), + MIC_UNPROCESSED("mic-unprocessed", MediaRecorder.AudioSource.UNPROCESSED), + MIC_CAMCORDER("mic-camcorder", MediaRecorder.AudioSource.CAMCORDER), + MIC_VOICE_RECOGNITION("mic-voice-recognition", MediaRecorder.AudioSource.VOICE_RECOGNITION), + MIC_VOICE_COMMUNICATION("mic-voice-communication", MediaRecorder.AudioSource.VOICE_COMMUNICATION), + VOICE_CALL("voice-call", MediaRecorder.AudioSource.VOICE_CALL), + VOICE_CALL_UPLINK("voice-call-uplink", MediaRecorder.AudioSource.VOICE_UPLINK), + VOICE_CALL_DOWNLINK("voice-call-downlink", MediaRecorder.AudioSource.VOICE_DOWNLINK), + VOICE_PERFORMANCE("voice-performance", MediaRecorder.AudioSource.VOICE_PERFORMANCE); + + private final String name; + private final int directAudioSource; + + AudioSource(String name, int directAudioSource) { + this.name = name; + this.directAudioSource = directAudioSource; + } + + public boolean isDirect() { + return this != PLAYBACK; + } + + public int getDirectAudioSource() { + return directAudioSource; + } + + public static AudioSource findByName(String name) { + for (AudioSource audioSource : AudioSource.values()) { + if (name.equals(audioSource.name)) { + return audioSource; + } + } + + return null; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlChannel.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlChannel.java new file mode 100644 index 0000000..2f12cdb --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlChannel.java @@ -0,0 +1,24 @@ +package com.genymobile.scrcpy.control; + +import android.net.LocalSocket; + +import java.io.IOException; + +public final class ControlChannel { + + private final ControlMessageReader reader; + private final DeviceMessageWriter writer; + + public ControlChannel(LocalSocket controlSocket) throws IOException { + reader = new ControlMessageReader(controlSocket.getInputStream()); + writer = new DeviceMessageWriter(controlSocket.getOutputStream()); + } + + public ControlMessage recv() throws IOException { + return reader.read(); + } + + public void send(DeviceMessage msg) throws IOException { + writer.write(msg); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessage.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessage.java new file mode 100644 index 0000000..777873b --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessage.java @@ -0,0 +1,289 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.model.Position; + +/** + * Union of all supported event types, identified by their {@code type}. + */ +public final class ControlMessage { + + public static final int TYPE_INJECT_KEYCODE = 0; + public static final int TYPE_INJECT_TEXT = 1; + public static final int TYPE_INJECT_TOUCH_EVENT = 2; + public static final int TYPE_INJECT_SCROLL_EVENT = 3; + public static final int TYPE_BACK_OR_SCREEN_ON = 4; + public static final int TYPE_EXPAND_NOTIFICATION_PANEL = 5; + public static final int TYPE_EXPAND_SETTINGS_PANEL = 6; + public static final int TYPE_COLLAPSE_PANELS = 7; + public static final int TYPE_GET_CLIPBOARD = 8; + public static final int TYPE_SET_CLIPBOARD = 9; + public static final int TYPE_SET_DISPLAY_POWER = 10; + public static final int TYPE_ROTATE_DEVICE = 11; + public static final int TYPE_UHID_CREATE = 12; + public static final int TYPE_UHID_INPUT = 13; + public static final int TYPE_UHID_DESTROY = 14; + public static final int TYPE_OPEN_HARD_KEYBOARD_SETTINGS = 15; + public static final int TYPE_START_APP = 16; + public static final int TYPE_RESET_VIDEO = 17; + public static final int TYPE_CAMERA_SET_TORCH = 18; + public static final int TYPE_CAMERA_ZOOM_IN = 19; + public static final int TYPE_CAMERA_ZOOM_OUT = 20; + public static final int TYPE_RESIZE_DISPLAY = 21; + public static final int TYPE_SCAN_FILE = 22; + + public static final long SEQUENCE_INVALID = 0; + + public static final int COPY_KEY_NONE = 0; + public static final int COPY_KEY_COPY = 1; + public static final int COPY_KEY_CUT = 2; + + private int type; + private String text; + private int metaState; // KeyEvent.META_* + private int action; // KeyEvent.ACTION_* or MotionEvent.ACTION_* + private int keycode; // KeyEvent.KEYCODE_* + private int actionButton; // MotionEvent.BUTTON_* + private int buttons; // MotionEvent.BUTTON_* + private long pointerId; + private float pressure; + private Position position; + private float hScroll; + private float vScroll; + private int copyKey; + private boolean paste; + private int repeat; + private long sequence; + private int id; + private byte[] data; + private boolean on; + private int vendorId; + private int productId; + private int width; + private int height; + + private ControlMessage() { + } + + public static ControlMessage createInjectKeycode(int action, int keycode, int repeat, int metaState) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_INJECT_KEYCODE; + msg.action = action; + msg.keycode = keycode; + msg.repeat = repeat; + msg.metaState = metaState; + return msg; + } + + public static ControlMessage createInjectText(String text) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_INJECT_TEXT; + msg.text = text; + return msg; + } + + public static ControlMessage createInjectTouchEvent(int action, long pointerId, Position position, float pressure, int actionButton, + int buttons) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_INJECT_TOUCH_EVENT; + msg.action = action; + msg.pointerId = pointerId; + msg.pressure = pressure; + msg.position = position; + msg.actionButton = actionButton; + msg.buttons = buttons; + return msg; + } + + public static ControlMessage createInjectScrollEvent(Position position, float hScroll, float vScroll, int buttons) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_INJECT_SCROLL_EVENT; + msg.position = position; + msg.hScroll = hScroll; + msg.vScroll = vScroll; + msg.buttons = buttons; + return msg; + } + + public static ControlMessage createBackOrScreenOn(int action) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_BACK_OR_SCREEN_ON; + msg.action = action; + return msg; + } + + public static ControlMessage createGetClipboard(int copyKey) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_GET_CLIPBOARD; + msg.copyKey = copyKey; + return msg; + } + + public static ControlMessage createSetClipboard(long sequence, String text, boolean paste) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_SET_CLIPBOARD; + msg.sequence = sequence; + msg.text = text; + msg.paste = paste; + return msg; + } + + public static ControlMessage createSetDisplayPower(boolean on) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_SET_DISPLAY_POWER; + msg.on = on; + return msg; + } + + public static ControlMessage createEmpty(int type) { + ControlMessage msg = new ControlMessage(); + msg.type = type; + return msg; + } + + public static ControlMessage createUhidCreate(int id, int vendorId, int productId, String name, byte[] reportDesc) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_UHID_CREATE; + msg.id = id; + msg.vendorId = vendorId; + msg.productId = productId; + msg.text = name; + msg.data = reportDesc; + return msg; + } + + public static ControlMessage createUhidInput(int id, byte[] data) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_UHID_INPUT; + msg.id = id; + msg.data = data; + return msg; + } + + public static ControlMessage createUhidDestroy(int id) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_UHID_DESTROY; + msg.id = id; + return msg; + } + + public static ControlMessage createStartApp(String name) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_START_APP; + msg.text = name; + return msg; + } + + public static ControlMessage createCameraSetTorch(boolean on) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_CAMERA_SET_TORCH; + msg.on = on; + return msg; + } + + public static ControlMessage createResizeDisplay(int width, int height) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_RESIZE_DISPLAY; + msg.width = width; + msg.height = height; + return msg; + } + + public static ControlMessage createScanFile(String path) { + ControlMessage msg = new ControlMessage(); + msg.type = TYPE_SCAN_FILE; + msg.text = path; + return msg; + } + + public int getType() { + return type; + } + + public String getText() { + return text; + } + + public int getMetaState() { + return metaState; + } + + public int getAction() { + return action; + } + + public int getKeycode() { + return keycode; + } + + public int getActionButton() { + return actionButton; + } + + public int getButtons() { + return buttons; + } + + public long getPointerId() { + return pointerId; + } + + public float getPressure() { + return pressure; + } + + public Position getPosition() { + return position; + } + + public float getHScroll() { + return hScroll; + } + + public float getVScroll() { + return vScroll; + } + + public int getCopyKey() { + return copyKey; + } + + public boolean getPaste() { + return paste; + } + + public int getRepeat() { + return repeat; + } + + public long getSequence() { + return sequence; + } + + public int getId() { + return id; + } + + public byte[] getData() { + return data; + } + + public boolean getOn() { + return on; + } + + public int getVendorId() { + return vendorId; + } + + public int getProductId() { + return productId; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessageReader.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessageReader.java new file mode 100644 index 0000000..b2591d6 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessageReader.java @@ -0,0 +1,200 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.model.Position; +import com.genymobile.scrcpy.util.Binary; + +import java.io.BufferedInputStream; +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public class ControlMessageReader { + + private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k + + public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 14; // type: 1 byte; sequence: 8 bytes; paste flag: 1 byte; length: 4 bytes + public static final int INJECT_TEXT_MAX_LENGTH = 300; + + private final DataInputStream dis; + + public ControlMessageReader(InputStream rawInputStream) { + dis = new DataInputStream(new BufferedInputStream(rawInputStream)); + } + + public ControlMessage read() throws IOException { + int type = dis.readUnsignedByte(); + switch (type) { + case ControlMessage.TYPE_INJECT_KEYCODE: + return parseInjectKeycode(); + case ControlMessage.TYPE_INJECT_TEXT: + return parseInjectText(); + case ControlMessage.TYPE_INJECT_TOUCH_EVENT: + return parseInjectTouchEvent(); + case ControlMessage.TYPE_INJECT_SCROLL_EVENT: + return parseInjectScrollEvent(); + case ControlMessage.TYPE_BACK_OR_SCREEN_ON: + return parseBackOrScreenOnEvent(); + case ControlMessage.TYPE_GET_CLIPBOARD: + return parseGetClipboard(); + case ControlMessage.TYPE_SET_CLIPBOARD: + return parseSetClipboard(); + case ControlMessage.TYPE_SET_DISPLAY_POWER: + return parseSetDisplayPower(); + case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL: + case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL: + case ControlMessage.TYPE_COLLAPSE_PANELS: + case ControlMessage.TYPE_ROTATE_DEVICE: + case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS: + case ControlMessage.TYPE_RESET_VIDEO: + case ControlMessage.TYPE_CAMERA_ZOOM_IN: + case ControlMessage.TYPE_CAMERA_ZOOM_OUT: + return ControlMessage.createEmpty(type); + case ControlMessage.TYPE_UHID_CREATE: + return parseUhidCreate(); + case ControlMessage.TYPE_UHID_INPUT: + return parseUhidInput(); + case ControlMessage.TYPE_UHID_DESTROY: + return parseUhidDestroy(); + case ControlMessage.TYPE_START_APP: + return parseStartApp(); + case ControlMessage.TYPE_CAMERA_SET_TORCH: + return parseCameraSetTorch(); + case ControlMessage.TYPE_RESIZE_DISPLAY: + return parseResizeDisplay(); + case ControlMessage.TYPE_SCAN_FILE: + return parseScanFile(); + default: + throw new ControlProtocolException("Unknown event type: " + type); + } + } + + private ControlMessage parseInjectKeycode() throws IOException { + int action = dis.readUnsignedByte(); + int keycode = dis.readInt(); + int repeat = dis.readInt(); + int metaState = dis.readInt(); + return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState); + } + + private int parseBufferLength(int sizeBytes) throws IOException { + assert sizeBytes > 0 && sizeBytes <= 4; + int value = 0; + for (int i = 0; i < sizeBytes; ++i) { + value = (value << 8) | dis.readUnsignedByte(); + } + return value; + } + + private String parseString(int sizeBytes) throws IOException { + assert sizeBytes > 0 && sizeBytes <= 4; + byte[] data = parseByteArray(sizeBytes); + return new String(data, StandardCharsets.UTF_8); + } + + private String parseString() throws IOException { + return parseString(4); + } + + private byte[] parseByteArray(int sizeBytes) throws IOException { + int len = parseBufferLength(sizeBytes); + byte[] data = new byte[len]; + dis.readFully(data); + return data; + } + + private ControlMessage parseInjectText() throws IOException { + String text = parseString(); + return ControlMessage.createInjectText(text); + } + + private ControlMessage parseInjectTouchEvent() throws IOException { + int action = dis.readUnsignedByte(); + long pointerId = dis.readLong(); + Position position = parsePosition(); + float pressure = Binary.u16FixedPointToFloat(dis.readShort()); + int actionButton = dis.readInt(); + int buttons = dis.readInt(); + return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, actionButton, buttons); + } + + private ControlMessage parseInjectScrollEvent() throws IOException { + Position position = parsePosition(); + // Binary.i16FixedPointToFloat() decodes values assuming the full range is [-1, 1], but the actual range is [-16, 16]. + float hScroll = Binary.i16FixedPointToFloat(dis.readShort()) * 16; + float vScroll = Binary.i16FixedPointToFloat(dis.readShort()) * 16; + int buttons = dis.readInt(); + return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll, buttons); + } + + private ControlMessage parseBackOrScreenOnEvent() throws IOException { + int action = dis.readUnsignedByte(); + return ControlMessage.createBackOrScreenOn(action); + } + + private ControlMessage parseGetClipboard() throws IOException { + int copyKey = dis.readUnsignedByte(); + return ControlMessage.createGetClipboard(copyKey); + } + + private ControlMessage parseSetClipboard() throws IOException { + long sequence = dis.readLong(); + boolean paste = dis.readByte() != 0; + String text = parseString(); + return ControlMessage.createSetClipboard(sequence, text, paste); + } + + private ControlMessage parseSetDisplayPower() throws IOException { + boolean on = dis.readBoolean(); + return ControlMessage.createSetDisplayPower(on); + } + + private ControlMessage parseUhidCreate() throws IOException { + int id = dis.readUnsignedShort(); + int vendorId = dis.readUnsignedShort(); + int productId = dis.readUnsignedShort(); + String name = parseString(1); + byte[] data = parseByteArray(2); + return ControlMessage.createUhidCreate(id, vendorId, productId, name, data); + } + + private ControlMessage parseUhidInput() throws IOException { + int id = dis.readUnsignedShort(); + byte[] data = parseByteArray(2); + return ControlMessage.createUhidInput(id, data); + } + + private ControlMessage parseUhidDestroy() throws IOException { + int id = dis.readUnsignedShort(); + return ControlMessage.createUhidDestroy(id); + } + + private ControlMessage parseStartApp() throws IOException { + String name = parseString(1); + return ControlMessage.createStartApp(name); + } + + private ControlMessage parseCameraSetTorch() throws IOException { + boolean on = dis.readBoolean(); + return ControlMessage.createCameraSetTorch(on); + } + + private ControlMessage parseResizeDisplay() throws IOException { + int width = dis.readUnsignedShort(); + int height = dis.readUnsignedShort(); + return ControlMessage.createResizeDisplay(width, height); + } + + private ControlMessage parseScanFile() throws IOException { + String path = parseString(); + return ControlMessage.createScanFile(path); + } + + private Position parsePosition() throws IOException { + int x = dis.readInt(); + int y = dis.readInt(); + int screenWidth = dis.readUnsignedShort(); + int screenHeight = dis.readUnsignedShort(); + return new Position(x, y, screenWidth, screenHeight); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlProtocolException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlProtocolException.java new file mode 100644 index 0000000..cabf63e --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlProtocolException.java @@ -0,0 +1,9 @@ +package com.genymobile.scrcpy.control; + +import java.io.IOException; + +public class ControlProtocolException extends IOException { + public ControlProtocolException(String message) { + super(message); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Controller.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Controller.java new file mode 100644 index 0000000..55d6e11 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Controller.java @@ -0,0 +1,893 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.AsyncProcessor; +import com.genymobile.scrcpy.CleanUp; +import com.genymobile.scrcpy.Options; +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.display.DisplayInfo; +import com.genymobile.scrcpy.model.DeviceApp; +import com.genymobile.scrcpy.model.Point; +import com.genymobile.scrcpy.model.Position; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; +import com.genymobile.scrcpy.video.CameraCapture; +import com.genymobile.scrcpy.video.CaptureControl; +import com.genymobile.scrcpy.video.NewDisplayCapture; +import com.genymobile.scrcpy.video.SurfaceCapture; +import com.genymobile.scrcpy.video.VideoSource; +import com.genymobile.scrcpy.video.VirtualDisplayListener; +import com.genymobile.scrcpy.wrappers.ClipboardManager; +import com.genymobile.scrcpy.wrappers.InputManager; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.SystemClock; +import android.util.Pair; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public class Controller implements AsyncProcessor, VirtualDisplayListener { + + /* + * For event injection, there are two display ids: + * - the displayId passed to the constructor (which comes from --display-id passed by the client, 0 for the main display); + * - the virtualDisplayId used for mirroring, notified by the capture instance via the VirtualDisplayListener interface. + * + * (In case the ScreenCapture uses the "SurfaceControl API", then both ids are equals, but this is an implementation detail.) + * + * In order to make events work correctly in all cases: + * - virtualDisplayId must be used for events relative to the display (mouse and touch events with coordinates); + * - displayId must be used for other events (like key events). + * + * If a new separate virtual display is created (using --new-display), then displayId == Device.DISPLAY_ID_NONE. In that case, all events are + * sent to the virtual display id. + */ + + private static final class DisplayData { + private final int virtualDisplayId; + private final PositionMapper positionMapper; + + private DisplayData(int virtualDisplayId, PositionMapper positionMapper) { + this.virtualDisplayId = virtualDisplayId; + this.positionMapper = positionMapper; + } + } + + private static final int DEFAULT_DEVICE_ID = 0; + + // control_msg.h values of the pointerId field in inject_touch_event message + private static final int POINTER_ID_MOUSE = -1; + + // Interval between simulated user activity events + private static final long KEEP_ACTIVE_INTERVAL_MS = 4000; + + private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(); + private ExecutorService startAppExecutor; + + private Thread thread; + private Thread keepActiveThread; + + private UhidManager uhidManager; + + private final boolean camera; + private final int displayId; + private final boolean supportsInputEvents; + private final ControlChannel controlChannel; + private final CleanUp cleanUp; + private final DeviceMessageSender sender; + private final boolean clipboardAutosync; + private final boolean powerOn; + private final boolean keepActive; + + private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + + private final AtomicBoolean isSettingClipboard = new AtomicBoolean(); + + private final AtomicReference displayData = new AtomicReference<>(); + private final Object displayDataAvailable = new Object(); // condition variable + + private long lastTouchDown; + private final PointersState pointersState = new PointersState(); + private final MotionEvent.PointerProperties[] pointerProperties = new MotionEvent.PointerProperties[PointersState.MAX_POINTERS]; + private final MotionEvent.PointerCoords[] pointerCoords = new MotionEvent.PointerCoords[PointersState.MAX_POINTERS]; + + private boolean keepDisplayPowerOff; + + // Used for resetting video encoding on RESET_VIDEO message or for sending camera controls + private SurfaceCapture surfaceCapture; + + public Controller(ControlChannel controlChannel, CleanUp cleanUp, Options options) { + this.camera = options.getVideoSource() == VideoSource.CAMERA; + this.controlChannel = controlChannel; + this.cleanUp = cleanUp; + + if (this.camera) { + // Unused for camera + this.displayId = Device.DISPLAY_ID_NONE; + this.supportsInputEvents = false; + this.sender = null; + this.clipboardAutosync = false; + this.powerOn = false; + this.keepActive = false; + return; + } + + this.displayId = options.getDisplayId(); + + this.clipboardAutosync = options.getClipboardAutosync(); + this.powerOn = options.getPowerOn(); + this.keepActive = options.getKeepActive(); + initPointers(); + sender = new DeviceMessageSender(controlChannel); + + supportsInputEvents = Device.supportsInputEvents(displayId); + if (!supportsInputEvents) { + Ln.w("Input events are not supported for secondary displays before Android 10"); + } + + // Make sure the clipboard manager is always created from the main thread (even if clipboardAutosync is disabled) + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardAutosync) { + // If control and autosync are enabled, synchronize Android clipboard to the computer automatically + if (clipboardManager != null) { + clipboardManager.addPrimaryClipChangedListener(() -> { + if (isSettingClipboard.get()) { + // This is a notification for the change we are currently applying, ignore it + return; + } + String text = Device.getClipboardText(); + if (text != null) { + DeviceMessage msg = DeviceMessage.createClipboard(text); + sender.send(msg); + } + }); + } else { + Ln.w("No clipboard manager, copy-paste between device and computer will not work"); + } + } + } + + @Override + public void onNewVirtualDisplay(int virtualDisplayId, PositionMapper positionMapper) { + DisplayData data = new DisplayData(virtualDisplayId, positionMapper); + DisplayData old = this.displayData.getAndSet(data); + if (old == null) { + // The very first time the Controller is notified of a new virtual display + synchronized (displayDataAvailable) { + displayDataAvailable.notify(); + } + } + } + + public void setSurfaceCapture(SurfaceCapture surfaceCapture) { + this.surfaceCapture = surfaceCapture; + } + + private UhidManager getUhidManager() { + if (uhidManager == null) { + int uhidDisplayId = displayId; + if (Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15) { + if (displayId == Device.DISPLAY_ID_NONE) { + // Mirroring a new virtual display id (using --new-display-id feature) on Android >= 15, where the UHID mouse pointer can be + // associated to the virtual display + try { + // Wait for at most 1 second until a virtual display id is known + DisplayData data = waitDisplayData(1000); + if (data != null) { + uhidDisplayId = data.virtualDisplayId; + } + } catch (InterruptedException e) { + // do nothing + } + } + } + + String displayUniqueId = null; + if (uhidDisplayId > 0) { + // Ignore Device.DISPLAY_ID_NONE and 0 (main display) + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(uhidDisplayId); + if (displayInfo != null) { + displayUniqueId = displayInfo.getUniqueId(); + } + } + uhidManager = new UhidManager(sender, displayUniqueId); + } + + return uhidManager; + } + + private void initPointers() { + for (int i = 0; i < PointersState.MAX_POINTERS; ++i) { + MotionEvent.PointerProperties props = new MotionEvent.PointerProperties(); + props.toolType = MotionEvent.TOOL_TYPE_FINGER; + + MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + coords.orientation = 0; + coords.size = 0; + + pointerProperties[i] = props; + pointerCoords[i] = coords; + } + } + + private void control() throws IOException { + // on start, power on the device + if (!camera && powerOn && displayId == 0 && !Device.isScreenOn(displayId)) { + Device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC); + + // dirty hack + // After POWER is injected, the device is powered on asynchronously. + // To turn the device screen off while mirroring, the client will send a message that + // would be handled before the device is actually powered on, so its effect would + // be "canceled" once the device is turned back on. + // Adding this delay prevents to handle the message before the device is actually + // powered on. + SystemClock.sleep(500); + } + + boolean alive = true; + while (!Thread.currentThread().isInterrupted() && alive) { + alive = handleEvent(); + } + } + + private void startKeepActiveThread() { + keepActiveThread = new Thread(() -> { + try { + while (true) { + Thread.sleep(KEEP_ACTIVE_INTERVAL_MS); + int actionDisplayId = getActionDisplayId(); + if (actionDisplayId != Device.DISPLAY_ID_NONE) { + Device.keepActive(actionDisplayId); + } + } + } catch (InterruptedException e) { + // ignore + } catch (Throwable e) { + Ln.e("Keep active error", e); + } finally { + Ln.d("Keep active thread stopped"); + } + }); + keepActiveThread.setName("keep-active"); + keepActiveThread.setDaemon(true); + keepActiveThread.start(); + } + + @Override + public void start(TerminationListener listener) { + if (keepActive) { + startKeepActiveThread(); + } + + thread = new Thread(() -> { + try { + control(); + } catch (IOException e) { + Ln.e("Controller error", e); + } finally { + Ln.d("Controller stopped"); + if (uhidManager != null) { + uhidManager.closeAll(); + } + listener.onTerminated(true); + } + }, "control-recv"); + thread.start(); + if (sender != null) { + sender.start(); + } + } + + @Override + public void stop() { + if (keepActiveThread != null) { + keepActiveThread.interrupt(); + } + if (thread != null) { + thread.interrupt(); + } + if (sender != null) { + sender.stop(); + } + } + + @Override + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + if (sender != null) { + sender.join(); + } + } + + private boolean handleEvent() throws IOException { + ControlMessage msg; + try { + msg = controlChannel.recv(); + } catch (ControlProtocolException e) { + Ln.e("Control protocol error", e); + return false; + } catch (IOException e) { + // this is expected on close + return false; + } + + int type = msg.getType(); + + // Events for all sources (display or camera) + switch (type) { + case ControlMessage.TYPE_RESET_VIDEO: + resetVideo(); + return true; + default: + // fall through + } + + if (!camera) { + switch (type) { + case ControlMessage.TYPE_INJECT_KEYCODE: + if (supportsInputEvents) { + injectKeycode(msg.getAction(), msg.getKeycode(), msg.getRepeat(), msg.getMetaState()); + } + return true; + case ControlMessage.TYPE_INJECT_TEXT: + if (supportsInputEvents) { + injectText(msg.getText()); + } + return true; + case ControlMessage.TYPE_INJECT_TOUCH_EVENT: + if (supportsInputEvents) { + injectTouch( + msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getActionButton(), msg.getButtons()); + } + return true; + case ControlMessage.TYPE_INJECT_SCROLL_EVENT: + if (supportsInputEvents) { + injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll(), msg.getButtons()); + } + return true; + case ControlMessage.TYPE_BACK_OR_SCREEN_ON: + if (supportsInputEvents) { + pressBackOrTurnScreenOn(msg.getAction()); + } + return true; + case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL: + Device.expandNotificationPanel(); + return true; + case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL: + Device.expandSettingsPanel(); + return true; + case ControlMessage.TYPE_COLLAPSE_PANELS: + Device.collapsePanels(); + return true; + case ControlMessage.TYPE_GET_CLIPBOARD: + getClipboard(msg.getCopyKey()); + return true; + case ControlMessage.TYPE_SET_CLIPBOARD: + setClipboard(msg.getText(), msg.getPaste(), msg.getSequence()); + return true; + case ControlMessage.TYPE_SET_DISPLAY_POWER: + if (supportsInputEvents) { + setDisplayPower(msg.getOn()); + } + return true; + case ControlMessage.TYPE_ROTATE_DEVICE: + int actionDisplayId = getActionDisplayId(); + if (actionDisplayId != Device.DISPLAY_ID_NONE) { + Device.rotateDevice(actionDisplayId); + } + return true; + case ControlMessage.TYPE_UHID_CREATE: + getUhidManager().open(msg.getId(), msg.getVendorId(), msg.getProductId(), msg.getText(), msg.getData()); + return true; + case ControlMessage.TYPE_UHID_INPUT: + getUhidManager().writeInput(msg.getId(), msg.getData()); + return true; + case ControlMessage.TYPE_UHID_DESTROY: + getUhidManager().close(msg.getId()); + return true; + case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS: + openHardKeyboardSettings(); + return true; + case ControlMessage.TYPE_START_APP: + startAppAsync(msg.getText()); + return true; + case ControlMessage.TYPE_RESIZE_DISPLAY: + resizeDisplay(msg.getWidth(), msg.getHeight()); + return true; + case ControlMessage.TYPE_SCAN_FILE: + scanFile(msg.getText()); + return true; + default: + // fall through + } + } else { + assert surfaceCapture instanceof CameraCapture; + CameraCapture cameraCapture = (CameraCapture) surfaceCapture; + switch (type) { + case ControlMessage.TYPE_CAMERA_SET_TORCH: + cameraCapture.setTorchEnabled(msg.getOn()); + return true; + case ControlMessage.TYPE_CAMERA_ZOOM_IN: + cameraCapture.zoomIn(); + return true; + case ControlMessage.TYPE_CAMERA_ZOOM_OUT: + cameraCapture.zoomOut(); + return true; + default: + // fall through + } + } + + throw new AssertionError("Unexpected message type: " + type); + } + + private boolean injectKeycode(int action, int keycode, int repeat, int metaState) { + if (keepDisplayPowerOff && action == KeyEvent.ACTION_UP && (keycode == KeyEvent.KEYCODE_POWER || keycode == KeyEvent.KEYCODE_WAKEUP)) { + assert displayId != Device.DISPLAY_ID_NONE; + scheduleDisplayPowerOff(displayId); + } + return injectKeyEvent(action, keycode, repeat, metaState, Device.INJECT_MODE_ASYNC); + } + + private boolean injectChar(char c) { + String decomposed = KeyComposition.decompose(c); + char[] chars = decomposed != null ? decomposed.toCharArray() : new char[]{c}; + KeyEvent[] events = charMap.getEvents(chars); + if (events == null) { + return false; + } + + int actionDisplayId = getActionDisplayId(); + if (actionDisplayId != Device.DISPLAY_ID_NONE) { + for (KeyEvent event : events) { + if (!Device.injectEvent(event, actionDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + } + } + return true; + } + + private int injectText(String text) { + int successCount = 0; + for (char c : text.toCharArray()) { + if (!injectChar(c)) { + Ln.w("Could not inject char u+" + String.format("%04x", (int) c)); + continue; + } + successCount++; + } + return successCount; + } + + private Pair getEventPointAndDisplayId(Position position) { + // it hides the field on purpose, to read it with atomic access + @SuppressWarnings("checkstyle:HiddenField") + DisplayData displayData = this.displayData.get(); + // In scrcpy, displayData should never be null (a touch event can only be generated from the client when a video frame is present). + // However, it is possible to send events without video playback when using scrcpy-server alone (except for virtual displays). + assert displayData != null || displayId != Device.DISPLAY_ID_NONE : "Cannot receive a positional event without a display"; + + Point point; + int targetDisplayId; + if (displayData != null) { + point = displayData.positionMapper.map(position); + if (point == null) { + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Size eventSize = position.getScreenSize(); + Size currentSize = displayData.positionMapper.getVideoSize(); + Ln.v("Ignore positional event generated for size " + eventSize + " (current size is " + currentSize + ")"); + } + return null; + } + targetDisplayId = displayData.virtualDisplayId; + } else { + // No display, use the raw coordinates + point = position.getPoint(); + targetDisplayId = displayId; + } + + return Pair.create(point, targetDisplayId); + } + + private boolean injectTouch(int action, long pointerId, Position position, float pressure, int actionButton, int buttons) { + long now = SystemClock.uptimeMillis(); + + Pair pair = getEventPointAndDisplayId(position); + if (pair == null) { + return false; + } + + Point point = pair.first; + int targetDisplayId = pair.second; + + int pointerIndex = pointersState.getPointerIndex(pointerId); + if (pointerIndex == -1) { + Ln.w("Too many pointers for touch event"); + return false; + } + Pointer pointer = pointersState.get(pointerIndex); + pointer.setPoint(point); + pointer.setPressure(pressure); + + int source; + boolean activeSecondaryButtons = ((actionButton | buttons) & ~MotionEvent.BUTTON_PRIMARY) != 0; + if (pointerId == POINTER_ID_MOUSE && (action == MotionEvent.ACTION_HOVER_MOVE || activeSecondaryButtons)) { + // real mouse event, or event incompatible with a finger + pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_MOUSE; + source = InputDevice.SOURCE_MOUSE; + pointer.setUp(buttons == 0); + } else { + // POINTER_ID_GENERIC_FINGER, POINTER_ID_VIRTUAL_FINGER or real touch from device + pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_FINGER; + source = InputDevice.SOURCE_TOUCHSCREEN; + // Buttons must not be set for touch events + buttons = 0; + pointer.setUp(action == MotionEvent.ACTION_UP); + } + + int pointerCount = pointersState.update(pointerProperties, pointerCoords); + if (pointerCount == 1) { + if (action == MotionEvent.ACTION_DOWN) { + lastTouchDown = now; + } + } else { + // secondary pointers must use ACTION_POINTER_* ORed with the pointerIndex + if (action == MotionEvent.ACTION_UP) { + action = MotionEvent.ACTION_POINTER_UP | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + } else if (action == MotionEvent.ACTION_DOWN) { + action = MotionEvent.ACTION_POINTER_DOWN | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + } + } + + /* If the input device is a mouse (on API >= 23): + * - the first button pressed must first generate ACTION_DOWN; + * - all button pressed (including the first one) must generate ACTION_BUTTON_PRESS; + * - all button released (including the last one) must generate ACTION_BUTTON_RELEASE; + * - the last button released must in addition generate ACTION_UP. + * + * Otherwise, Chrome does not work properly: + */ + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0 && source == InputDevice.SOURCE_MOUSE) { + if (action == MotionEvent.ACTION_DOWN) { + if (actionButton == buttons) { + // First button pressed: ACTION_DOWN + MotionEvent downEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_DOWN, pointerCount, pointerProperties, + pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); + if (!Device.injectEvent(downEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + } + + // Any button pressed: ACTION_BUTTON_PRESS + MotionEvent pressEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_PRESS, pointerCount, pointerProperties, + pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); + if (!InputManager.setActionButton(pressEvent, actionButton)) { + return false; + } + if (!Device.injectEvent(pressEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + + return true; + } + + if (action == MotionEvent.ACTION_UP) { + // Any button released: ACTION_BUTTON_RELEASE + MotionEvent releaseEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_RELEASE, pointerCount, pointerProperties, + pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); + if (!InputManager.setActionButton(releaseEvent, actionButton)) { + return false; + } + if (!Device.injectEvent(releaseEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + + if (buttons == 0) { + // Last button released: ACTION_UP + MotionEvent upEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_UP, pointerCount, pointerProperties, + pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0); + if (!Device.injectEvent(upEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) { + return false; + } + } + + return true; + } + } + + MotionEvent event = MotionEvent.obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, + DEFAULT_DEVICE_ID, 0, source, 0); + return Device.injectEvent(event, targetDisplayId, Device.INJECT_MODE_ASYNC); + } + + private boolean injectScroll(Position position, float hScroll, float vScroll, int buttons) { + long now = SystemClock.uptimeMillis(); + + Pair pair = getEventPointAndDisplayId(position); + if (pair == null) { + return false; + } + + Point point = pair.first; + int targetDisplayId = pair.second; + + MotionEvent.PointerProperties props = pointerProperties[0]; + props.id = 0; + + MotionEvent.PointerCoords coords = pointerCoords[0]; + coords.x = point.getX(); + coords.y = point.getY(); + coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll); + coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll); + + MotionEvent event = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, + DEFAULT_DEVICE_ID, 0, InputDevice.SOURCE_MOUSE, 0); + return Device.injectEvent(event, targetDisplayId, Device.INJECT_MODE_ASYNC); + } + + /** + * Schedule a call to set display power to off after a small delay. + */ + private static void scheduleDisplayPowerOff(int displayId) { + EXECUTOR.schedule(() -> { + Ln.i("Forcing display off"); + Device.setDisplayPower(displayId, false); + }, 200, TimeUnit.MILLISECONDS); + } + + private boolean pressBackOrTurnScreenOn(int action) { + boolean injectBack; + // Device.isScreenOn(displayId) ignores the displayId below Android 14 + if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) { + // Inject BACK if the screen is on for the current virtual display id + int actionDisplayId = getActionDisplayId(); + injectBack = actionDisplayId == Device.DISPLAY_ID_NONE || Device.isScreenOn(actionDisplayId); + } else { + // Inject BACK if the display is not the main display, or if the main display is on + injectBack = displayId != 0 || Device.isScreenOn(0); + } + if (injectBack) { + return injectKeyEvent(action, KeyEvent.KEYCODE_BACK, 0, 0, Device.INJECT_MODE_ASYNC); + } + + // Screen is off + // Only press POWER on ACTION_DOWN + if (action != KeyEvent.ACTION_DOWN) { + // do nothing + return true; + } + + if (keepDisplayPowerOff) { + assert displayId != Device.DISPLAY_ID_NONE; + scheduleDisplayPowerOff(displayId); + } + return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, Device.INJECT_MODE_ASYNC); + } + + private void getClipboard(int copyKey) { + // On Android >= 7, press the COPY or CUT key if requested + if (copyKey != ControlMessage.COPY_KEY_NONE && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && supportsInputEvents) { + int key = copyKey == ControlMessage.COPY_KEY_COPY ? KeyEvent.KEYCODE_COPY : KeyEvent.KEYCODE_CUT; + // Wait until the event is finished, to ensure that the clipboard text we read just after is the correct one + pressReleaseKeycode(key, Device.INJECT_MODE_WAIT_FOR_FINISH); + } + + // If clipboard autosync is enabled, then the device clipboard is synchronized to the computer clipboard whenever it changes, in + // particular when COPY or CUT are injected, so it should not be synchronized twice. On Android < 7, do not synchronize at all rather than + // copying an old clipboard content. + if (!clipboardAutosync) { + String clipboardText = Device.getClipboardText(); + if (clipboardText != null) { + DeviceMessage msg = DeviceMessage.createClipboard(clipboardText); + sender.send(msg); + } + } + } + + private boolean setClipboard(String text, boolean paste, long sequence) { + isSettingClipboard.set(true); + boolean ok = Device.setClipboardText(text); + isSettingClipboard.set(false); + if (ok) { + Ln.i("Device clipboard set"); + } + + // On Android >= 7, also press the PASTE key if requested + if (paste && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && supportsInputEvents) { + pressReleaseKeycode(KeyEvent.KEYCODE_PASTE, Device.INJECT_MODE_ASYNC); + } + + if (sequence != ControlMessage.SEQUENCE_INVALID) { + // Acknowledgement requested + DeviceMessage msg = DeviceMessage.createAckClipboard(sequence); + sender.send(msg); + } + + return ok; + } + + private void openHardKeyboardSettings() { + Intent intent = new Intent("android.settings.HARD_KEYBOARD_SETTINGS"); + ServiceManager.getActivityManager().startActivity(intent); + } + + private boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int injectMode) { + int actionDisplayId = getActionDisplayId(); + if (actionDisplayId == Device.DISPLAY_ID_NONE) { + return false; + } + return Device.injectKeyEvent(action, keyCode, repeat, metaState, actionDisplayId, injectMode); + } + + private boolean pressReleaseKeycode(int keyCode, int injectMode) { + int actionDisplayId = getActionDisplayId(); + if (actionDisplayId == Device.DISPLAY_ID_NONE) { + return false; + } + return Device.pressReleaseKeycode(keyCode, actionDisplayId, injectMode); + } + + private int getActionDisplayId() { + if (displayId != Device.DISPLAY_ID_NONE) { + // Real screen mirrored, use the source display id + return displayId; + } + + // Virtual display created by --new-display, use the virtualDisplayId + DisplayData data = displayData.get(); + if (data == null) { + return Device.DISPLAY_ID_NONE; + } + + return data.virtualDisplayId; + } + + private void startAppAsync(String name) { + if (startAppExecutor == null) { + startAppExecutor = Executors.newSingleThreadExecutor(); + } + + // Listing and selecting the app may take a lot of time + startAppExecutor.submit(() -> startApp(name)); + } + + private void startApp(String name) { + boolean forceStopBeforeStart = name.startsWith("+"); + if (forceStopBeforeStart) { + name = name.substring(1); + } + + DeviceApp app; + boolean searchByName = name.startsWith("?"); + if (searchByName) { + name = name.substring(1); + + Ln.i("Processing Android apps... (this may take some time)"); + List apps = Device.findByName(name); + if (apps.isEmpty()) { + Ln.w("No app found for name \"" + name + "\""); + return; + } + + if (apps.size() > 1) { + String title = "No unique app found for name \"" + name + "\":"; + Ln.w(LogUtils.buildAppListMessage(title, apps)); + return; + } + + app = apps.get(0); + } else { + app = Device.findByPackageName(name); + if (app == null) { + Ln.w("No app found for package \"" + name + "\""); + return; + } + } + + int startAppDisplayId = getStartAppDisplayId(); + if (startAppDisplayId == Device.DISPLAY_ID_NONE) { + Ln.e("No known display id to start app \"" + name + "\""); + return; + } + + Ln.i("Starting app \"" + app.getName() + "\" [" + app.getPackageName() + "] on display " + startAppDisplayId + "..."); + Device.startApp(app.getPackageName(), startAppDisplayId, forceStopBeforeStart); + } + + private int getStartAppDisplayId() { + if (displayId != Device.DISPLAY_ID_NONE) { + return displayId; + } + + // Mirroring a new virtual display id (using --new-display-id feature) + try { + // Wait for at most 1 second until a virtual display id is known + DisplayData data = waitDisplayData(1000); + if (data != null) { + return data.virtualDisplayId; + } + } catch (InterruptedException e) { + // do nothing + } + + // No display id available + return Device.DISPLAY_ID_NONE; + } + + private DisplayData waitDisplayData(long timeoutMillis) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMillis; + + synchronized (displayDataAvailable) { + DisplayData data = displayData.get(); + while (data == null) { + long timeout = deadline - System.currentTimeMillis(); + if (timeout < 0) { + return null; + } + if (timeout > 0) { + displayDataAvailable.wait(timeout); + } + data = displayData.get(); + } + + return data; + } + } + + private void setDisplayPower(boolean on) { + // Change the power of the main display when mirroring a virtual display + int targetDisplayId = displayId != Device.DISPLAY_ID_NONE ? displayId : 0; + boolean setDisplayPowerOk = Device.setDisplayPower(targetDisplayId, on); + if (setDisplayPowerOk) { + // Do not keep display power off for virtual displays: MOD+p must wake up the physical device + keepDisplayPowerOff = displayId != Device.DISPLAY_ID_NONE && !on; + Ln.i("Device display turned " + (on ? "on" : "off")); + if (cleanUp != null) { + boolean mustRestoreOnExit = !on; + cleanUp.setRestoreDisplayPower(mustRestoreOnExit); + } + } + } + + private void resetVideo() { + if (surfaceCapture != null) { + Ln.i("Video capture reset"); + surfaceCapture.getCaptureControl().reset(CaptureControl.RESET_REASON_CLIENT_RESET); + } + } + + private void resizeDisplay(int width, int height) { + NewDisplayCapture newDisplayCapture = (NewDisplayCapture) surfaceCapture; + newDisplayCapture.requestResize(width, height); + } + + private void scanFile(String path) { + try { + @SuppressWarnings("deprecation") + Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(new File(path))); + Device.sendBroadcast(intent); + } catch (Throwable t) { + Ln.e("MediaStore scan failed for " + path, t); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessage.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessage.java new file mode 100644 index 0000000..079a7a0 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessage.java @@ -0,0 +1,59 @@ +package com.genymobile.scrcpy.control; + +public final class DeviceMessage { + + public static final int TYPE_CLIPBOARD = 0; + public static final int TYPE_ACK_CLIPBOARD = 1; + public static final int TYPE_UHID_OUTPUT = 2; + + private int type; + private String text; + private long sequence; + private int id; + private byte[] data; + + private DeviceMessage() { + } + + public static DeviceMessage createClipboard(String text) { + DeviceMessage event = new DeviceMessage(); + event.type = TYPE_CLIPBOARD; + event.text = text; + return event; + } + + public static DeviceMessage createAckClipboard(long sequence) { + DeviceMessage event = new DeviceMessage(); + event.type = TYPE_ACK_CLIPBOARD; + event.sequence = sequence; + return event; + } + + public static DeviceMessage createUhidOutput(int id, byte[] data) { + DeviceMessage event = new DeviceMessage(); + event.type = TYPE_UHID_OUTPUT; + event.id = id; + event.data = data; + return event; + } + + public int getType() { + return type; + } + + public String getText() { + return text; + } + + public long getSequence() { + return sequence; + } + + public int getId() { + return id; + } + + public byte[] getData() { + return data; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageSender.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageSender.java new file mode 100644 index 0000000..dc5e6be --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageSender.java @@ -0,0 +1,57 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.util.Ln; + +import java.io.IOException; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +public final class DeviceMessageSender { + + private final ControlChannel controlChannel; + + private Thread thread; + private final BlockingQueue queue = new ArrayBlockingQueue<>(16); + + public DeviceMessageSender(ControlChannel controlChannel) { + this.controlChannel = controlChannel; + } + + public void send(DeviceMessage msg) { + if (!queue.offer(msg)) { + Ln.w("Device message dropped: " + msg.getType()); + } + } + + private void loop() throws IOException, InterruptedException { + while (!Thread.currentThread().isInterrupted()) { + DeviceMessage msg = queue.take(); + controlChannel.send(msg); + } + } + + public void start() { + thread = new Thread(() -> { + try { + loop(); + } catch (IOException | InterruptedException e) { + // this is expected on close + } finally { + Ln.d("Device message sender stopped"); + } + }, "control-send"); + thread.start(); + } + + public void stop() { + if (thread != null) { + thread.interrupt(); + } + } + + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageWriter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageWriter.java new file mode 100644 index 0000000..a18a2e5 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageWriter.java @@ -0,0 +1,47 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.util.StringUtils; + +import java.io.BufferedOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +public class DeviceMessageWriter { + + private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k + public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes + + private final DataOutputStream dos; + + public DeviceMessageWriter(OutputStream rawOutputStream) { + dos = new DataOutputStream(new BufferedOutputStream(rawOutputStream)); + } + + public void write(DeviceMessage msg) throws IOException { + int type = msg.getType(); + dos.writeByte(type); + switch (type) { + case DeviceMessage.TYPE_CLIPBOARD: + String text = msg.getText(); + byte[] raw = text.getBytes(StandardCharsets.UTF_8); + int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH); + dos.writeInt(len); + dos.write(raw, 0, len); + break; + case DeviceMessage.TYPE_ACK_CLIPBOARD: + dos.writeLong(msg.getSequence()); + break; + case DeviceMessage.TYPE_UHID_OUTPUT: + dos.writeShort(msg.getId()); + byte[] data = msg.getData(); + dos.writeShort(data.length); + dos.write(data); + break; + default: + throw new ControlProtocolException("Unknown event type: " + type); + } + dos.flush(); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/KeyComposition.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/KeyComposition.java new file mode 100644 index 0000000..5b988f5 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/KeyComposition.java @@ -0,0 +1,174 @@ +package com.genymobile.scrcpy.control; + +import java.util.HashMap; +import java.util.Map; + +/** + * Decompose accented characters. + *

+ * For example, {@link #decompose(char) decompose('é')} returns {@code "\u0301e"}. + *

+ * This is useful for injecting key events to generate the expected character ({@link android.view.KeyCharacterMap#getEvents(char[])} + * KeyCharacterMap.getEvents()} returns {@code null} with input {@code "é"} but works with input {@code "\u0301e"}). + *

+ * See diacritical dead key characters. + */ +public final class KeyComposition { + + private static final String KEY_DEAD_GRAVE = "\u0300"; + private static final String KEY_DEAD_ACUTE = "\u0301"; + private static final String KEY_DEAD_CIRCUMFLEX = "\u0302"; + private static final String KEY_DEAD_TILDE = "\u0303"; + private static final String KEY_DEAD_UMLAUT = "\u0308"; + + private static final Map COMPOSITION_MAP = createDecompositionMap(); + + private KeyComposition() { + // not instantiable + } + + public static String decompose(char c) { + return COMPOSITION_MAP.get(c); + } + + private static String grave(char c) { + return KEY_DEAD_GRAVE + c; + } + + private static String acute(char c) { + return KEY_DEAD_ACUTE + c; + } + + private static String circumflex(char c) { + return KEY_DEAD_CIRCUMFLEX + c; + } + + private static String tilde(char c) { + return KEY_DEAD_TILDE + c; + } + + private static String umlaut(char c) { + return KEY_DEAD_UMLAUT + c; + } + + private static Map createDecompositionMap() { + Map map = new HashMap<>(); + map.put('À', grave('A')); + map.put('È', grave('E')); + map.put('Ì', grave('I')); + map.put('Ò', grave('O')); + map.put('Ù', grave('U')); + map.put('à', grave('a')); + map.put('è', grave('e')); + map.put('ì', grave('i')); + map.put('ò', grave('o')); + map.put('ù', grave('u')); + map.put('Ǹ', grave('N')); + map.put('ǹ', grave('n')); + map.put('Ẁ', grave('W')); + map.put('ẁ', grave('w')); + map.put('Ỳ', grave('Y')); + map.put('ỳ', grave('y')); + + map.put('Á', acute('A')); + map.put('É', acute('E')); + map.put('Í', acute('I')); + map.put('Ó', acute('O')); + map.put('Ú', acute('U')); + map.put('Ý', acute('Y')); + map.put('á', acute('a')); + map.put('é', acute('e')); + map.put('í', acute('i')); + map.put('ó', acute('o')); + map.put('ú', acute('u')); + map.put('ý', acute('y')); + map.put('Ć', acute('C')); + map.put('ć', acute('c')); + map.put('Ĺ', acute('L')); + map.put('ĺ', acute('l')); + map.put('Ń', acute('N')); + map.put('ń', acute('n')); + map.put('Ŕ', acute('R')); + map.put('ŕ', acute('r')); + map.put('Ś', acute('S')); + map.put('ś', acute('s')); + map.put('Ź', acute('Z')); + map.put('ź', acute('z')); + map.put('Ǵ', acute('G')); + map.put('ǵ', acute('g')); + map.put('Ḉ', acute('Ç')); + map.put('ḉ', acute('ç')); + map.put('Ḱ', acute('K')); + map.put('ḱ', acute('k')); + map.put('Ḿ', acute('M')); + map.put('ḿ', acute('m')); + map.put('Ṕ', acute('P')); + map.put('ṕ', acute('p')); + map.put('Ẃ', acute('W')); + map.put('ẃ', acute('w')); + + map.put('Â', circumflex('A')); + map.put('Ê', circumflex('E')); + map.put('Î', circumflex('I')); + map.put('Ô', circumflex('O')); + map.put('Û', circumflex('U')); + map.put('â', circumflex('a')); + map.put('ê', circumflex('e')); + map.put('î', circumflex('i')); + map.put('ô', circumflex('o')); + map.put('û', circumflex('u')); + map.put('Ĉ', circumflex('C')); + map.put('ĉ', circumflex('c')); + map.put('Ĝ', circumflex('G')); + map.put('ĝ', circumflex('g')); + map.put('Ĥ', circumflex('H')); + map.put('ĥ', circumflex('h')); + map.put('Ĵ', circumflex('J')); + map.put('ĵ', circumflex('j')); + map.put('Ŝ', circumflex('S')); + map.put('ŝ', circumflex('s')); + map.put('Ŵ', circumflex('W')); + map.put('ŵ', circumflex('w')); + map.put('Ŷ', circumflex('Y')); + map.put('ŷ', circumflex('y')); + map.put('Ẑ', circumflex('Z')); + map.put('ẑ', circumflex('z')); + + map.put('Ã', tilde('A')); + map.put('Ñ', tilde('N')); + map.put('Õ', tilde('O')); + map.put('ã', tilde('a')); + map.put('ñ', tilde('n')); + map.put('õ', tilde('o')); + map.put('Ĩ', tilde('I')); + map.put('ĩ', tilde('i')); + map.put('Ũ', tilde('U')); + map.put('ũ', tilde('u')); + map.put('Ẽ', tilde('E')); + map.put('ẽ', tilde('e')); + map.put('Ỹ', tilde('Y')); + map.put('ỹ', tilde('y')); + + map.put('Ä', umlaut('A')); + map.put('Ë', umlaut('E')); + map.put('Ï', umlaut('I')); + map.put('Ö', umlaut('O')); + map.put('Ü', umlaut('U')); + map.put('ä', umlaut('a')); + map.put('ë', umlaut('e')); + map.put('ï', umlaut('i')); + map.put('ö', umlaut('o')); + map.put('ü', umlaut('u')); + map.put('ÿ', umlaut('y')); + map.put('Ÿ', umlaut('Y')); + map.put('Ḧ', umlaut('H')); + map.put('ḧ', umlaut('h')); + map.put('Ẅ', umlaut('W')); + map.put('ẅ', umlaut('w')); + map.put('Ẍ', umlaut('X')); + map.put('ẍ', umlaut('x')); + map.put('ẗ', umlaut('t')); + + return map; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Pointer.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Pointer.java new file mode 100644 index 0000000..4624a15 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Pointer.java @@ -0,0 +1,57 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.model.Point; + +public class Pointer { + + /** + * Pointer id as received from the client. + */ + private final long id; + + /** + * Local pointer id, using the lowest possible values to fill the {@link android.view.MotionEvent.PointerProperties PointerProperties}. + */ + private final int localId; + + private Point point; + private float pressure; + private boolean up; + + public Pointer(long id, int localId) { + this.id = id; + this.localId = localId; + } + + public long getId() { + return id; + } + + public int getLocalId() { + return localId; + } + + public Point getPoint() { + return point; + } + + public void setPoint(Point point) { + this.point = point; + } + + public float getPressure() { + return pressure; + } + + public void setPressure(float pressure) { + this.pressure = pressure; + } + + public boolean isUp() { + return up; + } + + public void setUp(boolean up) { + this.up = up; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PointersState.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PointersState.java new file mode 100644 index 0000000..efe16f0 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PointersState.java @@ -0,0 +1,105 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.model.Point; + +import android.view.MotionEvent; + +import java.util.ArrayList; +import java.util.List; + +public class PointersState { + + public static final int MAX_POINTERS = 10; + + private final List pointers = new ArrayList<>(); + + private int indexOf(long id) { + for (int i = 0; i < pointers.size(); ++i) { + Pointer pointer = pointers.get(i); + if (pointer.getId() == id) { + return i; + } + } + return -1; + } + + private boolean isLocalIdAvailable(int localId) { + for (int i = 0; i < pointers.size(); ++i) { + Pointer pointer = pointers.get(i); + if (pointer.getLocalId() == localId) { + return false; + } + } + return true; + } + + private int nextUnusedLocalId() { + for (int localId = 0; localId < MAX_POINTERS; ++localId) { + if (isLocalIdAvailable(localId)) { + return localId; + } + } + return -1; + } + + public Pointer get(int index) { + return pointers.get(index); + } + + public int getPointerIndex(long id) { + int index = indexOf(id); + if (index != -1) { + // already exists, return it + return index; + } + if (pointers.size() >= MAX_POINTERS) { + // it's full + return -1; + } + // id 0 is reserved for mouse events + int localId = nextUnusedLocalId(); + if (localId == -1) { + throw new AssertionError("pointers.size() < maxFingers implies that a local id is available"); + } + Pointer pointer = new Pointer(id, localId); + pointers.add(pointer); + // return the index of the pointer + return pointers.size() - 1; + } + + /** + * Initialize the motion event parameters. + * + * @param props the pointer properties + * @param coords the pointer coordinates + * @return The number of items initialized (the number of pointers). + */ + public int update(MotionEvent.PointerProperties[] props, MotionEvent.PointerCoords[] coords) { + int count = pointers.size(); + for (int i = 0; i < count; ++i) { + Pointer pointer = pointers.get(i); + + // id 0 is reserved for mouse events + props[i].id = pointer.getLocalId(); + + Point point = pointer.getPoint(); + coords[i].x = point.getX(); + coords[i].y = point.getY(); + coords[i].pressure = pointer.getPressure(); + } + cleanUp(); + return count; + } + + /** + * Remove all pointers which are UP. + */ + private void cleanUp() { + for (int i = pointers.size() - 1; i >= 0; --i) { + Pointer pointer = pointers.get(i); + if (pointer.isUp()) { + pointers.remove(i); + } + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PositionMapper.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PositionMapper.java new file mode 100644 index 0000000..e8daed9 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PositionMapper.java @@ -0,0 +1,48 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.model.Point; +import com.genymobile.scrcpy.model.Position; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.AffineMatrix; + +public final class PositionMapper { + + private final Size videoSize; + private final AffineMatrix videoToDeviceMatrix; + + public PositionMapper(Size videoSize, AffineMatrix videoToDeviceMatrix) { + this.videoSize = videoSize; + this.videoToDeviceMatrix = videoToDeviceMatrix; + } + + public static PositionMapper create(Size videoSize, AffineMatrix filterTransform, Size targetSize) { + boolean convertToPixels = !videoSize.equals(targetSize) || filterTransform != null; + AffineMatrix transform = filterTransform; + if (convertToPixels) { + AffineMatrix inputTransform = AffineMatrix.ndcFromPixels(videoSize); + AffineMatrix outputTransform = AffineMatrix.ndcToPixels(targetSize); + transform = outputTransform.multiply(transform).multiply(inputTransform); + } + + return new PositionMapper(videoSize, transform); + } + + public Size getVideoSize() { + return videoSize; + } + + public Point map(Position position) { + Size clientVideoSize = position.getScreenSize(); + if (!videoSize.equals(clientVideoSize)) { + // The client sends a click relative to a video with wrong dimensions, + // the device may have been rotated since the event was generated, so ignore the event + return null; + } + + Point point = position.getPoint(); + if (videoToDeviceMatrix != null) { + point = videoToDeviceMatrix.apply(point); + } + return point; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/UhidManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/UhidManager.java new file mode 100644 index 0000000..20532c0 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/UhidManager.java @@ -0,0 +1,287 @@ +package com.genymobile.scrcpy.control; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.StringUtils; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.os.Build; +import android.os.HandlerThread; +import android.os.MessageQueue; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; +import android.util.ArrayMap; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +public final class UhidManager { + + // Linux: include/uapi/linux/uhid.h + private static final int UHID_OUTPUT = 6; + private static final int UHID_CREATE2 = 11; + private static final int UHID_INPUT2 = 12; + + // Linux: include/uapi/linux/input.h + private static final short BUS_VIRTUAL = 0x06; + + private static final int SIZE_OF_UHID_EVENT = 4380; // sizeof(struct uhid_event) + + // Must be unique across the system + private static final String INPUT_PORT = "scrcpy:" + Os.getpid(); + + private final String displayUniqueId; + + private final ArrayMap fds = new ArrayMap<>(); + private final ByteBuffer buffer = ByteBuffer.allocate(SIZE_OF_UHID_EVENT).order(ByteOrder.nativeOrder()); + + private final DeviceMessageSender sender; + private final MessageQueue queue; + + public UhidManager(DeviceMessageSender sender, String displayUniqueId) { + this.sender = sender; + this.displayUniqueId = displayUniqueId; + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + HandlerThread thread = new HandlerThread("UHidManager"); + thread.start(); + queue = thread.getLooper().getQueue(); + } else { + queue = null; + } + } + + public void open(int id, int vendorId, int productId, String name, byte[] reportDesc) throws IOException { + try { + FileDescriptor fd = Os.open("/dev/uhid", OsConstants.O_RDWR, 0); + try { + // First UHID device added + boolean firstDevice = fds.isEmpty(); + + FileDescriptor old = fds.put(id, fd); + if (old != null) { + Ln.w("Duplicate UHID id: " + id); + close(old); + } + + String phys = mustUseInputPort() ? INPUT_PORT : null; + byte[] req = buildUhidCreate2Req(vendorId, productId, name, reportDesc, phys); + Os.write(fd, req, 0, req.length); + + if (firstDevice) { + addUniqueIdAssociation(); + } + registerUhidListener(id, fd); + } catch (Exception e) { + close(fd); + throw e; + } + } catch (ErrnoException e) { + throw new IOException(e); + } + } + + private void registerUhidListener(int id, FileDescriptor fd) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + queue.addOnFileDescriptorEventListener(fd, MessageQueue.OnFileDescriptorEventListener.EVENT_INPUT, (fd2, events) -> { + try { + buffer.clear(); + int r = Os.read(fd2, buffer); + buffer.flip(); + if (r > 0) { + int type = buffer.getInt(); + if (type == UHID_OUTPUT) { + byte[] data = extractHidOutputData(buffer); + if (data != null) { + DeviceMessage msg = DeviceMessage.createUhidOutput(id, data); + sender.send(msg); + } + } + } + } catch (ErrnoException | InterruptedIOException e) { + Ln.e("Failed to read UHID output", e); + return 0; + } + return events; + }); + } + } + + private void unregisterUhidListener(FileDescriptor fd) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + queue.removeOnFileDescriptorEventListener(fd); + } + } + + private static byte[] extractHidOutputData(ByteBuffer buffer) { + /* + * #define UHID_DATA_MAX 4096 + * struct uhid_event { + * uint32_t type; + * union { + * // ... + * struct uhid_output_req { + * __u8 data[UHID_DATA_MAX]; + * __u16 size; + * __u8 rtype; + * }; + * }; + * } __attribute__((__packed__)); + */ + + if (buffer.remaining() < 4099) { + Ln.w("Incomplete HID output"); + return null; + } + int size = buffer.getShort(buffer.position() + 4096) & 0xFFFF; + if (size > 4096) { + Ln.w("Incorrect HID output size: " + size); + return null; + } + byte[] data = new byte[size]; + buffer.get(data); + return data; + } + + public void writeInput(int id, byte[] data) throws IOException { + FileDescriptor fd = fds.get(id); + if (fd == null) { + Ln.w("Unknown UHID id: " + id); + return; + } + + try { + byte[] req = buildUhidInput2Req(data); + Os.write(fd, req, 0, req.length); + } catch (ErrnoException e) { + throw new IOException(e); + } + } + + private static byte[] buildUhidCreate2Req(int vendorId, int productId, String name, byte[] reportDesc, String phys) { + /* + * struct uhid_event { + * uint32_t type; + * union { + * // ... + * struct uhid_create2_req { + * uint8_t name[128]; + * uint8_t phys[64]; + * uint8_t uniq[64]; + * uint16_t rd_size; + * uint16_t bus; + * uint32_t vendor; + * uint32_t product; + * uint32_t version; + * uint32_t country; + * uint8_t rd_data[HID_MAX_DESCRIPTOR_SIZE]; + * }; + * }; + * } __attribute__((__packed__)); + */ + + ByteBuffer buf = ByteBuffer.allocate(280 + reportDesc.length).order(ByteOrder.nativeOrder()); + buf.putInt(UHID_CREATE2); + + String actualName = name.isEmpty() ? "scrcpy" : name; + byte[] nameBytes = actualName.getBytes(StandardCharsets.UTF_8); + int nameLen = StringUtils.getUtf8TruncationIndex(nameBytes, 127); + assert nameLen <= 127; + buf.put(nameBytes, 0, nameLen); + + if (phys != null) { + buf.position(4 + 128); + byte[] physBytes = phys.getBytes(StandardCharsets.US_ASCII); + assert physBytes.length <= 63; + buf.put(physBytes); + } + + buf.position(4 + 256); + buf.putShort((short) reportDesc.length); + buf.putShort(BUS_VIRTUAL); + buf.putInt(vendorId); + buf.putInt(productId); + buf.putInt(0); // version + buf.putInt(0); // country; + buf.put(reportDesc); + return buf.array(); + } + + private static byte[] buildUhidInput2Req(byte[] data) { + /* + * struct uhid_event { + * uint32_t type; + * union { + * // ... + * struct uhid_input2_req { + * uint16_t size; + * uint8_t data[UHID_DATA_MAX]; + * }; + * }; + * } __attribute__((__packed__)); + */ + + ByteBuffer buf = ByteBuffer.allocate(6 + data.length).order(ByteOrder.nativeOrder()); + buf.putInt(UHID_INPUT2); + buf.putShort((short) data.length); + buf.put(data); + return buf.array(); + } + + public void close(int id) { + // Linux: Documentation/hid/uhid.rst + // If you close() the fd, the device is automatically unregistered and destroyed internally. + FileDescriptor fd = fds.remove(id); + if (fd != null) { + unregisterUhidListener(fd); + close(fd); + + if (fds.isEmpty()) { + // Last UHID device removed + removeUniqueIdAssociation(); + } + } else { + Ln.w("Closing unknown UHID device: " + id); + } + } + + public void closeAll() { + if (fds.isEmpty()) { + return; + } + + for (FileDescriptor fd : fds.values()) { + close(fd); + } + + removeUniqueIdAssociation(); + } + + private static void close(FileDescriptor fd) { + try { + Os.close(fd); + } catch (ErrnoException e) { + Ln.e("Failed to close uhid: " + e.getMessage()); + } + } + + private boolean mustUseInputPort() { + return Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15 && displayUniqueId != null; + } + + private void addUniqueIdAssociation() { + if (mustUseInputPort()) { + ServiceManager.getInputManager().addUniqueIdAssociationByPort(INPUT_PORT, displayUniqueId); + } + } + + private void removeUniqueIdAssociation() { + if (mustUseInputPort()) { + ServiceManager.getInputManager().removeUniqueIdAssociationByPort(INPUT_PORT); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/DesktopConnection.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/DesktopConnection.java new file mode 100644 index 0000000..db75aec --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/DesktopConnection.java @@ -0,0 +1,178 @@ +package com.genymobile.scrcpy.device; + +import com.genymobile.scrcpy.control.ControlChannel; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.StringUtils; + +import android.net.LocalServerSocket; +import android.net.LocalSocket; +import android.net.LocalSocketAddress; + +import java.io.Closeable; +import java.io.FileDescriptor; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public final class DesktopConnection implements Closeable { + + private static final int DEVICE_NAME_FIELD_LENGTH = 64; + + private static final String SOCKET_NAME_PREFIX = "scrcpy"; + + private final LocalSocket videoSocket; + private final FileDescriptor videoFd; + + private final LocalSocket audioSocket; + private final FileDescriptor audioFd; + + private final LocalSocket controlSocket; + private final ControlChannel controlChannel; + + private DesktopConnection(LocalSocket videoSocket, LocalSocket audioSocket, LocalSocket controlSocket) throws IOException { + this.videoSocket = videoSocket; + this.audioSocket = audioSocket; + this.controlSocket = controlSocket; + + videoFd = videoSocket != null ? videoSocket.getFileDescriptor() : null; + audioFd = audioSocket != null ? audioSocket.getFileDescriptor() : null; + controlChannel = controlSocket != null ? new ControlChannel(controlSocket) : null; + } + + private static LocalSocket connect(String abstractName) throws IOException { + LocalSocket localSocket = new LocalSocket(); + localSocket.connect(new LocalSocketAddress(abstractName)); + return localSocket; + } + + private static String getSocketName(int scid) { + if (scid == -1) { + // If no SCID is set, use "scrcpy" to simplify using scrcpy-server alone + return SOCKET_NAME_PREFIX; + } + + return SOCKET_NAME_PREFIX + String.format("_%08x", scid); + } + + public static DesktopConnection open(int scid, boolean tunnelForward, boolean video, boolean audio, boolean control, boolean sendDummyByte) + throws IOException { + String socketName = getSocketName(scid); + + LocalSocket videoSocket = null; + LocalSocket audioSocket = null; + LocalSocket controlSocket = null; + try { + if (tunnelForward) { + try (LocalServerSocket localServerSocket = new LocalServerSocket(socketName)) { + if (video) { + videoSocket = localServerSocket.accept(); + if (sendDummyByte) { + // send one byte so the client may read() to detect a connection error + videoSocket.getOutputStream().write(0); + sendDummyByte = false; + } + } + if (audio) { + audioSocket = localServerSocket.accept(); + if (sendDummyByte) { + // send one byte so the client may read() to detect a connection error + audioSocket.getOutputStream().write(0); + sendDummyByte = false; + } + } + if (control) { + controlSocket = localServerSocket.accept(); + if (sendDummyByte) { + // send one byte so the client may read() to detect a connection error + controlSocket.getOutputStream().write(0); + sendDummyByte = false; + } + } + } + } else { + if (video) { + videoSocket = connect(socketName); + } + if (audio) { + audioSocket = connect(socketName); + } + if (control) { + controlSocket = connect(socketName); + } + } + } catch (IOException | RuntimeException e) { + if (videoSocket != null) { + videoSocket.close(); + } + if (audioSocket != null) { + audioSocket.close(); + } + if (controlSocket != null) { + controlSocket.close(); + } + throw e; + } + + return new DesktopConnection(videoSocket, audioSocket, controlSocket); + } + + private LocalSocket getFirstSocket() { + if (videoSocket != null) { + return videoSocket; + } + if (audioSocket != null) { + return audioSocket; + } + return controlSocket; + } + + public void shutdown() throws IOException { + if (videoSocket != null) { + videoSocket.shutdownInput(); + videoSocket.shutdownOutput(); + } + if (audioSocket != null) { + audioSocket.shutdownInput(); + audioSocket.shutdownOutput(); + } + if (controlSocket != null) { + controlSocket.shutdownInput(); + controlSocket.shutdownOutput(); + } + } + + public void close() throws IOException { + if (videoSocket != null) { + videoSocket.close(); + } + if (audioSocket != null) { + audioSocket.close(); + } + if (controlSocket != null) { + controlSocket.close(); + } + } + + public void sendDeviceMeta(String deviceName) throws IOException { + byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH]; + + byte[] deviceNameBytes = deviceName.getBytes(StandardCharsets.UTF_8); + int len = StringUtils.getUtf8TruncationIndex(deviceNameBytes, DEVICE_NAME_FIELD_LENGTH - 1); + System.arraycopy(deviceNameBytes, 0, buffer, 0, len); + // byte[] are always 0-initialized in java, no need to set '\0' explicitly + + FileDescriptor fd = getFirstSocket().getFileDescriptor(); + IO.writeFully(fd, buffer, 0, buffer.length); + } + + public FileDescriptor getVideoFd() { + return videoFd; + } + + public FileDescriptor getAudioFd() { + return audioFd; + } + + public ControlChannel getControlChannel() { + return controlChannel; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Device.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Device.java new file mode 100644 index 0000000..615675a --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Device.java @@ -0,0 +1,326 @@ +package com.genymobile.scrcpy.device; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.display.DisplayInfo; +import com.genymobile.scrcpy.model.DeviceApp; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.wrappers.ActivityManager; +import com.genymobile.scrcpy.wrappers.ClipboardManager; +import com.genymobile.scrcpy.wrappers.DisplayControl; +import com.genymobile.scrcpy.wrappers.InputManager; +import com.genymobile.scrcpy.wrappers.ServiceManager; +import com.genymobile.scrcpy.wrappers.SurfaceControl; +import com.genymobile.scrcpy.wrappers.WindowManager; + +import android.annotation.SuppressLint; +import android.app.ActivityOptions; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.os.SystemClock; +import android.view.InputDevice; +import android.view.InputEvent; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class Device { + + public static final int DISPLAY_ID_NONE = -1; + + public static final int POWER_MODE_OFF = SurfaceControl.POWER_MODE_OFF; + public static final int POWER_MODE_NORMAL = SurfaceControl.POWER_MODE_NORMAL; + + public static final int INJECT_MODE_ASYNC = InputManager.INJECT_INPUT_EVENT_MODE_ASYNC; + public static final int INJECT_MODE_WAIT_FOR_RESULT = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT; + public static final int INJECT_MODE_WAIT_FOR_FINISH = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH; + + // The new display power method introduced in Android 15 does not work as expected: + // + private static final boolean USE_ANDROID_15_DISPLAY_POWER = false; + + private Device() { + // not instantiable + } + + public static String getDeviceName() { + return Build.MODEL; + } + + public static boolean supportsInputEvents(int displayId) { + // main display or any display on Android >= 10 + return displayId == 0 || Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10; + } + + public static boolean injectEvent(InputEvent inputEvent, int displayId, int injectMode) { + if (!supportsInputEvents(displayId)) { + throw new AssertionError("Could not inject input event if !supportsInputEvents()"); + } + + if (displayId != 0 && !InputManager.setDisplayId(inputEvent, displayId)) { + return false; + } + + return ServiceManager.getInputManager().injectInputEvent(inputEvent, injectMode); + } + + public static boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int displayId, int injectMode) { + long now = SystemClock.uptimeMillis(); + KeyEvent event = new KeyEvent(now, now, action, keyCode, repeat, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, + InputDevice.SOURCE_KEYBOARD); + return injectEvent(event, displayId, injectMode); + } + + public static boolean pressReleaseKeycode(int keyCode, int displayId, int injectMode) { + return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0, displayId, injectMode) + && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0, displayId, injectMode); + } + + public static boolean isScreenOn(int displayId) { + assert displayId != DISPLAY_ID_NONE; + return ServiceManager.getPowerManager().isScreenOn(displayId); + } + + public static void keepActive(int displayId) { + assert displayId != DISPLAY_ID_NONE; + ServiceManager.getPowerManager().userActivity(displayId); + } + + public static void expandNotificationPanel() { + ServiceManager.getStatusBarManager().expandNotificationsPanel(); + } + + public static void expandSettingsPanel() { + ServiceManager.getStatusBarManager().expandSettingsPanel(); + } + + public static void collapsePanels() { + ServiceManager.getStatusBarManager().collapsePanels(); + } + + public static String getClipboardText() { + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardManager == null) { + return null; + } + CharSequence s = clipboardManager.getText(); + if (s == null) { + return null; + } + return s.toString(); + } + + public static boolean setClipboardText(String text) { + ClipboardManager clipboardManager = ServiceManager.getClipboardManager(); + if (clipboardManager == null) { + return false; + } + + String currentClipboard = getClipboardText(); + if (currentClipboard != null && currentClipboard.equals(text)) { + // The clipboard already contains the requested text. + // Since pasting text from the computer involves setting the device clipboard, it could be set twice on a copy-paste. This would cause + // the clipboard listeners to be notified twice, and that would flood the Android keyboard clipboard history. To workaround this + // problem, do not explicitly set the clipboard text if it already contains the expected content. + return false; + } + + return clipboardManager.setText(text); + } + + public static boolean setDisplayPower(int displayId, boolean on) { + assert displayId != Device.DISPLAY_ID_NONE; + + if (USE_ANDROID_15_DISPLAY_POWER && Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15) { + return ServiceManager.getDisplayManager().requestDisplayPower(displayId, on); + } + + boolean applyToMultiPhysicalDisplays = Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10; + + if (applyToMultiPhysicalDisplays + && Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14 + && Build.BRAND.equalsIgnoreCase("honor") + && SurfaceControl.hasGetBuildInDisplayMethod()) { + // Workaround for Honor devices with Android 14: + // - + // - + applyToMultiPhysicalDisplays = false; + } + + int mode = on ? POWER_MODE_NORMAL : POWER_MODE_OFF; + if (applyToMultiPhysicalDisplays) { + // On Android 14, these internal methods have been moved to DisplayControl + boolean useDisplayControl = + Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14 && !SurfaceControl.hasGetPhysicalDisplayIdsMethod(); + + // Change the power mode for all physical displays + long[] physicalDisplayIds = useDisplayControl ? DisplayControl.getPhysicalDisplayIds() : SurfaceControl.getPhysicalDisplayIds(); + if (physicalDisplayIds == null) { + Ln.e("Could not get physical display ids"); + return false; + } + + boolean allOk = true; + for (long physicalDisplayId : physicalDisplayIds) { + IBinder binder = useDisplayControl ? DisplayControl.getPhysicalDisplayToken( + physicalDisplayId) : SurfaceControl.getPhysicalDisplayToken(physicalDisplayId); + allOk &= SurfaceControl.setDisplayPowerMode(binder, mode); + } + return allOk; + } + + // Older Android versions, only 1 display + IBinder d = SurfaceControl.getBuiltInDisplay(); + if (d == null) { + Ln.e("Could not get built-in display"); + return false; + } + return SurfaceControl.setDisplayPowerMode(d, mode); + } + + public static boolean powerOffScreen(int displayId) { + assert displayId != DISPLAY_ID_NONE; + + if (!isScreenOn(displayId)) { + return true; + } + return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC); + } + + /** + * Disable auto-rotation (if enabled), set the screen rotation and re-enable auto-rotation (if it was enabled). + */ + public static void rotateDevice(int displayId) { + assert displayId != DISPLAY_ID_NONE; + + WindowManager wm = ServiceManager.getWindowManager(); + + boolean accelerometerRotation = !wm.isRotationFrozen(displayId); + + int currentRotation = getCurrentRotation(displayId); + int newRotation = (currentRotation & 1) ^ 1; // 0->1, 1->0, 2->1, 3->0 + String newRotationString = newRotation == 0 ? "portrait" : "landscape"; + + Ln.i("Device rotation requested: " + newRotationString); + wm.freezeRotation(displayId, newRotation); + + // restore auto-rotate if necessary + if (accelerometerRotation) { + wm.thawRotation(displayId); + } + } + + private static int getCurrentRotation(int displayId) { + assert displayId != DISPLAY_ID_NONE; + + if (displayId == 0) { + return ServiceManager.getWindowManager().getRotation(); + } + + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId); + return displayInfo.getRotation(); + } + + public static List listApps() { + List apps = new ArrayList<>(); + PackageManager pm = FakeContext.get().getPackageManager(); + for (ApplicationInfo appInfo : getLaunchableApps(pm)) { + apps.add(toApp(pm, appInfo)); + } + + return apps; + } + + @SuppressLint("QueryPermissionsNeeded") + private static List getLaunchableApps(PackageManager pm) { + List result = new ArrayList<>(); + for (ApplicationInfo appInfo : pm.getInstalledApplications(PackageManager.GET_META_DATA)) { + if (appInfo.enabled && getLaunchIntent(pm, appInfo.packageName) != null) { + result.add(appInfo); + } + } + + return result; + } + + public static Intent getLaunchIntent(PackageManager pm, String packageName) { + Intent launchIntent = pm.getLaunchIntentForPackage(packageName); + if (launchIntent != null) { + return launchIntent; + } + + return pm.getLeanbackLaunchIntentForPackage(packageName); + } + + private static DeviceApp toApp(PackageManager pm, ApplicationInfo appInfo) { + String name = pm.getApplicationLabel(appInfo).toString(); + boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; + return new DeviceApp(appInfo.packageName, name, system); + } + + @SuppressLint("QueryPermissionsNeeded") + public static DeviceApp findByPackageName(String packageName) { + PackageManager pm = FakeContext.get().getPackageManager(); + // No need to filter by "launchable" apps, an error will be reported on start if the app is not launchable + for (ApplicationInfo appInfo : pm.getInstalledApplications(PackageManager.GET_META_DATA)) { + if (packageName.equals(appInfo.packageName)) { + return toApp(pm, appInfo); + } + } + + return null; + } + + @SuppressLint("QueryPermissionsNeeded") + public static List findByName(String searchName) { + List result = new ArrayList<>(); + searchName = searchName.toLowerCase(Locale.getDefault()); + + PackageManager pm = FakeContext.get().getPackageManager(); + for (ApplicationInfo appInfo : getLaunchableApps(pm)) { + String name = pm.getApplicationLabel(appInfo).toString(); + if (name.toLowerCase(Locale.getDefault()).startsWith(searchName)) { + boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0; + result.add(new DeviceApp(appInfo.packageName, name, system)); + } + } + + return result; + } + + public static void startApp(String packageName, int displayId, boolean forceStop) { + PackageManager pm = FakeContext.get().getPackageManager(); + + Intent launchIntent = getLaunchIntent(pm, packageName); + if (launchIntent == null) { + Ln.w("Cannot create launch intent for app " + packageName); + return; + } + + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + Bundle options = null; + if (Build.VERSION.SDK_INT >= AndroidVersions.API_26_ANDROID_8_0) { + ActivityOptions launchOptions = ActivityOptions.makeBasic(); + launchOptions.setLaunchDisplayId(displayId); + options = launchOptions.toBundle(); + } + + ActivityManager am = ServiceManager.getActivityManager(); + if (forceStop) { + am.forceStopPackage(packageName); + } + am.startActivity(launchIntent, options); + } + + public static void sendBroadcast(Intent intent) { + ServiceManager.getActivityManager().sendBroadcast(intent); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Streamer.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Streamer.java new file mode 100644 index 0000000..e793f67 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Streamer.java @@ -0,0 +1,205 @@ +package com.genymobile.scrcpy.device; + +import com.genymobile.scrcpy.audio.AudioCodec; +import com.genymobile.scrcpy.model.Codec; +import com.genymobile.scrcpy.util.IO; + +import android.media.MediaCodec; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; + +public final class Streamer { + + private static final long PACKET_FLAG_SESSION = 1L << 63; + private static final long PACKET_FLAG_CONFIG = 1L << 62; + private static final long PACKET_FLAG_KEY_FRAME = 1L << 61; + + private final FileDescriptor fd; + private final Codec codec; + private final boolean sendStreamMeta; + private final boolean sendFrameMeta; + + private final ByteBuffer headerBuffer = ByteBuffer.allocate(12); + + public Streamer(FileDescriptor fd, Codec codec, boolean sendCodecMeta, boolean sendFrameMeta) { + this.fd = fd; + this.codec = codec; + this.sendStreamMeta = sendCodecMeta; + this.sendFrameMeta = sendFrameMeta; + } + + public Codec getCodec() { + return codec; + } + + public void writeAudioHeader() throws IOException { + if (sendStreamMeta) { + ByteBuffer buffer = ByteBuffer.allocate(4); + buffer.putInt(codec.getId()); + buffer.flip(); + IO.writeFully(fd, buffer); + } + } + + public void writeVideoHeader() throws IOException { + if (sendStreamMeta) { + ByteBuffer buffer = ByteBuffer.allocate(4); + buffer.putInt(codec.getId()); + buffer.flip(); + IO.writeFully(fd, buffer); + } + } + + public void writeDisableStream(boolean error) throws IOException { + // Writing a specific code as codec-id means that the device disables the stream + // code 0: it explicitly disables the stream (because it could not capture audio), scrcpy should continue mirroring video only + // code 1: a configuration error occurred, scrcpy must be stopped + byte[] code = new byte[4]; + if (error) { + code[3] = 1; + } + IO.writeFully(fd, code, 0, code.length); + } + + public void writePacket(ByteBuffer buffer, long pts, boolean config, boolean keyFrame) throws IOException { + if (config) { + if (codec == AudioCodec.OPUS) { + fixOpusConfigPacket(buffer); + } else if (codec == AudioCodec.FLAC) { + fixFlacConfigPacket(buffer); + } + } + + if (sendFrameMeta) { + writeFrameMeta(fd, buffer.remaining(), pts, config, keyFrame); + } + + IO.writeFully(fd, buffer); + } + + public void writePacket(ByteBuffer codecBuffer, MediaCodec.BufferInfo bufferInfo) throws IOException { + long pts = bufferInfo.presentationTimeUs; + boolean config = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0; + boolean keyFrame = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0; + writePacket(codecBuffer, pts, config, keyFrame); + } + + public void writeSessionMeta(int width, int height, boolean isClientResize) throws IOException { + if (sendStreamMeta) { + headerBuffer.clear(); + + int flags = (int) (PACKET_FLAG_SESSION >> 32); // set the first bit to 1 + if (isClientResize) { + flags |= 1; + } + headerBuffer.putInt(flags); + headerBuffer.putInt(width); + headerBuffer.putInt(height); + headerBuffer.flip(); + IO.writeFully(fd, headerBuffer); + } + } + + private void writeFrameMeta(FileDescriptor fd, int packetSize, long pts, boolean config, boolean keyFrame) throws IOException { + headerBuffer.clear(); + + long ptsAndFlags; + if (config) { + ptsAndFlags = PACKET_FLAG_CONFIG; // non-media data packet + } else { + ptsAndFlags = pts; + if (keyFrame) { + ptsAndFlags |= PACKET_FLAG_KEY_FRAME; + } + } + + headerBuffer.putLong(ptsAndFlags); + headerBuffer.putInt(packetSize); + headerBuffer.flip(); + IO.writeFully(fd, headerBuffer); + } + + private static void fixOpusConfigPacket(ByteBuffer buffer) throws IOException { + // Here is an example of the config packet received for an OPUS stream: + // + // 00000000 41 4f 50 55 53 48 44 52 13 00 00 00 00 00 00 00 |AOPUSHDR........| + // -------------- BELOW IS THE PART WE MUST PUT AS EXTRADATA ------------------- + // 00000010 4f 70 75 73 48 65 61 64 01 01 38 01 80 bb 00 00 |OpusHead..8.....| + // 00000020 00 00 00 |... | + // ------------------------------------------------------------------------------ + // 00000020 41 4f 50 55 53 44 4c 59 08 00 00 00 00 | AOPUSDLY.....| + // 00000030 00 00 00 a0 2e 63 00 00 00 00 00 41 4f 50 55 53 |.....c.....AOPUS| + // 00000040 50 52 4c 08 00 00 00 00 00 00 00 00 b4 c4 04 00 |PRL.............| + // 00000050 00 00 00 |...| + // + // Each "section" is prefixed by a 64-bit ID and a 64-bit length. + // + // + + if (buffer.remaining() < 16) { + throw new IOException("Not enough data in OPUS config packet"); + } + + final byte[] opusHeaderId = {'A', 'O', 'P', 'U', 'S', 'H', 'D', 'R'}; + byte[] idBuffer = new byte[8]; + buffer.get(idBuffer); + if (!Arrays.equals(idBuffer, opusHeaderId)) { + throw new IOException("OPUS header not found"); + } + + // The size is in native byte-order + long sizeLong = buffer.getLong(); + if (sizeLong < 0 || sizeLong >= 0x7FFFFFFF) { + throw new IOException("Invalid block size in OPUS header: " + sizeLong); + } + + int size = (int) sizeLong; + if (buffer.remaining() < size) { + throw new IOException("Not enough data in OPUS header (invalid size: " + size + ")"); + } + + // Set the buffer to point to the OPUS header slice + buffer.limit(buffer.position() + size); + } + + private static void fixFlacConfigPacket(ByteBuffer buffer) throws IOException { + // 00000000 66 4c 61 43 00 00 00 22 |fLaC..." | + // -------------- BELOW IS THE PART WE MUST PUT AS EXTRADATA ------------------- + // 00000000 10 00 10 00 00 00 00 00 | ........| + // 00000010 00 00 0b b8 02 f0 00 00 00 00 00 00 00 00 00 00 |................| + // 00000020 00 00 00 00 00 00 00 00 00 00 |.......... | + // ------------------------------------------------------------------------------ + // 00000020 84 00 00 28 20 00 | ...( .| + // 00000030 00 00 72 65 66 65 72 65 6e 63 65 20 6c 69 62 46 |..reference libF| + // 00000040 4c 41 43 20 31 2e 33 2e 32 20 32 30 32 32 31 30 |LAC 1.3.2 202210| + // 00000050 32 32 00 00 00 00 |22....| + // + // + + if (buffer.remaining() < 8) { + throw new IOException("Not enough data in FLAC config packet"); + } + + final byte[] flacHeaderId = {'f', 'L', 'a', 'C'}; + byte[] idBuffer = new byte[4]; + buffer.get(idBuffer); + if (!Arrays.equals(idBuffer, flacHeaderId)) { + throw new IOException("FLAC header not found"); + } + + // The size is in big-endian + buffer.order(ByteOrder.BIG_ENDIAN); + + int size = buffer.getInt(); + if (buffer.remaining() < size) { + throw new IOException("Not enough data in FLAC header (invalid size: " + size + ")"); + } + + // Set the buffer to point to the FLAC header slice + buffer.limit(buffer.position() + size); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayInfo.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayInfo.java new file mode 100644 index 0000000..ea966a3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayInfo.java @@ -0,0 +1,53 @@ +package com.genymobile.scrcpy.display; + +import com.genymobile.scrcpy.model.Size; + +public final class DisplayInfo { + private final int displayId; + private final Size size; + private final int rotation; + private final int layerStack; + private final int flags; + private final int dpi; + private final String uniqueId; + + public static final int FLAG_SUPPORTS_PROTECTED_BUFFERS = 0x00000001; + + public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags, int dpi, String uniqueId) { + this.displayId = displayId; + this.size = size; + this.rotation = rotation; + this.layerStack = layerStack; + this.flags = flags; + this.dpi = dpi; + this.uniqueId = uniqueId; + } + + public int getDisplayId() { + return displayId; + } + + public Size getSize() { + return size; + } + + public int getRotation() { + return rotation; + } + + public int getLayerStack() { + return layerStack; + } + + public int getFlags() { + return flags; + } + + public int getDpi() { + return dpi; + } + + public String getUniqueId() { + return uniqueId; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayMonitor.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayMonitor.java new file mode 100644 index 0000000..f6d871c --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayMonitor.java @@ -0,0 +1,144 @@ +package com.genymobile.scrcpy.display; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.wrappers.DisplayManager; +import com.genymobile.scrcpy.wrappers.DisplayWindowListener; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.content.res.Configuration; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.IDisplayWindowListener; + +public class DisplayMonitor { + + public interface Listener { + void onDisplayPropertiesChanged(DisplayProperties props); + } + + // On Android 14, DisplayListener may be broken (it never sends events). This is fixed in recent Android 14 upgrades, but we can't really + // detect it directly, so register a DisplayWindowListener (introduced in Android 11) to listen to configuration changes instead. + // It has been broken again after an Android 15 upgrade: + // So use the default method only before Android 14. + private static final boolean USE_DEFAULT_METHOD = Build.VERSION.SDK_INT < AndroidVersions.API_34_ANDROID_14; + + private DisplayManager.DisplayListenerHandle displayListenerHandle; + private HandlerThread handlerThread; + + private IDisplayWindowListener displayWindowListener; + + private int displayId = Device.DISPLAY_ID_NONE; + + private DisplayProperties props; + + private Listener listener; + + public void start(int displayId, Listener listener) { + // Once started, the listener and the displayId must never change + assert listener != null; + this.listener = listener; + + assert this.displayId == Device.DISPLAY_ID_NONE; + this.displayId = displayId; + + if (USE_DEFAULT_METHOD) { + handlerThread = new HandlerThread("DisplayListener"); + handlerThread.start(); + Handler handler = new Handler(handlerThread.getLooper()); + displayListenerHandle = ServiceManager.getDisplayManager().registerDisplayListener(eventDisplayId -> { + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplayMonitor: onDisplayChanged(" + eventDisplayId + ")"); + } + + if (eventDisplayId == displayId) { + try { + checkDisplayPropertiesChanged(); + } catch (Throwable e) { + Ln.e("DisplayMonitor error", e); + throw e; + } + } + }, handler); + } else { + displayWindowListener = new DisplayWindowListener() { + @Override + public void onDisplayConfigurationChanged(int eventDisplayId, Configuration newConfig) { + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplayMonitor: onDisplayConfigurationChanged(" + eventDisplayId + ")"); + } + + if (eventDisplayId == displayId) { + try { + checkDisplayPropertiesChanged(); + } catch (Throwable e) { + Ln.e("DisplayMonitor error", e); + throw e; + } + } + } + }; + ServiceManager.getWindowManager().registerDisplayWindowListener(displayWindowListener); + } + } + + /** + * Stop and release the monitor. + *

+ * It must not be used anymore. + * It is ok to call this method even if {@link #start(int, Listener)} was not called. + */ + public void stopAndRelease() { + if (USE_DEFAULT_METHOD) { + // displayListenerHandle may be null if registration failed + if (displayListenerHandle != null) { + ServiceManager.getDisplayManager().unregisterDisplayListener(displayListenerHandle); + displayListenerHandle = null; + } + + if (handlerThread != null) { + handlerThread.quitSafely(); + } + } else if (displayWindowListener != null) { + ServiceManager.getWindowManager().unregisterDisplayWindowListener(displayWindowListener); + } + } + + private synchronized DisplayProperties getAndSetDisplayProperties(DisplayProperties props) { + DisplayProperties oldProps = this.props; + this.props = props; + return oldProps; + } + + public synchronized void setSessionDisplayProperties(DisplayProperties props) { + this.props = props; + } + + private void checkDisplayPropertiesChanged() { + DisplayInfo di = ServiceManager.getDisplayManager().getDisplayInfo(displayId); + if (di == null) { + Ln.w("DisplayInfo for " + displayId + " cannot be retrieved"); + // We can't compare with the current properties, so reset unconditionally + DisplayProperties oldProps = getAndSetDisplayProperties(null); // exchange with synchronization + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplayMonitor: " + oldProps + " -> (unknown)"); + } + listener.onDisplayPropertiesChanged(null); + } else { + DisplayProperties newProps = new DisplayProperties(di.getSize(), di.getRotation()); + + DisplayProperties oldProps = getAndSetDisplayProperties(newProps); // exchange with synchronization + if (!newProps.equals(oldProps)) { + // Reset only if the properties are different + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplayMonitor: " + oldProps + " -> " + newProps); + } + listener.onDisplayPropertiesChanged(newProps); + } else if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v("DisplayMonitor: " + newProps + " (unchanged)"); + } + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayProperties.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayProperties.java new file mode 100644 index 0000000..d365833 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayProperties.java @@ -0,0 +1,52 @@ +package com.genymobile.scrcpy.display; + +import com.genymobile.scrcpy.model.Size; + +import java.util.Objects; + +public final class DisplayProperties { + private Size size; + private int rotation; + + public DisplayProperties(Size size, int rotation) { + assert size != null; + assert rotation >= 0 && rotation < 4; + this.size = size; + this.rotation = rotation; + } + + public Size getSize() { + return size; + } + + public void setSize(Size size) { + this.size = size; + } + + public int getRotation() { + return rotation; + } + + public void setRotation(int rotation) { + this.rotation = rotation; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + DisplayProperties that = (DisplayProperties) o; + return rotation == that.rotation && Objects.equals(size, that.size); + } + + @Override + public int hashCode() { + return Objects.hash(size, rotation); + } + + @Override + public String toString() { + return size + " [rotation=" + rotation + "]"; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayPropertiesTracker.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayPropertiesTracker.java new file mode 100644 index 0000000..adc606a --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayPropertiesTracker.java @@ -0,0 +1,84 @@ +package com.genymobile.scrcpy.display; + +import android.os.SystemClock; + +import java.util.ArrayList; +import java.util.List; + +public class DisplayPropertiesTracker { + + private static final long PENDING_CACHE_DURATION = 3000; // ms + + private static class PendingChange { + private final DisplayProperties props; + private final long timestamp; + + PendingChange(DisplayProperties props, long timestamp) { + this.props = props; + this.timestamp = timestamp; + } + } + + private final List pending = new ArrayList<>(); + + public synchronized void pushClientRequest(DisplayProperties props) { + long now = SystemClock.uptimeMillis(); + pending.add(new PendingChange(props, now)); + } + + /** + * Function to be called when the display properties changed. + * + * @param props the new display properties + * @return {@code true} if this change is the result of a client request + */ + public synchronized boolean onChanged(DisplayProperties props) { + cleanExpired(); + int index = getMatchingPendingIndex(props); + if (index == -1) { + return false; + } + + pending.subList(0, index + 1).clear(); + return true; + } + + private int getMatchingPendingIndex(DisplayProperties props) { + for (int i = 0; i < pending.size(); ++i) { + if (pending.get(i).props.equals(props)) { + return i; + } + } + return -1; + } + + private int getFirstNonExpiredIndex() { + long now = SystemClock.uptimeMillis(); + for (int i = 0; i < pending.size(); ++i) { + if (pending.get(i).timestamp + PENDING_CACHE_DURATION >= now) { + return i; + } + } + return -1; + } + + private void cleanExpired() { + if (pending.isEmpty()) { + return; + } + + int firstNonExpiredIndex = getFirstNonExpiredIndex(); + if (firstNonExpiredIndex == 0) { + // All items are fresh + return; + } + + if (firstNonExpiredIndex == -1) { + // All items have expired + pending.clear(); + } else { + // Remove all the items up to the first non-expired index + pending.subList(0, firstNonExpiredIndex).clear(); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayResizeDebouncer.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayResizeDebouncer.java new file mode 100644 index 0000000..0b961c6 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayResizeDebouncer.java @@ -0,0 +1,85 @@ +package com.genymobile.scrcpy.display; + +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.Ln; + +import android.os.SystemClock; + +public final class DisplayResizeDebouncer { + + private static final long DEBOUNCE_DELAY_MS = 300; + + public interface Callback { + void trigger(Size size); + } + + private final Callback callback; + private Size request; + private long deadline; + + private Thread thread; + + public DisplayResizeDebouncer(Callback callback) { + this.callback = callback; + } + + public void start() { + assert thread == null; + thread = new Thread(this::debounce); + thread.setName("debouncer"); + thread.setDaemon(true); + thread.start(); + } + + public void stop() { + if (thread != null) { + thread.interrupt(); + thread = null; + } + } + + private void debounce() { + try { + while (true) { + Size newSize; + synchronized (this) { + while (true) { + long now = SystemClock.uptimeMillis(); + if (request != null && now >= deadline) { + break; + } + if (request == null) { + wait(); + } else { + assert now < deadline; + wait(deadline - now); + } + } + assert request != null : "An active deadline implies request != null"; + newSize = request; + request = null; + } + callback.trigger(newSize); + } + } catch (InterruptedException e) { + // ignore + } finally { + Ln.d("Debouncer thread stopped"); + } + } + + public synchronized void requestResize(Size size) { + assert size != null; + if (request == null) { + deadline = SystemClock.uptimeMillis() + DEBOUNCE_DELAY_MS; + } + request = size; + notify(); + } + + public synchronized void cancelResize() { + request = null; + deadline = 0; + notify(); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Codec.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Codec.java new file mode 100644 index 0000000..b111710 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Codec.java @@ -0,0 +1,24 @@ +package com.genymobile.scrcpy.model; + +import android.media.MediaCodec; + +public interface Codec { + + enum Type { + VIDEO, + AUDIO, + } + + Type getType(); + + int getId(); + + String getName(); + + String getMimeType(); + + static String getMimeType(MediaCodec codec) { + String[] types = codec.getCodecInfo().getSupportedTypes(); + return types.length > 0 ? types[0] : null; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/CodecOption.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/CodecOption.java new file mode 100644 index 0000000..8b356cd --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/CodecOption.java @@ -0,0 +1,112 @@ +package com.genymobile.scrcpy.model; + +import java.util.ArrayList; +import java.util.List; + +public class CodecOption { + private final String key; + private final Object value; + + public CodecOption(String key, Object value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public Object getValue() { + return value; + } + + public static List parse(String codecOptions) { + if (codecOptions.isEmpty()) { + return null; + } + + List result = new ArrayList<>(); + + boolean escape = false; + StringBuilder buf = new StringBuilder(); + + for (char c : codecOptions.toCharArray()) { + switch (c) { + case '\\': + if (escape) { + buf.append('\\'); + escape = false; + } else { + escape = true; + } + break; + case ',': + if (escape) { + buf.append(','); + escape = false; + } else { + // This comma is a separator between codec options + String codecOption = buf.toString(); + result.add(parseOption(codecOption)); + // Clear buf + buf.setLength(0); + } + break; + default: + buf.append(c); + break; + } + } + + if (buf.length() > 0) { + String codecOption = buf.toString(); + result.add(parseOption(codecOption)); + } + + return result; + } + + private static CodecOption parseOption(String option) { + int equalSignIndex = option.indexOf('='); + if (equalSignIndex == -1) { + throw new IllegalArgumentException("'=' expected"); + } + String keyAndType = option.substring(0, equalSignIndex); + if (keyAndType.length() == 0) { + throw new IllegalArgumentException("Key may not be null"); + } + + String key; + String type; + + int colonIndex = keyAndType.indexOf(':'); + if (colonIndex != -1) { + key = keyAndType.substring(0, colonIndex); + type = keyAndType.substring(colonIndex + 1); + } else { + key = keyAndType; + type = "int"; // assume int by default + } + + Object value; + String valueString = option.substring(equalSignIndex + 1); + switch (type) { + case "int": + value = Integer.parseInt(valueString); + break; + case "long": + value = Long.parseLong(valueString); + break; + case "float": + value = Float.parseFloat(valueString); + break; + case "string": + value = valueString; + break; + default: + throw new IllegalArgumentException("Invalid codec option type (int, long, float, str): " + type); + } + + return new CodecOption(key, value); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/ConfigurationException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/ConfigurationException.java new file mode 100644 index 0000000..0e5deaa --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/ConfigurationException.java @@ -0,0 +1,7 @@ +package com.genymobile.scrcpy.model; + +public class ConfigurationException extends Exception { + public ConfigurationException(String message) { + super(message); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/DeviceApp.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/DeviceApp.java new file mode 100644 index 0000000..60e7e87 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/DeviceApp.java @@ -0,0 +1,26 @@ +package com.genymobile.scrcpy.model; + +public final class DeviceApp { + + private final String packageName; + private final String name; + private final boolean system; + + public DeviceApp(String packageName, String name, boolean system) { + this.packageName = packageName; + this.name = name; + this.system = system; + } + + public String getPackageName() { + return packageName; + } + + public String getName() { + return name; + } + + public boolean isSystem() { + return system; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/NewDisplay.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/NewDisplay.java new file mode 100644 index 0000000..2544f07 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/NewDisplay.java @@ -0,0 +1,31 @@ +package com.genymobile.scrcpy.model; + +public final class NewDisplay { + private Size size; + private int dpi; + + public NewDisplay() { + // Auto size and dpi + } + + public NewDisplay(Size size, int dpi) { + this.size = size; + this.dpi = dpi; + } + + public Size getSize() { + return size; + } + + public int getDpi() { + return dpi; + } + + public boolean hasExplicitSize() { + return size != null; + } + + public boolean hasExplicitDpi() { + return dpi != 0; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Orientation.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Orientation.java new file mode 100644 index 0000000..8e189d7 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Orientation.java @@ -0,0 +1,54 @@ +package com.genymobile.scrcpy.model; + +public enum Orientation { + + // @formatter:off + Orient0("0"), + Orient90("90"), + Orient180("180"), + Orient270("270"), + Flip0("flip0"), + Flip90("flip90"), + Flip180("flip180"), + Flip270("flip270"); + + public enum Lock { + Unlocked, LockedInitial, LockedValue, + } + + private final String name; + + Orientation(String name) { + this.name = name; + } + + public static Orientation getByName(String name) { + for (Orientation orientation : values()) { + if (orientation.name.equals(name)) { + return orientation; + } + } + + throw new IllegalArgumentException("Unknown orientation: " + name); + } + + public static Orientation fromRotation(int ccwRotation) { + assert ccwRotation >= 0 && ccwRotation < 4; + // Display rotation is expressed counter-clockwise, orientation is expressed clockwise + int cwRotation = (4 - ccwRotation) % 4; + return values()[cwRotation]; + } + + public boolean isFlipped() { + return (ordinal() & 4) != 0; + } + + public int getRotation() { + return ordinal() & 3; + } + + public boolean isSwap() { + // width and height are swapped on 90-degree rotations + return (ordinal() & 1) != 0; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Point.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Point.java new file mode 100644 index 0000000..8a8458f --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Point.java @@ -0,0 +1,43 @@ +package com.genymobile.scrcpy.model; + +import java.util.Objects; + +public class Point { + private final int x; + private final int y; + + public Point(int x, int y) { + this.x = x; + this.y = y; + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Point point = (Point) o; + return x == point.x && y == point.y; + } + + @Override + public int hashCode() { + return Objects.hash(x, y); + } + + @Override + public String toString() { + return "Point{" + "x=" + x + ", y=" + y + '}'; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Position.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Position.java new file mode 100644 index 0000000..61fd124 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Position.java @@ -0,0 +1,61 @@ +package com.genymobile.scrcpy.model; + +import java.util.Objects; + +public class Position { + private final Point point; + private final Size screenSize; + + public Position(Point point, Size screenSize) { + this.point = point; + this.screenSize = screenSize; + } + + public Position(int x, int y, int screenWidth, int screenHeight) { + this(new Point(x, y), new Size(screenWidth, screenHeight)); + } + + public Point getPoint() { + return point; + } + + public Size getScreenSize() { + return screenSize; + } + + public Position rotate(int rotation) { + switch (rotation) { + case 1: + return new Position(new Point(screenSize.getHeight() - point.getY(), point.getX()), screenSize.rotate()); + case 2: + return new Position(new Point(screenSize.getWidth() - point.getX(), screenSize.getHeight() - point.getY()), screenSize); + case 3: + return new Position(new Point(point.getY(), screenSize.getWidth() - point.getX()), screenSize.rotate()); + default: + return this; + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Position position = (Position) o; + return Objects.equals(point, position.point) && Objects.equals(screenSize, position.screenSize); + } + + @Override + public int hashCode() { + return Objects.hash(point, screenSize); + } + + @Override + public String toString() { + return "Position{" + "point=" + point + ", screenSize=" + screenSize + '}'; + } + +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Size.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Size.java new file mode 100644 index 0000000..5121196 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Size.java @@ -0,0 +1,198 @@ +package com.genymobile.scrcpy.model; + +import com.genymobile.scrcpy.util.BinarySearch; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.video.VideoConstraints; + +import android.graphics.Rect; +import android.media.MediaCodecInfo; +import android.util.Range; + +import java.util.Objects; + +public final class Size { + private final int width; + private final int height; + + public Size(int width, int height) { + this.width = width; + this.height = height; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public int getMax() { + return Math.max(width, height); + } + + public Size rotate() { + return new Size(height, width); + } + + public Size constrain(VideoConstraints constraints) { + return constrain(constraints, true); + } + + public Size constrain(VideoConstraints constraints, boolean preserveAspectRatio) { + int maxSize = constraints.getMaxSize(); + int alignment = constraints.getAlignment(); + MediaCodecInfo.VideoCapabilities caps = constraints.getEncoderCapabilities(); + + assert maxSize >= 0 : "Max size may not be negative"; + assert alignment > 0 : "Alignment must be positive"; + assert (alignment & (alignment - 1)) == 0 : "Alignment must be a power-of-two"; + + if (caps == null) { + int w, h; + if (maxSize > 0 && (width > maxSize || height > maxSize)) { + if (preserveAspectRatio) { + if (width > height) { + w = maxSize; + h = height * maxSize / width; + } else { + w = width * maxSize / height; + h = maxSize; + } + } else { + w = Math.min(width, maxSize); + h = Math.min(height, maxSize); + } + } else { + // No constraints + w = width; + h = height; + } + + w = Math.max(align(w, alignment), alignment); + h = Math.max(align(h, alignment), alignment); + + return new Size(w, h); + } + + boolean landscape = width >= height; + int major = landscape ? width : height; + int minor = landscape ? height : width; + + Range majorRange = landscape ? caps.getSupportedWidths() : caps.getSupportedHeights(); + int minMajor = majorRange.getLower(); + int maxMajor = majorRange.getUpper(); + if (maxMajor > major) { + // Never increase the size + maxMajor = major; + } + if (maxSize > 0 && maxMajor > maxSize) { + maxMajor = maxSize; + } + + int minBlock = (minMajor + alignment - 1) / alignment; + int maxBlock = maxMajor / alignment; + + int bestBlock = BinarySearch.findHighestTrue( + minBlock, maxBlock, block -> { + int pixels = block * alignment; + int w = align(width * pixels / major, alignment); + int h = align(height * pixels / major, alignment); + return caps.isSizeSupported(w, h); + }); + + if (bestBlock < minBlock) { + Ln.d("No matching size found, ignore encoder size validation"); + bestBlock = maxBlock; + } + + int bestMajor = bestBlock * alignment; + int bestMinor; + + if (preserveAspectRatio) { + bestMinor = align(minor * bestMajor / major, alignment); + } else { + // The minor dimension can potentially be extended + int maxMinor = landscape ? caps.getSupportedHeightsFor(bestMajor).getUpper() : caps.getSupportedWidthsFor(bestMajor).getUpper(); + if (maxMinor > minor) { + maxMinor = minor; + } + if (maxSize > 0 && maxMinor > maxSize) { + maxMinor = maxSize; + } + bestMinor = align(maxMinor, alignment); + + // The major dimension can potentially be extended + maxMajor = landscape ? caps.getSupportedWidthsFor(bestMinor).getUpper() : caps.getSupportedHeightsFor(bestMinor).getUpper(); + if (maxMajor > major) { + maxMajor = major; + } + if (maxSize > 0 && maxMajor > maxSize) { + maxMajor = maxSize; + } + bestMajor = align(maxMajor, alignment); + } + + minMajor = alignUp(minMajor, alignment); + if (bestMajor < minMajor) { + bestMajor = minMajor; + } + + int minMinor = landscape ? caps.getSupportedHeights().getLower() : caps.getSupportedWidths().getLower(); + minMinor = alignUp(minMinor, alignment); + if (bestMinor < minMinor) { + bestMinor = minMinor; + } + + int w = landscape ? bestMajor : bestMinor; + int h = landscape ? bestMinor : bestMajor; + + assert w % alignment == 0 : "The width must be a multiple of alignment"; + assert h % alignment == 0 : "The height must be a multiple of alignment"; + + return new Size(w, h); + } + + public Size align(int alignment) { + int w = align(width, alignment); + int h = align(height, alignment); + if (w == width && h == height) { + return this; + } + return new Size(w, h); + } + + private static int align(int value, int alignment) { + return value / alignment * alignment; + } + + private static int alignUp(int value, int alignment) { + return (value + alignment - 1) / alignment * alignment; + } + + public Rect toRect() { + return new Rect(0, 0, width, height); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Size size = (Size) o; + return width == size.width && height == size.height; + } + + @Override + public int hashCode() { + return Objects.hash(width, height); + } + + @Override + public String toString() { + return width + "x" + height; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java new file mode 100644 index 0000000..7608a57 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java @@ -0,0 +1,135 @@ +package com.genymobile.scrcpy.opengl; + +import com.genymobile.scrcpy.util.AffineMatrix; + +import android.opengl.GLES11Ext; +import android.opengl.GLES20; + +import java.nio.FloatBuffer; + +public class AffineOpenGLFilter implements OpenGLFilter { + + private int program; + private FloatBuffer vertexBuffer; + private FloatBuffer texCoordsBuffer; + private final float[] userMatrix; + + private int vertexPosLoc; + private int texCoordsInLoc; + + private int texLoc; + private int texMatrixLoc; + private int userMatrixLoc; + + public AffineOpenGLFilter(AffineMatrix transform) { + userMatrix = transform.to4x4(); + } + + @Override + public void init() throws OpenGLException { + // @formatter:off + String vertexShaderCode = "#version 100\n" + + "attribute vec4 vertex_pos;\n" + + "attribute vec4 tex_coords_in;\n" + + "varying vec2 tex_coords;\n" + + "uniform mat4 tex_matrix;\n" + + "uniform mat4 user_matrix;\n" + + "void main() {\n" + + " gl_Position = vertex_pos;\n" + + " tex_coords = (tex_matrix * user_matrix * tex_coords_in).xy;\n" + + "}"; + + // @formatter:off + String fragmentShaderCode = "#version 100\n" + + "#extension GL_OES_EGL_image_external : require\n" + + "precision highp float;\n" + + "uniform samplerExternalOES tex;\n" + + "varying vec2 tex_coords;\n" + + "void main() {\n" + + " if (tex_coords.x >= 0.0 && tex_coords.x <= 1.0\n" + + " && tex_coords.y >= 0.0 && tex_coords.y <= 1.0) {\n" + + " gl_FragColor = texture2D(tex, tex_coords);\n" + + " } else {\n" + + " gl_FragColor = vec4(0.0);\n" + + " }\n" + + "}"; + + program = GLUtils.createProgram(vertexShaderCode, fragmentShaderCode); + if (program == 0) { + throw new OpenGLException("Cannot create OpenGL program"); + } + + float[] vertices = { + -1, -1, // Bottom-left + 1, -1, // Bottom-right + -1, 1, // Top-left + 1, 1, // Top-right + }; + + float[] texCoords = { + 0, 0, // Bottom-left + 1, 0, // Bottom-right + 0, 1, // Top-left + 1, 1, // Top-right + }; + + // OpenGL will fill the 3rd and 4th coordinates of the vec4 automatically with 0.0 and 1.0 respectively + vertexBuffer = GLUtils.createFloatBuffer(vertices); + texCoordsBuffer = GLUtils.createFloatBuffer(texCoords); + + vertexPosLoc = GLES20.glGetAttribLocation(program, "vertex_pos"); + assert vertexPosLoc != -1; + + texCoordsInLoc = GLES20.glGetAttribLocation(program, "tex_coords_in"); + assert texCoordsInLoc != -1; + + texLoc = GLES20.glGetUniformLocation(program, "tex"); + assert texLoc != -1; + + texMatrixLoc = GLES20.glGetUniformLocation(program, "tex_matrix"); + assert texMatrixLoc != -1; + + userMatrixLoc = GLES20.glGetUniformLocation(program, "user_matrix"); + assert userMatrixLoc != -1; + } + + @Override + public void draw(int textureId, float[] texMatrix) { + GLES20.glUseProgram(program); + GLUtils.checkGlError(); + + GLES20.glEnableVertexAttribArray(vertexPosLoc); + GLUtils.checkGlError(); + GLES20.glEnableVertexAttribArray(texCoordsInLoc); + GLUtils.checkGlError(); + + GLES20.glVertexAttribPointer(vertexPosLoc, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer); + GLUtils.checkGlError(); + GLES20.glVertexAttribPointer(texCoordsInLoc, 2, GLES20.GL_FLOAT, false, 0, texCoordsBuffer); + GLUtils.checkGlError(); + + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLUtils.checkGlError(); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId); + GLUtils.checkGlError(); + GLES20.glUniform1i(texLoc, 0); + GLUtils.checkGlError(); + + GLES20.glUniformMatrix4fv(texMatrixLoc, 1, false, texMatrix, 0); + GLUtils.checkGlError(); + + GLES20.glUniformMatrix4fv(userMatrixLoc, 1, false, userMatrix, 0); + GLUtils.checkGlError(); + + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + GLUtils.checkGlError(); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GLUtils.checkGlError(); + } + + @Override + public void release() { + GLES20.glDeleteProgram(program); + GLUtils.checkGlError(); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/GLUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/GLUtils.java new file mode 100644 index 0000000..72a3f40 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/GLUtils.java @@ -0,0 +1,124 @@ +package com.genymobile.scrcpy.opengl; + +import com.genymobile.scrcpy.BuildConfig; +import com.genymobile.scrcpy.util.Ln; + +import android.opengl.GLES20; +import android.opengl.GLU; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +public final class GLUtils { + + private static final boolean DEBUG = BuildConfig.DEBUG; + + private GLUtils() { + // not instantiable + } + + public static int createProgram(String vertexSource, String fragmentSource) { + int vertexShader = createShader(GLES20.GL_VERTEX_SHADER, vertexSource); + if (vertexShader == 0) { + return 0; + } + + int fragmentShader = createShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + if (fragmentShader == 0) { + GLES20.glDeleteShader(vertexShader); + return 0; + } + + int program = GLES20.glCreateProgram(); + if (program == 0) { + GLES20.glDeleteShader(fragmentShader); + GLES20.glDeleteShader(vertexShader); + return 0; + } + + GLES20.glAttachShader(program, vertexShader); + checkGlError(); + GLES20.glAttachShader(program, fragmentShader); + checkGlError(); + GLES20.glLinkProgram(program); + checkGlError(); + + int[] linkStatus = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0); + if (linkStatus[0] == 0) { + Ln.e("Could not link program: " + GLES20.glGetProgramInfoLog(program)); + GLES20.glDeleteProgram(program); + GLES20.glDeleteShader(fragmentShader); + GLES20.glDeleteShader(vertexShader); + return 0; + } + + return program; + } + + public static int createShader(int type, String source) { + int shader = GLES20.glCreateShader(type); + if (shader == 0) { + Ln.e(getGlErrorMessage("Could not create shader")); + return 0; + } + + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + + int[] compileStatus = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0); + if (compileStatus[0] == 0) { + Ln.e("Could not compile " + getShaderTypeString(type) + ": " + GLES20.glGetShaderInfoLog(shader)); + GLES20.glDeleteShader(shader); + return 0; + } + + return shader; + } + + private static String getShaderTypeString(int type) { + switch (type) { + case GLES20.GL_VERTEX_SHADER: + return "vertex shader"; + case GLES20.GL_FRAGMENT_SHADER: + return "fragment shader"; + default: + return "shader"; + } + } + + /** + * Throws a runtime exception if {@link GLES20#glGetError()} returns an error (useful for debugging). + */ + public static void checkGlError() { + if (DEBUG) { + int error = GLES20.glGetError(); + if (error != GLES20.GL_NO_ERROR) { + throw new RuntimeException(toErrorString(error)); + } + } + } + + public static String getGlErrorMessage(String userError) { + int glError = GLES20.glGetError(); + if (glError == GLES20.GL_NO_ERROR) { + return userError; + } + + return userError + " (" + toErrorString(glError) + ")"; + } + + private static String toErrorString(int glError) { + String errorString = GLU.gluErrorString(glError); + return "glError 0x" + Integer.toHexString(glError) + " " + errorString; + } + + public static FloatBuffer createFloatBuffer(float[] values) { + FloatBuffer fb = ByteBuffer.allocateDirect(values.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer(); + fb.put(values); + fb.position(0); + return fb; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLException.java new file mode 100644 index 0000000..cbc9539 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLException.java @@ -0,0 +1,13 @@ +package com.genymobile.scrcpy.opengl; + +import java.io.IOException; + +public class OpenGLException extends IOException { + public OpenGLException(String message) { + super(message); + } + + public OpenGLException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLFilter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLFilter.java new file mode 100644 index 0000000..6f27777 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLFilter.java @@ -0,0 +1,21 @@ +package com.genymobile.scrcpy.opengl; + +public interface OpenGLFilter { + + /** + * Initialize the OpenGL filter (typically compile the shaders and create the program). + * + * @throws OpenGLException if an initialization error occurs + */ + void init() throws OpenGLException; + + /** + * Render a frame (call for each frame). + */ + void draw(int textureId, float[] texMatrix); + + /** + * Release resources. + */ + void release(); +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLRunner.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLRunner.java new file mode 100644 index 0000000..232f68d --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLRunner.java @@ -0,0 +1,232 @@ +package com.genymobile.scrcpy.opengl; + +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.Threads; + +import android.graphics.SurfaceTexture; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLExt; +import android.opengl.EGLSurface; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.os.Handler; +import android.os.HandlerThread; +import android.view.Surface; + +import java.util.concurrent.Callable; +import java.util.concurrent.Semaphore; + +public final class OpenGLRunner { + + private static HandlerThread handlerThread; + private static Handler handler; + + private EGLDisplay eglDisplay; + private EGLContext eglContext; + private EGLSurface eglSurface; + + private final OpenGLFilter filter; + private final float[] overrideTransformMatrix; + + private SurfaceTexture surfaceTexture; + private Surface inputSurface; + private int textureId; + + private boolean stopped; + + public OpenGLRunner(OpenGLFilter filter, float[] overrideTransformMatrix) { + this.filter = filter; + this.overrideTransformMatrix = overrideTransformMatrix; + } + + public OpenGLRunner(OpenGLFilter filter) { + this(filter, null); + } + + public static synchronized void initOnce() { + if (handlerThread == null) { + handlerThread = new HandlerThread("OpenGLRunner"); + handlerThread.start(); + handler = new Handler(handlerThread.getLooper()); + } + } + + public static void shutdown() throws InterruptedException { + HandlerThread thread; + synchronized (OpenGLRunner.class) { + thread = handlerThread; + } + if (thread != null) { + thread.quitSafely(); + thread.join(); + } + } + + public Surface start(Size inputSize, Size outputSize, Surface outputSurface) throws OpenGLException { + initOnce(); + + // The whole OpenGL execution must be performed on a Handler, so that SurfaceTexture.setOnFrameAvailableListener() works correctly. + // See + try { + Threads.executeSynchronouslyOn(handler, new Callable() { + @Override + public Void call() throws Exception { + run(inputSize, outputSize, outputSurface); + return null; + } + }); + } catch (Throwable throwable) { + if (throwable instanceof OpenGLException) { + throw (OpenGLException) throwable; + } + throw new OpenGLException("Asynchronous OpenGL runner init failed", throwable); + } + + // Synchronization is ok: inputSurface is written before sem.release() and read after sem.acquire() + return inputSurface; + } + + private void run(Size inputSize, Size outputSize, Surface outputSurface) throws OpenGLException { + eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + if (eglDisplay == EGL14.EGL_NO_DISPLAY) { + throw new OpenGLException("Unable to get EGL14 display"); + } + + int[] version = new int[2]; + if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { + throw new OpenGLException("Unable to initialize EGL14"); + } + + // @formatter:off + int[] attribList = { + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_NONE + }; + + EGLConfig[] configs = new EGLConfig[1]; + int[] numConfigs = new int[1]; + EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, configs.length, numConfigs, 0); + if (numConfigs[0] <= 0) { + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Unable to find ES2 EGL config"); + } + EGLConfig eglConfig = configs[0]; + + // @formatter:off + int[] contextAttribList = { + EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, + EGL14.EGL_NONE + }; + eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribList, 0); + if (eglContext == null) { + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Failed to create EGL context"); + } + + int[] surfaceAttribList = { + EGL14.EGL_NONE + }; + eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, outputSurface, surfaceAttribList, 0); + if (eglSurface == null) { + EGL14.eglDestroyContext(eglDisplay, eglContext); + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Failed to create EGL window surface"); + } + + if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { + EGL14.eglDestroySurface(eglDisplay, eglSurface); + EGL14.eglDestroyContext(eglDisplay, eglContext); + EGL14.eglTerminate(eglDisplay); + throw new OpenGLException("Failed to make EGL context current"); + } + + int[] textures = new int[1]; + GLES20.glGenTextures(1, textures, 0); + GLUtils.checkGlError(); + textureId = textures[0]; + + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLUtils.checkGlError(); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLUtils.checkGlError(); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLUtils.checkGlError(); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + GLUtils.checkGlError(); + + surfaceTexture = new SurfaceTexture(textureId); + surfaceTexture.setDefaultBufferSize(inputSize.getWidth(), inputSize.getHeight()); + inputSurface = new Surface(surfaceTexture); + + filter.init(); + + surfaceTexture.setOnFrameAvailableListener(surfaceTexture -> { + if (stopped) { + // Make sure to never render after resources have been released + return; + } + + render(outputSize); + }, handler); + } + + private void render(Size outputSize) { + GLES20.glViewport(0, 0, outputSize.getWidth(), outputSize.getHeight()); + GLUtils.checkGlError(); + + surfaceTexture.updateTexImage(); + + float[] matrix; + if (overrideTransformMatrix != null) { + matrix = overrideTransformMatrix; + } else { + matrix = new float[16]; + surfaceTexture.getTransformMatrix(matrix); + } + + filter.draw(textureId, matrix); + + EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, surfaceTexture.getTimestamp()); + EGL14.eglSwapBuffers(eglDisplay, eglSurface); + } + + public void stopAndRelease() { + final Semaphore sem = new Semaphore(0); + + handler.post(() -> { + stopped = true; + surfaceTexture.setOnFrameAvailableListener(null, handler); + + filter.release(); + + int[] textures = {textureId}; + GLES20.glDeleteTextures(1, textures, 0); + GLUtils.checkGlError(); + + EGL14.eglDestroySurface(eglDisplay, eglSurface); + EGL14.eglDestroyContext(eglDisplay, eglContext); + EGL14.eglTerminate(eglDisplay); + eglDisplay = EGL14.EGL_NO_DISPLAY; + eglContext = EGL14.EGL_NO_CONTEXT; + eglSurface = EGL14.EGL_NO_SURFACE; + surfaceTexture.release(); + inputSurface.release(); + + sem.release(); + }); + + try { + sem.acquire(); + } catch (InterruptedException e) { + // Behave as if this method call was synchronous + Thread.currentThread().interrupt(); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/AffineMatrix.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/AffineMatrix.java new file mode 100644 index 0000000..fb507ba --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/AffineMatrix.java @@ -0,0 +1,368 @@ +package com.genymobile.scrcpy.util; + +import com.genymobile.scrcpy.model.Point; +import com.genymobile.scrcpy.model.Size; + +/** + * Represents a 2D affine transform (a 3x3 matrix): + * + *

+ *     / a c e \
+ *     | b d f |
+ *     \ 0 0 1 /
+ * 
+ *

+ * Or, a 4x4 matrix if we add a z axis: + * + *

+ *     / a c 0 e \
+ *     | b d 0 f |
+ *     | 0 0 1 0 |
+ *     \ 0 0 0 1 /
+ * 
+ */ +public class AffineMatrix { + + private final double a, b, c, d, e, f; + + /** + * The identity matrix. + */ + public static final AffineMatrix IDENTITY = new AffineMatrix(1, 0, 0, 1, 0, 0); + + /** + * Create a new matrix: + * + *
+     *     / a c e \
+     *     | b d f |
+     *     \ 0 0 1 /
+     * 
+ */ + public AffineMatrix(double a, double b, double c, double d, double e, double f) { + this.a = a; + this.b = b; + this.c = c; + this.d = d; + this.e = e; + this.f = f; + } + + @Override + public String toString() { + return "[" + a + ", " + c + ", " + e + "; " + b + ", " + d + ", " + f + "]"; + } + + /** + * Return a matrix which converts from Normalized Device Coordinates to pixels. + * + * @param size the target size + * @return the transform matrix + */ + public static AffineMatrix ndcFromPixels(Size size) { + double w = size.getWidth(); + double h = size.getHeight(); + return new AffineMatrix(1 / w, 0, 0, -1 / h, 0, 1); + } + + /** + * Return a matrix which converts from pixels to Normalized Device Coordinates. + * + * @param size the source size + * @return the transform matrix + */ + public static AffineMatrix ndcToPixels(Size size) { + double w = size.getWidth(); + double h = size.getHeight(); + return new AffineMatrix(w, 0, 0, -h, 0, h); + } + + /** + * Apply the transform to a point ({@code this} should be a matrix converted to pixels coordinates via {@link #ndcToPixels(Size)}). + * + * @param point the source point + * @return the converted point + */ + public Point apply(Point point) { + int x = point.getX(); + int y = point.getY(); + int xx = (int) (a * x + c * y + e); + int yy = (int) (b * x + d * y + f); + return new Point(xx, yy); + } + + /** + * Compute this * rhs. + * + * @param rhs the matrix to multiply + * @return the product + */ + public AffineMatrix multiply(AffineMatrix rhs) { + if (rhs == null) { + // For convenience + return this; + } + + double aa = this.a * rhs.a + this.c * rhs.b; + double bb = this.b * rhs.a + this.d * rhs.b; + double cc = this.a * rhs.c + this.c * rhs.d; + double dd = this.b * rhs.c + this.d * rhs.d; + double ee = this.a * rhs.e + this.c * rhs.f + this.e; + double ff = this.b * rhs.e + this.d * rhs.f + this.f; + return new AffineMatrix(aa, bb, cc, dd, ee, ff); + } + + /** + * Multiply all matrices from left to right, ignoring any {@code null} matrix (for convenience). + * + * @param matrices the matrices + * @return the product + */ + public static AffineMatrix multiplyAll(AffineMatrix... matrices) { + AffineMatrix result = null; + for (AffineMatrix matrix : matrices) { + if (result == null) { + result = matrix; + } else { + result = result.multiply(matrix); + } + } + return result; + } + + /** + * Invert the matrix. + * + * @return the inverse matrix (or {@code null} if not invertible). + */ + public AffineMatrix invert() { + // The 3x3 matrix M can be decomposed into M = M1 * M2: + // M1 M2 + // / 1 0 e \ / a c 0 \ + // | 0 1 f | * | b d 0 | + // \ 0 0 1 / \ 0 0 1 / + // + // The inverse of an invertible 2x2 matrix is given by this formula: + // + // / A B \⁻¹ 1 / D -B \ + // \ C D / = ----- \ -C A / + // AD-BC + // + // Let B=c and C=b (to apply the general formula with the same letters). + // + // M⁻¹ = (M1 * M2)⁻¹ = M2⁻¹ * M1⁻¹ + // + // M2⁻¹ M1⁻¹ + // /----------------\ + // 1 / d -B 0 \ / 1 0 -e \ + // = ----- | -C a 0 | * | 0 1 -f | + // ad-BC \ 0 0 1 / \ 0 0 1 / + // + // With the original letters: + // + // 1 / d -c 0 \ / 1 0 -e \ + // M⁻¹ = ----- | -b a 0 | * | 0 1 -f | + // ad-cb \ 0 0 1 / \ 0 0 1 / + // + // 1 / d -c cf-de \ + // = ----- | -b a be-af | + // ad-cb \ 0 0 1 / + + double det = a * d - c * b; + if (det == 0) { + // Not invertible + return null; + } + + double aa = d / det; + double bb = -b / det; + double cc = -c / det; + double dd = a / det; + double ee = (c * f - d * e) / det; + double ff = (b * e - a * f) / det; + + return new AffineMatrix(aa, bb, cc, dd, ee, ff); + } + + /** + * Return this transform applied from the center (0.5, 0.5). + * + * @return the resulting matrix + */ + public AffineMatrix fromCenter() { + return translate(0.5, 0.5).multiply(this).multiply(translate(-0.5, -0.5)); + } + + /** + * Return this transform with the specified aspect ratio. + * + * @param ar the aspect ratio + * @return the resulting matrix + */ + public AffineMatrix withAspectRatio(double ar) { + return scale(1 / ar, 1).multiply(this).multiply(scale(ar, 1)); + } + + /** + * Return this transform with the specified aspect ratio. + * + * @param size the size describing the aspect ratio + * @return the transform + */ + public AffineMatrix withAspectRatio(Size size) { + double ar = (double) size.getWidth() / size.getHeight(); + return withAspectRatio(ar); + } + + /** + * Return a translation matrix. + * + * @param x the horizontal translation + * @param y the vertical translation + * @return the matrix + */ + public static AffineMatrix translate(double x, double y) { + return new AffineMatrix(1, 0, 0, 1, x, y); + } + + /** + * Return a scaling matrix. + * + * @param x the horizontal scaling + * @param y the vertical scaling + * @return the matrix + */ + public static AffineMatrix scale(double x, double y) { + return new AffineMatrix(x, 0, 0, y, 0, 0); + } + + /** + * Return a scaling matrix. + * + * @param from the source size + * @param to the destination size + * @return the matrix + */ + public static AffineMatrix scale(Size from, Size to) { + double scaleX = (double) to.getWidth() / from.getWidth(); + double scaleY = (double) to.getHeight() / from.getHeight(); + return scale(scaleX, scaleY); + } + + /** + * Return a matrix applying a "reframing" (cropping a rectangle). + *

+ * (x, y) is the bottom-left corner, (w, h) is the size of the rectangle. + * + * @param x horizontal coordinate (increasing to the right) + * @param y vertical coordinate (increasing upwards) + * @param w width + * @param h height + * @return the matrix + */ + public static AffineMatrix reframe(double x, double y, double w, double h) { + if (w == 0 || h == 0) { + throw new IllegalArgumentException("Cannot reframe to an empty area: " + w + "x" + h); + } + return scale(1 / w, 1 / h).multiply(translate(-x, -y)); + } + + /** + * Return an orthogonal rotation matrix. + * + * @param ccwRotation the counter-clockwise rotation + * @return the matrix + */ + public static AffineMatrix rotateOrtho(int ccwRotation) { + switch (ccwRotation) { + case 0: + return IDENTITY; + case 1: + // 90° counter-clockwise + return new AffineMatrix(0, 1, -1, 0, 1, 0); + case 2: + // 180° + return new AffineMatrix(-1, 0, 0, -1, 1, 1); + case 3: + // 90° clockwise + return new AffineMatrix(0, -1, 1, 0, 0, 1); + default: + throw new IllegalArgumentException("Invalid rotation: " + ccwRotation); + } + } + + /** + * Return an horizontal flip matrix. + * + * @return the matrix + */ + public static AffineMatrix hflip() { + return new AffineMatrix(-1, 0, 0, 1, 1, 0); + } + + /** + * Return a vertical flip matrix. + * + * @return the matrix + */ + public static AffineMatrix vflip() { + return new AffineMatrix(1, 0, 0, -1, 0, 1); + } + + /** + * Return a rotation matrix. + * + * @param ccwDegrees the angle, in degrees (counter-clockwise) + * @return the matrix + */ + public static AffineMatrix rotate(double ccwDegrees) { + double radians = Math.toRadians(ccwDegrees); + double cos = Math.cos(radians); + double sin = Math.sin(radians); + return new AffineMatrix(cos, sin, -sin, cos, 0, 0); + } + + /** + * Export this affine transform to a 4x4 column-major order matrix. + * + * @param matrix output 4x4 matrix + */ + public void to4x4(float[] matrix) { + // matrix is a 4x4 matrix in column-major order + + // Column 0 + matrix[0] = (float) a; + matrix[1] = (float) b; + matrix[2] = 0; + matrix[3] = 0; + + // Column 1 + matrix[4] = (float) c; + matrix[5] = (float) d; + matrix[6] = 0; + matrix[7] = 0; + + // Column 2 + matrix[8] = 0; + matrix[9] = 0; + matrix[10] = 1; + matrix[11] = 0; + + // Column 3 + matrix[12] = (float) e; + matrix[13] = (float) f; + matrix[14] = 0; + matrix[15] = 1; + } + + /** + * Export this affine transform to a 4x4 column-major order matrix. + * + * @return 4x4 matrix + */ + public float[] to4x4() { + float[] matrix = new float[16]; + to4x4(matrix); + return matrix; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Binary.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Binary.java new file mode 100644 index 0000000..f46ba69 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Binary.java @@ -0,0 +1,38 @@ +package com.genymobile.scrcpy.util; + +public final class Binary { + private Binary() { + // not instantiable + } + + public static int toUnsigned(short value) { + return value & 0xffff; + } + + public static int toUnsigned(byte value) { + return value & 0xff; + } + + /** + * Convert unsigned 16-bit fixed-point to a float between 0 and 1 + * + * @param value encoded value + * @return Float value between 0 and 1 + */ + public static float u16FixedPointToFloat(short value) { + int unsignedShort = Binary.toUnsigned(value); + // 0x1p16f is 2^16 as float + return unsignedShort == 0xffff ? 1f : (unsignedShort / 0x1p16f); + } + + /** + * Convert signed 16-bit fixed-point to a float between -1 and 1 + * + * @param value encoded value + * @return Float value between -1 and 1 + */ + public static float i16FixedPointToFloat(short value) { + // 0x1p15f is 2^15 as float + return value == 0x7fff ? 1f : (value / 0x1p15f); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/BinarySearch.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/BinarySearch.java new file mode 100644 index 0000000..381601f --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/BinarySearch.java @@ -0,0 +1,53 @@ +package com.genymobile.scrcpy.util; + +import com.genymobile.scrcpy.AndroidVersions; + +import android.annotation.TargetApi; + +import java.util.function.Predicate; + +public final class BinarySearch { + private BinarySearch() { + // not instantiable + } + + /** + * Find the highest value for which the predicate is true. + *

+ * This assumes that the predicate is monotonic: + * - if it returns {@code false} for a given value, it must return {@code false} for higher values, or equivalently + * - if it returns {@code true} for a given value, it must return {@code true} for lower values. + * + * @param low the lowest value + * @param high the highest value + * @param predicate the predicate + * @return the highest value for which {@code predicate.test(value)} is {@code true}, or {@code low - 1} if not found. + */ + // This function is used for virtual displays, so we don't need to support older versions anyway + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) // for Predicate + public static int findHighestTrue(int low, int high, Predicate predicate) { + if (low <= high) { + // Fast path + if (predicate.test(high)) { + return high; + } + --high; + } + + int result = low - 1; // low-1 means "not found" + while (low <= high) { + int mid = low + (high - low + 1) / 2; + if (predicate.test(mid)) { + result = mid; + + // predicate holds, go right + low = mid + 1; + } else { + // predicate false, go left + high = mid - 1; + } + } + + return result; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/CodecUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/CodecUtils.java new file mode 100644 index 0000000..3a01256 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/CodecUtils.java @@ -0,0 +1,38 @@ +package com.genymobile.scrcpy.util; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class CodecUtils { + + private CodecUtils() { + // not instantiable + } + + public static void setCodecOption(MediaFormat format, String key, Object value) { + if (value instanceof Integer) { + format.setInteger(key, (Integer) value); + } else if (value instanceof Long) { + format.setLong(key, (Long) value); + } else if (value instanceof Float) { + format.setFloat(key, (Float) value); + } else if (value instanceof String) { + format.setString(key, (String) value); + } + } + + public static MediaCodecInfo[] getEncoders(MediaCodecList codecs, String mimeType) { + List result = new ArrayList<>(); + for (MediaCodecInfo codecInfo : codecs.getCodecInfos()) { + if (codecInfo.isEncoder() && Arrays.asList(codecInfo.getSupportedTypes()).contains(mimeType)) { + result.add(codecInfo); + } + } + return result.toArray(new MediaCodecInfo[result.size()]); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Command.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Command.java new file mode 100644 index 0000000..b26158e --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Command.java @@ -0,0 +1,43 @@ +package com.genymobile.scrcpy.util; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Scanner; + +public final class Command { + private Command() { + // not instantiable + } + + public static void exec(String... cmd) throws IOException, InterruptedException { + Process process = Runtime.getRuntime().exec(cmd); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode); + } + } + + public static String execReadLine(String... cmd) throws IOException, InterruptedException { + String result = null; + Process process = Runtime.getRuntime().exec(cmd); + Scanner scanner = new Scanner(process.getInputStream()); + if (scanner.hasNextLine()) { + result = scanner.nextLine(); + } + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode); + } + return result; + } + + public static String execReadOutput(String... cmd) throws IOException, InterruptedException { + Process process = Runtime.getRuntime().exec(cmd); + String output = IO.toString(process.getInputStream()); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode); + } + return output; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/HandlerExecutor.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/HandlerExecutor.java new file mode 100644 index 0000000..0330998 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/HandlerExecutor.java @@ -0,0 +1,23 @@ +package com.genymobile.scrcpy.util; + +import android.os.Handler; + +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +// Inspired from hidden android.os.HandlerExecutor + +public class HandlerExecutor implements Executor { + private final Handler handler; + + public HandlerExecutor(Handler handler) { + this.handler = handler; + } + + @Override + public void execute(Runnable command) { + if (!handler.post(command)) { + throw new RejectedExecutionException(handler + " is shutting down"); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/IO.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/IO.java new file mode 100644 index 0000000..16ddaed --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/IO.java @@ -0,0 +1,79 @@ +package com.genymobile.scrcpy.util; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.BuildConfig; + +import android.os.Build; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; + +import java.io.FileDescriptor; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Scanner; + +public final class IO { + private IO() { + // not instantiable + } + + private static int write(FileDescriptor fd, ByteBuffer from) throws IOException { + while (true) { + try { + return Os.write(fd, from); + } catch (ErrnoException e) { + if (e.errno != OsConstants.EINTR) { + throw new IOException(e); + } + } + } + } + + public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + while (from.hasRemaining()) { + write(fd, from); + } + } else { + // ByteBuffer position is not updated as expected by Os.write() on old Android versions, so + // handle the position and the remaining bytes manually. + // See . + int position = from.position(); + int remaining = from.remaining(); + while (remaining > 0) { + int w = write(fd, from); + if (BuildConfig.DEBUG && w < 0) { + // w should not be negative, since an exception is thrown on error + throw new AssertionError("Os.write() returned a negative value (" + w + ")"); + } + remaining -= w; + position += w; + from.position(position); + } + } + } + + public static void writeFully(FileDescriptor fd, byte[] buffer, int offset, int len) throws IOException { + writeFully(fd, ByteBuffer.wrap(buffer, offset, len)); + } + + public static String toString(InputStream inputStream) { + StringBuilder builder = new StringBuilder(); + Scanner scanner = new Scanner(inputStream); + while (scanner.hasNextLine()) { + builder.append(scanner.nextLine()).append('\n'); + } + return builder.toString(); + } + + public static boolean isBrokenPipe(IOException e) { + Throwable cause = e.getCause(); + return cause instanceof ErrnoException && ((ErrnoException) cause).errno == OsConstants.EPIPE; + } + + public static boolean isBrokenPipe(Exception e) { + return e instanceof IOException && isBrokenPipe((IOException) e); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Ln.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Ln.java new file mode 100644 index 0000000..811a4f3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Ln.java @@ -0,0 +1,122 @@ +package com.genymobile.scrcpy.util; + +import android.util.Log; + +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; + +/** + * Log both to Android logger (so that logs are visible in "adb logcat") and standard output/error (so that they are visible in the terminal + * directly). + */ +public final class Ln { + + private static final String TAG = "scrcpy"; + private static final String PREFIX = "[server] "; + + private static final PrintStream CONSOLE_OUT = new PrintStream(new FileOutputStream(FileDescriptor.out)); + private static final PrintStream CONSOLE_ERR = new PrintStream(new FileOutputStream(FileDescriptor.err)); + + public enum Level { + VERBOSE, DEBUG, INFO, WARN, ERROR + } + + private static Level threshold = Level.INFO; + + private Ln() { + // not instantiable + } + + public static void disableSystemStreams() { + PrintStream nullStream = new PrintStream(new NullOutputStream()); + System.setOut(nullStream); + System.setErr(nullStream); + } + + /** + * Initialize the log level. + *

+ * Must be called before starting any new thread. + * + * @param level the log level + */ + public static void initLogLevel(Level level) { + threshold = level; + } + + public static boolean isEnabled(Level level) { + return level.ordinal() >= threshold.ordinal(); + } + + public static void v(String message) { + if (isEnabled(Level.VERBOSE)) { + Log.v(TAG, message); + CONSOLE_OUT.print(PREFIX + "VERBOSE: " + message + '\n'); + } + } + + public static void d(String message) { + if (isEnabled(Level.DEBUG)) { + Log.d(TAG, message); + CONSOLE_OUT.print(PREFIX + "DEBUG: " + message + '\n'); + } + } + + public static void i(String message) { + if (isEnabled(Level.INFO)) { + Log.i(TAG, message); + CONSOLE_OUT.print(PREFIX + "INFO: " + message + '\n'); + } + } + + public static void w(String message, Throwable throwable) { + if (isEnabled(Level.WARN)) { + Log.w(TAG, message, throwable); + synchronized (CONSOLE_ERR) { + CONSOLE_ERR.print(PREFIX + "WARN: " + message + '\n'); + if (throwable != null) { + throwable.printStackTrace(CONSOLE_ERR); + } + } + } + } + + public static void w(String message) { + w(message, null); + } + + public static void e(String message, Throwable throwable) { + if (isEnabled(Level.ERROR)) { + Log.e(TAG, message, throwable); + synchronized (CONSOLE_ERR) { + CONSOLE_ERR.print(PREFIX + "ERROR: " + message + '\n'); + if (throwable != null) { + throwable.printStackTrace(CONSOLE_ERR); + } + } + } + } + + public static void e(String message) { + e(message, null); + } + + static class NullOutputStream extends OutputStream { + @Override + public void write(byte[] b) { + // ignore + } + + @Override + public void write(byte[] b, int off, int len) { + // ignore + } + + @Override + public void write(int b) { + // ignore + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/LogUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/LogUtils.java new file mode 100644 index 0000000..4f4605e --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/LogUtils.java @@ -0,0 +1,299 @@ +package com.genymobile.scrcpy.util; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.audio.AudioCodec; +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.display.DisplayInfo; +import com.genymobile.scrcpy.model.Codec; +import com.genymobile.scrcpy.model.DeviceApp; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.video.VideoCodec; +import com.genymobile.scrcpy.wrappers.DisplayManager; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.os.Build; +import android.util.Range; + +import java.text.DecimalFormat; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.SortedSet; +import java.util.TreeSet; + +public final class LogUtils { + + private LogUtils() { + // not instantiable + } + + private static String buildEncoderListMessage(String type, Codec[] codecs) { + StringBuilder builder = new StringBuilder("List of ").append(type).append(" encoders:"); + MediaCodecList codecList = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + for (Codec codec : codecs) { + MediaCodecInfo[] encoders = CodecUtils.getEncoders(codecList, codec.getMimeType()); + for (MediaCodecInfo info : encoders) { + int lineStart = builder.length(); + builder.append("\n --").append(type).append("-codec=").append(codec.getName()); + builder.append(" --").append(type).append("-encoder=").append(info.getName()); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10) { + int lineLength = builder.length() - lineStart; + final int column = 70; + if (lineLength < column) { + int padding = column - lineLength; + builder.append(String.format("%" + padding + "s", " ")); + } + builder.append(" (").append(getHwCodecType(info)).append(')'); + if (info.isVendor()) { + builder.append(" [vendor]"); + } + if (info.isAlias()) { + builder.append(" (alias for ").append(info.getCanonicalName()).append(')'); + } + } + + } + } + + return builder.toString(); + } + + public static String buildVideoEncoderListMessage() { + return buildEncoderListMessage("video", VideoCodec.values()); + } + + public static String buildAudioEncoderListMessage() { + return buildEncoderListMessage("audio", AudioCodec.values()); + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + private static String getHwCodecType(MediaCodecInfo info) { + if (info.isSoftwareOnly()) { + return "sw"; + } + if (info.isHardwareAccelerated()) { + return "hw"; + } + return "hybrid"; + } + + public static String buildDisplayListMessage() { + StringBuilder builder = new StringBuilder("List of displays:"); + DisplayManager displayManager = ServiceManager.getDisplayManager(); + int[] displayIds = displayManager.getDisplayIds(); + if (displayIds == null || displayIds.length == 0) { + builder.append("\n (none)"); + } else { + for (int id : displayIds) { + builder.append("\n --display-id=").append(id).append(" ("); + DisplayInfo displayInfo = displayManager.getDisplayInfo(id); + if (displayInfo != null) { + Size size = displayInfo.getSize(); + builder.append(size.getWidth()).append("x").append(size.getHeight()); + } else { + builder.append("size unknown"); + } + builder.append(")"); + } + } + return builder.toString(); + } + + private static String getCameraFacingName(int facing) { + switch (facing) { + case CameraCharacteristics.LENS_FACING_FRONT: + return "front"; + case CameraCharacteristics.LENS_FACING_BACK: + return "back"; + case CameraCharacteristics.LENS_FACING_EXTERNAL: + return "external"; + default: + return "unknown"; + } + } + + private static boolean isCameraBackwardCompatible(CameraCharacteristics characteristics) { + int[] capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES); + if (capabilities == null) { + return false; + } + + for (int capability : capabilities) { + if (capability == CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) { + return true; + } + } + + return false; + } + + public static String buildCameraListMessage(boolean includeSizes) { + StringBuilder builder = new StringBuilder("List of cameras:"); + CameraManager cameraManager = ServiceManager.getCameraManager(); + try { + String[] cameraIds = cameraManager.getCameraIdList(); + if (cameraIds.length == 0) { + builder.append("\n (none)"); + } else { + for (String id : cameraIds) { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); + + if (!isCameraBackwardCompatible(characteristics)) { + // Ignore depth cameras as suggested by official documentation + // + continue; + } + + builder.append("\n --camera-id=").append(id); + + int facing = characteristics.get(CameraCharacteristics.LENS_FACING); + builder.append(" (").append(getCameraFacingName(facing)).append(", "); + + Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); + builder.append(activeSize.width()).append("x").append(activeSize.height()); + + try { + // Capture frame rates for low-FPS mode are the same for every resolution + Range[] lowFpsRanges = characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES); + if (lowFpsRanges != null) { + String uniqueLowFps = getFormattedUniqueSet(lowFpsRanges); + builder.append(", fps=").append(uniqueLowFps); + } + } catch (Exception e) { + // Some devices may provide invalid ranges, causing an IllegalArgumentException "lower must be less than or equal to upper" + Ln.w("Could not get available frame rates for camera " + id, e); + } + + if (Build.VERSION.SDK_INT >= AndroidVersions.API_30_ANDROID_11) { + try { + Range zoomRange = characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE); + if (zoomRange != null) { + String zoom = getFormattedZoomRange(zoomRange); + builder.append(", zoom-range=").append(zoom); + } + } catch (Exception e) { + Ln.w("Could not get available zoom ranges for camera " + id, e); + } + } + + builder.append(')'); + + if (includeSizes) { + StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + + android.util.Size[] sizes = configs.getOutputSizes(MediaCodec.class); + if (sizes == null || sizes.length == 0) { + builder.append("\n (none)"); + } else { + for (android.util.Size size : sizes) { + builder.append("\n - ").append(size.getWidth()).append('x').append(size.getHeight()); + } + } + + android.util.Size[] highSpeedSizes = configs.getHighSpeedVideoSizes(); + if (highSpeedSizes != null && highSpeedSizes.length > 0) { + builder.append("\n High speed capture (--camera-high-speed):"); + for (android.util.Size size : highSpeedSizes) { + Range[] highFpsRanges = configs.getHighSpeedVideoFpsRanges(); + String uniqueHighFps = getFormattedUniqueSet(highFpsRanges); + builder.append("\n - ").append(size.getWidth()).append("x").append(size.getHeight()); + builder.append(" (fps=").append(uniqueHighFps).append(')'); + } + } + } + } + } + } catch (CameraAccessException e) { + builder.append("\n (access denied)"); + } + return builder.toString(); + } + + private static String getFormattedUniqueSet(Range[] ranges) { + SortedSet set = new TreeSet<>(); + for (Range range : ranges) { + set.add(range.getUpper()); + } + + StringBuilder builder = new StringBuilder("{"); + boolean first = true; + for (Integer i : set) { + if (!first) { + builder.append(", "); + } else { + first = false; + } + builder.append(i); + } + builder.append("}"); + + return builder.toString(); + } + + private static String getFormattedZoomRange(Range range) { + DecimalFormat format = new DecimalFormat("#.##"); + return "[" + format.format(range.getLower()) + ", " + format.format(range.getUpper()) + "]"; + } + + public static String buildAppListMessage() { + List apps = Device.listApps(); + return buildAppListMessage("List of apps:", apps); + } + + @SuppressLint("QueryPermissionsNeeded") + public static String buildAppListMessage(String title, List apps) { + StringBuilder builder = new StringBuilder(title); + + // Sort by: + // 1. system flag (system apps are before non-system apps) + // 2. name + // 3. package name + // Comparator.comparing() was introduced in API 24, so it cannot be used here to simplify the code + Collections.sort(apps, (thisApp, otherApp) -> { + // System apps first + int cmp = -Boolean.compare(thisApp.isSystem(), otherApp.isSystem()); + if (cmp != 0) { + return cmp; + } + + cmp = Objects.compare(thisApp.getName(), otherApp.getName(), String::compareTo); + if (cmp != 0) { + return cmp; + } + + return Objects.compare(thisApp.getPackageName(), otherApp.getPackageName(), String::compareTo); + }); + + final int column = 30; + for (DeviceApp app : apps) { + String name = app.getName(); + int padding = column - name.length(); + builder.append("\n "); + if (app.isSystem()) { + builder.append("* "); + } else { + builder.append("- "); + + } + builder.append(name); + if (padding > 0) { + builder.append(String.format("%" + padding + "s", " ")); + } else { + builder.append("\n ").append(String.format("%" + column + "s", " ")); + } + builder.append(" ").append(app.getPackageName()); + } + + return builder.toString(); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Settings.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Settings.java new file mode 100644 index 0000000..4eb67d1 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Settings.java @@ -0,0 +1,38 @@ +package com.genymobile.scrcpy.util; + +import com.genymobile.scrcpy.wrappers.ContentProvider; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +public final class Settings { + + public static final String TABLE_SYSTEM = ContentProvider.TABLE_SYSTEM; + public static final String TABLE_SECURE = ContentProvider.TABLE_SECURE; + public static final String TABLE_GLOBAL = ContentProvider.TABLE_GLOBAL; + + private Settings() { + /* not instantiable */ + } + + public static String getValue(String table, String key) throws SettingsException { + try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) { + return provider.getValue(table, key); + } + } + + public static void putValue(String table, String key, String value) throws SettingsException { + try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) { + provider.putValue(table, key, value); + } + + } + + public static String getAndPutValue(String table, String key, String value) throws SettingsException { + try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) { + String oldValue = provider.getValue(table, key); + if (!value.equals(oldValue)) { + provider.putValue(table, key, value); + } + return oldValue; + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/SettingsException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/SettingsException.java new file mode 100644 index 0000000..87fa388 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/SettingsException.java @@ -0,0 +1,11 @@ +package com.genymobile.scrcpy.util; + +public class SettingsException extends Exception { + private static String createMessage(String method, String table, String key, String value) { + return "Could not access settings: " + method + " " + table + " " + key + (value != null ? " " + value : ""); + } + + public SettingsException(String method, String table, String key, String value, Throwable cause) { + super(createMessage(method, table, key, value), cause); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/StringUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/StringUtils.java new file mode 100644 index 0000000..8b19ca3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/StringUtils.java @@ -0,0 +1,22 @@ +package com.genymobile.scrcpy.util; + +public final class StringUtils { + private StringUtils() { + // not instantiable + } + + public static int getUtf8TruncationIndex(byte[] utf8, int maxLength) { + int len = utf8.length; + if (len <= maxLength) { + return len; + } + len = maxLength; + // see UTF-8 encoding + while ((utf8[len] & 0x80) != 0 && (utf8[len] & 0xc0) != 0xc0) { + // the next byte is not the start of a new UTF-8 codepoint + // so if we would cut there, the character would be truncated + len--; + } + return len; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Threads.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Threads.java new file mode 100644 index 0000000..c561d1e --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Threads.java @@ -0,0 +1,43 @@ +package com.genymobile.scrcpy.util; + +import android.os.Handler; + +import java.util.concurrent.Callable; +import java.util.concurrent.Semaphore; + +public final class Threads { + private Threads() { + // not instantiable + } + + public static T executeSynchronouslyOn(Handler handler, Callable callable) throws Throwable { + // Simulate CompletableFuture, but working for all Android versions + final Semaphore sem = new Semaphore(0); + @SuppressWarnings("unchecked") + T[] resultRef = (T[]) new Object[1]; + Throwable[] throwableRef = new Throwable[1]; + + handler.post(() -> { + try { + resultRef[0] = callable.call(); + } catch (Throwable throwable) { + throwableRef[0] = throwable; + } finally { + sem.release(); + } + }); + + try { + sem.acquire(); + } catch (InterruptedException e) { + // Behave as if this method call was synchronous + Thread.currentThread().interrupt(); + } + + if (throwableRef[0] != null) { + throw throwableRef[0]; + } + + return resultRef[0]; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraAspectRatio.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraAspectRatio.java new file mode 100644 index 0000000..bf1cba5 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraAspectRatio.java @@ -0,0 +1,37 @@ +package com.genymobile.scrcpy.video; + +public final class CameraAspectRatio { + private static final float SENSOR = -1; + + private float ar; + + private CameraAspectRatio(float ar) { + this.ar = ar; + } + + public static CameraAspectRatio fromFloat(float ar) { + if (ar < 0) { + throw new IllegalArgumentException("Invalid aspect ratio: " + ar); + } + return new CameraAspectRatio(ar); + } + + public static CameraAspectRatio fromFraction(int w, int h) { + if (w <= 0 || h <= 0) { + throw new IllegalArgumentException("Invalid aspect ratio: " + w + ":" + h); + } + return new CameraAspectRatio((float) w / h); + } + + public static CameraAspectRatio sensorAspectRatio() { + return new CameraAspectRatio(SENSOR); + } + + public boolean isSensor() { + return ar == SENSOR; + } + + public float getAspectRatio() { + return ar; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraCapture.java new file mode 100644 index 0000000..1aaeafa --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraCapture.java @@ -0,0 +1,531 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.Options; +import com.genymobile.scrcpy.model.ConfigurationException; +import com.genymobile.scrcpy.model.Orientation; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.opengl.AffineOpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.AffineMatrix; +import com.genymobile.scrcpy.util.HandlerExecutor; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraConstrainedHighSpeedCaptureSession; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CaptureFailure; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.OutputConfiguration; +import android.hardware.camera2.params.SessionConfiguration; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.MediaCodec; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Range; +import android.view.Surface; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +public class CameraCapture extends SurfaceCapture { + + public static final float[] VFLIP_MATRIX = { + 1, 0, 0, 0, // column 1 + 0, -1, 0, 0, // column 2 + 0, 0, 1, 0, // column 3 + 0, 1, 0, 1, // column 4 + }; + + private static final float ZOOM_FACTOR = 1 + 1 / 16f; + + private final String explicitCameraId; + private final CameraFacing cameraFacing; + private final Size explicitSize; + private final CameraAspectRatio aspectRatio; + private final int fps; + private final boolean highSpeed; + private final Rect crop; + private final Orientation captureOrientation; + private final float angle; + private final boolean initialTorch; + private float zoom; + + private VideoConstraints videoConstraints; + + private String cameraId; + private Size captureSize; + private Size videoSize; // after OpenGL transforms + private Range zoomRange; + + private AffineMatrix transform; + private OpenGLRunner glRunner; + + private HandlerThread cameraThread; + private Handler cameraHandler; + private CameraDevice cameraDevice; + private Executor cameraExecutor; + + private final AtomicBoolean disconnected = new AtomicBoolean(); + + // The following fields must be accessed only from the camera thread + private boolean started; + private CaptureRequest.Builder requestBuilder; + private CameraCaptureSession currentSession; + + public CameraCapture(Options options) { + this.explicitCameraId = options.getCameraId(); + this.cameraFacing = options.getCameraFacing(); + this.explicitSize = options.getCameraSize(); + this.aspectRatio = options.getCameraAspectRatio(); + this.fps = options.getCameraFps(); + this.highSpeed = options.getCameraHighSpeed(); + this.crop = options.getCrop(); + this.captureOrientation = options.getCaptureOrientation(); + assert captureOrientation != null; + this.angle = options.getAngle(); + this.initialTorch = options.getCameraTorch(); + this.zoom = options.getCameraZoom(); + } + + @Override + protected void init(VideoConstraints videoConstraints) throws ConfigurationException, IOException { + this.videoConstraints = videoConstraints; + + cameraThread = new HandlerThread("camera"); + cameraThread.start(); + cameraHandler = new Handler(cameraThread.getLooper()); + cameraExecutor = new HandlerExecutor(cameraHandler); + + try { + cameraId = selectCamera(explicitCameraId, cameraFacing); + if (cameraId == null) { + throw new ConfigurationException("No matching camera found"); + } + + Ln.i("Using camera '" + cameraId + "'"); + cameraDevice = openCamera(cameraId); + } catch (CameraAccessException | InterruptedException e) { + throw new IOException(e); + } + } + + @Override + public void prepare() throws IOException { + try { + int maxSize = videoConstraints.getMaxSize(); + captureSize = selectSize(cameraId, explicitSize, maxSize, aspectRatio, highSpeed); + if (captureSize == null) { + throw new IOException("Could not select camera size"); + } + } catch (CameraAccessException e) { + throw new IOException(e); + } + + VideoFilter filter = new VideoFilter(captureSize); + + if (crop != null) { + filter.addCrop(crop, false); + } + + if (captureOrientation != Orientation.Orient0) { + filter.addOrientation(captureOrientation); + } + + filter.addAngle(angle); + + transform = filter.getInverseTransform(); + videoSize = filter.getOutputSize().align(videoConstraints.getAlignment()); + + if (!videoSize.equals(captureSize) && transform == null) { + // The camera stream can only be rendered on a surface matching the input size. + // If the video constraints change the size, an intermediate OpenGL filter is required. + transform = AffineMatrix.IDENTITY; + } + } + + private static String selectCamera(String explicitCameraId, CameraFacing cameraFacing) throws CameraAccessException, ConfigurationException { + CameraManager cameraManager = ServiceManager.getCameraManager(); + + String[] cameraIds = cameraManager.getCameraIdList(); + if (explicitCameraId != null) { + if (!Arrays.asList(cameraIds).contains(explicitCameraId)) { + Ln.e("Camera with id " + explicitCameraId + " not found\n" + LogUtils.buildCameraListMessage(false)); + throw new ConfigurationException("Camera id not found"); + } + return explicitCameraId; + } + + if (cameraFacing == null) { + // Use the first one + return cameraIds.length > 0 ? cameraIds[0] : null; + } + + for (String cameraId : cameraIds) { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId); + + int facing = characteristics.get(CameraCharacteristics.LENS_FACING); + if (cameraFacing.value() == facing) { + return cameraId; + } + } + + // Not found + return null; + } + + @TargetApi(AndroidVersions.API_24_ANDROID_7_0) + private static Size selectSize(String cameraId, Size explicitSize, int maxSize, CameraAspectRatio aspectRatio, boolean highSpeed) + throws CameraAccessException { + if (explicitSize != null) { + return explicitSize; + } + + CameraManager cameraManager = ServiceManager.getCameraManager(); + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId); + + StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + android.util.Size[] sizes = highSpeed ? configs.getHighSpeedVideoSizes() : configs.getOutputSizes(MediaCodec.class); + if (sizes == null) { + return null; + } + + Stream stream = Arrays.stream(sizes); + if (maxSize > 0) { + stream = stream.filter(it -> it.getWidth() <= maxSize && it.getHeight() <= maxSize); + } + + Float targetAspectRatio = resolveAspectRatio(aspectRatio, characteristics); + if (targetAspectRatio != null) { + stream = stream.filter(it -> { + float ar = ((float) it.getWidth() / it.getHeight()); + float arRatio = ar / targetAspectRatio; + // Accept if the aspect ratio is the target aspect ratio + or - 10% + return arRatio >= 0.9f && arRatio <= 1.1f; + }); + } + + Optional selected = stream.max((s1, s2) -> { + // Greater width is better + int cmp = Integer.compare(s1.getWidth(), s2.getWidth()); + if (cmp != 0) { + return cmp; + } + + if (targetAspectRatio != null) { + // Closer to the target aspect ratio is better + float ar1 = ((float) s1.getWidth() / s1.getHeight()); + float arRatio1 = ar1 / targetAspectRatio; + float distance1 = Math.abs(1 - arRatio1); + + float ar2 = ((float) s2.getWidth() / s2.getHeight()); + float arRatio2 = ar2 / targetAspectRatio; + float distance2 = Math.abs(1 - arRatio2); + + // Reverse the order because lower distance is better + cmp = Float.compare(distance2, distance1); + if (cmp != 0) { + return cmp; + } + } + + // Greater height is better + return Integer.compare(s1.getHeight(), s2.getHeight()); + }); + + if (selected.isPresent()) { + android.util.Size size = selected.get(); + return new Size(size.getWidth(), size.getHeight()); + } + + // Not found + return null; + } + + private static Float resolveAspectRatio(CameraAspectRatio ratio, CameraCharacteristics characteristics) { + if (ratio == null) { + return null; + } + + if (ratio.isSensor()) { + Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE); + return (float) activeSize.width() / activeSize.height(); + } + + return ratio.getAspectRatio(); + } + + @TargetApi(AndroidVersions.API_30_ANDROID_11) + @Override + public void start(Surface surface) throws IOException { + if (transform != null) { + assert glRunner == null; + OpenGLFilter glFilter = new AffineOpenGLFilter(transform); + // The transform matrix returned by SurfaceTexture is incorrect for camera capture (it often contains an additional unexpected 90° + // rotation). Use a vertical flip transform matrix instead. + glRunner = new OpenGLRunner(glFilter, VFLIP_MATRIX); + surface = glRunner.start(captureSize, videoSize, surface); + } + + cameraHandler.post(() -> { + assertCameraThread(); + started = true; + }); + + Surface captureSurface = surface; + OutputConfiguration outputConfig = new OutputConfiguration(captureSurface); + List outputs = Collections.singletonList(outputConfig); + int sessionType = highSpeed ? SessionConfiguration.SESSION_HIGH_SPEED : SessionConfiguration.SESSION_REGULAR; + SessionConfiguration sessionConfig = new SessionConfiguration(sessionType, outputs, cameraExecutor, new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(CameraCaptureSession session) { + assertCameraThread(); + if (!started) { + // Stopped on the encoder thread between the call to start() and this callback + return; + } + + CameraManager cameraManager = ServiceManager.getCameraManager(); + try { + CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId); + zoomRange = characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE); + } catch (CameraAccessException e) { + Ln.w("Could not get camera characteristics"); + } + + try { + requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); + requestBuilder.addTarget(captureSurface); + + if (fps > 0) { + requestBuilder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, new Range<>(fps, fps)); + } + if (initialTorch) { + Ln.i("Turn camera torch on"); + requestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH); + } + if (zoom != 1) { + zoom = clampZoom(zoom); + Ln.i("Set camera zoom: " + zoom); + requestBuilder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoom); + } + + CaptureRequest request = requestBuilder.build(); + setRepeatingRequest(session, request); + currentSession = session; + } catch (CameraAccessException e) { + Ln.e("Camera error", e); + disconnected.set(true); + getCaptureControl().reset(CaptureControl.RESET_REASON_TERMINATED); + } + } + + @Override + public void onConfigureFailed(CameraCaptureSession session) { + Ln.e("Camera configuration error"); + disconnected.set(true); + getCaptureControl().reset(CaptureControl.RESET_REASON_TERMINATED); + } + }); + + try { + cameraDevice.createCaptureSession(sessionConfig); + } catch (CameraAccessException e) { + stop(); + throw new IOException(e); + } + } + + @Override + public void stop() { + cameraHandler.post(() -> { + assertCameraThread(); + currentSession = null; + requestBuilder = null; + started = false; + }); + + if (glRunner != null) { + glRunner.stopAndRelease(); + glRunner = null; + } + } + + @Override + public void release() { + if (cameraDevice != null) { + cameraDevice.close(); + } + if (cameraThread != null) { + cameraThread.quitSafely(); + } + } + + @Override + public Size getSize() { + return videoSize; + } + + @Override + protected boolean applyNewVideoConstraints(VideoConstraints videoConstraints) { + if (explicitSize != null) { + return false; + } + + this.videoConstraints = videoConstraints; + return true; + } + + @SuppressLint("MissingPermission") + @TargetApi(AndroidVersions.API_31_ANDROID_12) + private CameraDevice openCamera(String id) throws CameraAccessException, InterruptedException { + CompletableFuture future = new CompletableFuture<>(); + ServiceManager.getCameraManager().openCamera(id, new CameraDevice.StateCallback() { + @Override + public void onOpened(CameraDevice camera) { + Ln.d("Camera opened successfully"); + future.complete(camera); + } + + @Override + public void onDisconnected(CameraDevice camera) { + Ln.w("Camera disconnected"); + disconnected.set(true); + getCaptureControl().reset(CaptureControl.RESET_REASON_TERMINATED); + } + + @Override + public void onError(CameraDevice camera, int error) { + int cameraAccessExceptionErrorCode; + switch (error) { + case CameraDevice.StateCallback.ERROR_CAMERA_IN_USE: + cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_IN_USE; + break; + case CameraDevice.StateCallback.ERROR_MAX_CAMERAS_IN_USE: + cameraAccessExceptionErrorCode = CameraAccessException.MAX_CAMERAS_IN_USE; + break; + case CameraDevice.StateCallback.ERROR_CAMERA_DISABLED: + cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_DISABLED; + break; + case CameraDevice.StateCallback.ERROR_CAMERA_DEVICE: + case CameraDevice.StateCallback.ERROR_CAMERA_SERVICE: + default: + cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_ERROR; + break; + } + future.completeExceptionally(new CameraAccessException(cameraAccessExceptionErrorCode)); + } + }, cameraHandler); + + try { + return future.get(); + } catch (ExecutionException e) { + throw (CameraAccessException) e.getCause(); + } + } + + @TargetApi(AndroidVersions.API_31_ANDROID_12) + private void setRepeatingRequest(CameraCaptureSession session, CaptureRequest request) throws CameraAccessException { + CameraCaptureSession.CaptureCallback callback = new CameraCaptureSession.CaptureCallback() { + @Override + public void onCaptureStarted(CameraCaptureSession session, CaptureRequest request, long timestamp, long frameNumber) { + // Called for each frame captured, do nothing + } + + @Override + public void onCaptureFailed(CameraCaptureSession session, CaptureRequest request, CaptureFailure failure) { + Ln.w("Camera capture failed: frame " + failure.getFrameNumber()); + } + }; + + if (highSpeed) { + CameraConstrainedHighSpeedCaptureSession highSpeedSession = (CameraConstrainedHighSpeedCaptureSession) session; + List requests = highSpeedSession.createHighSpeedRequestList(request); + highSpeedSession.setRepeatingBurst(requests, callback, cameraHandler); + } else { + session.setRepeatingRequest(request, callback, cameraHandler); + } + } + + @Override + public boolean isClosed() { + return disconnected.get(); + } + + public void setTorchEnabled(boolean enabled) { + cameraHandler.post(() -> { + assertCameraThread(); + if (currentSession != null && requestBuilder != null) { + try { + Ln.i("Turn camera torch " + (enabled ? "on" : "off")); + requestBuilder.set(CaptureRequest.FLASH_MODE, enabled ? CaptureRequest.FLASH_MODE_TORCH : CaptureRequest.FLASH_MODE_OFF); + CaptureRequest request = requestBuilder.build(); + setRepeatingRequest(currentSession, request); + } catch (CameraAccessException e) { + Ln.e("Camera error", e); + } + } + }); + } + + @TargetApi(AndroidVersions.API_30_ANDROID_11) + private void zoom(boolean in) { + cameraHandler.post(() -> { + assertCameraThread(); + if (currentSession != null && requestBuilder != null) { + // Always align to log values + double z = Math.round(Math.log(zoom) / Math.log(ZOOM_FACTOR)); + double dir = in ? 1 : -1; + zoom = (float) Math.pow(ZOOM_FACTOR, z + dir); + + try { + zoom = clampZoom(zoom); + Ln.i("Set camera zoom: " + zoom); + requestBuilder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoom); + CaptureRequest request = requestBuilder.build(); + setRepeatingRequest(currentSession, request); + } catch (CameraAccessException e) { + Ln.e("Camera error", e); + } + } + }); + } + + public void zoomIn() { + zoom(true); + } + + public void zoomOut() { + zoom(false); + } + + private float clampZoom(float value) { + assertCameraThread(); + if (zoomRange == null) { + return value; + } + + return zoomRange.clamp(value); + } + + private void assertCameraThread() { + assert Thread.currentThread() == cameraThread; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraFacing.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraFacing.java new file mode 100644 index 0000000..f818e66 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraFacing.java @@ -0,0 +1,33 @@ +package com.genymobile.scrcpy.video; + +import android.annotation.SuppressLint; +import android.hardware.camera2.CameraCharacteristics; + +public enum CameraFacing { + FRONT("front", CameraCharacteristics.LENS_FACING_FRONT), + BACK("back", CameraCharacteristics.LENS_FACING_BACK), + @SuppressLint("InlinedApi") // introduced in API 23 + EXTERNAL("external", CameraCharacteristics.LENS_FACING_EXTERNAL); + + private final String name; + private final int value; + + CameraFacing(String name, int value) { + this.name = name; + this.value = value; + } + + int value() { + return value; + } + + public static CameraFacing findByName(String name) { + for (CameraFacing facing : CameraFacing.values()) { + if (name.equals(facing.name)) { + return facing; + } + } + + return null; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CaptureControl.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CaptureControl.java new file mode 100644 index 0000000..7933ea7 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CaptureControl.java @@ -0,0 +1,42 @@ +package com.genymobile.scrcpy.video; + +import android.media.MediaCodec; + +public class CaptureControl { + + public static final int RESET_REASON_TERMINATED = 1; + public static final int RESET_REASON_DISPLAY_PROPERTIES_CHANGED = 1 << 1; + public static final int RESET_REASON_CLIENT_RESET = 1 << 2; + public static final int RESET_REASON_CLIENT_RESIZED = 1 << 3; + + private int reset = 0; + + // Current instance of MediaCodec to "interrupt" on reset + private MediaCodec runningMediaCodec; + + public synchronized boolean isResetRequested() { + return reset != 0; + } + + public synchronized int consumeReset() { + int value = reset; + reset = 0; + return value; + } + + public synchronized void reset(int reason) { + assert reason != 0; + reset |= reason; + if (runningMediaCodec != null) { + try { + runningMediaCodec.signalEndOfInputStream(); + } catch (IllegalStateException e) { + // ignore + } + } + } + + public synchronized void setRunningMediaCodec(MediaCodec runningMediaCodec) { + this.runningMediaCodec = runningMediaCodec; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/NewDisplayCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/NewDisplayCapture.java new file mode 100644 index 0000000..5ed4c73 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/NewDisplayCapture.java @@ -0,0 +1,374 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.Options; +import com.genymobile.scrcpy.control.PositionMapper; +import com.genymobile.scrcpy.display.DisplayInfo; +import com.genymobile.scrcpy.display.DisplayMonitor; +import com.genymobile.scrcpy.display.DisplayProperties; +import com.genymobile.scrcpy.display.DisplayPropertiesTracker; +import com.genymobile.scrcpy.display.DisplayResizeDebouncer; +import com.genymobile.scrcpy.model.NewDisplay; +import com.genymobile.scrcpy.model.Orientation; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.opengl.AffineOpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.AffineMatrix; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.wrappers.ServiceManager; + +import android.graphics.Rect; +import android.hardware.display.VirtualDisplay; +import android.os.Build; +import android.view.Surface; + +import java.io.IOException; + +public class NewDisplayCapture extends SurfaceCapture { + + // Internal fields copied from android.hardware.display.DisplayManager + private static final int VIRTUAL_DISPLAY_FLAG_PUBLIC = android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC; + private static final int VIRTUAL_DISPLAY_FLAG_PRESENTATION = android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION; + private static final int VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY = android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY; + private static final int VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH = 1 << 6; + private static final int VIRTUAL_DISPLAY_FLAG_ROTATES_WITH_CONTENT = 1 << 7; + private static final int VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL = 1 << 8; + private static final int VIRTUAL_DISPLAY_FLAG_SHOULD_SHOW_SYSTEM_DECORATIONS = 1 << 9; + private static final int VIRTUAL_DISPLAY_FLAG_TRUSTED = 1 << 10; + private static final int VIRTUAL_DISPLAY_FLAG_OWN_DISPLAY_GROUP = 1 << 11; + private static final int VIRTUAL_DISPLAY_FLAG_ALWAYS_UNLOCKED = 1 << 12; + private static final int VIRTUAL_DISPLAY_FLAG_TOUCH_FEEDBACK_DISABLED = 1 << 13; + private static final int VIRTUAL_DISPLAY_FLAG_OWN_FOCUS = 1 << 14; + private static final int VIRTUAL_DISPLAY_FLAG_DEVICE_DISPLAY_GROUP = 1 << 15; + + private final VirtualDisplayListener vdListener; + private final NewDisplay newDisplay; + + private final DisplayMonitor displayMonitor = new DisplayMonitor(); + + private AffineMatrix displayTransform; + private AffineMatrix eventTransform; + private OpenGLRunner glRunner; + + private Size mainDisplaySize; + private int mainDisplayDpi; + private final int displayImePolicy; + private final Rect crop; + private final boolean captureOrientationLocked; + private final Orientation captureOrientation; + private final float angle; + private final boolean vdDestroyContent; + private final boolean vdSystemDecorations; + private final boolean flexDisplay; + + private VideoConstraints videoConstraints; + + private VirtualDisplay virtualDisplay; + private Size videoSize; + private Size displaySize; // the logical size of the display (including rotation) + private Size physicalSize; // the physical size of the display (without rotation) + + private DisplayPropertiesTracker tracker; + private DisplayResizeDebouncer debouncer; + + private int dpi; + + public NewDisplayCapture(VirtualDisplayListener vdListener, Options options) { + this.vdListener = vdListener; + this.newDisplay = options.getNewDisplay(); + assert newDisplay != null; + this.displayImePolicy = options.getDisplayImePolicy(); + this.crop = options.getCrop(); + assert options.getCaptureOrientationLock() != null; + this.captureOrientationLocked = options.getCaptureOrientationLock() != Orientation.Lock.Unlocked; + this.captureOrientation = options.getCaptureOrientation(); + assert captureOrientation != null; + this.angle = options.getAngle(); + this.vdDestroyContent = options.getVDDestroyContent(); + this.vdSystemDecorations = options.getVDSystemDecorations(); + this.flexDisplay = options.getFlexDisplay(); + } + + @Override + protected void init(VideoConstraints videoConstraints) { + setVideoConstraints(videoConstraints); // synchronized + + displaySize = newDisplay.getSize(); + dpi = newDisplay.getDpi(); + if (flexDisplay) { + if (crop != null) { + throw new IllegalArgumentException("Flex display does not support cropping"); + } + + tracker = new DisplayPropertiesTracker(); + debouncer = new DisplayResizeDebouncer(this::triggerResize); + debouncer.start(); + + // Hardcode default values if not defined + if (displaySize == null) { + displaySize = new Size(1280, 960); + } + if (dpi == 0) { + dpi = 160; + } + } else if (displaySize == null || dpi == 0) { + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(0); + if (displayInfo != null) { + mainDisplaySize = displayInfo.getSize(); + if ((displayInfo.getRotation() % 2) != 0) { + mainDisplaySize = mainDisplaySize.rotate(); // Use the natural device orientation (at rotation 0), not the current one + } + mainDisplayDpi = displayInfo.getDpi(); + } else { + Ln.w("Main display not found, fallback to 1920x1080 240dpi"); + mainDisplaySize = new Size(1920, 1080); + mainDisplayDpi = 240; + } + } + } + + @Override + public void prepare() { + int displayRotation; + if (virtualDisplay == null) { + if (flexDisplay) { + assert displaySize != null; + displaySize = displaySize.constrain(videoConstraints, false); + } else { + if (displaySize == null) { + assert !flexDisplay; + displaySize = mainDisplaySize; + } + + // Align the physical display size to avoid unnecessary mismatches with the output size + displaySize = displaySize.align(videoConstraints.getAlignment()); + } + + if (dpi == 0) { + assert !flexDisplay; + dpi = scaleDpi(mainDisplaySize, mainDisplayDpi, displaySize); + } + + displayRotation = 0; + // Set the current display properties to avoid an unnecessary capture reset + displayMonitor.setSessionDisplayProperties(new DisplayProperties(displaySize, displayRotation)); + } else { + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(virtualDisplay.getDisplay().getDisplayId()); + dpi = displayInfo.getDpi(); + displayRotation = displayInfo.getRotation(); + displaySize = displayInfo.getSize(); + if (flexDisplay) { + displaySize = displaySize.constrain(videoConstraints, false); + } else { + // Align the physical display size to avoid unnecessary mismatches with the output size + displaySize = displaySize.align(videoConstraints.getAlignment()); + } + } + + VideoFilter filter = new VideoFilter(displaySize); + + if (crop != null) { + boolean transposed = (displayRotation % 2) != 0; + filter.addCrop(crop, transposed); + } + + filter.addOrientation(displayRotation, captureOrientationLocked, captureOrientation); + filter.addAngle(angle); + + if (!flexDisplay) { + Size outputSize = filter.getOutputSize(); + Size filteredSize = outputSize.constrain(videoConstraints); + if (!filteredSize.equals(outputSize)) { + filter.addResize(filteredSize); + } + } + + eventTransform = filter.getInverseTransform(); + + // DisplayInfo gives the oriented size (so videoSize includes the display rotation) + videoSize = filter.getOutputSize(); + + // However, the virtual display video always remains in its original orientation, so it must be rotated manually. + // This additional display rotation must not be included in the input events transform (the expected coordinates are already in the + // physical display size) + if ((displayRotation % 2) == 0) { + physicalSize = displaySize; + } else { + physicalSize = displaySize.rotate(); + } + VideoFilter displayFilter = new VideoFilter(physicalSize); + displayFilter.addRotation(displayRotation); + AffineMatrix displayRotationMatrix = displayFilter.getInverseTransform(); + + // Take care of multiplication order: + // displayTransform = (FILTER_MATRIX * DISPLAY_FILTER_MATRIX)⁻¹ + // = DISPLAY_FILTER_MATRIX⁻¹ * FILTER_MATRIX⁻¹ + // = displayRotationMatrix * eventTransform + displayTransform = AffineMatrix.multiplyAll(displayRotationMatrix, eventTransform); + } + + public void startNew(Surface surface) { + try { + int flags = VIRTUAL_DISPLAY_FLAG_PUBLIC + | VIRTUAL_DISPLAY_FLAG_PRESENTATION + | VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY + | VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH + | VIRTUAL_DISPLAY_FLAG_ROTATES_WITH_CONTENT; + if (vdDestroyContent) { + flags |= VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL; + } + if (vdSystemDecorations) { + flags |= VIRTUAL_DISPLAY_FLAG_SHOULD_SHOW_SYSTEM_DECORATIONS; + } + if (Build.VERSION.SDK_INT >= AndroidVersions.API_33_ANDROID_13) { + flags |= VIRTUAL_DISPLAY_FLAG_TRUSTED + | VIRTUAL_DISPLAY_FLAG_OWN_DISPLAY_GROUP + | VIRTUAL_DISPLAY_FLAG_ALWAYS_UNLOCKED + | VIRTUAL_DISPLAY_FLAG_TOUCH_FEEDBACK_DISABLED; + if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) { + flags |= VIRTUAL_DISPLAY_FLAG_OWN_FOCUS + | VIRTUAL_DISPLAY_FLAG_DEVICE_DISPLAY_GROUP; + } + } + VirtualDisplay vd = ServiceManager.getDisplayManager() + .createNewVirtualDisplay("scrcpy", displaySize.getWidth(), displaySize.getHeight(), dpi, surface, flags); + setCurrentVirtualDisplay(vd); // used for client resize + int virtualDisplayId = vd.getDisplay().getDisplayId(); + Ln.i("New display: " + displaySize.getWidth() + "x" + displaySize.getHeight() + "/" + dpi + " (id=" + virtualDisplayId + ")"); + + if (displayImePolicy != -1) { + ServiceManager.getWindowManager().setDisplayImePolicy(virtualDisplayId, displayImePolicy); + } + + displayMonitor.start(virtualDisplayId, (props) -> { + int reason; + if (flexDisplay) { + boolean isClientResize = tracker.onChanged(props); + if (isClientResize) { + reason = CaptureControl.RESET_REASON_CLIENT_RESIZED; + } else { + reason = CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED; + // Display properties have changed, cancel pending client resize requests + debouncer.cancelResize(); + } + } else { + reason = CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED; + } + getCaptureControl().reset(reason); + }); + } catch (Exception e) { + Ln.e("Could not create display", e); + throw new AssertionError("Could not create display"); + } + } + + @Override + public void start(Surface surface) throws IOException { + if (displayTransform != null) { + assert glRunner == null; + OpenGLFilter glFilter = new AffineOpenGLFilter(displayTransform); + glRunner = new OpenGLRunner(glFilter); + surface = glRunner.start(physicalSize, videoSize, surface); + } + + if (virtualDisplay == null) { + startNew(surface); + } else { + virtualDisplay.setSurface(surface); + } + + if (vdListener != null) { + PositionMapper positionMapper = PositionMapper.create(videoSize, eventTransform, displaySize); + vdListener.onNewVirtualDisplay(virtualDisplay.getDisplay().getDisplayId(), positionMapper); + } + } + + @Override + public void stop() { + if (glRunner != null) { + glRunner.stopAndRelease(); + glRunner = null; + } + } + + @Override + public void release() { + displayMonitor.stopAndRelease(); + + if (debouncer != null) { + debouncer.stop(); + } + + if (virtualDisplay != null) { + // synchronized with triggerResize() + synchronized (this) { + virtualDisplay.release(); + setCurrentVirtualDisplay(null); + } + } + } + + @Override + public Size getSize() { + return videoSize; + } + + @Override + protected boolean applyNewVideoConstraints(VideoConstraints videoConstraints) { + setVideoConstraints(videoConstraints); // with synchronization + return true; + } + + private static int scaleDpi(Size initialSize, int initialDpi, Size size) { + int den = initialSize.getMax(); + int num = size.getMax(); + return initialDpi * num / den; + } + + public void requestResize(int width, int height) { + if (!flexDisplay) { + throw new IllegalStateException("Cannot resize a non-flex display"); + } + + VideoConstraints constraints = getVideoConstraints(); // synchronized + Size newSize = new Size(width, height).constrain(constraints, false); + if (Ln.isEnabled(Ln.Level.VERBOSE)) { + Ln.v(getClass().getSimpleName() + ": requestResize(" + width + ", " + height + ")"); + Ln.v(getClass().getSimpleName() + ": constrained size = " + newSize); + } + + debouncer.requestResize(newSize); + } + + private synchronized void setCurrentVirtualDisplay(VirtualDisplay virtualDisplay) { + this.virtualDisplay = virtualDisplay; + } + + private synchronized VideoConstraints getVideoConstraints() { + return videoConstraints; + } + + private synchronized void setVideoConstraints(VideoConstraints videoConstraints) { + this.videoConstraints = videoConstraints; + } + + private synchronized void triggerResize(Size size) { + if (virtualDisplay != null) { + size = size.constrain(videoConstraints, false); // in case the constraints have changed + int displayId = virtualDisplay.getDisplay().getDisplayId(); + DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId); + @SuppressWarnings("checkstyle:HiddenField") // hides this.dpi on purpose + int dpi = displayInfo.getDpi(); + int displayRotation = displayInfo.getRotation(); + if (captureOrientation.isSwap()) { + size = size.rotate(); + } + tracker.pushClientRequest(new DisplayProperties(size, displayRotation)); + + // Although the display size (as detected by the DisplayMonitor) is rotated, the virtual display itself is not + Size vdSize = (displayRotation % 2) == 0 ? size : size.rotate(); + virtualDisplay.resize(vdSize.getWidth(), vdSize.getHeight(), dpi); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/ScreenCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/ScreenCapture.java new file mode 100644 index 0000000..0768181 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/ScreenCapture.java @@ -0,0 +1,228 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.Options; +import com.genymobile.scrcpy.control.PositionMapper; +import com.genymobile.scrcpy.device.Device; +import com.genymobile.scrcpy.display.DisplayInfo; +import com.genymobile.scrcpy.display.DisplayMonitor; +import com.genymobile.scrcpy.display.DisplayProperties; +import com.genymobile.scrcpy.model.ConfigurationException; +import com.genymobile.scrcpy.model.Orientation; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.opengl.AffineOpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLFilter; +import com.genymobile.scrcpy.opengl.OpenGLRunner; +import com.genymobile.scrcpy.util.AffineMatrix; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; +import com.genymobile.scrcpy.wrappers.ServiceManager; +import com.genymobile.scrcpy.wrappers.SurfaceControl; + +import android.graphics.Rect; +import android.hardware.display.VirtualDisplay; +import android.os.Build; +import android.os.IBinder; +import android.view.Surface; + +import java.io.IOException; +import java.util.Locale; + +public class ScreenCapture extends SurfaceCapture { + + private final VirtualDisplayListener vdListener; + private final int displayId; + private final Rect crop; + private Orientation.Lock captureOrientationLock; + private Orientation captureOrientation; + private final float angle; + + private VideoConstraints videoConstraints; + + private DisplayInfo displayInfo; + private Size videoSize; + + private final DisplayMonitor displayMonitor = new DisplayMonitor(); + + private IBinder display; + private VirtualDisplay virtualDisplay; + + private AffineMatrix transform; + private OpenGLRunner glRunner; + + public ScreenCapture(VirtualDisplayListener vdListener, Options options) { + this.vdListener = vdListener; + this.displayId = options.getDisplayId(); + assert displayId != Device.DISPLAY_ID_NONE; + this.crop = options.getCrop(); + this.captureOrientationLock = options.getCaptureOrientationLock(); + this.captureOrientation = options.getCaptureOrientation(); + assert captureOrientationLock != null; + assert captureOrientation != null; + this.angle = options.getAngle(); + } + + @Override + public void init(VideoConstraints videoConstraints) { + this.videoConstraints = videoConstraints; + displayMonitor.start(displayId, (props) -> getCaptureControl().reset(CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED)); + } + + @Override + public void prepare() throws ConfigurationException { + displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId); + if (displayInfo == null) { + Ln.e("Display " + displayId + " not found\n" + LogUtils.buildDisplayListMessage()); + throw new ConfigurationException("Unknown display id: " + displayId); + } + + if ((displayInfo.getFlags() & DisplayInfo.FLAG_SUPPORTS_PROTECTED_BUFFERS) == 0) { + Ln.w("Display doesn't have FLAG_SUPPORTS_PROTECTED_BUFFERS flag, mirroring can be restricted"); + } + + Size displaySize = displayInfo.getSize(); + int displayRotation = displayInfo.getRotation(); + displayMonitor.setSessionDisplayProperties(new DisplayProperties(displaySize, displayRotation)); + + if (captureOrientationLock == Orientation.Lock.LockedInitial) { + // The user requested to lock the video orientation to the current orientation + captureOrientationLock = Orientation.Lock.LockedValue; + captureOrientation = Orientation.fromRotation(displayRotation); + } + + VideoFilter filter = new VideoFilter(displaySize); + + if (crop != null) { + boolean transposed = (displayRotation % 2) != 0; + filter.addCrop(crop, transposed); + } + + boolean locked = captureOrientationLock != Orientation.Lock.Unlocked; + filter.addOrientation(displayRotation, locked, captureOrientation); + filter.addAngle(angle); + + transform = filter.getInverseTransform(); + videoSize = filter.getOutputSize().constrain(videoConstraints); + } + + @Override + public void start(Surface surface) throws IOException { + if (display != null) { + SurfaceControl.destroyDisplay(display); + display = null; + } + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + + Size inputSize; + if (transform != null) { + // If there is a filter, it must receive the full display content + inputSize = displayInfo.getSize(); + assert glRunner == null; + OpenGLFilter glFilter = new AffineOpenGLFilter(transform); + glRunner = new OpenGLRunner(glFilter); + surface = glRunner.start(inputSize, videoSize, surface); + } else { + // If there is no filter, the display must be rendered at target video size directly + inputSize = videoSize; + } + + try { + virtualDisplay = ServiceManager.getDisplayManager() + .createVirtualDisplay("scrcpy", inputSize.getWidth(), inputSize.getHeight(), displayId, surface); + Ln.d("Display: using DisplayManager API"); + } catch (Exception displayManagerException) { + if (Build.BRAND.equalsIgnoreCase("oculus") && Build.MODEL.toLowerCase(Locale.ROOT).startsWith("quest")) { + // Workaround for buggy createVirtualDisplay on Quest + try { + virtualDisplay = (VirtualDisplay) VirtualDisplay.class.getDeclaredConstructors()[0].newInstance(null, null, null, surface); + } catch (ReflectiveOperationException e) { + Ln.e("Could not create VirtualDisplay", e); + } + } else { + try { + display = createDisplay(); + + Size deviceSize = displayInfo.getSize(); + int layerStack = displayInfo.getLayerStack(); + setDisplaySurface(display, surface, deviceSize.toRect(), inputSize.toRect(), layerStack); + Ln.d("Display: using SurfaceControl API"); + } catch (Exception surfaceControlException) { + Ln.e("Could not create display using DisplayManager", displayManagerException); + Ln.e("Could not create display using SurfaceControl", surfaceControlException); + throw new AssertionError("Could not create display"); + } + } + } + + if (vdListener != null) { + int virtualDisplayId; + PositionMapper positionMapper; + if (virtualDisplay == null || displayId == 0) { + // Surface control or main display: send all events to the original display, relative to the device size + Size deviceSize = displayInfo.getSize(); + positionMapper = PositionMapper.create(videoSize, transform, deviceSize); + virtualDisplayId = displayId; + } else { + // The positions are relative to the virtual display, not the original display (so use inputSize, not deviceSize!) + positionMapper = PositionMapper.create(videoSize, transform, inputSize); + virtualDisplayId = virtualDisplay.getDisplay().getDisplayId(); + } + vdListener.onNewVirtualDisplay(virtualDisplayId, positionMapper); + } + } + + @Override + public void stop() { + if (glRunner != null) { + glRunner.stopAndRelease(); + glRunner = null; + } + } + + @Override + public void release() { + displayMonitor.stopAndRelease(); + + if (display != null) { + SurfaceControl.destroyDisplay(display); + display = null; + } + if (virtualDisplay != null) { + virtualDisplay.release(); + virtualDisplay = null; + } + } + + @Override + public Size getSize() { + return videoSize; + } + + @Override + protected boolean applyNewVideoConstraints(VideoConstraints videoConstraints) { + this.videoConstraints = videoConstraints; + return true; + } + + private static IBinder createDisplay() throws Exception { + // Since Android 12 (preview), secure displays could not be created with shell permissions anymore. + // On Android 12 preview, SDK_INT is still R (not S), but CODENAME is "S". + boolean secure = Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11 || (Build.VERSION.SDK_INT == AndroidVersions.API_30_ANDROID_11 + && !"S".equals(Build.VERSION.CODENAME)); + return SurfaceControl.createDisplay("scrcpy", secure); + } + + private static void setDisplaySurface(IBinder display, Surface surface, Rect deviceRect, Rect displayRect, int layerStack) { + SurfaceControl.openTransaction(); + try { + SurfaceControl.setDisplaySurface(display, surface); + SurfaceControl.setDisplayProjection(display, 0, deviceRect, displayRect); + SurfaceControl.setDisplayLayerStack(display, layerStack); + } finally { + SurfaceControl.closeTransaction(); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceCapture.java new file mode 100644 index 0000000..4dd2879 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceCapture.java @@ -0,0 +1,85 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.model.ConfigurationException; +import com.genymobile.scrcpy.model.Size; + +import android.view.Surface; + +import java.io.IOException; + +/** + * A video source which can be rendered on a Surface for encoding. + */ +public abstract class SurfaceCapture { + + private CaptureControl captureControl; + + /** + * Called once before the first capture starts. + */ + public final void init(CaptureControl captureControl, VideoConstraints videoConstraints) throws ConfigurationException, IOException { + this.captureControl = captureControl; + init(videoConstraints); + } + + public CaptureControl getCaptureControl() { + return captureControl; + } + + /** + * Called once before the first capture starts. + * + * @param videoConstraints the video constraints + */ + protected abstract void init(VideoConstraints videoConstraints) throws ConfigurationException, IOException; + + /** + * Called after the last capture ends (if and only if {@link #init(VideoConstraints)} has been called). + */ + public abstract void release(); + + /** + * Called once before each capture starts, before {@link #getSize()}. + */ + public void prepare() throws ConfigurationException, IOException { + // empty by default + } + + /** + * Start the capture to the target surface. + * + * @param surface the surface which will be encoded + */ + public abstract void start(Surface surface) throws IOException; + + /** + * Stop the capture. + */ + public void stop() { + // Do nothing by default + } + + /** + * Return the video size + * + * @return the video size + */ + public abstract Size getSize(); + + /** + * Indicate if the capture has been closed internally. + * + * @return {@code true} is the capture is closed, {@code false} otherwise. + */ + public boolean isClosed() { + return false; + } + + /** + * Set new video constraints (called by the encoder to retry when encoding fails) + * + * @param videoConstraints the video constraints + * @return {@code true} if the implementation accepts the new constraints, {@code false} otherwise. + */ + protected abstract boolean applyNewVideoConstraints(VideoConstraints videoConstraints); +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceEncoder.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceEncoder.java new file mode 100644 index 0000000..539681b --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceEncoder.java @@ -0,0 +1,388 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.AsyncProcessor; +import com.genymobile.scrcpy.Options; +import com.genymobile.scrcpy.device.Streamer; +import com.genymobile.scrcpy.model.Codec; +import com.genymobile.scrcpy.model.CodecOption; +import com.genymobile.scrcpy.model.ConfigurationException; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.CodecUtils; +import com.genymobile.scrcpy.util.IO; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.LogUtils; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Looper; +import android.os.SystemClock; +import android.view.Surface; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +public class SurfaceEncoder implements AsyncProcessor { + + private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds + private static final int REPEAT_FRAME_DELAY_US = 100_000; // repeat after 100ms + private static final String KEY_MAX_FPS_TO_ENCODER = "max-fps-to-encoder"; + + // Keep the values in descending order + private static final int[] MAX_SIZE_FALLBACK = {2560, 1920, 1600, 1280, 1024, 800}; + private static final int MAX_CONSECUTIVE_ERRORS = 3; + + private final SurfaceCapture capture; + private final Streamer streamer; + private final String encoderName; + private final List codecOptions; + private final int videoBitRate; + private final int maxSize; + private final float maxFps; + private final boolean downsizeOnError; + private final int minSizeAlignment; + private final boolean ignoreVideoEncoderConstraints; + + private boolean firstFrameSent; + private int consecutiveErrors; + + private Thread thread; + private final AtomicBoolean stopped = new AtomicBoolean(); + + private final CaptureControl captureControl = new CaptureControl(); + + private VideoConstraints videoConstraints; + + public SurfaceEncoder(SurfaceCapture capture, Streamer streamer, Options options) { + this.capture = capture; + this.streamer = streamer; + this.videoBitRate = options.getVideoBitRate(); + this.maxSize = options.getMaxSize(); + this.maxFps = options.getMaxFps(); + this.codecOptions = options.getVideoCodecOptions(); + this.encoderName = options.getVideoEncoder(); + this.downsizeOnError = options.getDownsizeOnError(); + this.minSizeAlignment = options.getMinSizeAlignment(); + this.ignoreVideoEncoderConstraints = options.getIgnoreVideoEncoderConstraints(); + } + + private void streamCapture() throws IOException, ConfigurationException { + Codec codec = streamer.getCodec(); + MediaCodec mediaCodec = createMediaCodec(codec, encoderName); + MediaFormat format = createFormat(codec.getMimeType(), videoBitRate, maxFps, codecOptions); + + MediaCodecInfo.VideoCapabilities caps; + int alignment; + if (ignoreVideoEncoderConstraints) { + caps = null; + alignment = 1; + } else { + caps = mediaCodec.getCodecInfo().getCapabilitiesForType(codec.getMimeType()).getVideoCapabilities(); + assert caps != null; // caps cannot be null for a video codec + alignment = Math.max(caps.getWidthAlignment(), caps.getHeightAlignment()); + Ln.d("Video codec size alignment requirement: " + alignment + "px"); + } + if (alignment < minSizeAlignment) { + alignment = minSizeAlignment; + Ln.d("Actual video size alignment: " + alignment + "px"); + } + + // Do not constrain by the declared video encoder capabilities before encoding actually fails + videoConstraints = new VideoConstraints(maxSize, alignment, null); + + capture.init(captureControl, videoConstraints); + + try { + boolean alive; + + streamer.writeVideoHeader(); + + int retainedResetReasons = 0; + + do { + int resetReasons = captureControl.consumeReset(); + if ((resetReasons & CaptureControl.RESET_REASON_TERMINATED) != 0) { + break; + } + if (retainedResetReasons != 0) { + // The reasons for the previous failed encoding must be preserved when retrying + resetReasons |= retainedResetReasons; + retainedResetReasons = 0; + } + + capture.prepare(); + Size size = capture.getSize(); + + format.setInteger(MediaFormat.KEY_WIDTH, size.getWidth()); + format.setInteger(MediaFormat.KEY_HEIGHT, size.getHeight()); + + Surface surface = null; + boolean mediaCodecStarted = false; + boolean captureStarted = false; + try { + mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + surface = mediaCodec.createInputSurface(); + + capture.start(surface); + captureStarted = true; + + mediaCodec.start(); + mediaCodecStarted = true; + + // Set the MediaCodec instance to "interrupt" (by signaling an EOS) on reset + captureControl.setRunningMediaCodec(mediaCodec); + + if (stopped.get()) { + alive = false; + } else { + if (!captureControl.isResetRequested()) { + // The reset is due to a resize initiated by the client + boolean isClientResize = (resetReasons & CaptureControl.RESET_REASON_CLIENT_RESIZED) != 0 + && (resetReasons & CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED) == 0; + streamer.writeSessionMeta(size.getWidth(), size.getHeight(), isClientResize); + + // If a reset is requested during encode(), it will interrupt the encoding by an EOS + encode(mediaCodec, streamer); + } + + // The capture might have been closed internally (for example if the camera is disconnected) + alive = !stopped.get() && !capture.isClosed(); + } + } catch (IllegalStateException | IllegalArgumentException | IOException e) { + if (IO.isBrokenPipe(e)) { + // Do not retry on broken pipe, which is expected on close because the socket is closed by the client + throw e; + } + Ln.e("Capture/encoding error: " + e.getClass().getName() + ": " + e.getMessage()); + if (!prepareRetry(caps, size)) { + throw e; + } + // Keep the current resetReasons flags for the retry + retainedResetReasons = resetReasons; + alive = true; + } finally { + captureControl.setRunningMediaCodec(null); + if (captureStarted) { + capture.stop(); + } + if (mediaCodecStarted) { + try { + mediaCodec.stop(); + } catch (IllegalStateException e) { + // ignore (just in case) + } + } + mediaCodec.reset(); + if (surface != null) { + surface.release(); + } + } + } while (alive); + } finally { + mediaCodec.release(); + capture.release(); + } + } + + private boolean prepareRetry(MediaCodecInfo.VideoCapabilities caps, Size currentSize) { + if (firstFrameSent) { + ++consecutiveErrors; + if (consecutiveErrors < MAX_CONSECUTIVE_ERRORS) { + // Wait a bit to increase the probability that retrying will fix the problem + SystemClock.sleep(50); + return true; + } + } + + if (!downsizeOnError) { + // Must fail immediately + return false; + } + + if (caps != null && videoConstraints.getEncoderCapabilities() == null) { + assert !ignoreVideoEncoderConstraints : "caps != null implies !ignoreVideoEncoderConstraints"; + Ln.i("Applying video encoder constraints"); + videoConstraints = videoConstraints.withCapabilities(caps); + boolean accepted = capture.applyNewVideoConstraints(videoConstraints); + if (accepted) { + return true; + } + } + + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + // Definitively fail + return false; + } + + // Downsizing on error is only enabled if an encoding failure occurs before the first frame, or if the video constraints were not applied + // (downsizing later could be surprising) + + int newMaxSize = chooseMaxSizeFallback(currentSize); + if (newMaxSize == 0) { + // Must definitively fail + return false; + } + + boolean accepted = capture.applyNewVideoConstraints(videoConstraints.withMaxSize(newMaxSize)); + if (!accepted) { + return false; + } + + // Retry with a smaller size + Ln.i("Retrying with -m" + newMaxSize + "..."); + return true; + } + + private static int chooseMaxSizeFallback(Size failedSize) { + int currentMaxSize = Math.max(failedSize.getWidth(), failedSize.getHeight()); + for (int value : MAX_SIZE_FALLBACK) { + if (value < currentMaxSize) { + // We found a smaller value to reduce the video size + return value; + } + } + // No fallback, fail definitively + return 0; + } + + private void encode(MediaCodec codec, Streamer streamer) throws IOException { + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + boolean eos; + do { + int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1); + try { + eos = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + // On EOS, there might be data or not, depending on bufferInfo.size + if (outputBufferId >= 0 && bufferInfo.size > 0) { + boolean isConfig = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0; + if (!isConfig) { + // If this is not a config packet, then it contains a frame + firstFrameSent = true; + consecutiveErrors = 0; + } + + ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId); + streamer.writePacket(codecBuffer, bufferInfo); + } + } finally { + if (outputBufferId >= 0) { + codec.releaseOutputBuffer(outputBufferId, false); + } + } + } while (!eos); + } + + private static MediaCodec createMediaCodec(Codec codec, String encoderName) throws IOException, ConfigurationException { + if (encoderName != null) { + Ln.d("Creating encoder by name: '" + encoderName + "'"); + try { + MediaCodec mediaCodec = MediaCodec.createByCodecName(encoderName); + String mimeType = Codec.getMimeType(mediaCodec); + if (!codec.getMimeType().equals(mimeType)) { + Ln.e("Video encoder type for \"" + encoderName + "\" (" + mimeType + ") does not match codec type (" + codec.getMimeType() + ")"); + throw new ConfigurationException("Incorrect encoder type: " + encoderName); + } + return mediaCodec; + } catch (IllegalArgumentException e) { + Ln.e("Video encoder '" + encoderName + "' for " + codec.getName() + " not found\n" + LogUtils.buildVideoEncoderListMessage()); + throw new ConfigurationException("Unknown encoder: " + encoderName); + } catch (IOException e) { + Ln.e("Could not create video encoder '" + encoderName + "' for " + codec.getName() + "\n" + LogUtils.buildVideoEncoderListMessage()); + throw e; + } + } + + try { + MediaCodec mediaCodec = MediaCodec.createEncoderByType(codec.getMimeType()); + Ln.d("Using video encoder: '" + mediaCodec.getName() + "'"); + return mediaCodec; + } catch (IOException | IllegalArgumentException e) { + Ln.e("Could not create default video encoder for " + codec.getName() + "\n" + LogUtils.buildVideoEncoderListMessage()); + throw e; + } + } + + private static MediaFormat createFormat(String videoMimeType, int bitRate, float maxFps, List codecOptions) { + MediaFormat format = new MediaFormat(); + format.setString(MediaFormat.KEY_MIME, videoMimeType); + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); + // must be present to configure the encoder, but does not impact the actual frame rate, which is variable + format.setInteger(MediaFormat.KEY_FRAME_RATE, 60); + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0) { + format.setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED); + } + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL); + // display the very first frame, and recover from bad quality when no new frames + format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs + if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) { + // real-time priority + format.setInteger(MediaFormat.KEY_PRIORITY, 0); + } + if (Build.VERSION.SDK_INT >= AndroidVersions.API_26_ANDROID_8_0) { + // output 1 frame as soon as 1 frame is queued + format.setInteger(MediaFormat.KEY_LATENCY, 1); + } + if (maxFps > 0) { + // The key existed privately before Android 10: + // + // + format.setFloat(KEY_MAX_FPS_TO_ENCODER, maxFps); + } + + if (codecOptions != null) { + for (CodecOption option : codecOptions) { + String key = option.getKey(); + Object value = option.getValue(); + CodecUtils.setCodecOption(format, key, value); + Ln.d("Video codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value); + } + } + + return format; + } + + @Override + public void start(TerminationListener listener) { + thread = new Thread(() -> { + // Some devices (Meizu) deadlock if the video encoding thread has no Looper + // + Looper.prepare(); + + try { + streamCapture(); + } catch (ConfigurationException e) { + // Do not print stack trace, a user-friendly error-message has already been logged + } catch (IOException e) { + // Broken pipe is expected on close, because the socket is closed by the client + if (!IO.isBrokenPipe(e)) { + Ln.e("Video encoding error", e); + } + } finally { + Ln.d("Screen streaming stopped"); + listener.onTerminated(true); + } + }, "video"); + thread.start(); + } + + @Override + public void stop() { + if (thread != null) { + stopped.set(true); + captureControl.reset(CaptureControl.RESET_REASON_TERMINATED); + } + } + + @Override + public void join() throws InterruptedException { + if (thread != null) { + thread.join(); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoCodec.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoCodec.java new file mode 100644 index 0000000..8b9de0f --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoCodec.java @@ -0,0 +1,54 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.model.Codec; + +import android.annotation.SuppressLint; +import android.media.MediaFormat; + +public enum VideoCodec implements Codec { + H264(0x68_32_36_34, "h264", MediaFormat.MIMETYPE_VIDEO_AVC), + H265(0x68_32_36_35, "h265", MediaFormat.MIMETYPE_VIDEO_HEVC), + @SuppressLint("InlinedApi") // introduced in API 29 + AV1(0x00_61_76_31, "av1", MediaFormat.MIMETYPE_VIDEO_AV1), + VP8(0x00_76_70_38, "vp8", MediaFormat.MIMETYPE_VIDEO_VP8), + VP9(0x00_76_70_39, "vp9", MediaFormat.MIMETYPE_VIDEO_VP9); + + private final int id; // 4-byte ASCII representation of the name + private final String name; + private final String mimeType; + + VideoCodec(int id, String name, String mimeType) { + this.id = id; + this.name = name; + this.mimeType = mimeType; + } + + @Override + public Type getType() { + return Type.VIDEO; + } + + @Override + public int getId() { + return id; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getMimeType() { + return mimeType; + } + + public static VideoCodec findByName(String name) { + for (VideoCodec codec : values()) { + if (codec.name.equals(name)) { + return codec; + } + } + return null; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoConstraints.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoConstraints.java new file mode 100644 index 0000000..fe08631 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoConstraints.java @@ -0,0 +1,69 @@ +package com.genymobile.scrcpy.video; + +import android.media.MediaCodecInfo; + +public class VideoConstraints { + private final int maxSize; + private final int alignment; + private final MediaCodecInfo.VideoCapabilities caps; + + public VideoConstraints(int maxSize, int alignment, MediaCodecInfo.VideoCapabilities caps) { + assert maxSize >= 0 : "Max size must not be negative"; + this.maxSize = maxSize; + + assert alignment > 0 : "Alignment must be positive"; + assert (alignment & (alignment - 1)) == 0 : "Alignment must be a power-of-two"; + this.alignment = alignment; + + this.caps = caps; + } + + /** + * Return the max size (0 if not requested) + * + * @return the max requested size + */ + public int getMaxSize() { + return maxSize; + } + + /** + * Return the video alignment. + *

+ * This a power-of-2 value that the video width and height must be multiples of. + * + * @return the video alignment + */ + public int getAlignment() { + return alignment; + } + + /** + * Return the video encoder capabilities. + * + * @return the video encoder capabilities + */ + public MediaCodecInfo.VideoCapabilities getEncoderCapabilities() { + return caps; + } + + /** + * Return the video constraints with the provided max size. + * + * @param maxSize the max requested size + * @return the new video constraints + */ + public VideoConstraints withMaxSize(int maxSize) { + return new VideoConstraints(maxSize, alignment, caps); + } + + /** + * Return the video constraints with the provided video capabilities. + * + * @param caps the video encoder capabilities + * @return the new video constraints + */ + public VideoConstraints withCapabilities(MediaCodecInfo.VideoCapabilities caps) { + return new VideoConstraints(maxSize, alignment, caps); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoFilter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoFilter.java new file mode 100644 index 0000000..9a0dec6 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoFilter.java @@ -0,0 +1,119 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.model.Orientation; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.AffineMatrix; + +import android.graphics.Rect; + +public class VideoFilter { + + private Size size; + private AffineMatrix transform; + + public VideoFilter(Size inputSize) { + this.size = inputSize; + } + + public Size getOutputSize() { + return size; + } + + public AffineMatrix getTransform() { + return transform; + } + + /** + * Return the inverse transform. + *

+ * The direct affine transform describes how the input image is transformed. + *

+ * It is often useful to retrieve the inverse transform instead: + *

    + *
  • The OpenGL filter expects the matrix to transform the image coordinates, which is the inverse transform;
  • + *
  • The click positions must be transformed back to the device positions, using the inverse transform too.
  • + *
+ * + * @return the inverse transform + */ + public AffineMatrix getInverseTransform() { + if (transform == null) { + return null; + } + return transform.invert(); + } + + private static Rect transposeRect(Rect rect) { + return new Rect(rect.top, rect.left, rect.bottom, rect.right); + } + + public void addCrop(Rect crop, boolean transposed) { + if (transposed) { + crop = transposeRect(crop); + } + + double inputWidth = size.getWidth(); + double inputHeight = size.getHeight(); + + if (crop.left < 0 || crop.top < 0 || crop.right > inputWidth || crop.bottom > inputHeight) { + throw new IllegalArgumentException("Crop " + crop + " exceeds the input area (" + size + ")"); + } + + double x = crop.left / inputWidth; + double y = 1 - (crop.bottom / inputHeight); // OpenGL origin is bottom-left + double w = crop.width() / inputWidth; + double h = crop.height() / inputHeight; + + transform = AffineMatrix.reframe(x, y, w, h).multiply(transform); + size = new Size(crop.width(), crop.height()); + } + + public void addRotation(int ccwRotation) { + if (ccwRotation == 0) { + return; + } + + transform = AffineMatrix.rotateOrtho(ccwRotation).multiply(transform); + if (ccwRotation % 2 != 0) { + size = size.rotate(); + } + } + + public void addOrientation(Orientation captureOrientation) { + if (captureOrientation.isFlipped()) { + transform = AffineMatrix.hflip().multiply(transform); + } + int ccwRotation = (4 - captureOrientation.getRotation()) % 4; + addRotation(ccwRotation); + } + + public void addOrientation(int displayRotation, boolean locked, Orientation captureOrientation) { + if (locked) { + // flip/rotate the current display from the natural device orientation (i.e. where display rotation is 0) + int reverseDisplayRotation = (4 - displayRotation) % 4; + addRotation(reverseDisplayRotation); + } + addOrientation(captureOrientation); + } + + public void addAngle(double cwAngle) { + if (cwAngle == 0) { + return; + } + double ccwAngle = -cwAngle; + transform = AffineMatrix.rotate(ccwAngle).withAspectRatio(size).fromCenter().multiply(transform); + } + + public void addResize(Size targetSize) { + if (size.equals(targetSize)) { + return; + } + + if (transform == null) { + // The requested scaling is performed by the viewport (by changing the output size), but the OpenGL filter must still run, even if + // resizing is not performed by the shader. So transform MUST NOT be null. + transform = AffineMatrix.IDENTITY; + } + size = targetSize; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoSource.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoSource.java new file mode 100644 index 0000000..53b54a5 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoSource.java @@ -0,0 +1,22 @@ +package com.genymobile.scrcpy.video; + +public enum VideoSource { + DISPLAY("display"), + CAMERA("camera"); + + private final String name; + + VideoSource(String name) { + this.name = name; + } + + public static VideoSource findByName(String name) { + for (VideoSource videoSource : VideoSource.values()) { + if (name.equals(videoSource.name)) { + return videoSource; + } + } + + return null; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VirtualDisplayListener.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VirtualDisplayListener.java new file mode 100644 index 0000000..c079265 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VirtualDisplayListener.java @@ -0,0 +1,7 @@ +package com.genymobile.scrcpy.video; + +import com.genymobile.scrcpy.control.PositionMapper; + +public interface VirtualDisplayListener { + void onNewVirtualDisplay(int displayId, PositionMapper positionMapper); +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ActivityManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ActivityManager.java new file mode 100644 index 0000000..0150888 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ActivityManager.java @@ -0,0 +1,192 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.IContentProvider; +import android.content.Intent; +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.os.IInterface; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +@SuppressLint("PrivateApi,DiscouragedPrivateApi") +public final class ActivityManager { + + private final IInterface manager; + private Method getContentProviderExternalMethod; + private boolean getContentProviderExternalMethodNewVersion = true; + private Method removeContentProviderExternalMethod; + private Method startActivityAsUserMethod; + private Method forceStopPackageMethod; + private Method broadcastIntentMethod; + + static ActivityManager create() { + try { + // On old Android versions, the ActivityManager is not exposed via AIDL, + // so use ActivityManagerNative.getDefault() + Class cls = Class.forName("android.app.ActivityManagerNative"); + Method getDefaultMethod = cls.getDeclaredMethod("getDefault"); + IInterface am = (IInterface) getDefaultMethod.invoke(null); + return new ActivityManager(am); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private ActivityManager(IInterface manager) { + this.manager = manager; + } + + private Method getGetContentProviderExternalMethod() throws NoSuchMethodException { + if (getContentProviderExternalMethod == null) { + try { + getContentProviderExternalMethod = manager.getClass() + .getMethod("getContentProviderExternal", String.class, int.class, IBinder.class, String.class); + } catch (NoSuchMethodException e) { + // old version + getContentProviderExternalMethod = manager.getClass().getMethod("getContentProviderExternal", String.class, int.class, IBinder.class); + getContentProviderExternalMethodNewVersion = false; + } + } + return getContentProviderExternalMethod; + } + + private Method getRemoveContentProviderExternalMethod() throws NoSuchMethodException { + if (removeContentProviderExternalMethod == null) { + removeContentProviderExternalMethod = manager.getClass().getMethod("removeContentProviderExternal", String.class, IBinder.class); + } + return removeContentProviderExternalMethod; + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + public IContentProvider getContentProviderExternal(String name, IBinder token) { + try { + Method method = getGetContentProviderExternalMethod(); + Object[] args; + if (getContentProviderExternalMethodNewVersion) { + // new version + args = new Object[]{name, FakeContext.ROOT_UID, token, null}; + } else { + // old version + args = new Object[]{name, FakeContext.ROOT_UID, token}; + } + // ContentProviderHolder providerHolder = getContentProviderExternal(...); + Object providerHolder = method.invoke(manager, args); + if (providerHolder == null) { + return null; + } + // IContentProvider provider = providerHolder.provider; + Field providerField = providerHolder.getClass().getDeclaredField("provider"); + providerField.setAccessible(true); + return (IContentProvider) providerField.get(providerHolder); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } + + void removeContentProviderExternal(String name, IBinder token) { + try { + Method method = getRemoveContentProviderExternalMethod(); + method.invoke(manager, name, token); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public ContentProvider createSettingsProvider() { + IBinder token = new Binder(); + IContentProvider provider = getContentProviderExternal("settings", token); + if (provider == null) { + return null; + } + return new ContentProvider(this, provider, "settings", token); + } + + private Method getStartActivityAsUserMethod() throws NoSuchMethodException, ClassNotFoundException { + if (startActivityAsUserMethod == null) { + Class iApplicationThreadClass = Class.forName("android.app.IApplicationThread"); + Class profilerInfo = Class.forName("android.app.ProfilerInfo"); + startActivityAsUserMethod = manager.getClass() + .getMethod("startActivityAsUser", iApplicationThreadClass, String.class, Intent.class, String.class, IBinder.class, String.class, + int.class, int.class, profilerInfo, Bundle.class, int.class); + } + return startActivityAsUserMethod; + } + + public int startActivity(Intent intent) { + return startActivity(intent, null); + } + + @SuppressWarnings("ConstantConditions") + public int startActivity(Intent intent, Bundle options) { + try { + Method method = getStartActivityAsUserMethod(); + return (int) method.invoke( + /* this */ manager, + /* caller */ null, + /* callingPackage */ FakeContext.PACKAGE_NAME, + /* intent */ intent, + /* resolvedType */ null, + /* resultTo */ null, + /* resultWho */ null, + /* requestCode */ 0, + /* startFlags */ 0, + /* profilerInfo */ null, + /* bOptions */ options, + /* userId */ /* UserHandle.USER_CURRENT */ -2); + } catch (Throwable e) { + Ln.e("Could not invoke method", e); + return 0; + } + } + + private Method getForceStopPackageMethod() throws NoSuchMethodException { + if (forceStopPackageMethod == null) { + forceStopPackageMethod = manager.getClass().getMethod("forceStopPackage", String.class, int.class); + } + return forceStopPackageMethod; + } + + public void forceStopPackage(String packageName) { + try { + Method method = getForceStopPackageMethod(); + method.invoke(manager, packageName, /* userId */ /* UserHandle.USER_CURRENT */ -2); + } catch (Throwable e) { + Ln.e("Could not invoke method", e); + } + } + + private Method getBroadcastIntentMethod() throws NoSuchMethodException { + if (broadcastIntentMethod == null) { + try { + Class iApplicationThreadClass = Class.forName("android.app.IApplicationThread"); + Class iIntentReceiverClass = Class.forName("android.content.IIntentReceiver"); + broadcastIntentMethod = manager.getClass() + .getMethod("broadcastIntent", iApplicationThreadClass, Intent.class, String.class, iIntentReceiverClass, int.class, + String.class, Bundle.class, String[].class, int.class, Bundle.class, boolean.class, boolean.class, int.class); + } catch (ClassNotFoundException e) { + throw new AssertionError(e); + } + } + return broadcastIntentMethod; + } + + public void sendBroadcast(Intent intent) { + try { + // Equivalent to: + // adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:// + Method method = getBroadcastIntentMethod(); + method.invoke(manager, null, intent, null, null, 0, null, null, null, -1, null, true, false, /* userId */ -2); + } catch (Throwable e) { + Ln.e("Could not invoke method", e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ClipboardManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ClipboardManager.java new file mode 100644 index 0000000..5493612 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ClipboardManager.java @@ -0,0 +1,43 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.FakeContext; + +import android.content.ClipData; +import android.content.Context; + +public final class ClipboardManager { + private final android.content.ClipboardManager manager; + + static ClipboardManager create() { + android.content.ClipboardManager manager = (android.content.ClipboardManager) FakeContext.get().getSystemService(Context.CLIPBOARD_SERVICE); + if (manager == null) { + // Some devices have no clipboard manager + // + // + return null; + } + return new ClipboardManager(manager); + } + + private ClipboardManager(android.content.ClipboardManager manager) { + this.manager = manager; + } + + public CharSequence getText() { + ClipData clipData = manager.getPrimaryClip(); + if (clipData == null || clipData.getItemCount() == 0) { + return null; + } + return clipData.getItemAt(0).getText(); + } + + public boolean setText(CharSequence text) { + ClipData clipData = ClipData.newPlainText(null, text); + manager.setPrimaryClip(clipData); + return true; + } + + public void addPrimaryClipChangedListener(android.content.ClipboardManager.OnPrimaryClipChangedListener listener) { + manager.addPrimaryClipChangedListener(listener); + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ContentProvider.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ContentProvider.java new file mode 100644 index 0000000..f625b39 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ContentProvider.java @@ -0,0 +1,162 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; +import com.genymobile.scrcpy.util.SettingsException; + +import android.annotation.SuppressLint; +import android.content.AttributionSource; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; + +import java.io.Closeable; +import java.lang.reflect.Method; + +public final class ContentProvider implements Closeable { + + public static final String TABLE_SYSTEM = "system"; + public static final String TABLE_SECURE = "secure"; + public static final String TABLE_GLOBAL = "global"; + + // See android/providerHolder/Settings.java + private static final String CALL_METHOD_GET_SYSTEM = "GET_system"; + private static final String CALL_METHOD_GET_SECURE = "GET_secure"; + private static final String CALL_METHOD_GET_GLOBAL = "GET_global"; + + private static final String CALL_METHOD_PUT_SYSTEM = "PUT_system"; + private static final String CALL_METHOD_PUT_SECURE = "PUT_secure"; + private static final String CALL_METHOD_PUT_GLOBAL = "PUT_global"; + + private static final String CALL_METHOD_USER_KEY = "_user"; + + private static final String NAME_VALUE_TABLE_VALUE = "value"; + + private final ActivityManager manager; + // android.content.IContentProvider + private final Object provider; + private final String name; + private final IBinder token; + + private Method callMethod; + private int callMethodVersion; + + ContentProvider(ActivityManager manager, Object provider, String name, IBinder token) { + this.manager = manager; + this.provider = provider; + this.name = name; + this.token = token; + } + + @SuppressLint("PrivateApi") + private Method getCallMethod() throws NoSuchMethodException { + if (callMethod == null) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + callMethod = provider.getClass().getMethod("call", AttributionSource.class, String.class, String.class, String.class, Bundle.class); + callMethodVersion = 0; + } else { + // old versions + try { + callMethod = provider.getClass() + .getMethod("call", String.class, String.class, String.class, String.class, String.class, Bundle.class); + callMethodVersion = 1; + } catch (NoSuchMethodException e1) { + try { + callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, String.class, Bundle.class); + callMethodVersion = 2; + } catch (NoSuchMethodException e2) { + callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, Bundle.class); + callMethodVersion = 3; + } + } + } + } + return callMethod; + } + + private Bundle call(String callMethod, String arg, Bundle extras) throws ReflectiveOperationException { + try { + Method method = getCallMethod(); + Object[] args; + + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12 && callMethodVersion == 0) { + args = new Object[]{FakeContext.get().getAttributionSource(), "settings", callMethod, arg, extras}; + } else { + switch (callMethodVersion) { + case 1: + args = new Object[]{FakeContext.PACKAGE_NAME, null, "settings", callMethod, arg, extras}; + break; + case 2: + args = new Object[]{FakeContext.PACKAGE_NAME, "settings", callMethod, arg, extras}; + break; + default: + args = new Object[]{FakeContext.PACKAGE_NAME, callMethod, arg, extras}; + break; + } + } + return (Bundle) method.invoke(provider, args); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + throw e; + } + } + + public void close() { + manager.removeContentProviderExternal(name, token); + } + + private static String getGetMethod(String table) { + switch (table) { + case TABLE_SECURE: + return CALL_METHOD_GET_SECURE; + case TABLE_SYSTEM: + return CALL_METHOD_GET_SYSTEM; + case TABLE_GLOBAL: + return CALL_METHOD_GET_GLOBAL; + default: + throw new IllegalArgumentException("Invalid table: " + table); + } + } + + private static String getPutMethod(String table) { + switch (table) { + case TABLE_SECURE: + return CALL_METHOD_PUT_SECURE; + case TABLE_SYSTEM: + return CALL_METHOD_PUT_SYSTEM; + case TABLE_GLOBAL: + return CALL_METHOD_PUT_GLOBAL; + default: + throw new IllegalArgumentException("Invalid table: " + table); + } + } + + public String getValue(String table, String key) throws SettingsException { + String method = getGetMethod(table); + Bundle arg = new Bundle(); + arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID); + try { + Bundle bundle = call(method, key, arg); + if (bundle == null) { + return null; + } + return bundle.getString("value"); + } catch (Exception e) { + throw new SettingsException(table, "get", key, null, e); + } + + } + + public void putValue(String table, String key, String value) throws SettingsException { + String method = getPutMethod(table); + Bundle arg = new Bundle(); + arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID); + arg.putString(NAME_VALUE_TABLE_VALUE, value); + try { + call(method, key, arg); + } catch (Exception e) { + throw new SettingsException(table, "put", key, value, e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayControl.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayControl.java new file mode 100644 index 0000000..88ca3d3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayControl.java @@ -0,0 +1,82 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.os.IBinder; +import android.system.Os; + +import java.lang.reflect.Method; + +@SuppressLint({"PrivateApi", "SoonBlockedPrivateApi", "BlockedPrivateApi"}) +@TargetApi(AndroidVersions.API_34_ANDROID_14) +public final class DisplayControl { + + private static final Class CLASS; + + static { + Class displayControlClass = null; + try { + Class classLoaderFactoryClass = Class.forName("com.android.internal.os.ClassLoaderFactory"); + Method createClassLoaderMethod = classLoaderFactoryClass.getDeclaredMethod("createClassLoader", String.class, String.class, String.class, + ClassLoader.class, int.class, boolean.class, String.class); + + String systemServerClasspath = Os.getenv("SYSTEMSERVERCLASSPATH"); + ClassLoader classLoader = (ClassLoader) createClassLoaderMethod.invoke(null, systemServerClasspath, null, null, + ClassLoader.getSystemClassLoader(), 0, true, null); + + displayControlClass = classLoader.loadClass("com.android.server.display.DisplayControl"); + + Method loadMethod = Runtime.class.getDeclaredMethod("loadLibrary0", Class.class, String.class); + loadMethod.setAccessible(true); + loadMethod.invoke(Runtime.getRuntime(), displayControlClass, "android_servers"); + } catch (Throwable e) { + Ln.e("Could not initialize DisplayControl", e); + // Do not throw an exception here, the methods will fail when they are called + } + CLASS = displayControlClass; + } + + private static Method getPhysicalDisplayTokenMethod; + private static Method getPhysicalDisplayIdsMethod; + + private DisplayControl() { + // only static methods + } + + private static Method getGetPhysicalDisplayTokenMethod() throws NoSuchMethodException { + if (getPhysicalDisplayTokenMethod == null) { + getPhysicalDisplayTokenMethod = CLASS.getMethod("getPhysicalDisplayToken", long.class); + } + return getPhysicalDisplayTokenMethod; + } + + public static IBinder getPhysicalDisplayToken(long physicalDisplayId) { + try { + Method method = getGetPhysicalDisplayTokenMethod(); + return (IBinder) method.invoke(null, physicalDisplayId); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } + + private static Method getGetPhysicalDisplayIdsMethod() throws NoSuchMethodException { + if (getPhysicalDisplayIdsMethod == null) { + getPhysicalDisplayIdsMethod = CLASS.getMethod("getPhysicalDisplayIds"); + } + return getPhysicalDisplayIdsMethod; + } + + public static long[] getPhysicalDisplayIds() { + try { + Method method = getGetPhysicalDisplayIdsMethod(); + return (long[]) method.invoke(null); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayManager.java new file mode 100644 index 0000000..1be04c3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayManager.java @@ -0,0 +1,250 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.display.DisplayInfo; +import com.genymobile.scrcpy.model.Size; +import com.genymobile.scrcpy.util.Command; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.VirtualDisplay; +import android.os.Handler; +import android.view.Display; +import android.view.Surface; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@SuppressLint("PrivateApi,DiscouragedPrivateApi") +public final class DisplayManager { + + // android.hardware.display.DisplayManager.EVENT_FLAG_DISPLAY_CHANGED + public static final long EVENT_FLAG_DISPLAY_CHANGED = 1L << 2; + + public interface DisplayListener { + /** + * Called whenever the properties of a logical {@link android.view.Display}, + * such as size and density, have changed. + * + * @param displayId The id of the logical display that changed. + */ + void onDisplayChanged(int displayId); + } + + public static final class DisplayListenerHandle { + private final Object displayListenerProxy; + private DisplayListenerHandle(Object displayListenerProxy) { + this.displayListenerProxy = displayListenerProxy; + } + } + + private final Object manager; // instance of hidden class android.hardware.display.DisplayManagerGlobal + private Method getDisplayInfoMethod; + private Method createVirtualDisplayMethod; + private Method requestDisplayPowerMethod; + + static DisplayManager create() { + try { + Class clazz = Class.forName("android.hardware.display.DisplayManagerGlobal"); + Method getInstanceMethod = clazz.getDeclaredMethod("getInstance"); + Object dmg = getInstanceMethod.invoke(null); + return new DisplayManager(dmg); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private DisplayManager(Object manager) { + this.manager = manager; + } + + // public to call it from unit tests + public static DisplayInfo parseDisplayInfo(String dumpsysDisplayOutput, int displayId) { + Pattern regex = Pattern.compile( + "^ mOverrideDisplayInfo=DisplayInfo\\{\".*?, displayId " + displayId + ".*?(, FLAG_.*)?, real ([0-9]+) x ([0-9]+).*?, " + + "rotation ([0-9]+).*?, density ([0-9]+).*?, layerStack ([0-9]+)", + Pattern.MULTILINE); + Matcher m = regex.matcher(dumpsysDisplayOutput); + if (!m.find()) { + return null; + } + int flags = parseDisplayFlags(m.group(1)); + int width = Integer.parseInt(m.group(2)); + int height = Integer.parseInt(m.group(3)); + int rotation = Integer.parseInt(m.group(4)); + int density = Integer.parseInt(m.group(5)); + int layerStack = Integer.parseInt(m.group(6)); + + return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, density, null); + } + + private static DisplayInfo getDisplayInfoFromDumpsysDisplay(int displayId) { + try { + String dumpsysDisplayOutput = Command.execReadOutput("dumpsys", "display"); + return parseDisplayInfo(dumpsysDisplayOutput, displayId); + } catch (Exception e) { + Ln.e("Could not get display info from \"dumpsys display\" output", e); + return null; + } + } + + private static int parseDisplayFlags(String text) { + if (text == null) { + return 0; + } + + int flags = 0; + Pattern regex = Pattern.compile("FLAG_[A-Z_]+"); + Matcher m = regex.matcher(text); + while (m.find()) { + String flagString = m.group(); + try { + Field filed = Display.class.getDeclaredField(flagString); + flags |= filed.getInt(null); + } catch (ReflectiveOperationException e) { + // Silently ignore, some flags reported by "dumpsys display" are @TestApi + } + } + return flags; + } + + // getDisplayInfo() may be used from both the Controller thread and the video (main) thread + private synchronized Method getGetDisplayInfoMethod() throws NoSuchMethodException { + if (getDisplayInfoMethod == null) { + getDisplayInfoMethod = manager.getClass().getMethod("getDisplayInfo", int.class); + } + return getDisplayInfoMethod; + } + + public DisplayInfo getDisplayInfo(int displayId) { + try { + Method method = getGetDisplayInfoMethod(); + Object displayInfo = method.invoke(manager, displayId); + if (displayInfo == null) { + // fallback when displayInfo is null + return getDisplayInfoFromDumpsysDisplay(displayId); + } + Class cls = displayInfo.getClass(); + // width and height already take the rotation into account + int width = cls.getDeclaredField("logicalWidth").getInt(displayInfo); + int height = cls.getDeclaredField("logicalHeight").getInt(displayInfo); + int rotation = cls.getDeclaredField("rotation").getInt(displayInfo); + int layerStack = cls.getDeclaredField("layerStack").getInt(displayInfo); + int flags = cls.getDeclaredField("flags").getInt(displayInfo); + int dpi = cls.getDeclaredField("logicalDensityDpi").getInt(displayInfo); + String uniqueId; + try { + uniqueId = (String) cls.getDeclaredField("uniqueId").get(displayInfo); + } catch (NoSuchFieldException e) { + // This field might not exist: + uniqueId = null; + } + return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, dpi, uniqueId); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + public int[] getDisplayIds() { + try { + return (int[]) manager.getClass().getMethod("getDisplayIds").invoke(manager); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } + + private Method getCreateVirtualDisplayMethod() throws NoSuchMethodException { + if (createVirtualDisplayMethod == null) { + createVirtualDisplayMethod = android.hardware.display.DisplayManager.class + .getMethod("createVirtualDisplay", String.class, int.class, int.class, int.class, Surface.class); + } + return createVirtualDisplayMethod; + } + + public VirtualDisplay createVirtualDisplay(String name, int width, int height, int displayIdToMirror, Surface surface) throws Exception { + Method method = getCreateVirtualDisplayMethod(); + return (VirtualDisplay) method.invoke(null, name, width, height, displayIdToMirror, surface); + } + + public VirtualDisplay createNewVirtualDisplay(String name, int width, int height, int dpi, Surface surface, int flags) throws Exception { + Constructor ctor = android.hardware.display.DisplayManager.class.getDeclaredConstructor( + Context.class); + ctor.setAccessible(true); + android.hardware.display.DisplayManager dm = ctor.newInstance(FakeContext.get()); + return dm.createVirtualDisplay(name, width, height, dpi, surface, flags); + } + + private Method getRequestDisplayPowerMethod() throws NoSuchMethodException { + if (requestDisplayPowerMethod == null) { + requestDisplayPowerMethod = manager.getClass().getMethod("requestDisplayPower", int.class, boolean.class); + } + return requestDisplayPowerMethod; + } + + @TargetApi(AndroidVersions.API_35_ANDROID_15) + public boolean requestDisplayPower(int displayId, boolean on) { + try { + Method method = getRequestDisplayPowerMethod(); + return (boolean) method.invoke(manager, displayId, on); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } + + public DisplayListenerHandle registerDisplayListener(DisplayListener listener, Handler handler) { + try { + Class displayListenerClass = Class.forName("android.hardware.display.DisplayManager$DisplayListener"); + Object displayListenerProxy = Proxy.newProxyInstance( + ClassLoader.getSystemClassLoader(), + new Class[] {displayListenerClass}, + (proxy, method, args) -> { + if ("onDisplayChanged".equals(method.getName())) { + listener.onDisplayChanged((int) args[0]); + } + if ("toString".equals(method.getName())) { + return "DisplayListener"; + } + return null; + }); + try { + manager.getClass() + .getMethod("registerDisplayListener", displayListenerClass, Handler.class, long.class, String.class) + .invoke(manager, displayListenerProxy, handler, EVENT_FLAG_DISPLAY_CHANGED, FakeContext.PACKAGE_NAME); + } catch (NoSuchMethodException e) { + try { + manager.getClass() + .getMethod("registerDisplayListener", displayListenerClass, Handler.class, long.class) + .invoke(manager, displayListenerProxy, handler, EVENT_FLAG_DISPLAY_CHANGED); + } catch (NoSuchMethodException e2) { + manager.getClass() + .getMethod("registerDisplayListener", displayListenerClass, Handler.class) + .invoke(manager, displayListenerProxy, handler); + } + } + + return new DisplayListenerHandle(displayListenerProxy); + } catch (Exception e) { + // Rotation and screen size won't be updated, not a fatal error + Ln.e("Could not register display listener", e); + } + + return null; + } + + public void unregisterDisplayListener(DisplayListenerHandle listener) { + try { + Class displayListenerClass = Class.forName("android.hardware.display.DisplayManager$DisplayListener"); + manager.getClass().getMethod("unregisterDisplayListener", displayListenerClass).invoke(manager, listener.displayListenerProxy); + } catch (Exception e) { + Ln.e("Could not unregister display listener", e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java new file mode 100644 index 0000000..1573d81 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java @@ -0,0 +1,37 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.util.Ln; + +import android.content.res.Configuration; +import android.os.Parcel; +import android.os.RemoteException; +import android.view.IDisplayWindowListener; + +public class DisplayWindowListener extends IDisplayWindowListener.Stub { + @Override + public void onDisplayAdded(int displayId) { + // empty default implementation + } + + @Override + public void onDisplayConfigurationChanged(int displayId, Configuration newConfig) { + // empty default implementation + } + + @Override + public void onDisplayRemoved(int displayId) { + // empty default implementation + } + + @Override + public boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException { + try { + return super.onTransact(code, data, reply, flags); + } catch (AbstractMethodError e) { + Ln.v("Ignoring AbstractMethodError: " + e.getMessage()); + // Ignore unknown methods, write default response to reply parcel + reply.writeNoException(); + return true; + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/InputManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/InputManager.java new file mode 100644 index 0000000..f55648d --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/InputManager.java @@ -0,0 +1,146 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.FakeContext; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.view.InputEvent; +import android.view.MotionEvent; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +@SuppressLint("PrivateApi,DiscouragedPrivateApi") +public final class InputManager { + + public static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0; + public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT = 1; + public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2; + + private final android.hardware.input.InputManager manager; + private long lastPermissionLogDate; + + private static Method injectInputEventMethod; + private static Method setDisplayIdMethod; + private static Method setActionButtonMethod; + private static Method addUniqueIdAssociationByPortMethod; + private static Method removeUniqueIdAssociationByPortMethod; + + static InputManager create() { + android.hardware.input.InputManager manager = (android.hardware.input.InputManager) FakeContext.get() + .getSystemService(FakeContext.INPUT_SERVICE); + return new InputManager(manager); + } + + private InputManager(android.hardware.input.InputManager manager) { + this.manager = manager; + } + + private static Method getInjectInputEventMethod() throws NoSuchMethodException { + if (injectInputEventMethod == null) { + injectInputEventMethod = android.hardware.input.InputManager.class.getMethod("injectInputEvent", InputEvent.class, int.class); + } + return injectInputEventMethod; + } + + public boolean injectInputEvent(InputEvent inputEvent, int mode) { + try { + Method method = getInjectInputEventMethod(); + return (boolean) method.invoke(manager, inputEvent, mode); + } catch (ReflectiveOperationException e) { + if (e instanceof InvocationTargetException) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + String message = e.getCause().getMessage(); + if (message != null && message.contains("INJECT_EVENTS permission")) { + // Do not flood the console, limit to one permission error log every 3 seconds + long now = System.currentTimeMillis(); + if (lastPermissionLogDate <= now - 3000) { + Ln.e(message); + Ln.e("Make sure you have enabled \"USB debugging (Security Settings)\" and then rebooted your device."); + lastPermissionLogDate = now; + } + // Do not print the stack trace + return false; + } + } + } + Ln.e("Could not invoke method", e); + return false; + } + } + + private static Method getSetDisplayIdMethod() throws NoSuchMethodException { + if (setDisplayIdMethod == null) { + setDisplayIdMethod = InputEvent.class.getMethod("setDisplayId", int.class); + } + return setDisplayIdMethod; + } + + public static boolean setDisplayId(InputEvent inputEvent, int displayId) { + try { + Method method = getSetDisplayIdMethod(); + method.invoke(inputEvent, displayId); + return true; + } catch (ReflectiveOperationException e) { + Ln.e("Cannot associate a display id to the input event", e); + return false; + } + } + + private static Method getSetActionButtonMethod() throws NoSuchMethodException { + if (setActionButtonMethod == null) { + setActionButtonMethod = MotionEvent.class.getMethod("setActionButton", int.class); + } + return setActionButtonMethod; + } + + public static boolean setActionButton(MotionEvent motionEvent, int actionButton) { + try { + Method method = getSetActionButtonMethod(); + method.invoke(motionEvent, actionButton); + return true; + } catch (ReflectiveOperationException e) { + Ln.e("Cannot set action button on MotionEvent", e); + return false; + } + } + + private static Method getAddUniqueIdAssociationByPortMethod() throws NoSuchMethodException { + if (addUniqueIdAssociationByPortMethod == null) { + addUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod( + "addUniqueIdAssociationByPort", String.class, String.class); + } + return addUniqueIdAssociationByPortMethod; + } + + @TargetApi(AndroidVersions.API_35_ANDROID_15) + public void addUniqueIdAssociationByPort(String inputPort, String uniqueId) { + try { + Method method = getAddUniqueIdAssociationByPortMethod(); + method.invoke(manager, inputPort, uniqueId); + } catch (ReflectiveOperationException e) { + Ln.e("Cannot add unique id association by port", e); + } + } + + private static Method getRemoveUniqueIdAssociationByPortMethod() throws NoSuchMethodException { + if (removeUniqueIdAssociationByPortMethod == null) { + removeUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod( + "removeUniqueIdAssociationByPort", String.class); + } + return removeUniqueIdAssociationByPortMethod; + } + + @TargetApi(AndroidVersions.API_35_ANDROID_15) + public void removeUniqueIdAssociationByPort(String inputPort) { + try { + Method method = getRemoveUniqueIdAssociationByPortMethod(); + method.invoke(manager, inputPort); + } catch (ReflectiveOperationException e) { + Ln.e("Cannot remove unique id association by port", e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/PowerManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/PowerManager.java new file mode 100644 index 0000000..c7ba3f1 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/PowerManager.java @@ -0,0 +1,80 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; + +import android.os.Build; +import android.os.IInterface; +import android.os.SystemClock; + +import java.lang.reflect.Method; + +public final class PowerManager { + + private static final int USER_ACTIVITY_EVENT_OTHER = 0; + + private final IInterface manager; + private Method isScreenOnMethod; + private Method userActivityMethod; + + static PowerManager create() { + IInterface manager = ServiceManager.getService("power", "android.os.IPowerManager"); + return new PowerManager(manager); + } + + private PowerManager(IInterface manager) { + this.manager = manager; + } + + private Method getIsScreenOnMethod() throws NoSuchMethodException { + if (isScreenOnMethod == null) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) { + isScreenOnMethod = manager.getClass().getMethod("isDisplayInteractive", int.class); + } else { + isScreenOnMethod = manager.getClass().getMethod("isInteractive"); + } + } + return isScreenOnMethod; + } + + public boolean isScreenOn(int displayId) { + + try { + Method method = getIsScreenOnMethod(); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) { + return (boolean) method.invoke(manager, displayId); + } + return (boolean) method.invoke(manager); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } + + private Method getUserActivityMethod() throws NoSuchMethodException { + if (userActivityMethod == null) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + // userActivity(int displayId, long time, int event, int flags); + userActivityMethod = manager.getClass().getMethod("userActivity", int.class, long.class, int.class, int.class); + } else { + // userActivity(long time, int event, int flags); + userActivityMethod = manager.getClass().getMethod("userActivity", long.class, int.class, int.class); + } + } + return userActivityMethod; + } + + public void userActivity(int displayId) { + try { + Method method = getUserActivityMethod(); + long time = SystemClock.uptimeMillis(); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + method.invoke(manager, displayId, time, USER_ACTIVITY_EVENT_OTHER, 0); + return; + } + method.invoke(manager, time, USER_ACTIVITY_EVENT_OTHER, 0); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ServiceManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ServiceManager.java new file mode 100644 index 0000000..b1123b5 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ServiceManager.java @@ -0,0 +1,112 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.FakeContext; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.hardware.camera2.CameraManager; +import android.os.IBinder; +import android.os.IInterface; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; + +@SuppressLint("PrivateApi,DiscouragedPrivateApi") +public final class ServiceManager { + + private static final Method GET_SERVICE_METHOD; + + static { + try { + GET_SERVICE_METHOD = Class.forName("android.os.ServiceManager").getDeclaredMethod("getService", String.class); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + private static WindowManager windowManager; + private static DisplayManager displayManager; + private static InputManager inputManager; + private static PowerManager powerManager; + private static StatusBarManager statusBarManager; + private static ClipboardManager clipboardManager; + private static ActivityManager activityManager; + private static CameraManager cameraManager; + + private ServiceManager() { + /* not instantiable */ + } + + static IInterface getService(String service, String type) { + try { + IBinder binder = (IBinder) GET_SERVICE_METHOD.invoke(null, service); + Method asInterfaceMethod = Class.forName(type + "$Stub").getMethod("asInterface", IBinder.class); + return (IInterface) asInterfaceMethod.invoke(null, binder); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + public static WindowManager getWindowManager() { + if (windowManager == null) { + windowManager = WindowManager.create(); + } + return windowManager; + } + + // The DisplayManager may be used from both the Controller thread and the video (main) thread + public static synchronized DisplayManager getDisplayManager() { + if (displayManager == null) { + displayManager = DisplayManager.create(); + } + return displayManager; + } + + public static InputManager getInputManager() { + if (inputManager == null) { + inputManager = InputManager.create(); + } + return inputManager; + } + + public static PowerManager getPowerManager() { + if (powerManager == null) { + powerManager = PowerManager.create(); + } + return powerManager; + } + + public static StatusBarManager getStatusBarManager() { + if (statusBarManager == null) { + statusBarManager = StatusBarManager.create(); + } + return statusBarManager; + } + + public static ClipboardManager getClipboardManager() { + if (clipboardManager == null) { + // May be null, some devices have no clipboard manager + clipboardManager = ClipboardManager.create(); + } + return clipboardManager; + } + + public static ActivityManager getActivityManager() { + if (activityManager == null) { + activityManager = ActivityManager.create(); + } + return activityManager; + } + + public static CameraManager getCameraManager() { + if (cameraManager == null) { + try { + Constructor ctor = CameraManager.class.getDeclaredConstructor(Context.class); + cameraManager = ctor.newInstance(FakeContext.get()); + } catch (Exception e) { + throw new AssertionError(e); + } + } + return cameraManager; + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/StatusBarManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/StatusBarManager.java new file mode 100644 index 0000000..ca80dde --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/StatusBarManager.java @@ -0,0 +1,97 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.util.Ln; + +import android.os.IInterface; + +import java.lang.reflect.Method; + +public final class StatusBarManager { + + private final IInterface manager; + private Method expandNotificationsPanelMethod; + private boolean expandNotificationPanelMethodCustomVersion; + private Method expandSettingsPanelMethod; + private boolean expandSettingsPanelMethodNewVersion = true; + private Method collapsePanelsMethod; + + static StatusBarManager create() { + IInterface manager = ServiceManager.getService("statusbar", "com.android.internal.statusbar.IStatusBarService"); + return new StatusBarManager(manager); + } + + private StatusBarManager(IInterface manager) { + this.manager = manager; + } + + private Method getExpandNotificationsPanelMethod() throws NoSuchMethodException { + if (expandNotificationsPanelMethod == null) { + try { + expandNotificationsPanelMethod = manager.getClass().getMethod("expandNotificationsPanel"); + } catch (NoSuchMethodException e) { + // Custom version for custom vendor ROM: + expandNotificationsPanelMethod = manager.getClass().getMethod("expandNotificationsPanel", int.class); + expandNotificationPanelMethodCustomVersion = true; + } + } + return expandNotificationsPanelMethod; + } + + private Method getExpandSettingsPanel() throws NoSuchMethodException { + if (expandSettingsPanelMethod == null) { + try { + // Since Android 7: https://android.googlesource.com/platform/frameworks/base.git/+/a9927325eda025504d59bb6594fee8e240d95b01%5E%21/ + expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel", String.class); + } catch (NoSuchMethodException e) { + // old version + expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel"); + expandSettingsPanelMethodNewVersion = false; + } + } + return expandSettingsPanelMethod; + } + + private Method getCollapsePanelsMethod() throws NoSuchMethodException { + if (collapsePanelsMethod == null) { + collapsePanelsMethod = manager.getClass().getMethod("collapsePanels"); + } + return collapsePanelsMethod; + } + + public void expandNotificationsPanel() { + try { + Method method = getExpandNotificationsPanelMethod(); + if (expandNotificationPanelMethodCustomVersion) { + method.invoke(manager, 0); + } else { + method.invoke(manager); + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public void expandSettingsPanel() { + try { + Method method = getExpandSettingsPanel(); + if (expandSettingsPanelMethodNewVersion) { + // new version + method.invoke(manager, (Object) null); + } else { + // old version + method.invoke(manager); + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public void collapsePanels() { + try { + Method method = getCollapsePanelsMethod(); + method.invoke(manager); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/SurfaceControl.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/SurfaceControl.java new file mode 100644 index 0000000..3bae4a3 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/SurfaceControl.java @@ -0,0 +1,191 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.SuppressLint; +import android.graphics.Rect; +import android.os.Build; +import android.os.IBinder; +import android.view.Surface; + +import java.lang.reflect.Method; + +@SuppressLint("PrivateApi") +public final class SurfaceControl { + + private static final Class CLASS; + + // see + public static final int POWER_MODE_OFF = 0; + public static final int POWER_MODE_NORMAL = 2; + + static { + try { + CLASS = Class.forName("android.view.SurfaceControl"); + } catch (ClassNotFoundException e) { + throw new AssertionError(e); + } + } + + private static Method getBuiltInDisplayMethod; + private static Method setDisplayPowerModeMethod; + private static Method getPhysicalDisplayTokenMethod; + private static Method getPhysicalDisplayIdsMethod; + + private SurfaceControl() { + // only static methods + } + + public static void openTransaction() { + try { + CLASS.getMethod("openTransaction").invoke(null); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + public static void closeTransaction() { + try { + CLASS.getMethod("closeTransaction").invoke(null); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + public static void setDisplayProjection(IBinder displayToken, int orientation, Rect layerStackRect, Rect displayRect) { + try { + CLASS.getMethod("setDisplayProjection", IBinder.class, int.class, Rect.class, Rect.class) + .invoke(null, displayToken, orientation, layerStackRect, displayRect); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + public static void setDisplayLayerStack(IBinder displayToken, int layerStack) { + try { + CLASS.getMethod("setDisplayLayerStack", IBinder.class, int.class).invoke(null, displayToken, layerStack); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + public static void setDisplaySurface(IBinder displayToken, Surface surface) { + try { + CLASS.getMethod("setDisplaySurface", IBinder.class, Surface.class).invoke(null, displayToken, surface); + } catch (Exception e) { + throw new AssertionError(e); + } + } + + public static IBinder createDisplay(String name, boolean secure) throws Exception { + return (IBinder) CLASS.getMethod("createDisplay", String.class, boolean.class).invoke(null, name, secure); + } + + private static Method getGetBuiltInDisplayMethod() throws NoSuchMethodException { + if (getBuiltInDisplayMethod == null) { + // the method signature has changed in Android 10 + // + if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) { + getBuiltInDisplayMethod = CLASS.getMethod("getBuiltInDisplay", int.class); + } else { + getBuiltInDisplayMethod = CLASS.getMethod("getInternalDisplayToken"); + } + } + return getBuiltInDisplayMethod; + } + + public static boolean hasGetBuildInDisplayMethod() { + try { + getGetBuiltInDisplayMethod(); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } + + public static IBinder getBuiltInDisplay() { + try { + Method method = getGetBuiltInDisplayMethod(); + if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) { + // call getBuiltInDisplay(0) + return (IBinder) method.invoke(null, 0); + } + + // call getInternalDisplayToken() + return (IBinder) method.invoke(null); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } + + private static Method getGetPhysicalDisplayTokenMethod() throws NoSuchMethodException { + if (getPhysicalDisplayTokenMethod == null) { + getPhysicalDisplayTokenMethod = CLASS.getMethod("getPhysicalDisplayToken", long.class); + } + return getPhysicalDisplayTokenMethod; + } + + public static IBinder getPhysicalDisplayToken(long physicalDisplayId) { + try { + Method method = getGetPhysicalDisplayTokenMethod(); + return (IBinder) method.invoke(null, physicalDisplayId); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } + + private static Method getGetPhysicalDisplayIdsMethod() throws NoSuchMethodException { + if (getPhysicalDisplayIdsMethod == null) { + getPhysicalDisplayIdsMethod = CLASS.getMethod("getPhysicalDisplayIds"); + } + return getPhysicalDisplayIdsMethod; + } + + public static boolean hasGetPhysicalDisplayIdsMethod() { + try { + getGetPhysicalDisplayIdsMethod(); + return true; + } catch (NoSuchMethodException e) { + return false; + } + } + + public static long[] getPhysicalDisplayIds() { + try { + Method method = getGetPhysicalDisplayIdsMethod(); + return (long[]) method.invoke(null); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return null; + } + } + + private static Method getSetDisplayPowerModeMethod() throws NoSuchMethodException { + if (setDisplayPowerModeMethod == null) { + setDisplayPowerModeMethod = CLASS.getMethod("setDisplayPowerMode", IBinder.class, int.class); + } + return setDisplayPowerModeMethod; + } + + public static boolean setDisplayPowerMode(IBinder displayToken, int mode) { + try { + Method method = getSetDisplayPowerModeMethod(); + method.invoke(null, displayToken, mode); + return true; + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } + + public static void destroyDisplay(IBinder displayToken) { + try { + CLASS.getMethod("destroyDisplay", IBinder.class).invoke(null, displayToken); + } catch (Exception e) { + throw new AssertionError(e); + } + } +} diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/WindowManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/WindowManager.java new file mode 100644 index 0000000..7ba5cc0 --- /dev/null +++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/WindowManager.java @@ -0,0 +1,267 @@ +package com.genymobile.scrcpy.wrappers; + +import com.genymobile.scrcpy.AndroidVersions; +import com.genymobile.scrcpy.util.Ln; + +import android.annotation.TargetApi; +import android.os.Build; +import android.os.IInterface; +import android.view.IDisplayWindowListener; + +import java.lang.reflect.Method; + +public final class WindowManager { + + @SuppressWarnings("checkstyle:LineLength") + // + public static final int DISPLAY_IME_POLICY_LOCAL = 0; + public static final int DISPLAY_IME_POLICY_FALLBACK_DISPLAY = 1; + public static final int DISPLAY_IME_POLICY_HIDE = 2; + + private final IInterface manager; + private Method getRotationMethod; + + private Method freezeDisplayRotationMethod; + private int freezeDisplayRotationMethodVersion; + + private Method isDisplayRotationFrozenMethod; + private int isDisplayRotationFrozenMethodVersion; + + private Method thawDisplayRotationMethod; + private int thawDisplayRotationMethodVersion; + + private Method getDisplayImePolicyMethod; + private Method setDisplayImePolicyMethod; + + static WindowManager create() { + IInterface manager = ServiceManager.getService("window", "android.view.IWindowManager"); + return new WindowManager(manager); + } + + private WindowManager(IInterface manager) { + this.manager = manager; + } + + private Method getGetRotationMethod() throws NoSuchMethodException { + if (getRotationMethod == null) { + Class cls = manager.getClass(); + try { + // method changed since this commit: + // https://android.googlesource.com/platform/frameworks/base/+/8ee7285128c3843401d4c4d0412cd66e86ba49e3%5E%21/#F2 + getRotationMethod = cls.getMethod("getDefaultDisplayRotation"); + } catch (NoSuchMethodException e) { + // old version + getRotationMethod = cls.getMethod("getRotation"); + } + } + return getRotationMethod; + } + + private Method getFreezeDisplayRotationMethod() throws NoSuchMethodException { + if (freezeDisplayRotationMethod == null) { + try { + // Android 15 preview and 14 QPR3 Beta added a String caller parameter for debugging: + // + freezeDisplayRotationMethod = manager.getClass().getMethod("freezeDisplayRotation", int.class, int.class, String.class); + freezeDisplayRotationMethodVersion = 0; + } catch (NoSuchMethodException e) { + try { + // New method added by this commit: + // + freezeDisplayRotationMethod = manager.getClass().getMethod("freezeDisplayRotation", int.class, int.class); + freezeDisplayRotationMethodVersion = 1; + } catch (NoSuchMethodException e1) { + freezeDisplayRotationMethod = manager.getClass().getMethod("freezeRotation", int.class); + freezeDisplayRotationMethodVersion = 2; + } + } + } + return freezeDisplayRotationMethod; + } + + private Method getIsDisplayRotationFrozenMethod() throws NoSuchMethodException { + if (isDisplayRotationFrozenMethod == null) { + try { + // New method added by this commit: + // + isDisplayRotationFrozenMethod = manager.getClass().getMethod("isDisplayRotationFrozen", int.class); + isDisplayRotationFrozenMethodVersion = 0; + } catch (NoSuchMethodException e) { + isDisplayRotationFrozenMethod = manager.getClass().getMethod("isRotationFrozen"); + isDisplayRotationFrozenMethodVersion = 1; + } + } + return isDisplayRotationFrozenMethod; + } + + private Method getThawDisplayRotationMethod() throws NoSuchMethodException { + if (thawDisplayRotationMethod == null) { + try { + // Android 15 preview and 14 QPR3 Beta added a String caller parameter for debugging: + // + thawDisplayRotationMethod = manager.getClass().getMethod("thawDisplayRotation", int.class, String.class); + thawDisplayRotationMethodVersion = 0; + } catch (NoSuchMethodException e) { + try { + // New method added by this commit: + // + thawDisplayRotationMethod = manager.getClass().getMethod("thawDisplayRotation", int.class); + thawDisplayRotationMethodVersion = 1; + } catch (NoSuchMethodException e1) { + thawDisplayRotationMethod = manager.getClass().getMethod("thawRotation"); + thawDisplayRotationMethodVersion = 2; + } + } + } + return thawDisplayRotationMethod; + } + + public int getRotation() { + try { + Method method = getGetRotationMethod(); + return (int) method.invoke(manager); + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return 0; + } + } + + public void freezeRotation(int displayId, int rotation) { + try { + Method method = getFreezeDisplayRotationMethod(); + switch (freezeDisplayRotationMethodVersion) { + case 0: + method.invoke(manager, displayId, rotation, "scrcpy#freezeRotation"); + break; + case 1: + method.invoke(manager, displayId, rotation); + break; + default: + if (displayId != 0) { + Ln.e("Secondary display rotation not supported on this device"); + return; + } + method.invoke(manager, rotation); + break; + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + public boolean isRotationFrozen(int displayId) { + try { + Method method = getIsDisplayRotationFrozenMethod(); + switch (isDisplayRotationFrozenMethodVersion) { + case 0: + return (boolean) method.invoke(manager, displayId); + default: + if (displayId != 0) { + Ln.e("Secondary display rotation not supported on this device"); + return false; + } + return (boolean) method.invoke(manager); + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return false; + } + } + + public void thawRotation(int displayId) { + try { + Method method = getThawDisplayRotationMethod(); + switch (thawDisplayRotationMethodVersion) { + case 0: + method.invoke(manager, displayId, "scrcpy#thawRotation"); + break; + case 1: + method.invoke(manager, displayId); + break; + default: + if (displayId != 0) { + Ln.e("Secondary display rotation not supported on this device"); + return; + } + method.invoke(manager); + break; + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } + + @TargetApi(AndroidVersions.API_30_ANDROID_11) + public int[] registerDisplayWindowListener(IDisplayWindowListener listener) { + try { + return (int[]) manager.getClass().getMethod("registerDisplayWindowListener", IDisplayWindowListener.class).invoke(manager, listener); + } catch (Exception e) { + Ln.e("Could not register display window listener", e); + } + return null; + } + + @TargetApi(AndroidVersions.API_30_ANDROID_11) + public void unregisterDisplayWindowListener(IDisplayWindowListener listener) { + try { + manager.getClass().getMethod("unregisterDisplayWindowListener", IDisplayWindowListener.class).invoke(manager, listener); + } catch (Exception e) { + Ln.e("Could not unregister display window listener", e); + } + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + private Method getGetDisplayImePolicyMethod() throws NoSuchMethodException { + if (getDisplayImePolicyMethod == null) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + getDisplayImePolicyMethod = manager.getClass().getMethod("getDisplayImePolicy", int.class); + } else { + getDisplayImePolicyMethod = manager.getClass().getMethod("shouldShowIme", int.class); + } + } + return getDisplayImePolicyMethod; + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + public int getDisplayImePolicy(int displayId) { + try { + Method method = getGetDisplayImePolicyMethod(); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + return (int) method.invoke(manager, displayId); + } + boolean shouldShowIme = (boolean) method.invoke(manager, displayId); + return shouldShowIme ? DISPLAY_IME_POLICY_LOCAL : DISPLAY_IME_POLICY_FALLBACK_DISPLAY; + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + return -1; + } + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + private Method getSetDisplayImePolicyMethod() throws NoSuchMethodException { + if (setDisplayImePolicyMethod == null) { + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + setDisplayImePolicyMethod = manager.getClass().getMethod("setDisplayImePolicy", int.class, int.class); + } else { + setDisplayImePolicyMethod = manager.getClass().getMethod("setShouldShowIme", int.class, boolean.class); + } + } + return setDisplayImePolicyMethod; + } + + @TargetApi(AndroidVersions.API_29_ANDROID_10) + public void setDisplayImePolicy(int displayId, int displayImePolicy) { + try { + Method method = getSetDisplayImePolicyMethod(); + if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) { + method.invoke(manager, displayId, displayImePolicy); + } else if (displayImePolicy != DISPLAY_IME_POLICY_HIDE) { + method.invoke(manager, displayId, displayImePolicy == DISPLAY_IME_POLICY_LOCAL); + } else { + Ln.w("DISPLAY_IME_POLICY_HIDE is not supported before Android 12"); + } + } catch (ReflectiveOperationException e) { + Ln.e("Could not invoke method", e); + } + } +} diff --git a/tools/devtest.py b/tools/devtest.py new file mode 100644 index 0000000..afa91a1 --- /dev/null +++ b/tools/devtest.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Drives droidbridge-server on a connected device without the Mac app. +Usage: devtest.py tests: enter, edge, clipboard""" +import socket, struct, subprocess, sys, time + +ADB = "adb" +REMOTE = "/data/local/tmp/droidbridge-server.jar" +VERSION = "0.1.0" +LEFT, RIGHT, TOP, BOTTOM = 0, 1, 2, 3 + +def adb(*a): + return subprocess.run([ADB, *a], capture_output=True, text=True).stdout + +class Server: + def __init__(self, jar): + adb("push", jar, REMOTE) + port = int(adb("forward", "tcp:0", "localabstract:droidbridge").strip()) + self.port = port + self.proc = subprocess.Popen([ADB, "shell", f"CLASSPATH={REMOTE}", "app_process", "/", "dev.droidbridge.Main", VERSION], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + line = self.proc.stdout.readline() + while line and "READY" not in line: + print("server:", line.strip()); line = self.proc.stdout.readline() + self.s = socket.create_connection(("127.0.0.1", port)) + self.send(0x01, struct.pack(">H", 1)) + t, p = self.recv() + assert t == 0x81, t + v, w, h = struct.unpack(">HHH", p[:6]) + self.size = (w, h) + print("device:", p[6:].decode(), w, h) + + def send(self, t, p=b""): self.s.sendall(struct.pack(">BI", t, len(p)) + p) + def recv(self, timeout=None): + self.s.settimeout(timeout) + try: + hdr = self._n(5) + except socket.timeout: + return None, None + t, n = struct.unpack(">BI", hdr) + return t, self._n(n) + def _n(self, n): + b = b"" + while len(b) < n: + c = self.s.recv(n - len(b)) + if not c: raise EOFError + b += c + return b + def mouse(self, dx, dy, buttons=0, wheel=0): self.send(0x04, struct.pack(">BhhbB", buttons, dx, dy, wheel, 0)) + def enter(self, side, ratio): self.send(0x02, struct.pack(">BH", side, int(ratio * 65535))) + def close(self): + self.s.close(); time.sleep(0.5) + for line in self.proc.stdout.read().splitlines()[-15:]: print("server:", line) + adb("forward", "--remove", f"tcp:{self.port}") + +def main(): + jar, test = sys.argv[1], sys.argv[2] + S = Server(jar) + try: + if test in ("enter", "edge"): + for ratio in (0.25, 0.75): + t0 = time.time(); S.enter(LEFT, ratio); S.send(0x07) + t, _ = S.recv(5); print(f"enter left {ratio}: {time.time()-t0:.2f}s (pong={t==0x84})") + time.sleep(0.5) + if test == "edge": + for _ in range(40): S.mouse(8, 0); time.sleep(0.008) # into the screen + t, p = S.recv(0.3); print("after moving in, unexpected:", t) + t0 = time.time(); got = None + for i in range(400): + S.mouse(-8, 0); time.sleep(0.008) + t, p = S.recv(0.001) + if t == 0x82: + side, r = struct.unpack(">BH", p); got = (side, r / 65535); break + print("edge:", got, f"after {time.time()-t0:.2f}s") + if test.startswith("type"): + # Types dead-key sequences with the given Android layout: devtest.py type: + layout = test.split(":", 1)[1] if ":" in test else "english_us_intl" + S.send(0x08, layout.encode()); time.sleep(0.8) + SHIFT = 0x02 + def key(mods, usage): + S.send(0x05, bytes([mods, 0, usage, 0, 0, 0, 0, 0])); time.sleep(0.03) + S.send(0x05, bytes(8)); time.sleep(0.03) + A, C, E, O, U, SPACE, ENTER = 0x04, 0x06, 0x08, 0x12, 0x18, 0x2C, 0x28 + QUOTE, GRAVE, SIX = 0x34, 0x35, 0x23 + seqs = [("'a", [(0, QUOTE), (0, A)]), ("~a", [(SHIFT, GRAVE), (0, A)]), ("`a", [(0, GRAVE), (0, A)]), + ("^e", [(SHIFT, SIX), (0, E)]), ('"u', [(SHIFT, QUOTE), (0, U)]), ("'c", [(0, QUOTE), (0, C)]), + ("~o", [(SHIFT, GRAVE), (0, O)]), ("' space", [(0, QUOTE), (0, SPACE)])] + for name, keys in seqs: + for k in keys: key(*k) + key(0, SPACE) + key(0, ENTER) + print("typed:", " | ".join(n for n, _ in seqs)) + if test == "clipboard": + S.send(0x06, "droidbridge mac->android ção".encode()) + time.sleep(0.5) + print("set; device echo (should be none):", S.recv(1)[0]) + finally: + S.close() + +main()