diff --git a/.github/workflows/checks-android.yml b/.github/workflows/checks-android.yml new file mode 100644 index 0000000..90186f7 --- /dev/null +++ b/.github/workflows/checks-android.yml @@ -0,0 +1,68 @@ +name: Checks (Android) + +on: + push: + branches: [main] + paths: + - 'android/**' + - '.github/workflows/checks-android.yml' + pull_request: + branches: [main] + paths: + - 'android/**' + - '.github/workflows/checks-android.yml' + +jobs: + checks: + name: Android Checks + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/libs.versions.toml', 'android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Make Gradle wrapper executable + run: chmod +x android/gradlew + + - name: Detekt + working-directory: android + run: ./gradlew detekt --no-daemon --stacktrace + + - name: ktlintCheck + working-directory: android + run: ./gradlew ktlintCheck --no-daemon --stacktrace + + - name: Lint + working-directory: android + run: ./gradlew lint --no-daemon --stacktrace + + - name: Unit tests + working-directory: android + run: ./gradlew test --no-daemon --stacktrace + + - name: Assemble debug + working-directory: android + run: ./gradlew :app:assembleDebug --no-daemon --stacktrace + + - name: Upload reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-reports + path: | + android/**/build/reports/** diff --git a/.github/workflows/release-android.yml b/.github/workflows/release-android.yml new file mode 100644 index 0000000..f3a68f7 --- /dev/null +++ b/.github/workflows/release-android.yml @@ -0,0 +1,117 @@ +name: Release (Android) + +on: + push: + tags: + - 'v*-android' + workflow_dispatch: + inputs: + tag: + description: "Release tag (must already exist on the branch and end in -android, e.g. v0.1.0-android)" + required: true + type: string + +jobs: + build: + name: Build signed APK + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Resolve ref + id: resolve + run: | + if [ -n "${{ inputs.tag }}" ]; then + echo "ref=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT" + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ steps.resolve.outputs.ref }} + fetch-depth: 0 + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('android/gradle/libs.versions.toml', 'android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Make Gradle wrapper executable + run: chmod +x android/gradlew + + - name: Decode signing keystore + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + mkdir -p ${{ runner.temp }}/keystore + echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > ${{ runner.temp }}/keystore/release.jks + echo "MUXY_ANDROID_KEYSTORE_PATH=${{ runner.temp }}/keystore/release.jks" >> "$GITHUB_ENV" + + - name: Assemble release APK + env: + MUXY_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + MUXY_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + MUXY_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + working-directory: android + run: ./gradlew :app:assembleRelease --no-daemon --stacktrace + + - name: Locate artifacts + id: artifacts + run: | + APK=$(ls android/app/build/outputs/apk/release/*.apk | head -1) + MAPPING=android/app/build/outputs/mapping/release/mapping.txt + TAG="${{ steps.resolve.outputs.tag }}" + VERSION="${TAG%-android}" + VERSION="${VERSION#v}" + OUT="muxy-android-${VERSION}.apk" + MAPOUT="muxy-android-${VERSION}-mapping.txt" + cp "$APK" "$OUT" + if [ -f "$MAPPING" ]; then + cp "$MAPPING" "$MAPOUT" + fi + echo "apk=$OUT" >> "$GITHUB_OUTPUT" + echo "mapping=$MAPOUT" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Generate release notes + id: notes + run: | + { + echo "Muxy Android ${{ steps.artifacts.outputs.version }}" + echo + echo "Sideload the attached APK after enabling \"Install unknown apps\" for your browser or file manager." + echo + echo "**Source tag:** [\`${{ steps.resolve.outputs.tag }}\`](https://github.com/${{ github.repository }}/tree/${{ steps.resolve.outputs.tag }})" + echo + echo "Use only on Tailscale or a network you control — the desktop server speaks plain WebSocket without TLS." + } > release-notes.md + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ASSETS=("${{ steps.artifacts.outputs.apk }}") + if [ -f "${{ steps.artifacts.outputs.mapping }}" ]; then + ASSETS+=("${{ steps.artifacts.outputs.mapping }}") + fi + gh release create "${{ steps.resolve.outputs.tag }}" \ + --repo "${{ github.repository }}" \ + --title "Muxy Android ${{ steps.artifacts.outputs.version }}" \ + --notes-file release-notes.md \ + --draft \ + "${ASSETS[@]}" diff --git a/MuxyServer/MuxyRemoteServer.swift b/MuxyServer/MuxyRemoteServer.swift new file mode 100644 index 0000000..2616317 --- /dev/null +++ b/MuxyServer/MuxyRemoteServer.swift @@ -0,0 +1,683 @@ +import Foundation +import MuxyShared +import Network +import os + +private let logger = Logger(subsystem: "app.muxy", category: "RemoteServer") + +public enum DeviceAuthDecision: Sendable { + case approved(deviceName: String) + case unknown + case denied +} + +public enum MuxyRemoteServerError: LocalizedError { + case invalidPort(UInt16) + case startSuperseded + + public var errorDescription: String? { + switch self { + case let .invalidPort(port): + "Invalid port \(port)." + case .startSuperseded: + "Server start was superseded by a new start request." + } + } +} + +@MainActor +public protocol MuxyRemoteServerDelegate: AnyObject { + func listProjects() -> [ProjectDTO] + func selectProject(_ projectID: UUID) + func listWorktrees(projectID: UUID) -> [WorktreeDTO] + func selectWorktree(projectID: UUID, worktreeID: UUID) + func getWorkspace(projectID: UUID) -> WorkspaceDTO? + func createTab(projectID: UUID, areaID: UUID?, kind: TabKindDTO) -> TabDTO? + func closeTab(projectID: UUID, areaID: UUID, tabID: UUID) + func selectTab(projectID: UUID, areaID: UUID, tabID: UUID) + func splitArea(projectID: UUID, areaID: UUID, direction: SplitDirectionDTO, position: SplitPositionDTO) + func closeArea(projectID: UUID, areaID: UUID) + func focusArea(projectID: UUID, areaID: UUID) + func sendTerminalInput(paneID: UUID, bytes: Data, clientID: UUID) + func resizeTerminal(paneID: UUID, cols: UInt32, rows: UInt32, clientID: UUID) + func scrollTerminal(paneID: UUID, deltaX: Double, deltaY: Double, precise: Bool, clientID: UUID) + func getTerminalContent(paneID: UUID) -> TerminalCellsDTO? + func takeOverPane(paneID: UUID, clientID: UUID, cols: UInt32, rows: UInt32) + func releasePane(paneID: UUID, clientID: UUID) + func registerDevice(clientID: UUID, name: String) + func authenticateDevice(deviceID: UUID, token: String, name: String) -> DeviceAuthDecision + func requestPairing(deviceID: UUID, token: String, name: String) async -> DeviceAuthDecision + func getDeviceTheme() -> DeviceThemeEventDTO? + func clientDisconnected(clientID: UUID) + func getPaneOwner(paneID: UUID) -> PaneOwnerDTO? + func getVCSStatus(projectID: UUID) async -> VCSStatusDTO? + func vcsCommit(projectID: UUID, message: String, stageAll: Bool) async throws + func vcsPush(projectID: UUID) async throws + func vcsPull(projectID: UUID) async throws + func vcsStageFiles(projectID: UUID, paths: [String]) async throws + func vcsUnstageFiles(projectID: UUID, paths: [String]) async throws + func vcsDiscardFiles(projectID: UUID, paths: [String], untrackedPaths: [String]) async throws + func vcsListBranches(projectID: UUID) async throws -> VCSBranchesDTO + func vcsSwitchBranch(projectID: UUID, branch: String) async throws + func vcsCreateBranch(projectID: UUID, name: String) async throws + func vcsCreatePR(projectID: UUID, title: String, body: String, baseBranch: String?, draft: Bool) async throws -> VCSCreatePRResultDTO + func vcsAddWorktree(projectID: UUID, name: String, branch: String, createBranch: Bool) async throws -> WorktreeDTO + func vcsRemoveWorktree(projectID: UUID, worktreeID: UUID) async throws + func getProjectLogo(projectID: UUID) -> ProjectLogoDTO? + func listNotifications() -> [NotificationDTO] + func markNotificationRead(_ notificationID: UUID) +} + +public final class MuxyRemoteServer: @unchecked Sendable { + public static let defaultPort: UInt16 = 4865 + + private let port: UInt16 + private var listener: NWListener? + private var connections: [UUID: ClientConnection] = [:] + private var authenticatedClients: Set = [] + private var deviceIDByClient: [UUID: UUID] = [:] + private let queue = DispatchQueue(label: "app.muxy.remoteServer") + private var startCompletion: (@Sendable (Result) -> Void)? + private var stopCompletions: [@Sendable () -> Void] = [] + public weak var delegate: (any MuxyRemoteServerDelegate)? + + public init(port: UInt16 = MuxyRemoteServer.defaultPort) { + self.port = port + } + + public func start(completion: (@Sendable (Result) -> Void)? = nil) { + queue.async { [weak self] in + guard let self else { return } + self.finishStart(.failure(MuxyRemoteServerError.startSuperseded)) + self.startCompletion = completion + self.startListener() + } + } + + public func stop(completion: (@Sendable () -> Void)? = nil) { + queue.async { [weak self] in + guard let self else { + completion?() + return + } + for connection in self.connections.values { + connection.cancel() + } + self.connections.removeAll() + self.authenticatedClients.removeAll() + self.deviceIDByClient.removeAll() + + guard let listener = self.listener else { + logger.info("Remote server stopped") + completion?() + return + } + if let completion { self.stopCompletions.append(completion) } + listener.cancel() + } + } + + public func broadcast(_ event: MuxyEvent) { + guard let data = try? MuxyCodec.encode(.event(event)) else { return } + queue.async { [weak self] in + guard let self else { return } + for clientID in self.authenticatedClients { + self.connections[clientID]?.send(data) + } + } + } + + public func send(_ event: MuxyEvent, to clientID: UUID) { + guard let data = try? MuxyCodec.encode(.event(event)) else { return } + queue.async { [weak self] in + guard let self, + self.authenticatedClients.contains(clientID) + else { return } + self.connections[clientID]?.send(data) + } + } + + public func disconnect(clientID: UUID) { + queue.async { [weak self] in + self?.connections[clientID]?.cancel() + } + } + + public func disconnect(deviceID: UUID) { + queue.async { [weak self] in + guard let self else { return } + let clientIDs = self.deviceIDByClient.filter { $0.value == deviceID }.map(\.key) + for clientID in clientIDs { + self.connections[clientID]?.cancel() + } + } + } + + private func startListener() { + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { + logger.error("Invalid port: \(self.port)") + finishStart(.failure(MuxyRemoteServerError.invalidPort(port))) + return + } + + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + let ws = NWProtocolWebSocket.Options() + ws.autoReplyPing = true + params.defaultProtocolStack.applicationProtocols.insert(ws, at: 0) + listener = try NWListener(using: params, on: endpointPort) + } catch { + logger.error("Failed to create listener: \(error)") + finishStart(.failure(error)) + return + } + + listener?.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + logger.info("Remote server listening on port \(self.port)") + self.finishStart(.success(())) + case let .failed(error): + logger.error("Listener failed: \(error)") + self.finishStart(.failure(error)) + self.listener?.cancel() + case .cancelled: + self.listener = nil + logger.info("Remote server stopped") + let completions = self.stopCompletions + self.stopCompletions.removeAll() + for completion in completions { + completion() + } + default: + break + } + } + + listener?.newConnectionHandler = { [weak self] nwConnection in + self?.handleNewConnection(nwConnection) + } + + listener?.start(queue: queue) + } + + private func finishStart(_ result: Result) { + guard let completion = startCompletion else { return } + startCompletion = nil + completion(result) + } + + private func handleNewConnection(_ nwConnection: NWConnection) { + let id = UUID() + let connection = ClientConnection(id: id, connection: nwConnection, server: self) + connections[id] = connection + connection.start(on: queue) + logger.info("Client connected: \(id)") + } + + func removeConnection(_ id: UUID) { + queue.async { [weak self] in + self?.connections.removeValue(forKey: id) + self?.authenticatedClients.remove(id) + self?.deviceIDByClient.removeValue(forKey: id) + logger.info("Client disconnected: \(id)") + } + Task { @MainActor in + self.delegate?.clientDisconnected(clientID: id) + } + } + + private func markAuthenticated(_ id: UUID, deviceID: UUID) { + queue.async { [weak self] in + self?.authenticatedClients.insert(id) + self?.deviceIDByClient[id] = deviceID + } + } + + func _testingMarkAuthenticated(_ id: UUID) { + queue.sync { + _ = authenticatedClients.insert(id) + } + } + + private func isAuthenticated(_ id: UUID) -> Bool { + queue.sync { authenticatedClients.contains(id) } + } + + func handleRequest(_ request: MuxyRequest, from clientID: UUID) { + if Self.voidMethods.contains(request.method) { + Task { @MainActor in _ = await processRequest(request, clientID: clientID) } + return + } + Task { @MainActor in + let response = await processRequest(request, clientID: clientID) + guard let data = try? MuxyCodec.encode(.response(response)) else { return } + self.queue.async { [weak self] in + self?.connections[clientID]?.send(data) + } + } + } + + private static let voidMethods: Set = [.terminalInput] + + @MainActor + func processRequest(_ request: MuxyRequest, clientID: UUID) async -> MuxyResponse { + guard let delegate else { + return MuxyResponse(id: request.id, error: MuxyError.internalError) + } + + switch request.method { + case .pairDevice: + guard case let .pairDevice(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + let decision = await delegate.requestPairing( + deviceID: params.deviceID, + token: params.token, + name: params.deviceName + ) + return finalizeAuth( + requestID: request.id, + clientID: clientID, + deviceID: params.deviceID, + decision: decision + ) + + case .authenticateDevice: + guard case let .authenticateDevice(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + let decision = delegate.authenticateDevice( + deviceID: params.deviceID, + token: params.token, + name: params.deviceName + ) + return finalizeAuth( + requestID: request.id, + clientID: clientID, + deviceID: params.deviceID, + decision: decision + ) + + default: + break + } + + guard isAuthenticated(clientID) else { + return MuxyResponse(id: request.id, error: .unauthorized) + } + + switch request.method { + case .pairDevice, + .authenticateDevice: + return MuxyResponse(id: request.id, error: .internalError) + + case .listProjects: + let projects = delegate.listProjects() + return MuxyResponse(id: request.id, result: .projects(projects)) + + case .selectProject: + guard case let .selectProject(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.selectProject(params.projectID) + return MuxyResponse(id: request.id, result: .ok) + + case .listWorktrees: + guard case let .listWorktrees(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + let worktrees = delegate.listWorktrees(projectID: params.projectID) + return MuxyResponse(id: request.id, result: .worktrees(worktrees)) + + case .selectWorktree: + guard case let .selectWorktree(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.selectWorktree(projectID: params.projectID, worktreeID: params.worktreeID) + return MuxyResponse(id: request.id, result: .ok) + + case .getWorkspace: + guard case let .getWorkspace(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + guard let workspace = delegate.getWorkspace(projectID: params.projectID) else { + return MuxyResponse(id: request.id, error: .notFound) + } + return MuxyResponse(id: request.id, result: .workspace(workspace)) + + case .createTab: + guard case let .createTab(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + guard let tab = delegate.createTab(projectID: params.projectID, areaID: params.areaID, kind: params.kind) else { + return MuxyResponse(id: request.id, error: .internalError) + } + return MuxyResponse(id: request.id, result: .tab(tab)) + + case .closeTab: + guard case let .closeTab(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.closeTab(projectID: params.projectID, areaID: params.areaID, tabID: params.tabID) + return MuxyResponse(id: request.id, result: .ok) + + case .selectTab: + guard case let .selectTab(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.selectTab(projectID: params.projectID, areaID: params.areaID, tabID: params.tabID) + return MuxyResponse(id: request.id, result: .ok) + + case .splitArea: + guard case let .splitArea(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.splitArea( + projectID: params.projectID, + areaID: params.areaID, + direction: params.direction, + position: params.position + ) + return MuxyResponse(id: request.id, result: .ok) + + case .closeArea: + guard case let .closeArea(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.closeArea(projectID: params.projectID, areaID: params.areaID) + return MuxyResponse(id: request.id, result: .ok) + + case .focusArea: + guard case let .focusArea(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.focusArea(projectID: params.projectID, areaID: params.areaID) + return MuxyResponse(id: request.id, result: .ok) + + case .terminalInput: + guard case let .terminalInput(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.sendTerminalInput(paneID: params.paneID, bytes: params.bytes, clientID: clientID) + return MuxyResponse(id: request.id, result: .ok) + + case .terminalResize: + guard case let .terminalResize(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.resizeTerminal( + paneID: params.paneID, + cols: params.cols, + rows: params.rows, + clientID: clientID + ) + return MuxyResponse(id: request.id, result: .ok) + + case .terminalScroll: + guard case let .terminalScroll(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.scrollTerminal( + paneID: params.paneID, + deltaX: params.deltaX, + deltaY: params.deltaY, + precise: params.precise, + clientID: clientID + ) + return MuxyResponse(id: request.id, result: .ok) + + case .getTerminalContent: + guard case let .getTerminalContent(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + guard let content = delegate.getTerminalContent(paneID: params.paneID) else { + return MuxyResponse(id: request.id, error: .notFound) + } + return MuxyResponse(id: request.id, result: .terminalCells(content)) + + case .getVCSStatus: + guard case let .getVCSStatus(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + guard let status = await delegate.getVCSStatus(projectID: params.projectID) else { + return MuxyResponse(id: request.id, error: .notFound) + } + return MuxyResponse(id: request.id, result: .vcsStatus(status)) + + case .vcsCommit: + guard case let .vcsCommit(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsCommit(projectID: params.projectID, message: params.message, stageAll: params.stageAll) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsPush: + guard case let .vcsPush(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsPush(projectID: params.projectID) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsPull: + guard case let .vcsPull(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsPull(projectID: params.projectID) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsStageFiles: + guard case let .vcsStageFiles(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsStageFiles(projectID: params.projectID, paths: params.paths) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsUnstageFiles: + guard case let .vcsUnstageFiles(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsUnstageFiles(projectID: params.projectID, paths: params.paths) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsDiscardFiles: + guard case let .vcsDiscardFiles(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsDiscardFiles( + projectID: params.projectID, + paths: params.paths, + untrackedPaths: params.untrackedPaths + ) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsListBranches: + guard case let .vcsListBranches(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + let branches = try await delegate.vcsListBranches(projectID: params.projectID) + return MuxyResponse(id: request.id, result: .vcsBranches(branches)) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsSwitchBranch: + guard case let .vcsSwitchBranch(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsSwitchBranch(projectID: params.projectID, branch: params.branch) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsCreateBranch: + guard case let .vcsCreateBranch(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsCreateBranch(projectID: params.projectID, name: params.name) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsCreatePR: + guard case let .vcsCreatePR(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + let info = try await delegate.vcsCreatePR( + projectID: params.projectID, + title: params.title, + body: params.body, + baseBranch: params.baseBranch, + draft: params.draft + ) + return MuxyResponse(id: request.id, result: .vcsPRCreated(info)) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsAddWorktree: + guard case let .vcsAddWorktree(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + let worktree = try await delegate.vcsAddWorktree( + projectID: params.projectID, + name: params.name, + branch: params.branch, + createBranch: params.createBranch + ) + return MuxyResponse(id: request.id, result: .worktrees([worktree])) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .vcsRemoveWorktree: + guard case let .vcsRemoveWorktree(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + do { + try await delegate.vcsRemoveWorktree(projectID: params.projectID, worktreeID: params.worktreeID) + return MuxyResponse(id: request.id, result: .ok) + } catch { + return MuxyResponse(id: request.id, error: MuxyError(code: 500, message: error.localizedDescription)) + } + + case .getProjectLogo: + guard case let .getProjectLogo(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + guard let logo = delegate.getProjectLogo(projectID: params.projectID) else { + return MuxyResponse(id: request.id, error: .notFound) + } + return MuxyResponse(id: request.id, result: .projectLogo(logo)) + + case .listNotifications: + let notifications = delegate.listNotifications() + return MuxyResponse(id: request.id, result: .notifications(notifications)) + + case .markNotificationRead: + guard case let .markNotificationRead(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.markNotificationRead(params.notificationID) + return MuxyResponse(id: request.id, result: .ok) + + case .subscribe, + .unsubscribe: + return MuxyResponse(id: request.id, result: .ok) + + case .registerDevice: + guard case let .registerDevice(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.registerDevice(clientID: clientID, name: params.deviceName) + let theme = delegate.getDeviceTheme() + let info = DeviceInfoDTO( + clientID: clientID, + deviceName: params.deviceName, + themeFg: theme?.fg, + themeBg: theme?.bg, + themePalette: theme?.palette + ) + return MuxyResponse(id: request.id, result: .deviceInfo(info)) + + case .takeOverPane: + guard case let .takeOverPane(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.takeOverPane( + paneID: params.paneID, + clientID: clientID, + cols: params.cols, + rows: params.rows + ) + return MuxyResponse(id: request.id, result: .ok) + + case .releasePane: + guard case let .releasePane(params) = request.params else { + return MuxyResponse(id: request.id, error: .invalidParams) + } + delegate.releasePane(paneID: params.paneID, clientID: clientID) + return MuxyResponse(id: request.id, result: .ok) + } + } + + @MainActor + private func finalizeAuth( + requestID: String, + clientID: UUID, + deviceID: UUID, + decision: DeviceAuthDecision + ) -> MuxyResponse { + switch decision { + case let .approved(deviceName): + markAuthenticated(clientID, deviceID: deviceID) + delegate?.registerDevice(clientID: clientID, name: deviceName) + let theme = delegate?.getDeviceTheme() + let result = PairingResultDTO( + clientID: clientID, + deviceName: deviceName, + themeFg: theme?.fg, + themeBg: theme?.bg, + themePalette: theme?.palette + ) + return MuxyResponse(id: requestID, result: .pairing(result)) + case .unknown: + return MuxyResponse(id: requestID, error: .unauthorized) + case .denied: + return MuxyResponse(id: requestID, error: .pairingDenied) + } + } +} diff --git a/README.md b/README.md index 386a97e..e6764e0 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,121 @@ -# Muxy Mobile +

+ Muxy +

-Monorepo for the Muxy mobile clients. +

Muxy

-``` -. -├── ios/ Native SwiftUI app (iOS 17+) -├── android/ Native Kotlin / Jetpack Compose app -├── docs/ -└── .github/workflows/ - ├── ios-checks.yml - ├── ios-release.yml - ├── android-checks.yml - └── android-release.yml -``` +

Lightweight and Memory efficient terminal for Mac built with SwiftUI and libghostty.

+

Mac | iOS | Android | Discord

-The two apps are completely separate — no shared code between them. They each -talk to a Muxy server (the macOS app in `~/Projects/muxy`) over WebSocket using -the protocol defined in `ios/MuxyShared/`. The Android side has its own -re-implementation of that protocol in Kotlin. +
+ + + + +
-## iOS +## Screenshots -```sh -cd ios -open MuxyMobile.xcodeproj -# or run on a simulator: -scripts/run-mobile.sh -``` +image -`MuxyShared/` is a local Swift package consumed by the Xcode project via -`XCLocalSwiftPackageReference "."` (i.e. it picks up `ios/Package.swift`). +## Features -## Android +- **Project-based workflow** — Organize terminals by project with persistent workspace state +- **Vertical tabs** — Sidebar tab strip with drag-and-drop reordering, pinning, renaming, and middle-click close +- **Split panes** — Horizontal and vertical splits with keyboard navigation and resizable dividers +- **Built-in VCS** — Simple and lightweight basic git diff and operations +- **200+ themes** — Browse and search Ghostty themes with a built-in theme picker +- **Customizable shortcuts** — 40+ configurable keyboard shortcuts with conflict detection +- **Workspace persistence** — Tabs, splits, and focus state are saved and restored per project +- **In-terminal search** — Find text in terminal output with match navigation +- **Drag and drop** — Reorder tabs and projects, drag tabs between panes to create splits +- **Auto-updates** — Built-in update checking via Sparkle +- **Text Editor** - Native, Lightweight Text (not code) Editor with code highlight support for most of the programming languages -```sh -cd android -./gradlew assembleDebug +## Requirements + +- macOS 14+ +- Swift 6.0+ +- Ghostty installed (optional for themes) +- `gh` installed (optional for PR management) + +## Install + +### Homebrew + +```bash +brew tap muxy-app/tap +brew install --cask muxy ``` -Open `android/` in Android Studio for development. +### Manual + +Download the latest release from the [releases page](https://github.com/muxy-app/muxy/releases) + +### iOS app (Testing) + +The iOS app is available for testers on TestFlight -## CI +- Install the iOS app via TestFlight (https://testflight.apple.com/join/7t1AaYHW) +- Open Muxy on your Mac +- Go to Settings (Cmd + `,`) +- Go to Mobile tab +- Toggle the `Allow mobile device connection` +- Open the iOS app +- Enter the IP and Port +- Approve the connection on your Mac +- Test and open issues for the bugs -- `ios-checks` — SwiftFormat, SwiftLint, simulator build on every PR touching `ios/**`. -- `ios-release` — manual `workflow_dispatch`; archives, signs, uploads to App Store Connect. -- `android-checks` — Gradle lint, debug assemble, unit tests on every PR touching `android/**`. -- `android-release` — scaffold; signing + Play Store upload are TODO (see workflow comments). +**The iOS app is also open-source and the source is in this repo** -iOS release secrets carried over from the original repo: -`APPLE_DISTRIBUTION_CERTIFICATE`, `APPLE_DISTRIBUTION_CERTIFICATE_PASSWORD`, -`KEYCHAIN_PASSWORD`, `APP_STORE_CONNECT_API_KEY`, `APP_STORE_CONNECT_KEY_ID`, -`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_PROVISIONING_PROFILE`, `APPLE_TEAM_ID`. +### Android app (Testing) -## Migration +The Android app is a remote-control client that mirrors the iOS app. It +ships as a sideloadable APK from GitHub Releases — no Play Store / F-Droid +yet. + +- Open Muxy on your Mac +- Settings → Mobile → toggle **Allow mobile device connection** +- Download `muxy-android-X.Y.Z.apk` from the + [releases page](https://github.com/muxy-app/muxy/releases) +- On your phone, enable **Install unknown apps** for your browser or + file manager (Settings → Apps → your-browser → Install unknown apps), + then open the APK to install +- Open the Android app, tap **Add Device**, enter the Mac's IP and port + (default `4865`), tap Connect +- Approve the pairing alert on your Mac + +**Use only on Tailscale, a VPN, or a private network you control.** The +desktop server speaks plain WebSocket — there is no TLS, so the pairing +token and every keystroke travel in the clear. + +The Android app is GPL-3.0 because it vendors Termux's +`terminal-emulator` and `terminal-view` libraries; the rest of this repo +keeps its existing MIT license. Source is at `android/`. + +## Local Development + +```bash +scripts/setup.sh # downloads GhosttyKit.xcframework +swift build # debug build +swift run Muxy # run +``` + +For the Android app: + +```bash +cd android +./gradlew :app:assembleDebug # debug APK at app/build/outputs/apk/debug/ +./gradlew test # unit tests +../scripts/checks-android.sh # detekt + ktlint + lint + tests + assemble +``` -This repo was extracted from `~/Projects/muxy` (mac app + iOS app + shared) -and `~/Projects/muxy-android`. See `docs/migration-task.md` for the cleanup -checklist on the source mac repo. +See `android/README.md` for full Android build, signing, and security +notes. ## License -This project is source-available under the Functional Source License 1.1 with -an Apache 2.0 future grant (`FSL-1.1-ALv2`). See `LICENSE` for the full terms -and `LICENSE-NOTES.md` for a plain-language summary. +This repo is mixed-license. The macOS app (Swift sources at the repo +root, plus `MuxyMobile/`) is [MIT](LICENSE). The Android app and +everything under `android/` is GPL-3.0 because it vendors Termux's +terminal core. See `android/LICENSE` and `android/UPSTREAM` for details. diff --git a/android/.editorconfig b/android/.editorconfig new file mode 100644 index 0000000..3dc4903 --- /dev/null +++ b/android/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{kt,kts}] +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +max_line_length = unset + +# ktlint: @Composable PascalCase is the standard JetBrains convention. +ktlint_standard_function-naming = disabled +ktlint_standard_property-naming = disabled +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_filename = disabled +ktlint_standard_max-line-length = disabled + +# Vendored Termux + generated sources are not formatted by us. +[**/vendor/**.{kt,kts,java}] +ktlint_standard = disabled + +[**/build/generated/**.{kt,kts,java}] +ktlint_standard = disabled diff --git a/android/.gitignore b/android/.gitignore index e245b5a..7dab13a 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,12 +1,12 @@ +.gradle/ +build/ +captures/ +.cxx/ +local.properties + +.idea/ *.iml -.gradle -/local.properties -/.idea +.kotlin/ + .DS_Store -/build -/captures -.externalNativeBuild -.cxx -local.properties -/app/build -/app/release +*.log diff --git a/android/README.md b/android/README.md index 54f460b..53e41d7 100644 --- a/android/README.md +++ b/android/README.md @@ -1,30 +1,172 @@ # Muxy Android -Native Android (Kotlin + Jetpack Compose) port of the iOS MuxyMobile client. +Android companion app for Muxy. Mirrors the iOS app under `MuxyMobile/`: +pair with the desktop app, browse projects, view a terminal pane, send +keyboard input. Transport is the desktop app's `MuxyRemoteServer` +WebSocket on port `4865`. Muxy debug builds listen on `4866` (the Connect +screen lets you override the default port when adding a device). -## Status +The Android binary and everything under `android/` is licensed under +**GPL-3.0** because the terminal layer vendors Termux's `terminal-emulator` +and `terminal-view`. The rest of the Muxy repo keeps its existing license. -Phase 1 — connect & pair: +## Build requirements -- [x] Project scaffold, Gradle wrapper, Compose -- [x] Encrypted device credential store (UUID + token) -- [x] Saved devices list with add/remove -- [x] WebSocket client (OkHttp) and `{type, payload}` envelope codec -- [x] `authenticateDevice` → on `401` fall back to `pairDevice` -- [ ] Manually verified end-to-end against a Mac running Muxy server +| Tool | Version | +|---|---| +| Android Studio | Ladybug or newer | +| JDK | 17 | +| Android SDK platform | 35 (Android 15) | +| Build tools | 35.0.0 | +| Min SDK | 31 (Android 12) | +| Target / compile SDK | 35 | +| Gradle | 8.10.2 (downloaded by the wrapper) | +| Android Gradle Plugin | 8.7.3 | +| Kotlin | 2.0.21 | -## Build +## First-time setup -```sh -./gradlew assembleDebug -./gradlew test # JSON envelope round-trip tests -./gradlew installDebug # to a connected device or running emulator +1. Install Android Studio and accept all SDK licenses for API 35. +2. Copy `local.properties.example` to `local.properties` and set + `sdk.dir` to the path of your Android SDK. +3. From `android/`, run the Gradle wrapper to fetch dependencies and + build the debug APK: + + ``` + ./gradlew assembleDebug + ``` + +The unsigned debug APK lands at +`app/build/outputs/apk/debug/app-debug.apk`. + +## Modules + +| Module | Type | Purpose | +|---|---|---| +| `:app` | Android application | UI, navigation, ViewModels | +| `:protocol` | JVM library | Wire DTOs, envelope, codec | +| `:net` | Android library | WebSocket client, connection manager, credential store | +| `:terminal` | Android library | Vendored Termux terminal core + Compose wrapper | + +## Security notes + +**Trusted-network only.** The desktop server speaks plain `ws://` (no +TLS). The pairing token and every keystroke travel in the clear. Use +the app on Tailscale or a VPN you control. Do not pair across an open +Wi-Fi network. The Connect screen surfaces this warning the first time +you add a device. + +**Cleartext traffic is enabled app-wide** (`network_security_config.xml`) +because users type arbitrary hosts and IPs at runtime. Android cannot +scope cleartext to a dynamic host list, so the choice is "all hosts" +or "fixed allow-list", and we picked the former for now. + +**Auto-backup is disabled** (`android:allowBackup="false"`). The +device pairing token is stored encrypted with an Android Keystore key +(AES/GCM/NoPadding, 256-bit, generated on first launch). The Keystore +key is hardware-bound and cannot survive a backup-and-restore, so +re-installing from a backup would either leak stale ciphertext or +break authentication. Disabling backup keeps things simple. + +**Verifying backup exclusion.** To confirm the credential blob never +leaves the device, after pairing, run: + +``` +adb shell bmgr backupnow com.muxy.android +adb shell bmgr list transports ``` -## Test plan for Phase 1 +`bmgr backupnow` reports `Package com.muxy.android with result: Backup is not allowed` +when `allowBackup="false"` is honored. Inspecting the backup transport +output should show no entries for `com.muxy.android`. + +**Forget device** (Settings) deletes the encrypted credential blob and +the Android Keystore key. The desktop side keeps the approved-device +record until the user removes it from Mac settings, since there is no +remote-revoke RPC today. + +## Connection lifecycle + +The app cleanly closes its WebSocket when it goes to the background and +auto-reconnects (silently) when it returns to the foreground or when the +network changes. There is no foreground service in v1, so the device's +battery optimizer can hold up reconnects on aggressive OEMs. + +**Aggressive OEMs (Samsung, Xiaomi, OnePlus, Huawei, etc.)** kill +backgrounded apps faster than stock Android, and may delay the +network-callback / foreground reconnect path by tens of seconds. If +reconnect is slow on your phone, whitelist Muxy in +**Settings → Battery → App optimization** (the exact path varies by +manufacturer). This is a known limitation of the no-foreground-service +design and is not fixed in v1. + +After a process restart (system kill / reboot), the app reads the last +connected host/port from a small DataStore-backed `LastSession` record +and re-runs the full connect → authenticate → select-project flow on +launch. Pairing credentials are durable across process restarts as long +as the app data and Android Keystore key remain intact. + +## Tests + checks + +Either run individual Gradle tasks: + +``` +./gradlew :protocol:test :net:test :terminal:test :app:test +./gradlew detekt ktlintCheck lint +./gradlew :app:assembleDebug +./gradlew :app:assembleRelease # exercises R8 rules +``` + +Or run the bundled script (the same set the CI uses): + +``` +../scripts/checks-android.sh # detekt + ktlint + lint + tests + APK +../scripts/checks-android.sh --fix # auto-format with ktlint then run checks +``` + +`:protocol:test` covers JSON round-trips for every DTO, the three +custom envelope shapes (`MuxyMessage`, `MuxyParams`/`MuxyResult`/ +`MuxyEventData`, `SplitNodeDTO`), and the Swift-style enum-with- +associated-values shapes (`PaneOwnerDTO`, `NotificationDTO.SourceDTO`). +`:net:test` drives `MuxyClient` against an OkHttp `MockWebServer` for +authenticate-then-pair, RPC round-trip, fire-and-forget `terminalInput`, +event delivery, silent reconnect, and pending-request cancellation, plus +the diagnostic ring buffer, exponential backoff with jitter, the +DataStore-backed `SavedDevicesStore`, and the `DeviceCredentialsStore` +encryption / forget-device flows (using a fake `CryptoBox`). + +## Release builds + signing + +Debug builds use the default Android debug keystore. Release builds pick +up a JKS keystore via four environment variables: + +``` +MUXY_ANDROID_KEYSTORE_PATH # absolute path to .jks +MUXY_ANDROID_KEYSTORE_PASSWORD +MUXY_ANDROID_KEY_ALIAS +MUXY_ANDROID_KEY_PASSWORD +``` + +If `MUXY_ANDROID_KEYSTORE_PATH` is unset, `:app:assembleRelease` still +runs (R8 + signing with the debug key) so contributors can validate that +release-mode obfuscation rules cover every kotlinx.serialization +`@Serializable` shape locally. The CI release workflow +(`.github/workflows/release-android.yml`) decodes a base64 keystore +secret into the runner and exports those four variables before +assembling the signed APK on `v*-android` tags. The same workflow +attaches both the APK and the R8 `mapping.txt` to the GitHub release +draft so user-supplied stack traces can be de-obfuscated later. + +## Phase status -1. Start the macOS Muxy server. Note the LAN IP and port (default `4865`). -2. Launch the Android app, tap **Add Device**, enter the host/port, tap **Add**. -3. Tap the device row → Android shows "Connecting…" then "Awaiting approval". -4. Approve the device on the Mac → Android shows "Connected" with a client ID. -5. Reopen the app and tap the same device row → it skips the approval step. +Tracked in `docs/plans/android-companion.md` at the repo root. v1 is now +feature-complete through Phase 13: scaffolding, protocol port, network +client, pairing + credential vault, UI shell, terminal rendering with +Termux core, accessory bar, workspace + tab picker, full VCS sheet stack +(status / branches / worktrees / create PR), notifications, lifecycle +hooks (foreground / background, network callback, silent reconnect, +process-death recovery via `LastSessionStore`), Settings screen +(font size 8…24, Use Nerd Font, About, Forget Device), error-report +sheet, splash screen, responsive layouts, accessibility passes, CI +(detekt + ktlint + lint + tests + APK), and a tag-driven release +workflow. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8c0ae1a..4523d97 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,27 +1,61 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.kotlin.plugin.serialization") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } android { - namespace = "com.muxy.app" + namespace = "com.muxy.android" compileSdk = 35 defaultConfig { - applicationId = "com.muxy.app" - minSdk = 29 + applicationId = "com.muxy.android" + minSdk = 31 targetSdk = 35 versionCode = 1 versionName = "0.1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + val releaseKeystorePath = providers.environmentVariable("MUXY_ANDROID_KEYSTORE_PATH").orNull + val releaseKeystorePassword = providers.environmentVariable("MUXY_ANDROID_KEYSTORE_PASSWORD").orNull + val releaseKeyAlias = providers.environmentVariable("MUXY_ANDROID_KEY_ALIAS").orNull + val releaseKeyPassword = providers.environmentVariable("MUXY_ANDROID_KEY_PASSWORD").orNull + + signingConfigs { + create("release") { + if (releaseKeystorePath != null) { + storeFile = file(releaseKeystorePath) + storePassword = releaseKeystorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword + enableV2Signing = true + enableV3Signing = true + } + } + } + buildTypes { - release { + debug { isMinifyEnabled = false - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + signingConfig = + if (releaseKeystorePath != null) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } @@ -30,10 +64,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { compose = true } @@ -45,37 +75,41 @@ android { } } -dependencies { - val composeBom = platform("androidx.compose:compose-bom:2024.10.01") - implementation(composeBom) - androidTestImplementation(composeBom) - - implementation("androidx.core:core-ktx:1.13.1") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") - implementation("androidx.lifecycle:lifecycle-process:2.8.7") - implementation("androidx.activity:activity-compose:1.9.3") - - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.8.4") - - implementation("androidx.security:security-crypto:1.1.0-alpha06") - - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - - implementation("com.squareup.okhttp3:okhttp:4.12.0") - - implementation("io.coil-kt:coil-compose:2.7.0") - - debugImplementation("androidx.compose.ui:ui-tooling") - - testImplementation("junit:junit:4.13.2") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} - androidTestImplementation("androidx.test.ext:junit:1.2.1") +dependencies { + implementation(project(":protocol")) + implementation(project(":net")) + implementation(project(":terminal")) + + implementation(libs.kotlinx.coroutines.android) + + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.core.splashscreen) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.windowsizeclass) + implementation(libs.androidx.compose.material.icons.extended) + + debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 0f31a99..6d30700 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,9 +1,24 @@ -# Default ProGuard rules; expanded later for release builds. -keepattributes *Annotation*, InnerClasses --keep,includedescriptorclasses class com.muxy.app.**$$serializer { *; } --keepclassmembers class com.muxy.app.** { +-dontnote kotlinx.serialization.AnnotationsKt + +-keep,includedescriptorclasses class com.muxy.**$$serializer { *; } +-keepclassmembers class com.muxy.** { *** Companion; } --keepclasseswithmembers class com.muxy.app.** { +-keepclasseswithmembers class com.muxy.** { kotlinx.serialization.KSerializer serializer(...); } + +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; + private static final ** $cachedSerializer$delegate; +} + +-keep class kotlinx.serialization.** { *; } +-keep class kotlinx.serialization.json.** { *; } +-keep class kotlinx.serialization.internal.** { *; } + +-keep class com.termux.terminal.** { *; } +-keep class com.termux.view.** { *; } +-keep class com.muxy.terminal.MuxyTerminalSession { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa12857..baad8d6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,27 +3,28 @@ + + android:theme="@style/Theme.Muxy"> + android:label="@string/app_name" + android:theme="@style/Theme.Muxy.Splash" + android:windowSoftInputMode="adjustResize"> - diff --git a/android/app/src/main/java/com/muxy/android/AppContainer.kt b/android/app/src/main/java/com/muxy/android/AppContainer.kt new file mode 100644 index 0000000..10b7c25 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/AppContainer.kt @@ -0,0 +1,44 @@ +package com.muxy.android + +import android.app.Application +import android.content.pm.PackageInfo +import android.os.Build +import androidx.compose.runtime.compositionLocalOf +import com.muxy.android.connect.UserPreferences +import com.muxy.android.settings.TerminalPreferences +import com.muxy.net.DeviceCredentialsStore +import com.muxy.net.LastSessionStore +import com.muxy.net.MuxyClient +import com.muxy.net.MuxyLifecycleBinder +import com.muxy.net.SavedDevicesStore + +class AppContainer(private val application: Application) { + val deviceCredentialsStore: DeviceCredentialsStore = DeviceCredentialsStore.create(application) + val savedDevicesStore: SavedDevicesStore = SavedDevicesStore.create(application) + val userPreferences: UserPreferences = UserPreferences.create(application) + val terminalPreferences: TerminalPreferences = TerminalPreferences.create(application) + val lastSessionStore: LastSessionStore = LastSessionStore.create(application) + val muxyClient: MuxyClient = MuxyClient(credentialsProvider = deviceCredentialsStore) + val lifecycleBinder: MuxyLifecycleBinder = + MuxyLifecycleBinder( + client = muxyClient, + connectivityManager = MuxyLifecycleBinder.systemConnectivityManager(application), + ) + + fun appVersionName(): String = packageInfo()?.versionName ?: "-" + + fun appVersionCode(): Long = + packageInfo()?.let { info -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() + } ?: 0L + + private fun packageInfo(): PackageInfo? = + runCatching { + application.packageManager.getPackageInfo(application.packageName, 0) + }.getOrNull() +} + +val LocalAppContainer = + compositionLocalOf { + error("AppContainer not provided. Wrap content in CompositionLocalProvider(LocalAppContainer provides ...).") + } diff --git a/android/app/src/main/java/com/muxy/android/MainActivity.kt b/android/app/src/main/java/com/muxy/android/MainActivity.kt new file mode 100644 index 0000000..5d2bd70 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/MainActivity.kt @@ -0,0 +1,62 @@ +package com.muxy.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.lifecycleScope +import com.muxy.android.nav.MuxyNavHost +import com.muxy.android.ui.theme.MuxyTheme +import com.muxy.net.ConnectionState +import com.muxy.net.ConnectionTarget +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + enableEdgeToEdge() + attemptColdStartRestore(savedInstanceState) + setContent { MuxyRoot() } + } + + private fun attemptColdStartRestore(savedInstanceState: Bundle?) { + if (savedInstanceState != null) return + val app = applicationContext as MuxyApp + val client = app.container.muxyClient + if (client.state.value !is ConnectionState.Idle) return + + lifecycleScope.launch { + val last = app.container.lastSessionStore.flow.first() ?: return@launch + if (client.state.value !is ConnectionState.Idle) return@launch + client.connect( + ConnectionTarget(host = last.host, port = last.port, deviceName = last.deviceName), + ) + } + } +} + +@Composable +private fun MuxyRoot() { + val container = (LocalContext.current.applicationContext as MuxyApp).container + CompositionLocalProvider(LocalAppContainer provides container) { + MuxyTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets(0), + ) { padding -> + MuxyNavHost(modifier = Modifier.padding(padding).fillMaxSize()) + } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/MuxyApp.kt b/android/app/src/main/java/com/muxy/android/MuxyApp.kt new file mode 100644 index 0000000..583a36d --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/MuxyApp.kt @@ -0,0 +1,39 @@ +package com.muxy.android + +import android.app.Application +import androidx.lifecycle.ProcessLifecycleOwner +import com.muxy.net.ConnectionState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class MuxyApp : Application() { + lateinit var container: AppContainer + private set + + private val applicationScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + override fun onCreate() { + super.onCreate() + container = AppContainer(this) + ProcessLifecycleOwner.get().lifecycle.addObserver(container.lifecycleBinder) + observeConnectionForLastSession() + } + + private fun observeConnectionForLastSession() { + applicationScope.launch { + container.muxyClient.state.collectLatest { state -> + if (state is ConnectionState.Connected) { + container.lastSessionStore.saveTarget(state.target) + } + } + } + applicationScope.launch { + container.muxyClient.activeProjectID.collectLatest { projectID -> + container.lastSessionStore.saveActiveProject(projectID) + } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/connect/AddDeviceSheet.kt b/android/app/src/main/java/com/muxy/android/connect/AddDeviceSheet.kt new file mode 100644 index 0000000..4e59496 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/AddDeviceSheet.kt @@ -0,0 +1,115 @@ +package com.muxy.android.connect + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddDeviceSheet( + defaultPort: Int, + onDismiss: () -> Unit, + onSubmit: (name: String, host: String, port: Int) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var name by remember { mutableStateOf("") } + var host by remember { mutableStateOf("") } + var port by remember { mutableStateOf(defaultPort.toString()) } + + val canSubmit = host.isNotBlank() && port.toIntOrNull() != null + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + Text(text = "Add device", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + placeholder = { Text("My Mac") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host") }, + placeholder = { Text("100.x.x.x or 192.168.1.10") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = port, + onValueChange = { value -> port = value.filter { it.isDigit() }.take(5) }, + label = { Text("Port") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Default is 4865. Muxy debug builds listen on 4866.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + val parsedPort = port.toIntOrNull() ?: defaultPort + onSubmit(name, host, parsedPort) + }, + enabled = canSubmit, + ) { Text("Connect") } + } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/connect/ConnectScreen.kt b/android/app/src/main/java/com/muxy/android/connect/ConnectScreen.kt new file mode 100644 index 0000000..c026de9 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/ConnectScreen.kt @@ -0,0 +1,278 @@ +package com.muxy.android.connect + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.DesktopMac +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.muxy.android.LocalAppContainer +import com.muxy.android.ui.layout.ResponsiveContent +import com.muxy.net.SavedDevice + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectScreen( + modifier: Modifier = Modifier, + onOpenSettings: () -> Unit, +) { + val container = LocalAppContainer.current + val viewModel: ConnectViewModel = + viewModel( + factory = + ConnectViewModel.factory( + savedDevicesStore = container.savedDevicesStore, + muxyClient = container.muxyClient, + userPreferences = container.userPreferences, + ), + ) + + val savedDevices by viewModel.savedDevices.collectAsStateWithLifecycle() + val noticeAcknowledged by viewModel.trustedNetworkNoticeAcknowledged.collectAsStateWithLifecycle() + var showAddSheet by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Devices") }, + navigationIcon = { + IconButton(onClick = onOpenSettings) { + Icon(Icons.Outlined.Settings, contentDescription = "Settings") + } + }, + actions = { + IconButton(onClick = { showAddSheet = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add device") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + containerColor = MaterialTheme.colorScheme.background, + ) { padding -> + ResponsiveContent(modifier = Modifier.padding(padding)) { + Column(modifier = Modifier.fillMaxSize()) { + if (!noticeAcknowledged) { + TrustedNetworkNotice( + onAcknowledge = viewModel::acknowledgeTrustedNetworkNotice, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + } + if (savedDevices.isEmpty()) { + EmptyDevicesState( + onAdd = { showAddSheet = true }, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) + } else { + SavedDevicesList( + devices = savedDevices, + onConnect = { viewModel.connect(it) }, + onRemove = { viewModel.remove(it) }, + contentPadding = PaddingValues(vertical = 8.dp), + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) + } + } + } + } + + if (showAddSheet) { + AddDeviceSheet( + defaultPort = ConnectViewModel.DEFAULT_PORT, + onDismiss = { showAddSheet = false }, + onSubmit = { name, host, port -> + viewModel.connect(name = name, host = host, port = port) + showAddSheet = false + }, + ) + } +} + +@Composable +private fun EmptyDevicesState( + onAdd: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Icon( + imageVector = Icons.Outlined.DesktopMac, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "No Devices", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Add your Mac to get started.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + FilledTonalButton(onClick = onAdd) { Text("Add Device") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SavedDevicesList( + devices: List, + onConnect: (SavedDevice) -> Unit, + onRemove: (SavedDevice) -> Unit, + contentPadding: PaddingValues, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier, contentPadding = contentPadding) { + items(items = devices, key = { it.id }) { device -> + SwipeableDeviceRow( + device = device, + onConnect = { onConnect(device) }, + onRemove = { onRemove(device) }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SwipeableDeviceRow( + device: SavedDevice, + onConnect: () -> Unit, + onRemove: () -> Unit, +) { + val dismissState = + rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + onRemove() + true + } else { + false + } + }, + positionalThreshold = { distance -> distance * 0.5f }, + ) + + SwipeToDismissBox( + state = dismissState, + backgroundContent = { SwipeRemoveBackground() }, + enableDismissFromStartToEnd = false, + modifier = Modifier.fillMaxWidth(), + ) { + ListItem( + headlineContent = { + Text(device.name, style = MaterialTheme.typography.bodyLarge) + }, + supportingContent = { + Text( + text = "${device.host}:${device.port}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Box( + modifier = + Modifier + .size(40.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(10.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.DesktopMac, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .clickable(onClick = onConnect), + tonalElevation = 0.dp, + ) + } +} + +@Composable +private fun SwipeRemoveBackground() { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} diff --git a/android/app/src/main/java/com/muxy/android/connect/ConnectViewModel.kt b/android/app/src/main/java/com/muxy/android/connect/ConnectViewModel.kt new file mode 100644 index 0000000..f70a6d6 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/ConnectViewModel.kt @@ -0,0 +1,105 @@ +package com.muxy.android.connect + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.muxy.net.ConnectionTarget +import com.muxy.net.MuxyClient +import com.muxy.net.SavedDevice +import com.muxy.net.SavedDevicesStore +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class ConnectViewModel( + private val savedDevicesStore: SavedDevicesStore, + private val muxyClient: MuxyClient, + private val userPreferences: UserPreferences, + private val defaultPort: Int = DEFAULT_PORT, +) : ViewModel() { + val savedDevices: StateFlow> = + savedDevicesStore.flow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + initialValue = emptyList(), + ) + + val trustedNetworkNoticeAcknowledged: StateFlow = + userPreferences.trustedNetworkNoticeAcknowledged.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + initialValue = true, + ) + + fun connect( + name: String, + host: String, + port: Int, + ) { + val device = + normalizeConnectInput(name = name, host = host, port = port, defaultPort = defaultPort) + ?: return + viewModelScope.launch { + savedDevicesStore.add(device) + muxyClient.connect( + ConnectionTarget(host = device.host, port = device.port, deviceName = device.name), + ) + } + } + + fun connect(device: SavedDevice) { + viewModelScope.launch { + savedDevicesStore.add(device) + muxyClient.connect( + ConnectionTarget(host = device.host, port = device.port, deviceName = device.name), + ) + } + } + + fun remove(device: SavedDevice) { + viewModelScope.launch { savedDevicesStore.remove(device) } + } + + fun acknowledgeTrustedNetworkNotice() { + viewModelScope.launch { userPreferences.acknowledgeTrustedNetworkNotice() } + } + + companion object { + const val DEFAULT_PORT: Int = 4865 + private const val STOP_TIMEOUT_MS = 5_000L + + fun factory( + savedDevicesStore: SavedDevicesStore, + muxyClient: MuxyClient, + userPreferences: UserPreferences, + ): ViewModelProvider.Factory = + viewModelFactory { + initializer { + ConnectViewModel( + savedDevicesStore = savedDevicesStore, + muxyClient = muxyClient, + userPreferences = userPreferences, + ) + } + } + } +} + +internal const val MIN_PORT = 1 +internal const val MAX_PORT = 65535 + +internal fun normalizeConnectInput( + name: String, + host: String, + port: Int, + defaultPort: Int, +): SavedDevice? { + val trimmedHost = host.trim() + if (trimmedHost.isEmpty()) return null + val trimmedName = name.trim().ifEmpty { "Mac" } + val resolvedPort = if (port in MIN_PORT..MAX_PORT) port else defaultPort + return SavedDevice(name = trimmedName, host = trimmedHost, port = resolvedPort) +} diff --git a/android/app/src/main/java/com/muxy/android/connect/ConnectingView.kt b/android/app/src/main/java/com/muxy/android/connect/ConnectingView.kt new file mode 100644 index 0000000..623c67d --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/ConnectingView.kt @@ -0,0 +1,55 @@ +package com.muxy.android.connect + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun ConnectingView( + deviceName: String, + host: String, + port: Int, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 48.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(24.dp)) + Text( + text = "Connecting…", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "$deviceName · $host:$port", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + OutlinedButton(onClick = onCancel, modifier = Modifier.fillMaxWidth()) { + Text("Cancel") + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/connect/ConnectionFailedView.kt b/android/app/src/main/java/com/muxy/android/connect/ConnectionFailedView.kt new file mode 100644 index 0000000..6953206 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/ConnectionFailedView.kt @@ -0,0 +1,197 @@ +package com.muxy.android.connect + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.outlined.WifiOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.muxy.android.LocalAppContainer +import com.muxy.net.ConnectionIssue +import com.muxy.net.technicalDetails + +@Composable +fun ConnectionFailedView( + issue: ConnectionIssue, + onRetry: () -> Unit, + onDisconnect: () -> Unit, + modifier: Modifier = Modifier, +) { + var showDetails by remember { mutableStateOf(false) } + + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 48.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.WifiOff, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "Connection Failed", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = issue.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + ) { + Button(onClick = onRetry) { Text("Retry") } + OutlinedButton(onClick = { showDetails = true }) { Text("Debug Info") } + } + Spacer(Modifier.height(16.dp)) + TextButton(onClick = onDisconnect) { + Text("Disconnect", color = MaterialTheme.colorScheme.error) + } + } + + if (showDetails) { + ConnectionIssueDetailsSheet( + issue = issue, + onDismiss = { showDetails = false }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionIssueDetailsSheet( + issue: ConnectionIssue, + onDismiss: () -> Unit, +) { + val container = LocalAppContainer.current + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val details = + remember(issue) { + issue.technicalDetails( + appVersion = container.appVersionName(), + appBuild = container.appVersionCode().toString(), + osVersion = "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})", + ) + } + var didCopy by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Connection Details", + style = MaterialTheme.typography.titleMedium, + ) + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + Text( + text = details, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = { + copyToClipboard(context, details) + didCopy = true + }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Outlined.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(if (didCopy) "Copied" else "Copy") + } + OutlinedButton( + onClick = { shareText(context, details) }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Outlined.IosShare, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text("Share") + } + } + } + } +} + +private fun copyToClipboard( + context: Context, + text: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + clipboard.setPrimaryClip(ClipData.newPlainText("Muxy connection details", text)) +} + +private fun shareText( + context: Context, + text: String, +) { + val intent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + val chooser = + Intent.createChooser(intent, "Share connection details").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(chooser) +} diff --git a/android/app/src/main/java/com/muxy/android/connect/PairingPendingView.kt b/android/app/src/main/java/com/muxy/android/connect/PairingPendingView.kt new file mode 100644 index 0000000..d010ead --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/PairingPendingView.kt @@ -0,0 +1,73 @@ +package com.muxy.android.connect + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.muxy.android.ui.theme.MuxyTheme + +@Composable +fun PairingPendingView( + deviceName: String, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 48.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(32.dp)) + Text( + text = "Awaiting approval on Mac", + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "$deviceName is waiting for you to tap Approve in the Muxy alert on your Mac.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "If you cancel here, dismiss the alert on the Mac manually — Android cannot withdraw it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(40.dp)) + OutlinedButton( + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Cancel") + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PairingPendingPreview() { + MuxyTheme { + PairingPendingView(deviceName = "Saleh's Mac Studio", onCancel = {}) + } +} diff --git a/android/app/src/main/java/com/muxy/android/connect/TrustedNetworkNotice.kt b/android/app/src/main/java/com/muxy/android/connect/TrustedNetworkNotice.kt new file mode 100644 index 0000000..7cbcfc1 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/TrustedNetworkNotice.kt @@ -0,0 +1,78 @@ +package com.muxy.android.connect + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.muxy.android.ui.theme.MuxyTheme + +@Composable +fun TrustedNetworkNotice( + onAcknowledge: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + Text( + text = "Use only on a trusted network", + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = + "Muxy connects to your Mac over plain WebSocket (no TLS). Pairing tokens and " + + "every keystroke travel in the clear. Stick to Tailscale, a private VPN, or a " + + "home Wi-Fi network you control.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = onAcknowledge) { + Text("Got it") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TrustedNetworkNoticePreview() { + MuxyTheme { + TrustedNetworkNotice(onAcknowledge = {}, modifier = Modifier.padding(24.dp)) + } +} diff --git a/android/app/src/main/java/com/muxy/android/connect/UserPreferences.kt b/android/app/src/main/java/com/muxy/android/connect/UserPreferences.kt new file mode 100644 index 0000000..97c5816 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/connect/UserPreferences.kt @@ -0,0 +1,35 @@ +package com.muxy.android.connect + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.userPreferencesDataStore: DataStore by preferencesDataStore( + name = "muxy_user_preferences", +) + +class UserPreferences(private val dataStore: DataStore) { + val trustedNetworkNoticeAcknowledged: Flow = + dataStore.data.map { prefs -> + prefs[KEY_TRUSTED_NETWORK_ACK] ?: false + } + + suspend fun acknowledgeTrustedNetworkNotice() { + dataStore.edit { it[KEY_TRUSTED_NETWORK_ACK] = true } + } + + suspend fun resetTrustedNetworkNotice() { + dataStore.edit { it.remove(KEY_TRUSTED_NETWORK_ACK) } + } + + companion object { + private val KEY_TRUSTED_NETWORK_ACK = booleanPreferencesKey("trusted_network_ack_v1") + + fun create(context: Context): UserPreferences = UserPreferences(context.applicationContext.userPreferencesDataStore) + } +} diff --git a/android/app/src/main/java/com/muxy/android/nav/MuxyNavHost.kt b/android/app/src/main/java/com/muxy/android/nav/MuxyNavHost.kt new file mode 100644 index 0000000..9becb12 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/nav/MuxyNavHost.kt @@ -0,0 +1,139 @@ +package com.muxy.android.nav + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.muxy.android.LocalAppContainer +import com.muxy.android.connect.ConnectScreen +import com.muxy.android.connect.ConnectingView +import com.muxy.android.connect.ConnectionFailedView +import com.muxy.android.connect.PairingPendingView +import com.muxy.android.notifications.NotificationsScreen +import com.muxy.android.projects.ProjectListScreen +import com.muxy.android.settings.SettingsScreen +import com.muxy.android.workspace.WorkspaceScreen +import com.muxy.net.ConnectionState +import java.util.UUID + +internal object MuxyRoutes { + const val CONNECT = "connect" + const val SETTINGS = "settings" + const val PROJECTS = "projects" + const val WORKSPACE = "workspace/{projectID}" + const val WORKSPACE_ARG = "projectID" + const val NOTIFICATIONS = "notifications" + + fun workspace(projectID: UUID): String = "workspace/$projectID" +} + +@Composable +fun MuxyNavHost(modifier: Modifier = Modifier) { + val container = LocalAppContainer.current + val state by container.muxyClient.state.collectAsStateWithLifecycle() + + val safeModifier = modifier.windowInsetsPadding(WindowInsets.safeDrawing) + when (val current = state) { + is ConnectionState.Idle -> ConnectFlow(modifier = modifier) + is ConnectionState.Connecting -> + ConnectingView( + deviceName = current.target.deviceName, + host = current.target.host, + port = current.target.port, + onCancel = container.muxyClient::disconnect, + modifier = safeModifier, + ) + is ConnectionState.Authenticating -> + ConnectingView( + deviceName = current.target.deviceName, + host = current.target.host, + port = current.target.port, + onCancel = container.muxyClient::disconnect, + modifier = safeModifier, + ) + is ConnectionState.AwaitingApproval -> + PairingPendingView( + deviceName = current.target.deviceName, + onCancel = container.muxyClient::disconnect, + modifier = safeModifier, + ) + is ConnectionState.Connected, is ConnectionState.Reconnecting -> ConnectedFlow(modifier = modifier) + is ConnectionState.Failed -> + ConnectionFailedView( + issue = current.issue, + onRetry = { + val target = current.target + if (target != null) container.muxyClient.connect(target) + }, + onDisconnect = container.muxyClient::disconnect, + modifier = safeModifier, + ) + } +} + +@Composable +private fun ConnectFlow(modifier: Modifier = Modifier) { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = MuxyRoutes.CONNECT, + modifier = modifier, + ) { + composable(MuxyRoutes.CONNECT) { + ConnectScreen( + onOpenSettings = { navController.navigate(MuxyRoutes.SETTINGS) }, + ) + } + composable(MuxyRoutes.SETTINGS) { + SettingsScreen(onBack = { navController.popBackStack() }) + } + } +} + +@Composable +private fun ConnectedFlow(modifier: Modifier = Modifier) { + val container = LocalAppContainer.current + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = MuxyRoutes.PROJECTS, + modifier = modifier, + ) { + composable(MuxyRoutes.PROJECTS) { + ProjectListScreen( + onProjectSelected = { id -> navController.navigate(MuxyRoutes.workspace(id)) }, + onOpenNotifications = { navController.navigate(MuxyRoutes.NOTIFICATIONS) }, + onDisconnect = container.muxyClient::disconnect, + ) + } + composable(MuxyRoutes.WORKSPACE) { backStack -> + val raw = backStack.arguments?.getString(MuxyRoutes.WORKSPACE_ARG) + val projectID = runCatching { UUID.fromString(raw) }.getOrNull() + if (projectID == null) { + navController.popBackStack() + return@composable + } + WorkspaceScreen( + projectID = projectID, + onBack = { navController.popBackStack() }, + onOpenNotifications = { navController.navigate(MuxyRoutes.NOTIFICATIONS) }, + ) + } + composable(MuxyRoutes.NOTIFICATIONS) { + NotificationsScreen( + onBack = { navController.popBackStack() }, + onNavigateToProject = { id -> + navController.navigate(MuxyRoutes.workspace(id)) { + popUpTo(MuxyRoutes.PROJECTS) + } + }, + ) + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/notifications/NotificationsScreen.kt b/android/app/src/main/java/com/muxy/android/notifications/NotificationsScreen.kt new file mode 100644 index 0000000..02f9e8f --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/notifications/NotificationsScreen.kt @@ -0,0 +1,319 @@ +package com.muxy.android.notifications + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.NotificationsNone +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.muxy.android.LocalAppContainer +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.NotificationSourceDTO +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationsScreen( + onBack: () -> Unit, + onNavigateToProject: (UUID) -> Unit, + modifier: Modifier = Modifier, +) { + val container = LocalAppContainer.current + val viewModel: NotificationsViewModel = + viewModel( + factory = NotificationsViewModel.factory(container.muxyClient), + ) + + val notifications by viewModel.notifications.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle() + val staleMessage by viewModel.staleNotificationMessage.collectAsStateWithLifecycle() + val pendingProjectID by viewModel.pendingNavigationProjectID.collectAsStateWithLifecycle() + + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { viewModel.refresh() } + + LaunchedEffect(pendingProjectID) { + val id = pendingProjectID + if (id != null) { + onNavigateToProject(id) + viewModel.clearPendingNavigation() + } + } + + LaunchedEffect(staleMessage) { + val message = staleMessage + if (message != null) { + snackbarHostState.showSnackbar(message) + viewModel.dismissStaleMessage() + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Notifications") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = viewModel::refresh) { + Icon(Icons.Outlined.Refresh, contentDescription = "Refresh") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + containerColor = MaterialTheme.colorScheme.background, + ) { padding -> + when { + isLoading && notifications.isEmpty() -> LoadingPlaceholder(modifier = Modifier.padding(padding)) + errorMessage != null && notifications.isEmpty() -> + ErrorPlaceholder( + message = errorMessage!!, + onRetry = viewModel::refresh, + modifier = Modifier.padding(padding), + ) + notifications.isEmpty() -> + EmptyNotificationsState( + onRefresh = viewModel::refresh, + modifier = Modifier.padding(padding), + ) + else -> + NotificationList( + notifications = notifications, + onTap = viewModel::openNotification, + contentPadding = + PaddingValues( + top = padding.calculateTopPadding() + 8.dp, + bottom = padding.calculateBottomPadding() + 8.dp, + ), + ) + } + } +} + +@Composable +private fun NotificationList( + notifications: List, + onTap: (NotificationDTO) -> Unit, + contentPadding: PaddingValues, +) { + LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) { + items(items = notifications, key = { it.id }) { notification -> + NotificationRow(notification = notification, onTap = { onTap(notification) }) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } +} + +@Composable +private fun NotificationRow( + notification: NotificationDTO, + onTap: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.Top, + ) { + UnreadDot(isUnread = !notification.isRead) + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = notification.title.ifBlank { "Notification" }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (notification.isRead) FontWeight.Normal else FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + text = relativeTime(notification.timestamp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (notification.body.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = notification.body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = sourceLabel(notification.source), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +private fun UnreadDot(isUnread: Boolean) { + Box( + modifier = + Modifier + .size(8.dp) + .background( + color = if (isUnread) MaterialTheme.colorScheme.primary else androidx.compose.ui.graphics.Color.Transparent, + shape = CircleShape, + ), + ) +} + +private fun sourceLabel(source: NotificationSourceDTO): String = + when (source) { + is NotificationSourceDTO.Osc -> "Terminal" + is NotificationSourceDTO.Socket -> "Socket" + is NotificationSourceDTO.AiProvider -> source.provider + } + +private fun relativeTime(timestamp: Instant): String { + val now = Instant.now() + val duration = Duration.between(timestamp, now) + if (duration.isNegative) return absoluteShort(timestamp) + val seconds = duration.seconds + return when { + seconds < 60 -> "now" + seconds < 3600 -> "${seconds / 60}m" + seconds < 86_400 -> "${seconds / 3600}h" + seconds < 604_800 -> "${seconds / 86_400}d" + else -> absoluteShort(timestamp) + } +} + +private val absoluteFormatter: DateTimeFormatter = + DateTimeFormatter + .ofPattern("MMM d") + .withZone(ZoneId.systemDefault()) + +private fun absoluteShort(timestamp: Instant): String = absoluteFormatter.format(timestamp.truncatedTo(ChronoUnit.MINUTES)) + +@Composable +private fun LoadingPlaceholder(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun EmptyNotificationsState( + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Icon( + imageVector = Icons.Outlined.NotificationsNone, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "No notifications", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Notifications from the Mac will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + OutlinedButton(onClick = onRefresh) { Text("Refresh") } + } + } +} + +@Composable +private fun ErrorPlaceholder( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + OutlinedButton(onClick = onRetry) { Text("Try Again") } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/notifications/NotificationsViewModel.kt b/android/app/src/main/java/com/muxy/android/notifications/NotificationsViewModel.kt new file mode 100644 index 0000000..66e4db0 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/notifications/NotificationsViewModel.kt @@ -0,0 +1,140 @@ +package com.muxy.android.notifications + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.muxy.net.MuxyClient +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.SplitNodeDTO +import com.muxy.protocol.dto.TabAreaDTO +import com.muxy.protocol.dto.WorkspaceDTO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.UUID + +class NotificationsViewModel(private val muxyClient: MuxyClient) : ViewModel() { + val notifications: StateFlow> = muxyClient.notifications + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _staleNotificationMessage = MutableStateFlow(null) + val staleNotificationMessage: StateFlow = _staleNotificationMessage.asStateFlow() + + private val _pendingNavigationProjectID = MutableStateFlow(null) + val pendingNavigationProjectID: StateFlow = _pendingNavigationProjectID.asStateFlow() + + fun refresh() { + viewModelScope.launch { + _isLoading.value = true + _errorMessage.value = null + val ok = muxyClient.refreshNotifications() + if (!ok) _errorMessage.value = "Could not load notifications" + _isLoading.value = false + } + } + + fun openNotification(notification: NotificationDTO) { + viewModelScope.launch { + _staleNotificationMessage.value = null + _errorMessage.value = null + + val projectExists = muxyClient.projects.value.any { it.id == notification.projectID } + if (!projectExists) { + _staleNotificationMessage.value = "This notification points to a closed tab" + muxyClient.markNotificationRead(notification.id) + return@launch + } + + val activeID = muxyClient.activeProjectID.value + val needsProjectSwitch = activeID != notification.projectID + if (needsProjectSwitch) { + val ok = muxyClient.selectProject(notification.projectID) + if (!ok) { + _errorMessage.value = "Could not open project" + return@launch + } + } else if (muxyClient.workspace.value == null) { + muxyClient.refreshWorkspace(notification.projectID) + } + + val workspace = muxyClient.workspace.value + if (workspace == null) { + _errorMessage.value = "Could not load workspace" + return@launch + } + + if (workspace.worktreeID != notification.worktreeID) { + val worktreeKnown = + muxyClient.projectWorktrees.value[notification.projectID] + ?.any { it.id == notification.worktreeID } == true + if (!worktreeKnown) { + _staleNotificationMessage.value = "This notification points to a closed tab" + muxyClient.markNotificationRead(notification.id) + return@launch + } + val ok = muxyClient.selectWorktree(notification.projectID, notification.worktreeID) + if (!ok) { + _errorMessage.value = "Could not switch worktree" + return@launch + } + } + + val refreshed = muxyClient.workspace.value + if (refreshed == null || !areaContainsTab(refreshed, notification.areaID, notification.tabID)) { + _staleNotificationMessage.value = "This notification points to a closed tab" + muxyClient.markNotificationRead(notification.id) + return@launch + } + + muxyClient.focusArea(projectID = notification.projectID, areaID = notification.areaID) + muxyClient.selectTab( + projectID = notification.projectID, + areaID = notification.areaID, + tabID = notification.tabID, + ) + muxyClient.markNotificationRead(notification.id) + _pendingNavigationProjectID.value = notification.projectID + } + } + + fun clearPendingNavigation() { + _pendingNavigationProjectID.value = null + } + + fun dismissStaleMessage() { + _staleNotificationMessage.value = null + } + + private fun areaContainsTab( + workspace: WorkspaceDTO, + areaID: UUID, + tabID: UUID, + ): Boolean { + val area = findArea(workspace.root, areaID) ?: return false + return area.tabs.any { it.id == tabID } + } + + private fun findArea( + node: SplitNodeDTO, + areaID: UUID, + ): TabAreaDTO? = + when (node) { + is SplitNodeDTO.TabArea -> if (node.tabArea.id == areaID) node.tabArea else null + is SplitNodeDTO.Split -> findArea(node.split.first, areaID) ?: findArea(node.split.second, areaID) + } + + companion object { + fun factory(muxyClient: MuxyClient): ViewModelProvider.Factory = + viewModelFactory { + initializer { NotificationsViewModel(muxyClient = muxyClient) } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/projects/ProjectIcon.kt b/android/app/src/main/java/com/muxy/android/projects/ProjectIcon.kt new file mode 100644 index 0000000..f72d481 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/projects/ProjectIcon.kt @@ -0,0 +1,93 @@ +package com.muxy.android.projects + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.ProjectIconColor + +@Composable +fun ProjectIcon( + project: ProjectDTO, + logoBytes: ByteArray?, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { + val cornerRadius = size * 0.22f + val initial = project.name.firstOrNull()?.uppercaseChar()?.toString().orEmpty() + + if (logoBytes != null) { + val imageBitmap = + remember(logoBytes) { + BitmapFactory.decodeByteArray(logoBytes, 0, logoBytes.size)?.asImageBitmap() + } + if (imageBitmap != null) { + Image( + bitmap = imageBitmap, + contentDescription = project.name, + modifier = + modifier + .size(size) + .clip(RoundedCornerShape(cornerRadius)), + ) + return + } + } + + val swatch = ProjectIconColor.swatch(forIdentifier = project.iconColor) + if (swatch != null) { + val rgb = ProjectIconColor.rgb(fromHex = swatch.hex) + if (rgb != null) { + val fill = Color(red = rgb.first.toFloat(), green = rgb.second.toFloat(), blue = rgb.third.toFloat()) + Box( + modifier = + modifier + .size(size) + .background(fill, RoundedCornerShape(cornerRadius)), + contentAlignment = Alignment.Center, + ) { + Text( + text = initial, + color = if (swatch.prefersDarkForeground) Color.Black else Color.White, + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.4f).sp, + ) + } + return + } + } + + Box( + modifier = + modifier + .size(size) + .background( + MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + RoundedCornerShape(cornerRadius), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = initial, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + fontSize = (size.value * 0.4f).sp, + ) + } +} diff --git a/android/app/src/main/java/com/muxy/android/projects/ProjectListScreen.kt b/android/app/src/main/java/com/muxy/android/projects/ProjectListScreen.kt new file mode 100644 index 0000000..694c1ad --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/projects/ProjectListScreen.kt @@ -0,0 +1,234 @@ +package com.muxy.android.projects + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.muxy.android.LocalAppContainer +import com.muxy.android.ui.layout.ResponsiveContent +import com.muxy.protocol.dto.ProjectDTO +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectListScreen( + onProjectSelected: (UUID) -> Unit, + onOpenNotifications: () -> Unit, + onDisconnect: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = LocalAppContainer.current + val viewModel: ProjectListViewModel = + viewModel( + factory = ProjectListViewModel.factory(container.muxyClient), + ) + + val projects by viewModel.projects.collectAsStateWithLifecycle() + val logos by viewModel.projectLogos.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle() + val pendingNavigationID by viewModel.pendingNavigationID.collectAsStateWithLifecycle() + val unreadCount by viewModel.unreadNotificationCount.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.refresh() + viewModel.refreshNotifications() + } + + LaunchedEffect(pendingNavigationID) { + val id = pendingNavigationID + if (id != null) { + onProjectSelected(id) + viewModel.clearPendingNavigation() + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Projects") }, + actions = { + IconButton(onClick = onOpenNotifications) { + BadgedBox( + badge = { + if (unreadCount > 0) { + Badge { Text(text = if (unreadCount > 99) "99+" else "$unreadCount") } + } + }, + ) { + Icon(Icons.Outlined.Notifications, contentDescription = "Notifications") + } + } + IconButton(onClick = onDisconnect) { + Icon(Icons.Outlined.Close, contentDescription = "Disconnect") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + containerColor = MaterialTheme.colorScheme.background, + ) { padding -> + ResponsiveContent(modifier = Modifier.padding(padding)) { + when { + isLoading && projects.isEmpty() -> LoadingPlaceholder() + errorMessage != null && projects.isEmpty() -> + ErrorPlaceholder( + message = errorMessage!!, + onRetry = viewModel::refresh, + ) + projects.isEmpty() -> EmptyProjectsState(onRefresh = viewModel::refresh) + else -> + ProjectsList( + projects = projects, + logos = logos, + onTap = viewModel::selectProject, + contentPadding = PaddingValues(vertical = 8.dp), + ) + } + } + } +} + +@Composable +private fun ProjectsList( + projects: List, + logos: Map, + onTap: (UUID) -> Unit, + contentPadding: PaddingValues, +) { + LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = contentPadding) { + items(items = projects, key = { it.id }) { project -> + ListItem( + headlineContent = { + Text(project.name, style = MaterialTheme.typography.bodyLarge) + }, + supportingContent = { + Text( + text = project.path, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + }, + leadingContent = { + ProjectIcon(project = project, logoBytes = logos[project.id]) + }, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .clickable { onTap(project.id) }, + tonalElevation = 0.dp, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } +} + +@Composable +private fun LoadingPlaceholder(modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun EmptyProjectsState( + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Icon( + imageVector = Icons.Outlined.FolderOpen, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "No Projects", + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Add a project on the Mac to see it here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + OutlinedButton(onClick = onRefresh) { Text("Refresh") } + } + } +} + +@Composable +private fun ErrorPlaceholder( + message: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 32.dp), + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + OutlinedButton(onClick = onRetry) { Text("Try Again") } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/projects/ProjectListViewModel.kt b/android/app/src/main/java/com/muxy/android/projects/ProjectListViewModel.kt new file mode 100644 index 0000000..515c81f --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/projects/ProjectListViewModel.kt @@ -0,0 +1,77 @@ +package com.muxy.android.projects + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.muxy.net.MuxyClient +import com.muxy.protocol.dto.ProjectDTO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.UUID + +class ProjectListViewModel(private val muxyClient: MuxyClient) : ViewModel() { + val projects: StateFlow> = muxyClient.projects + val projectLogos: StateFlow> = muxyClient.projectLogos + + val unreadNotificationCount: StateFlow = + muxyClient.notifications + .map { list -> list.count { !it.isRead } } + .stateIn(viewModelScope, SharingStarted.Eagerly, 0) + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + private val _pendingNavigationID = MutableStateFlow(null) + val pendingNavigationID: StateFlow = _pendingNavigationID.asStateFlow() + + fun refresh() { + viewModelScope.launch { + _isLoading.value = true + _errorMessage.value = null + val ok = muxyClient.refreshProjects() + if (!ok) _errorMessage.value = "Could not load projects" + _isLoading.value = false + } + } + + fun refreshNotifications() { + viewModelScope.launch { muxyClient.refreshNotifications() } + } + + fun selectProject(projectID: UUID) { + viewModelScope.launch { + _errorMessage.value = null + val ok = muxyClient.selectProject(projectID) + if (!ok) { + _errorMessage.value = "Could not open project session" + return@launch + } + _pendingNavigationID.value = projectID + } + } + + fun clearPendingNavigation() { + _pendingNavigationID.value = null + } + + fun disconnect() { + muxyClient.disconnect() + } + + companion object { + fun factory(muxyClient: MuxyClient): ViewModelProvider.Factory = + viewModelFactory { + initializer { ProjectListViewModel(muxyClient = muxyClient) } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/settings/SettingsScreen.kt b/android/app/src/main/java/com/muxy/android/settings/SettingsScreen.kt new file mode 100644 index 0000000..58e4631 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/settings/SettingsScreen.kt @@ -0,0 +1,318 @@ +package com.muxy.android.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.muxy.android.LocalAppContainer +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = LocalAppContainer.current + val viewModel: SettingsViewModel = + viewModel( + factory = + SettingsViewModel.factory( + terminalPreferences = container.terminalPreferences, + credentialsStore = container.deviceCredentialsStore, + savedDevicesStore = container.savedDevicesStore, + lastSessionStore = container.lastSessionStore, + ), + ) + + val fontSize by viewModel.fontSize.collectAsStateWithLifecycle() + val useNerdFont by viewModel.useNerdFont.collectAsStateWithLifecycle() + val appVersionName = remember { container.appVersionName() } + val appVersionCode = remember { container.appVersionCode() } + val coroutineScope = rememberCoroutineScope() + var showForgetDialog by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Back") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + containerColor = MaterialTheme.colorScheme.background, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + TerminalSection( + fontSize = fontSize, + useNerdFont = useNerdFont, + onFontSizeChange = { viewModel.setFontSize(it) }, + onUseNerdFontChange = { viewModel.setUseNerdFont(it) }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + DevicesSection() + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + AboutSection(versionName = appVersionName, versionCode = appVersionCode) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + DangerSection(onForgetTap = { showForgetDialog = true }) + } + } + + if (showForgetDialog) { + ForgetDeviceDialog( + onConfirm = { + showForgetDialog = false + coroutineScope.launch { viewModel.forgetDevice() } + }, + onDismiss = { showForgetDialog = false }, + ) + } +} + +@Composable +private fun TerminalSection( + fontSize: Int, + useNerdFont: Boolean, + onFontSizeChange: (Int) -> Unit, + onUseNerdFontChange: (Boolean) -> Unit, +) { + SectionTitle(text = "Terminal") + Spacer(Modifier.height(8.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .semantics { contentDescription = "Use Nerd Font" }, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Use Nerd Font", style = MaterialTheme.typography.bodyLarge) + Text( + text = "Enables glyph icons in terminals that use a Nerd Font.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = useNerdFont, onCheckedChange = onUseNerdFontChange) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Font size", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + FontSizeStepper(value = fontSize, onChange = onFontSizeChange) + } + Text( + text = "The quick brown fox", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun FontSizeStepper( + value: Int, + onChange: (Int) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledIconButton( + onClick = { onChange((value - 1).coerceAtLeast(TerminalPreferences.MIN_FONT_SIZE)) }, + enabled = value > TerminalPreferences.MIN_FONT_SIZE, + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + modifier = + Modifier + .size(32.dp) + .semantics { contentDescription = "Decrease font size" }, + ) { + Icon(Icons.Outlined.Remove, contentDescription = null, modifier = Modifier.size(18.dp)) + } + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .padding(horizontal = 4.dp) + .semantics { contentDescription = "Font size $value" }, + ) + FilledIconButton( + onClick = { onChange((value + 1).coerceAtMost(TerminalPreferences.MAX_FONT_SIZE)) }, + enabled = value < TerminalPreferences.MAX_FONT_SIZE, + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + modifier = + Modifier + .size(32.dp) + .semantics { contentDescription = "Increase font size" }, + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +private fun DevicesSection() { + SectionTitle(text = "Devices") + Spacer(Modifier.height(8.dp)) + Text( + text = "Saved devices live on the Connect screen. Tap a device to reconnect or swipe to remove it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun AboutSection( + versionName: String, + versionCode: Long, +) { + SectionTitle(text = "About") + Spacer(Modifier.height(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + InfoRow(label = "Version", value = versionName) + InfoRow(label = "Build", value = versionCode.toString()) + InfoRow(label = "Source code", value = "github.com/muxy-app/muxy") + InfoRow(label = "Terminal core", value = "Termux terminal-emulator + terminal-view") + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text(text = value, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun DangerSection(onForgetTap: () -> Unit) { + SectionTitle(text = "Pairing") + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = onForgetTap, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Outlined.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text("Forget this device", color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "Forgetting clears the pairing token and saved devices on this phone. The Mac keeps its approved-device record until you remove it from Mac settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +@Composable +private fun ForgetDeviceDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Forget this device?") }, + text = { + Text( + text = "This deletes your pairing token and removes every saved Mac from this phone. You will need to pair again the next time you connect.", + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Forget", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/android/app/src/main/java/com/muxy/android/settings/SettingsViewModel.kt b/android/app/src/main/java/com/muxy/android/settings/SettingsViewModel.kt new file mode 100644 index 0000000..7bb43fa --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/settings/SettingsViewModel.kt @@ -0,0 +1,69 @@ +package com.muxy.android.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.muxy.net.DeviceCredentialsStore +import com.muxy.net.LastSessionStore +import com.muxy.net.SavedDevicesStore +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class SettingsViewModel( + private val terminalPreferences: TerminalPreferences, + private val credentialsStore: DeviceCredentialsStore, + private val savedDevicesStore: SavedDevicesStore, + private val lastSessionStore: LastSessionStore, +) : ViewModel() { + val fontSize: StateFlow = + terminalPreferences.fontSize.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + initialValue = TerminalPreferences.DEFAULT_FONT_SIZE, + ) + val useNerdFont: StateFlow = + terminalPreferences.useNerdFont.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), + initialValue = false, + ) + + fun setFontSize(size: Int) { + viewModelScope.launch { terminalPreferences.setFontSize(size) } + } + + fun setUseNerdFont(enabled: Boolean) { + viewModelScope.launch { terminalPreferences.setUseNerdFont(enabled) } + } + + suspend fun forgetDevice() { + credentialsStore.forget() + savedDevicesStore.clear() + lastSessionStore.clear() + } + + companion object { + private const val STOP_TIMEOUT_MS = 5_000L + + fun factory( + terminalPreferences: TerminalPreferences, + credentialsStore: DeviceCredentialsStore, + savedDevicesStore: SavedDevicesStore, + lastSessionStore: LastSessionStore, + ): ViewModelProvider.Factory = + viewModelFactory { + initializer { + SettingsViewModel( + terminalPreferences = terminalPreferences, + credentialsStore = credentialsStore, + savedDevicesStore = savedDevicesStore, + lastSessionStore = lastSessionStore, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/settings/TerminalPreferences.kt b/android/app/src/main/java/com/muxy/android/settings/TerminalPreferences.kt new file mode 100644 index 0000000..02f3168 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/settings/TerminalPreferences.kt @@ -0,0 +1,48 @@ +package com.muxy.android.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.terminalPreferencesDataStore: DataStore by preferencesDataStore( + name = "muxy_terminal_preferences", +) + +class TerminalPreferences(private val dataStore: DataStore) { + val fontSize: Flow = + dataStore.data.map { prefs -> + (prefs[KEY_FONT_SIZE] ?: DEFAULT_FONT_SIZE).coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE) + } + + val useNerdFont: Flow = + dataStore.data.map { prefs -> + prefs[KEY_USE_NERD_FONT] ?: false + } + + suspend fun setFontSize(size: Int) { + dataStore.edit { prefs -> + prefs[KEY_FONT_SIZE] = size.coerceIn(MIN_FONT_SIZE, MAX_FONT_SIZE) + } + } + + suspend fun setUseNerdFont(enabled: Boolean) { + dataStore.edit { prefs -> prefs[KEY_USE_NERD_FONT] = enabled } + } + + companion object { + const val DEFAULT_FONT_SIZE: Int = 12 + const val MIN_FONT_SIZE: Int = 8 + const val MAX_FONT_SIZE: Int = 24 + + private val KEY_FONT_SIZE = intPreferencesKey("font_size_v1") + private val KEY_USE_NERD_FONT = booleanPreferencesKey("use_nerd_font_v1") + + fun create(context: Context): TerminalPreferences = TerminalPreferences(context.applicationContext.terminalPreferencesDataStore) + } +} diff --git a/android/app/src/main/java/com/muxy/android/ui/layout/ResponsiveContainer.kt b/android/app/src/main/java/com/muxy/android/ui/layout/ResponsiveContainer.kt new file mode 100644 index 0000000..28b28ba --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/ui/layout/ResponsiveContainer.kt @@ -0,0 +1,40 @@ +package com.muxy.android.ui.layout + +import android.app.Activity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun rememberWindowSizeClass(): WindowSizeClass? { + val activity = (LocalContext.current as? Activity) ?: return null + return calculateWindowSizeClass(activity) +} + +@Composable +fun ResponsiveContent( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val sizeClass = rememberWindowSizeClass() + val maxWidth = + when (sizeClass?.widthSizeClass) { + WindowWidthSizeClass.Compact, null -> Modifier.fillMaxWidth() + WindowWidthSizeClass.Medium -> Modifier.widthIn(max = 600.dp) + else -> Modifier.widthIn(max = 760.dp) + } + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Box(modifier = maxWidth.fillMaxSize()) { content() } + } +} diff --git a/android/app/src/main/java/com/muxy/android/ui/theme/Color.kt b/android/app/src/main/java/com/muxy/android/ui/theme/Color.kt new file mode 100644 index 0000000..3bbd5b7 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/ui/theme/Color.kt @@ -0,0 +1,12 @@ +package com.muxy.android.ui.theme + +import androidx.compose.ui.graphics.Color + +internal val MuxyDarkBackground = Color(0xFF0F1115) +internal val MuxyDarkSurface = Color(0xFF161A20) +internal val MuxyDarkSurfaceVariant = Color(0xFF1F242C) +internal val MuxyAccent = Color(0xFF7AA2F7) +internal val MuxyOnAccent = Color(0xFF0B0F14) +internal val MuxyTextPrimary = Color(0xFFE6E8EE) +internal val MuxyTextSecondary = Color(0xFF9AA4B2) +internal val MuxyError = Color(0xFFF7768E) diff --git a/android/app/src/main/java/com/muxy/android/ui/theme/Theme.kt b/android/app/src/main/java/com/muxy/android/ui/theme/Theme.kt new file mode 100644 index 0000000..cb641bb --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/ui/theme/Theme.kt @@ -0,0 +1,27 @@ +package com.muxy.android.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable + +private val MuxyDarkColors = + darkColorScheme( + primary = MuxyAccent, + onPrimary = MuxyOnAccent, + background = MuxyDarkBackground, + onBackground = MuxyTextPrimary, + surface = MuxyDarkSurface, + onSurface = MuxyTextPrimary, + surfaceVariant = MuxyDarkSurfaceVariant, + onSurfaceVariant = MuxyTextSecondary, + error = MuxyError, + ) + +@Composable +fun MuxyTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = MuxyDarkColors, + typography = MuxyTypography, + content = content, + ) +} diff --git a/android/app/src/main/java/com/muxy/android/ui/theme/ThemeColors.kt b/android/app/src/main/java/com/muxy/android/ui/theme/ThemeColors.kt new file mode 100644 index 0000000..9109e97 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/ui/theme/ThemeColors.kt @@ -0,0 +1,44 @@ +package com.muxy.android.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import com.muxy.net.DeviceTheme + +@Stable +data class MuxyColors( + val foreground: Color, + val background: Color, + val isDark: Boolean, +) { + val cardBackground: Color get() = foreground.copy(alpha = 0.06f) + val mutedForeground: Color get() = foreground.copy(alpha = 0.7f) + val faintForeground: Color get() = foreground.copy(alpha = 0.5f) + val outline: Color get() = foreground.copy(alpha = 0.4f) +} + +@Composable +fun muxyColors( + theme: DeviceTheme?, + fallbackDark: Boolean = true, +): MuxyColors { + if (theme == null) { + return if (fallbackDark) { + MuxyColors(foreground = Color.White, background = Color.Black, isDark = true) + } else { + MuxyColors(foreground = Color.Black, background = Color.White, isDark = false) + } + } + return MuxyColors( + foreground = rgbColor(theme.fg), + background = rgbColor(theme.bg), + isDark = theme.isDark, + ) +} + +private fun rgbColor(rgb: UInt): Color { + val r = ((rgb shr 16) and 0xFFu).toInt() + val g = ((rgb shr 8) and 0xFFu).toInt() + val b = (rgb and 0xFFu).toInt() + return Color(red = r, green = g, blue = b) +} diff --git a/android/app/src/main/java/com/muxy/android/ui/theme/Type.kt b/android/app/src/main/java/com/muxy/android/ui/theme/Type.kt new file mode 100644 index 0000000..609644d --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.muxy.android.ui.theme + +import androidx.compose.material3.Typography + +internal val MuxyTypography = Typography() diff --git a/android/app/src/main/java/com/muxy/android/vcs/BranchesSheet.kt b/android/app/src/main/java/com/muxy/android/vcs/BranchesSheet.kt new file mode 100644 index 0000000..abec755 --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/vcs/BranchesSheet.kt @@ -0,0 +1,250 @@ +package com.muxy.android.vcs + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.RadioButtonUnchecked +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.muxy.android.LocalAppContainer +import com.muxy.android.ui.theme.muxyColors +import com.muxy.net.createBranch +import com.muxy.net.listBranches +import com.muxy.net.switchBranch +import com.muxy.protocol.dto.VCSBranchesDTO +import kotlinx.coroutines.launch +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BranchesSheet( + projectID: UUID, + onDismiss: () -> Unit, + onChange: () -> Unit, +) { + val container = LocalAppContainer.current + val client = container.muxyClient + val theme by client.deviceTheme.collectAsStateWithLifecycle() + val colors = muxyColors(theme) + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var branches by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var busyBranch by remember { mutableStateOf(null) } + var showCreate by remember { mutableStateOf(false) } + var newBranchName by remember { mutableStateOf("") } + + suspend fun load() { + isLoading = true + errorMessage = null + try { + branches = client.listBranches(projectID) + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + isLoading = false + } + } + + LaunchedEffect(projectID) { load() } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = colors.background, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { Text("Close", color = colors.foreground) } + Spacer(Modifier.weight(1f)) + Text( + text = "Branches", + style = MaterialTheme.typography.titleMedium, + color = colors.foreground, + ) + Spacer(Modifier.weight(1f)) + IconButton(onClick = { showCreate = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Create branch", tint = colors.foreground) + } + } + HorizontalDivider(color = colors.outline) + when { + isLoading && branches == null -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(48.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(color = colors.foreground) } + branches == null -> + Text( + text = errorMessage ?: "No branches", + color = colors.mutedForeground, + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + ) + else -> + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = branches!!.locals, key = { it }) { branch -> + val isCurrent = branch == branches!!.current + Row( + modifier = + Modifier + .fillMaxWidth() + .background(colors.cardBackground, shape = RoundedCornerShape(8.dp)) + .clickable(enabled = !isCurrent) { + if (isCurrent) return@clickable + busyBranch = branch + scope.launch { + try { + client.switchBranch(projectID, branch) + onChange() + onDismiss() + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + busyBranch = null + } + } + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isCurrent) Icons.Outlined.CheckCircle else Icons.Outlined.RadioButtonUnchecked, + contentDescription = null, + tint = if (isCurrent) Color(0xFF2E7D32) else colors.outline, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(10.dp)) + Text(branch, color = colors.foreground, modifier = Modifier.weight(1f)) + if (busyBranch == branch) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colors.foreground, + ) + } + } + } + if (errorMessage != null) { + item { + Text( + errorMessage!!, + color = Color(0xFFE53935), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + item { Spacer(Modifier.height(8.dp)) } + } + } + } + } + + if (showCreate) { + AlertDialog( + onDismissRequest = { + showCreate = false + newBranchName = "" + }, + title = { Text("New Branch") }, + text = { + Column { + Text("Creates and switches to a new branch from HEAD.") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = newBranchName, + onValueChange = { newBranchName = it }, + placeholder = { Text("branch-name") }, + singleLine = true, + colors = TextFieldDefaults.colors(), + ) + } + }, + confirmButton = { + TextButton(onClick = { + val name = newBranchName.trim() + showCreate = false + newBranchName = "" + if (name.isEmpty()) return@TextButton + busyBranch = name + scope.launch { + try { + client.createBranch(projectID, name) + onChange() + onDismiss() + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + busyBranch = null + } + } + }) { Text("Create") } + }, + dismissButton = { + TextButton(onClick = { + showCreate = false + newBranchName = "" + }) { Text("Cancel") } + }, + ) + } +} diff --git a/android/app/src/main/java/com/muxy/android/vcs/CreatePRSheet.kt b/android/app/src/main/java/com/muxy/android/vcs/CreatePRSheet.kt new file mode 100644 index 0000000..8c8fc1d --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/vcs/CreatePRSheet.kt @@ -0,0 +1,183 @@ +package com.muxy.android.vcs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.muxy.android.LocalAppContainer +import com.muxy.android.ui.theme.muxyColors +import com.muxy.net.createPullRequest +import kotlinx.coroutines.launch +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreatePRSheet( + projectID: UUID, + defaultBase: String?, + currentBranch: String, + onDismiss: () -> Unit, + onCreated: () -> Unit, +) { + val container = LocalAppContainer.current + val client = container.muxyClient + val theme by client.deviceTheme.collectAsStateWithLifecycle() + val colors = muxyColors(theme) + val scope = rememberCoroutineScope() + val uriHandler = LocalUriHandler.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var title by remember { mutableStateOf("") } + var body by remember { mutableStateOf("") } + var baseBranch by remember { mutableStateOf(defaultBase ?: "") } + var draft by remember { mutableStateOf(false) } + var inProgress by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + val canSubmit = title.isNotBlank() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = colors.background, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onDismiss) { Text("Cancel", color = colors.foreground) } + Spacer(Modifier.weight(1f)) + Text( + text = "New Pull Request", + style = MaterialTheme.typography.titleMedium, + color = colors.foreground, + ) + Spacer(Modifier.weight(1f)) + if (inProgress) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = colors.foreground, + ) + } else { + TextButton( + enabled = canSubmit, + onClick = { + inProgress = true + val baseTrim = baseBranch.trim().ifEmpty { null } + scope.launch { + try { + val result = + client.createPullRequest( + projectID = projectID, + title = title.trim(), + body = body, + baseBranch = baseTrim, + draft = draft, + ) + onCreated() + onDismiss() + runCatching { uriHandler.openUri(result.url) } + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + inProgress = false + } + } + }, + ) { Text("Create", color = colors.foreground) } + } + } + HorizontalDivider(color = colors.outline) + + Column { + Text("From", color = colors.mutedForeground, style = MaterialTheme.typography.labelMedium) + Text(currentBranch, color = colors.foreground) + } + OutlinedTextField( + value = baseBranch, + onValueChange = { baseBranch = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Base (e.g. main)", color = colors.faintForeground) }, + colors = textFieldColors(colors.foreground, colors.outline), + ) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("Title", color = colors.faintForeground) }, + colors = textFieldColors(colors.foreground, colors.outline), + ) + + OutlinedTextField( + value = body, + onValueChange = { body = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Body", color = colors.faintForeground) }, + minLines = 4, + maxLines = 10, + colors = textFieldColors(colors.foreground, colors.outline), + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Draft", color = colors.foreground, modifier = Modifier.weight(1f)) + Switch(checked = draft, onCheckedChange = { draft = it }) + } + + if (errorMessage != null) { + Text( + errorMessage!!, + color = Color(0xFFE53935), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +private fun textFieldColors( + foreground: Color, + outline: Color, +) = TextFieldDefaults.colors( + focusedTextColor = foreground, + unfocusedTextColor = foreground, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + cursorColor = foreground, + focusedIndicatorColor = foreground, + unfocusedIndicatorColor = outline, +) diff --git a/android/app/src/main/java/com/muxy/android/vcs/VCSSheet.kt b/android/app/src/main/java/com/muxy/android/vcs/VCSSheet.kt new file mode 100644 index 0000000..0003f0e --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/vcs/VCSSheet.kt @@ -0,0 +1,765 @@ +package com.muxy.android.vcs + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.muxy.android.LocalAppContainer +import com.muxy.android.ui.theme.MuxyColors +import com.muxy.android.ui.theme.muxyColors +import com.muxy.net.VCSClientError +import com.muxy.net.discardFiles +import com.muxy.net.fetchVCSStatus +import com.muxy.net.stageFiles +import com.muxy.net.unstageFiles +import com.muxy.net.vcsCommit +import com.muxy.net.vcsPull +import com.muxy.net.vcsPush +import com.muxy.protocol.dto.GitFileDTO +import com.muxy.protocol.dto.GitFileStatusDTO +import com.muxy.protocol.dto.VCSStatusDTO +import kotlinx.coroutines.launch +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VCSSheet( + projectID: UUID, + onDismiss: () -> Unit, +) { + val container = LocalAppContainer.current + val client = container.muxyClient + val theme by client.deviceTheme.collectAsStateWithLifecycle() + val colors = muxyColors(theme) + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var status by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var commitMessage by remember { mutableStateOf("") } + val inFlight = remember { mutableStateMapOf() } + var showBranches by remember { mutableStateOf(false) } + var showWorktrees by remember { mutableStateOf(false) } + var showCreatePR by remember { mutableStateOf(false) } + var menuExpanded by remember { mutableStateOf(false) } + + suspend fun refresh() { + isLoading = true + errorMessage = null + val fresh = client.fetchVCSStatus(projectID) + status = fresh + isLoading = false + if (fresh == null) { + errorMessage = + "Could not read repository status. This project may not be a Git repository, or the Mac is unreachable." + } + } + + LaunchedEffect(projectID) { refresh() } + + suspend fun run( + key: String, + op: suspend () -> Unit, + ) { + inFlight[key] = true + try { + op() + errorMessage = null + refresh() + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + inFlight.remove(key) + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = colors.background, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + VCSHeader( + colors = colors, + onDismiss = onDismiss, + hasPullRequest = status?.pullRequest != null, + onShowMenu = { menuExpanded = true }, + menuExpanded = menuExpanded, + onMenuDismiss = { menuExpanded = false }, + onBranches = { + menuExpanded = false + showBranches = true + }, + onWorktrees = { + menuExpanded = false + showWorktrees = true + }, + onCreatePR = { + menuExpanded = false + showCreatePR = true + }, + ) + HorizontalDivider(color = colors.outline) + when { + isLoading && status == null -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(48.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(color = colors.foreground) } + status == null -> + StatusUnavailable( + colors = colors, + errorMessage = errorMessage, + onRetry = { scope.launch { refresh() } }, + ) + else -> + StatusContent( + status = status!!, + colors = colors, + commitMessage = commitMessage, + onCommitMessageChange = { commitMessage = it }, + inFlight = inFlight, + errorMessage = errorMessage, + onPull = { scope.launch { run("pull") { client.vcsPull(projectID) } } }, + onPush = { scope.launch { run("push") { client.vcsPush(projectID) } } }, + onCommit = { + scope.launch { + run("commit") { + client.vcsCommit(projectID, commitMessage, stageAll = false) + commitMessage = "" + } + } + }, + onStageAll = { paths -> + scope.launch { run("stageAll") { client.stageFiles(projectID, paths) } } + }, + onUnstageAll = { paths -> + scope.launch { run("unstageAll") { client.unstageFiles(projectID, paths) } } + }, + onStage = { file -> + scope.launch { run("stage:${file.path}") { client.stageFiles(projectID, listOf(file.path)) } } + }, + onUnstage = { file -> + scope.launch { + run("unstage:${file.path}") { client.unstageFiles(projectID, listOf(file.path)) } + } + }, + onDiscard = { file -> + scope.launch { + run("discard:${file.path}") { + if (file.isUntracked) { + client.discardFiles( + projectID = projectID, + paths = emptyList(), + untrackedPaths = listOf(file.path), + ) + } else { + client.discardFiles( + projectID = projectID, + paths = listOf(file.path), + untrackedPaths = emptyList(), + ) + } + } + } + }, + onPullRequestTap = status?.pullRequest?.url, + ) + } + } + } + + if (showBranches) { + BranchesSheet( + projectID = projectID, + onDismiss = { showBranches = false }, + onChange = { scope.launch { refresh() } }, + ) + } + if (showWorktrees) { + WorktreesSheet( + projectID = projectID, + onDismiss = { showWorktrees = false }, + onChange = { scope.launch { refresh() } }, + ) + } + if (showCreatePR) { + CreatePRSheet( + projectID = projectID, + defaultBase = status?.defaultBranch, + currentBranch = status?.branch ?: "", + onDismiss = { showCreatePR = false }, + onCreated = { scope.launch { refresh() } }, + ) + } +} + +@Composable +private fun VCSHeader( + colors: MuxyColors, + onDismiss: () -> Unit, + hasPullRequest: Boolean, + onShowMenu: () -> Unit, + menuExpanded: Boolean, + onMenuDismiss: () -> Unit, + onBranches: () -> Unit, + onWorktrees: () -> Unit, + onCreatePR: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { Text("Done", color = colors.foreground) } + Spacer(Modifier.weight(1f)) + Text( + text = "Source Control", + style = MaterialTheme.typography.titleMedium, + color = colors.foreground, + ) + Spacer(Modifier.weight(1f)) + Box { + IconButton(onClick = onShowMenu) { + Icon(Icons.Outlined.MoreVert, contentDescription = "More", tint = colors.foreground) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = onMenuDismiss) { + DropdownMenuItem( + text = { Text("Branches") }, + leadingIcon = { Icon(Icons.Outlined.AccountTree, contentDescription = null) }, + onClick = onBranches, + ) + DropdownMenuItem( + text = { Text("Worktrees") }, + leadingIcon = { Icon(Icons.Outlined.AccountTree, contentDescription = null) }, + onClick = onWorktrees, + ) + if (!hasPullRequest) { + DropdownMenuItem( + text = { Text("Create Pull Request") }, + leadingIcon = { Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null) }, + onClick = onCreatePR, + ) + } + } + } + } +} + +@Composable +private fun StatusContent( + status: VCSStatusDTO, + colors: MuxyColors, + commitMessage: String, + onCommitMessageChange: (String) -> Unit, + inFlight: Map, + errorMessage: String?, + onPull: () -> Unit, + onPush: () -> Unit, + onCommit: () -> Unit, + onStageAll: (List) -> Unit, + onUnstageAll: (List) -> Unit, + onStage: (GitFileDTO) -> Unit, + onUnstage: (GitFileDTO) -> Unit, + onDiscard: (GitFileDTO) -> Unit, + onPullRequestTap: String?, +) { + val uriHandler = LocalUriHandler.current + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SummaryCard( + status = status, + colors = colors, + pullInFlight = inFlight.containsKey("pull"), + pushInFlight = inFlight.containsKey("push"), + onPull = onPull, + onPush = onPush, + onPullRequestTap = onPullRequestTap?.let { url -> { uriHandler.openUri(url) } }, + ) + } + if (status.stagedFiles.isNotEmpty()) { + item { + SectionHeader( + text = "Staged (${status.stagedFiles.size})", + actionLabel = "Unstage All", + onAction = { onUnstageAll(status.stagedFiles.map(GitFileDTO::path)) }, + colors = colors, + ) + } + items(items = status.stagedFiles, key = { "staged-${it.path}" }) { file -> + FileRow( + file = file, + staged = true, + inFlight = inFlight, + colors = colors, + onStage = onStage, + onUnstage = onUnstage, + onDiscard = onDiscard, + ) + } + } + if (status.changedFiles.isNotEmpty()) { + item { + SectionHeader( + text = "Changes (${status.changedFiles.size})", + actionLabel = "Stage All", + onAction = { onStageAll(status.changedFiles.map(GitFileDTO::path)) }, + colors = colors, + ) + } + items(items = status.changedFiles, key = { "change-${it.path}" }) { file -> + FileRow( + file = file, + staged = false, + inFlight = inFlight, + colors = colors, + onStage = onStage, + onUnstage = onUnstage, + onDiscard = onDiscard, + ) + } + } + if (status.stagedFiles.isEmpty() && status.changedFiles.isEmpty()) { + item { CleanCard(colors = colors) } + } + if (status.stagedFiles.isNotEmpty()) { + item { + CommitCard( + colors = colors, + message = commitMessage, + onMessageChange = onCommitMessageChange, + inFlight = inFlight.containsKey("commit"), + onCommit = onCommit, + ) + } + } + if (errorMessage != null) { + item { + Text( + text = errorMessage, + color = Color(0xFFE53935), + style = MaterialTheme.typography.bodySmall, + modifier = + Modifier + .fillMaxWidth() + .background(colors.cardBackground, shape = RoundedCornerShape(8.dp)) + .padding(12.dp), + ) + } + } + } +} + +@Composable +private fun SummaryCard( + status: VCSStatusDTO, + colors: MuxyColors, + pullInFlight: Boolean, + pushInFlight: Boolean, + onPull: () -> Unit, + onPush: () -> Unit, + onPullRequestTap: (() -> Unit)?, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(colors.cardBackground, shape = RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.AccountTree, contentDescription = null, tint = colors.mutedForeground) + Spacer(Modifier.width(8.dp)) + Text( + text = status.branch, + color = colors.foreground, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.weight(1f)) + if (status.aheadCount > 0) { + Icon( + Icons.Outlined.ArrowUpward, + contentDescription = "Ahead", + tint = colors.mutedForeground, + modifier = Modifier.size(16.dp), + ) + Text( + text = "${status.aheadCount}", + style = MaterialTheme.typography.bodySmall, + color = colors.mutedForeground, + ) + Spacer(Modifier.width(8.dp)) + } + if (status.behindCount > 0) { + Icon( + Icons.Outlined.ArrowDownward, + contentDescription = "Behind", + tint = colors.mutedForeground, + modifier = Modifier.size(16.dp), + ) + Text( + text = "${status.behindCount}", + style = MaterialTheme.typography.bodySmall, + color = colors.mutedForeground, + ) + } + } + + val pr = status.pullRequest + if (pr != null && onPullRequestTap != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onPullRequestTap() }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.AutoMirrored.Outlined.OpenInNew, contentDescription = null, tint = colors.mutedForeground) + Spacer(Modifier.width(8.dp)) + Text( + text = "PR #${pr.number} (${pr.state.lowercase()})", + style = MaterialTheme.typography.bodySmall, + color = colors.foreground, + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = onPull, + modifier = Modifier.weight(1f), + enabled = !pullInFlight, + ) { + Icon(Icons.Outlined.ArrowDownward, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Pull") + } + OutlinedButton( + onClick = onPush, + modifier = Modifier.weight(1f), + enabled = !pushInFlight && !(status.aheadCount == 0 && status.hasUpstream), + ) { + Icon(Icons.Outlined.ArrowUpward, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Push") + } + } + } +} + +@Composable +private fun SectionHeader( + text: String, + actionLabel: String, + onAction: () -> Unit, + colors: MuxyColors, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = colors.mutedForeground, + ) + Spacer(Modifier.weight(1f)) + TextButton(onClick = onAction) { + Text(actionLabel, color = colors.foreground) + } + } +} + +@Composable +private fun FileRow( + file: GitFileDTO, + staged: Boolean, + inFlight: Map, + colors: MuxyColors, + onStage: (GitFileDTO) -> Unit, + onUnstage: (GitFileDTO) -> Unit, + onDiscard: (GitFileDTO) -> Unit, +) { + val key = if (staged) "unstage:${file.path}" else "stage:${file.path}" + val rowInFlight = inFlight.containsKey(key) || inFlight.containsKey("discard:${file.path}") + Row( + modifier = + Modifier + .fillMaxWidth() + .background(colors.cardBackground, shape = RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StatusBadge(file.status) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = fileName(file.path), + style = MaterialTheme.typography.bodyMedium, + color = colors.foreground, + maxLines = 1, + ) + Text( + text = file.path, + style = MaterialTheme.typography.bodySmall, + color = colors.faintForeground, + maxLines = 1, + ) + } + if (rowInFlight) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = colors.foreground, + ) + } else if (staged) { + TextButton(onClick = { onUnstage(file) }) { + Text("Unstage", color = colors.foreground) + } + } else { + TextButton(onClick = { onStage(file) }) { + Text("Stage", color = colors.foreground) + } + TextButton(onClick = { onDiscard(file) }) { + Text("Discard", color = Color(0xFFE53935)) + } + } + } +} + +@Composable +private fun StatusBadge(status: GitFileStatusDTO) { + val label = + when (status) { + GitFileStatusDTO.ADDED -> "A" + GitFileStatusDTO.MODIFIED -> "M" + GitFileStatusDTO.DELETED -> "D" + GitFileStatusDTO.RENAMED -> "R" + GitFileStatusDTO.COPIED -> "C" + GitFileStatusDTO.UNTRACKED -> "U" + GitFileStatusDTO.UNMERGED -> "!" + } + val color = + when (status) { + GitFileStatusDTO.ADDED, GitFileStatusDTO.UNTRACKED -> Color(0xFF2E7D32) + GitFileStatusDTO.MODIFIED, GitFileStatusDTO.RENAMED, GitFileStatusDTO.COPIED -> Color(0xFFEF6C00) + GitFileStatusDTO.DELETED -> Color(0xFFC62828) + GitFileStatusDTO.UNMERGED -> Color(0xFF6A1B9A) + } + Box( + modifier = + Modifier + .size(20.dp) + .background(color.copy(alpha = 0.2f), shape = RoundedCornerShape(4.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = color, + style = MaterialTheme.typography.labelSmall, + ) + } +} + +@Composable +private fun CleanCard(colors: MuxyColors) { + Row( + modifier = + Modifier + .fillMaxWidth() + .background(colors.cardBackground, shape = RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.CheckCircle, contentDescription = null, tint = Color(0xFF2E7D32)) + Spacer(Modifier.width(8.dp)) + Text( + text = "Working tree clean", + color = colors.mutedForeground, + ) + } +} + +@Composable +private fun CommitCard( + colors: MuxyColors, + message: String, + onMessageChange: (String) -> Unit, + inFlight: Boolean, + onCommit: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background(colors.cardBackground, shape = RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Commit", + style = MaterialTheme.typography.labelMedium, + color = colors.mutedForeground, + ) + OutlinedTextField( + value = message, + onValueChange = onMessageChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Commit message", color = colors.faintForeground) }, + minLines = 2, + maxLines = 5, + colors = + TextFieldDefaults.colors( + focusedTextColor = colors.foreground, + unfocusedTextColor = colors.foreground, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + cursorColor = colors.foreground, + focusedIndicatorColor = colors.foreground, + unfocusedIndicatorColor = colors.outline, + ), + ) + Button( + onClick = onCommit, + modifier = Modifier.fillMaxWidth(), + enabled = message.isNotBlank() && !inFlight, + colors = + ButtonDefaults.buttonColors( + containerColor = colors.foreground, + contentColor = colors.background, + ), + ) { + if (inFlight) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = colors.background, + ) + } else { + Icon(Icons.Outlined.Check, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Commit") + } + } + } +} + +@Composable +private fun StatusUnavailable( + colors: MuxyColors, + errorMessage: String?, + onRetry: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Outlined.AccountTree, + contentDescription = null, + tint = colors.outline, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "Could not load repository status", + color = colors.mutedForeground, + textAlign = TextAlign.Center, + ) + if (errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = errorMessage, + color = Color(0xFFE53935), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = onRetry, + colors = + ButtonDefaults.buttonColors( + containerColor = colors.foreground, + contentColor = colors.background, + ), + ) { Text("Retry") } + } +} + +internal fun fileName(path: String): String = path.substringAfterLast('/', missingDelimiterValue = path) + +internal fun errorMessageOf(t: Throwable): String = + when (t) { + is VCSClientError -> t.message ?: "Unknown error" + else -> t.message ?: "Unknown error" + } diff --git a/android/app/src/main/java/com/muxy/android/vcs/WorktreesSheet.kt b/android/app/src/main/java/com/muxy/android/vcs/WorktreesSheet.kt new file mode 100644 index 0000000..377e09d --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/vcs/WorktreesSheet.kt @@ -0,0 +1,440 @@ +package com.muxy.android.vcs + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.muxy.android.LocalAppContainer +import com.muxy.android.ui.theme.muxyColors +import com.muxy.net.addWorktree +import com.muxy.net.listBranches +import com.muxy.net.removeWorktree +import com.muxy.protocol.dto.WorktreeDTO +import kotlinx.coroutines.launch +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorktreesSheet( + projectID: UUID, + onDismiss: () -> Unit, + onChange: () -> Unit, +) { + val container = LocalAppContainer.current + val client = container.muxyClient + val theme by client.deviceTheme.collectAsStateWithLifecycle() + val workspace by client.workspace.collectAsStateWithLifecycle() + val projectWorktrees by client.projectWorktrees.collectAsStateWithLifecycle() + val colors = muxyColors(theme) + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var errorMessage by remember { mutableStateOf(null) } + var busyID by remember { mutableStateOf(null) } + var showAdd by remember { mutableStateOf(false) } + + val worktrees = projectWorktrees[projectID] ?: emptyList() + val activeID = workspace?.worktreeID + + LaunchedEffect(projectID) { client.refreshWorktrees(projectID) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = colors.background, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { Text("Close", color = colors.foreground) } + Spacer(Modifier.weight(1f)) + Text( + text = "Worktrees", + style = MaterialTheme.typography.titleMedium, + color = colors.foreground, + ) + Spacer(Modifier.weight(1f)) + IconButton(onClick = { showAdd = true }) { + Icon(Icons.Outlined.Add, contentDescription = "Add worktree", tint = colors.foreground) + } + } + HorizontalDivider(color = colors.outline) + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = worktrees, key = { it.id }) { worktree -> + WorktreeRow( + worktree = worktree, + isActive = worktree.id == activeID, + busy = busyID == worktree.id, + foreground = colors.foreground, + muted = colors.mutedForeground, + cardBackground = colors.cardBackground, + onTap = { + if (worktree.id == activeID) return@WorktreeRow + busyID = worktree.id + scope.launch { + try { + val ok = client.selectWorktree(projectID, worktree.id) + if (ok) { + onChange() + onDismiss() + } else { + errorMessage = "Could not switch worktree" + } + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + busyID = null + } + } + }, + onRemove = { + busyID = worktree.id + scope.launch { + try { + client.removeWorktree(projectID, worktree.id) + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + busyID = null + } + } + }, + ) + } + if (errorMessage != null) { + item { + Text( + errorMessage!!, + color = Color(0xFFE53935), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + item { Spacer(Modifier.height(8.dp)) } + } + } + } + + if (showAdd) { + AddWorktreeSheet( + projectID = projectID, + onDismiss = { showAdd = false }, + onAdded = { + scope.launch { client.refreshWorktrees(projectID) } + onChange() + }, + ) + } +} + +@Composable +private fun WorktreeRow( + worktree: WorktreeDTO, + isActive: Boolean, + busy: Boolean, + foreground: Color, + muted: Color, + cardBackground: Color, + onTap: () -> Unit, + onRemove: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + Row( + modifier = + Modifier + .fillMaxWidth() + .background(cardBackground, shape = RoundedCornerShape(8.dp)) + .clickable(enabled = !isActive, onClick = onTap) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = + when { + isActive -> Icons.Outlined.CheckCircle + worktree.isPrimary -> Icons.Outlined.Home + else -> Icons.Outlined.Folder + } + val tint = if (isActive) Color(0xFF2E7D32) else muted + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(worktree.name, color = foreground) + val branch = worktree.branch + if (branch != null) { + Text(branch, color = muted, style = MaterialTheme.typography.bodySmall) + } + } + if (busy) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = foreground, + ) + } else if (worktree.canBeRemoved && !isActive) { + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Outlined.Delete, contentDescription = "Remove", tint = foreground) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Remove") }, + onClick = { + menuOpen = false + onRemove() + }, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddWorktreeSheet( + projectID: UUID, + onDismiss: () -> Unit, + onAdded: () -> Unit, +) { + val container = LocalAppContainer.current + val client = container.muxyClient + val theme by client.deviceTheme.collectAsStateWithLifecycle() + val colors = muxyColors(theme) + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var name by remember { mutableStateOf("") } + var branchName by remember { mutableStateOf("") } + var useExistingBranch by remember { mutableStateOf(false) } + var existingBranches by remember { mutableStateOf>(emptyList()) } + var selectedExisting by remember { mutableStateOf("") } + var inProgress by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var dropdownOpen by remember { mutableStateOf(false) } + + LaunchedEffect(projectID) { + try { + val branches = client.listBranches(projectID) + existingBranches = branches.locals + if (selectedExisting.isEmpty()) selectedExisting = branches.locals.firstOrNull() ?: "" + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } + } + + val canSubmit = + name.isNotBlank() && + if (useExistingBranch) selectedExisting.isNotEmpty() else branchName.isNotBlank() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = colors.background, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onDismiss) { Text("Cancel", color = colors.foreground) } + Spacer(Modifier.weight(1f)) + Text( + text = "Add Worktree", + style = MaterialTheme.typography.titleMedium, + color = colors.foreground, + ) + Spacer(Modifier.weight(1f)) + if (inProgress) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = colors.foreground, + ) + } else { + TextButton( + enabled = canSubmit, + onClick = { + inProgress = true + val branch = if (useExistingBranch) selectedExisting else branchName.trim() + scope.launch { + try { + client.addWorktree( + projectID = projectID, + name = name.trim(), + branch = branch, + createBranch = !useExistingBranch, + ) + onAdded() + onDismiss() + } catch (t: Throwable) { + errorMessage = errorMessageOf(t) + } finally { + inProgress = false + } + } + }, + ) { Text("Add", color = colors.foreground) } + } + } + + Text("Worktree name", color = colors.mutedForeground, style = MaterialTheme.typography.labelMedium) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text("Name", color = colors.faintForeground) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = + TextFieldDefaults.colors( + focusedTextColor = colors.foreground, + unfocusedTextColor = colors.foreground, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + cursorColor = colors.foreground, + focusedIndicatorColor = colors.foreground, + unfocusedIndicatorColor = colors.outline, + ), + ) + + Text("Branch", color = colors.mutedForeground, style = MaterialTheme.typography.labelMedium) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = !useExistingBranch, + onClick = { useExistingBranch = false }, + shape = SegmentedButtonDefaults.itemShape(0, 2), + ) { Text("New Branch") } + SegmentedButton( + selected = useExistingBranch, + onClick = { useExistingBranch = true }, + shape = SegmentedButtonDefaults.itemShape(1, 2), + ) { Text("Existing") } + } + + if (useExistingBranch) { + Box { + OutlinedTextField( + value = selectedExisting, + onValueChange = { }, + readOnly = true, + modifier = + Modifier + .fillMaxWidth() + .clickable { dropdownOpen = true }, + placeholder = { Text("Select branch", color = colors.faintForeground) }, + colors = + TextFieldDefaults.colors( + focusedTextColor = colors.foreground, + unfocusedTextColor = colors.foreground, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = colors.foreground, + unfocusedIndicatorColor = colors.outline, + ), + ) + DropdownMenu( + expanded = dropdownOpen, + onDismissRequest = { dropdownOpen = false }, + ) { + existingBranches.forEach { b -> + DropdownMenuItem( + text = { Text(b) }, + onClick = { + selectedExisting = b + dropdownOpen = false + }, + ) + } + } + } + } else { + OutlinedTextField( + value = branchName, + onValueChange = { branchName = it }, + placeholder = { Text("new-branch-name", color = colors.faintForeground) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = + TextFieldDefaults.colors( + focusedTextColor = colors.foreground, + unfocusedTextColor = colors.foreground, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + cursorColor = colors.foreground, + focusedIndicatorColor = colors.foreground, + unfocusedIndicatorColor = colors.outline, + ), + ) + } + + if (errorMessage != null) { + Text( + errorMessage!!, + color = Color(0xFFE53935), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/muxy/android/workspace/WorkspaceScreen.kt b/android/app/src/main/java/com/muxy/android/workspace/WorkspaceScreen.kt new file mode 100644 index 0000000..d996fed --- /dev/null +++ b/android/app/src/main/java/com/muxy/android/workspace/WorkspaceScreen.kt @@ -0,0 +1,447 @@ +package com.muxy.android.workspace + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Terminal +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.muxy.android.LocalAppContainer +import com.muxy.android.ui.theme.muxyColors +import com.muxy.android.vcs.VCSSheet +import com.muxy.protocol.dto.SplitNodeDTO +import com.muxy.protocol.dto.TabAreaDTO +import com.muxy.protocol.dto.TabDTO +import com.muxy.protocol.dto.TabKindDTO +import com.muxy.protocol.dto.WorkspaceDTO +import com.muxy.terminal.MuxyTerminalView +import kotlinx.coroutines.launch +import java.util.UUID + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkspaceScreen( + projectID: UUID, + onBack: () -> Unit, + onOpenNotifications: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = LocalAppContainer.current + val client = container.muxyClient + val scope = rememberCoroutineScope() + + val workspace by client.workspace.collectAsStateWithLifecycle() + val activeProjectID by client.activeProjectID.collectAsStateWithLifecycle() + val projects by client.projects.collectAsStateWithLifecycle() + val theme by client.deviceTheme.collectAsStateWithLifecycle() + val notifications by client.notifications.collectAsStateWithLifecycle() + val unreadCount = notifications.count { !it.isRead } + val colors = muxyColors(theme) + val fontSize by container.terminalPreferences.fontSize.collectAsStateWithLifecycle( + initialValue = com.muxy.android.settings.TerminalPreferences.DEFAULT_FONT_SIZE, + ) + val useNerdFont by container.terminalPreferences.useNerdFont.collectAsStateWithLifecycle( + initialValue = false, + ) + + var showVCS by remember { mutableStateOf(false) } + var showTabPicker by remember { mutableStateOf(false) } + var pendingClose by remember { mutableStateOf(null) } + + LaunchedEffect(projectID, activeProjectID) { + if (activeProjectID != projectID) { + client.selectProject(projectID) + } else if (workspace == null) { + client.refreshWorkspace(projectID) + } + } + + val activeProject = remember(projects, projectID) { projects.firstOrNull { it.id == projectID } } + val tabsList = remember(workspace) { workspace?.let { collectTabsByArea(it) } ?: emptyList() } + val active = remember(workspace) { workspace?.let { activeAreaTab(it) } } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = colors.background, + topBar = { + TopAppBar( + title = { + Text( + text = activeProject?.name ?: "Workspace", + style = MaterialTheme.typography.titleMedium, + color = colors.foreground, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = "Back", + tint = colors.foreground, + ) + } + }, + actions = { + IconButton(onClick = onOpenNotifications) { + BadgedBox( + badge = { + if (unreadCount > 0) { + Badge { Text(text = if (unreadCount > 99) "99+" else "$unreadCount") } + } + }, + ) { + Icon( + Icons.Outlined.Notifications, + contentDescription = "Notifications", + tint = colors.foreground, + ) + } + } + IconButton( + onClick = { showVCS = true }, + ) { + Icon( + Icons.Outlined.AccountTree, + contentDescription = "Source Control", + tint = colors.foreground, + ) + } + IconButton( + onClick = { scope.launch { client.refreshWorkspace(projectID) } }, + ) { + Icon( + Icons.Outlined.Refresh, + contentDescription = "Refresh", + tint = colors.foreground, + ) + } + Box { + IconButton(onClick = { showTabPicker = true }) { + Icon( + Icons.Outlined.Terminal, + contentDescription = "Tabs", + tint = colors.foreground, + ) + } + TabPickerMenu( + expanded = showTabPicker, + onDismiss = { showTabPicker = false }, + entries = tabsList, + activeTabID = active?.tab?.id, + onTabSelected = { entry -> + showTabPicker = false + scope.launch { + client.selectTab( + projectID = projectID, + areaID = entry.area.id, + tabID = entry.tab.id, + ) + } + }, + onTabClose = { entry -> + showTabPicker = false + pendingClose = entry + }, + onNewTerminal = { + showTabPicker = false + scope.launch { client.createTab(projectID = projectID) } + }, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = colors.background), + ) + }, + ) { padding -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .background(colors.background), + ) { + when { + workspace == null -> WorkspaceLoading(modifier = Modifier.fillMaxSize()) + active == null -> + EmptyTabsState( + foreground = colors.foreground, + onCreate = { scope.launch { client.createTab(projectID = projectID) } }, + ) + else -> + ActiveTabContent( + tab = active.tab, + foreground = colors.foreground, + background = colors.background, + fontSize = fontSize, + useNerdFont = useNerdFont, + ) + } + } + } + + if (showVCS) { + VCSSheet( + projectID = projectID, + onDismiss = { showVCS = false }, + ) + } + + pendingClose?.let { entry -> + val labels = remember(tabsList) { disambiguateLabels(tabsList) } + val label = remember(tabsList, entry) { + tabsList.indexOf(entry).takeIf { it >= 0 }?.let { labels[it] } ?: shortTitle(entry.tab.title) + } + AlertDialog( + onDismissRequest = { pendingClose = null }, + title = { Text("Close \"$label\"?") }, + text = { Text("This ends any running process in the tab.") }, + confirmButton = { + TextButton(onClick = { + pendingClose = null + scope.launch { + client.closeTab( + projectID = projectID, + areaID = entry.area.id, + tabID = entry.tab.id, + ) + } + }) { Text("Close") } + }, + dismissButton = { + TextButton(onClick = { pendingClose = null }) { Text("Cancel") } + }, + ) + } +} + +private data class AreaTab(val area: TabAreaDTO, val tab: TabDTO) + +private fun collectTabsByArea(workspace: WorkspaceDTO): List = + collectAreas(workspace.root).flatMap { area -> + area.tabs.map { AreaTab(area = area, tab = it) } + } + +private fun activeAreaTab(workspace: WorkspaceDTO): AreaTab? { + val areas = collectAreas(workspace.root) + val focused = areas.firstOrNull { it.id == workspace.focusedAreaID } ?: areas.firstOrNull() ?: return null + val activeID = focused.activeTabID ?: return null + val tab = focused.tabs.firstOrNull { it.id == activeID } ?: return null + return AreaTab(area = focused, tab = tab) +} + +private fun collectAreas(node: SplitNodeDTO): List = + when (node) { + is SplitNodeDTO.TabArea -> listOf(node.tabArea) + is SplitNodeDTO.Split -> collectAreas(node.split.first) + collectAreas(node.split.second) + } + +@Composable +private fun TabPickerMenu( + expanded: Boolean, + onDismiss: () -> Unit, + entries: List, + activeTabID: UUID?, + onTabSelected: (AreaTab) -> Unit, + onTabClose: (AreaTab) -> Unit, + onNewTerminal: () -> Unit, +) { + val labels = remember(entries) { disambiguateLabels(entries) } + DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + entries.forEachIndexed { index, entry -> + DropdownMenuItem( + text = { Text(labels[index]) }, + leadingIcon = { + if (entry.tab.id == activeTabID) { + Icon(Icons.Outlined.Check, contentDescription = null) + } else { + Spacer(Modifier.size(24.dp)) + } + }, + trailingIcon = { + IconButton(onClick = { onTabClose(entry) }) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "Close ${labels[index]}", + ) + } + }, + onClick = { onTabSelected(entry) }, + ) + } + if (entries.isNotEmpty()) HorizontalDivider() + DropdownMenuItem( + text = { Text("New Terminal") }, + leadingIcon = { Icon(Icons.Outlined.Add, contentDescription = null) }, + onClick = onNewTerminal, + ) + } +} + +private fun shortTitle(title: String): String { + val parts = title.split('/').filter { it.isNotEmpty() } + return parts.lastOrNull() ?: title +} + +private fun disambiguateLabels(entries: List): List { + val shorts = entries.map { shortTitle(it.tab.title) } + val counts = shorts.groupingBy { it }.eachCount() + val seen = mutableMapOf() + return shorts.map { short -> + if ((counts[short] ?: 0) <= 1) return@map short + val n = (seen[short] ?: 0) + 1 + seen[short] = n + "$short ($n)" + } +} + +@Composable +private fun ActiveTabContent( + tab: TabDTO, + foreground: androidx.compose.ui.graphics.Color, + background: androidx.compose.ui.graphics.Color, + fontSize: Int, + useNerdFont: Boolean, +) { + val container = LocalAppContainer.current + when (tab.kind) { + TabKindDTO.TERMINAL -> { + val paneID = tab.paneID + if (paneID == null) { + NonTerminalPlaceholder(title = "No pane available", foreground = foreground, background = background) + } else { + MuxyTerminalView( + client = container.muxyClient, + paneID = paneID, + fontSizeSp = fontSize, + useNerdFont = useNerdFont, + modifier = Modifier.fillMaxSize(), + ) + } + } + TabKindDTO.VCS -> + NonTerminalPlaceholder( + title = "Source Control — open on desktop", + foreground = foreground, + background = background, + ) + TabKindDTO.EDITOR -> + NonTerminalPlaceholder( + title = tab.title.ifBlank { "Editor — open on desktop" }, + foreground = foreground, + background = background, + ) + TabKindDTO.DIFF_VIEWER -> + NonTerminalPlaceholder( + title = tab.title.ifBlank { "Diff — open on desktop" }, + foreground = foreground, + background = background, + ) + } +} + +@Composable +private fun NonTerminalPlaceholder( + title: String, + foreground: androidx.compose.ui.graphics.Color, + background: androidx.compose.ui.graphics.Color, +) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(background), + contentAlignment = Alignment.Center, + ) { + Text( + text = title, + color = foreground.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(24.dp), + ) + } +} + +@Composable +private fun EmptyTabsState( + foreground: androidx.compose.ui.graphics.Color, + onCreate: () -> Unit, +) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(horizontal = 24.dp), + ) { + Icon( + imageVector = Icons.Outlined.Terminal, + contentDescription = null, + tint = foreground.copy(alpha = 0.4f), + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "No tabs", + color = foreground, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Create a new terminal to get started.", + color = foreground.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.OutlinedButton(onClick = onCreate) { + Text("New Terminal") + } + } + } +} + +@Composable +private fun WorkspaceLoading(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index 713414e..5929034 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,13 +1,14 @@ - + android:fillColor="#7AA2F7" + android:pathData="M30,32 L40,32 L54,58 L68,32 L78,32 L78,76 L70,76 L70,46 L60,68 L48,68 L38,46 L38,76 L30,76 Z" + android:strokeColor="#FFFFFF" + android:strokeWidth="0" /> + android:fillColor="#9ECE6A" + android:pathData="M64,72 L74,72 L74,82 L64,82 Z" /> diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..bd90d37 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6b78462..c78bee3 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6b78462..c78bee3 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ - + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..646d9b8 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0F1115 + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml index 16a4a7e..fecd804 100644 --- a/android/app/src/main/res/values/themes.xml +++ b/android/app/src/main/res/values/themes.xml @@ -1,4 +1,15 @@ - + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..d7b4192 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/app/src/test/kotlin/com/muxy/android/connect/ConnectViewModelInputTest.kt b/android/app/src/test/kotlin/com/muxy/android/connect/ConnectViewModelInputTest.kt new file mode 100644 index 0000000..bb72cea --- /dev/null +++ b/android/app/src/test/kotlin/com/muxy/android/connect/ConnectViewModelInputTest.kt @@ -0,0 +1,45 @@ +package com.muxy.android.connect + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConnectViewModelInputTest { + @Test + fun `empty host returns null`() { + assertNull(normalizeConnectInput(name = "Mac", host = " ", port = 4865, defaultPort = 4865)) + } + + @Test + fun `empty name defaults to Mac`() { + val device = normalizeConnectInput(name = " ", host = "10.0.0.1", port = 4865, defaultPort = 4865) + assertEquals("Mac", device?.name) + } + + @Test + fun `whitespace name and host are trimmed`() { + val device = + normalizeConnectInput( + name = " Pixel ", + host = " 100.64.0.1 ", + port = 4865, + defaultPort = 4865, + ) + assertEquals("Pixel", device?.name) + assertEquals("100.64.0.1", device?.host) + } + + @Test + fun `port out of range falls back to default`() { + val tooLow = normalizeConnectInput(name = "Mac", host = "10.0.0.1", port = 0, defaultPort = 4865) + val tooHigh = normalizeConnectInput(name = "Mac", host = "10.0.0.1", port = 70_000, defaultPort = 4865) + assertEquals(4865, tooLow?.port) + assertEquals(4865, tooHigh?.port) + } + + @Test + fun `valid port is preserved`() { + val device = normalizeConnectInput(name = "Mac", host = "10.0.0.1", port = 4866, defaultPort = 4865) + assertEquals(4866, device?.port) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 70dfb63..00aa610 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,6 +1,31 @@ plugins { - id("com.android.application") version "8.7.3" apply false - id("org.jetbrains.kotlin.android") version "2.0.21" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.ktlint) apply false +} + +subprojects { + apply(plugin = "io.gitlab.arturbosch.detekt") + apply(plugin = "org.jlleitschuh.gradle.ktlint") + + extensions.configure(io.gitlab.arturbosch.detekt.extensions.DetektExtension::class.java) { + buildUponDefaultConfig = true + allRules = false + config.setFrom(rootProject.files("config/detekt/detekt.yml")) + ignoreFailures = false + } + extensions.configure(org.jlleitschuh.gradle.ktlint.KtlintExtension::class.java) { + android.set(true) + ignoreFailures.set(false) + filter { + exclude { entry -> entry.file.path.contains("/vendor/") } + exclude { entry -> entry.file.path.contains("/generated/") } + exclude { entry -> entry.file.path.contains("/build/") } + } + } } diff --git a/android/config/detekt/detekt.yml b/android/config/detekt/detekt.yml new file mode 100644 index 0000000..be3235a --- /dev/null +++ b/android/config/detekt/detekt.yml @@ -0,0 +1,100 @@ +build: + maxIssues: 0 + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: false + +processors: + active: true + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + +complexity: + active: true + LongMethod: + active: false + LongParameterList: + active: false + TooManyFunctions: + active: false + ComplexCondition: + active: true + threshold: 6 + ComplexInterface: + active: false + CyclomaticComplexMethod: + active: false + +empty-blocks: + active: true + +exceptions: + TooGenericExceptionCaught: + active: false + SwallowedException: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: false + +naming: + active: true + FunctionNaming: + active: true + functionPattern: '[a-zA-Z][a-zA-Z0-9]*' + excludes: ['**/test/**', '**/androidTest/**'] + TopLevelPropertyNaming: + active: false + ConstructorParameterNaming: + active: true + ClassNaming: + active: true + PackageNaming: + active: false + MatchingDeclarationName: + active: false + +performance: + active: true + SpreadOperator: + active: false + +potential-bugs: + active: true + UnreachableCode: + active: true + +style: + active: true + WildcardImport: + active: false + MaxLineLength: + active: false + MagicNumber: + active: false + ReturnCount: + active: false + UnusedPrivateMember: + active: false + UnusedPrivateProperty: + active: false + ForbiddenComment: + active: false + NewLineAtEndOfFile: + active: true + UnnecessaryAbstractClass: + active: false + DataClassContainsFunctions: + active: false + UseDataClass: + active: false + ThrowsCount: + active: false diff --git a/android/gradle.properties b/android/gradle.properties index 3f6f2ce..ada18eb 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,11 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseParallelGC org.gradle.parallel=true org.gradle.caching=true +org.gradle.configuration-cache=true + android.useAndroidX=true android.nonTransitiveRClass=true +android.defaults.buildfeatures.buildconfig=false +android.nonFinalResIds=true + kotlin.code.style=official diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml new file mode 100644 index 0000000..b8081d8 --- /dev/null +++ b/android/gradle/libs.versions.toml @@ -0,0 +1,65 @@ +[versions] +agp = "8.7.3" +kotlin = "2.0.21" +coroutines = "1.9.0" +serialization = "1.7.3" +okhttp = "4.12.0" +composeBom = "2024.10.01" +activityCompose = "1.9.3" +lifecycle = "2.8.7" +navigation = "2.8.4" +datastore = "1.1.1" +splashscreen = "1.0.1" +windowSizeClass = "1.3.1" +junit = "4.13.2" +androidxJunit = "1.2.1" +espresso = "3.6.1" +annotation = "1.9.1" +detekt = "1.23.7" +ktlint = "12.1.1" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } + +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } + +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } + +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } +androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } + +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } + +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } + +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" } +androidx-compose-material3-windowsizeclass = { module = "androidx.compose.material3:material3-window-size-class", version.ref = "windowSizeClass" } + +junit = { module = "junit:junit", version.ref = "junit" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index a4b76b9..b1b8ef5 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index df97d72..74a4ead 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,6 +2,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew index d95bf61..b9bb139 100755 --- a/android/gradlew +++ b/android/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -173,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,18 +200,17 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/android/gradlew.bat b/android/gradlew.bat index 640d686..aa5f10b 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,94 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/local.properties.example b/android/local.properties.example new file mode 100644 index 0000000..24f0750 --- /dev/null +++ b/android/local.properties.example @@ -0,0 +1,11 @@ +## Copy this file to local.properties and adjust paths to match your machine. +## local.properties is git-ignored on purpose - never commit it. + +## macOS default Android SDK location: +sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk + +## Linux default: +# sdk.dir=/home/YOUR_USERNAME/Android/Sdk + +## Windows default (escape backslashes): +# sdk.dir=C\:\\Users\\YOUR_USERNAME\\AppData\\Local\\Android\\Sdk diff --git a/android/net/build.gradle.kts b/android/net/build.gradle.kts new file mode 100644 index 0000000..cef1c1c --- /dev/null +++ b/android/net/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "com.muxy.net" + compileSdk = 35 + + defaultConfig { + minSdk = 31 + + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + api(project(":protocol")) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + api(libs.okhttp) + + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.lifecycle.common) + implementation(libs.androidx.annotation) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver) +} diff --git a/android/net/consumer-rules.pro b/android/net/consumer-rules.pro new file mode 100644 index 0000000..d59a5d0 --- /dev/null +++ b/android/net/consumer-rules.pro @@ -0,0 +1,15 @@ +-keepattributes *Annotation* + +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; + private static final ** $cachedSerializer$delegate; +} + +-keep,includedescriptorclasses class com.muxy.**$$serializer { *; } +-keepclassmembers class com.muxy.** { + *** Companion; +} +-keepclasseswithmembers class com.muxy.** { + kotlinx.serialization.KSerializer serializer(...); +} diff --git a/android/net/src/main/AndroidManifest.xml b/android/net/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d1438ca --- /dev/null +++ b/android/net/src/main/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/net/src/main/kotlin/com/muxy/net/ConnectionIssueDetails.kt b/android/net/src/main/kotlin/com/muxy/net/ConnectionIssueDetails.kt new file mode 100644 index 0000000..0923b10 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/ConnectionIssueDetails.kt @@ -0,0 +1,32 @@ +package com.muxy.net + +fun ConnectionIssue.technicalDetails( + appVersion: String? = null, + appBuild: String? = null, + osVersion: String? = null, + additionalNotes: List = emptyList(), +): String { + val lines = mutableListOf() + lines += "Summary: $message" + lines += "Operation: $operation" + lines += "Timestamp: $timestamp" + target?.let { + lines += "Device: ${it.deviceName}" + lines += "Target: ${it.host}:${it.port}" + } + requestMethod?.let { lines += "Request: $it" } + requestID?.let { lines += "Request ID: $it" } + responseError?.let { lines += "Response error: ${it.code} ${it.message}" } + underlyingError?.let { lines += "Underlying error: $it" } + appVersion?.let { lines += "App version: $it${appBuild?.let { build -> " ($build)" } ?: ""}" } + osVersion?.let { lines += "OS: $it" } + if (additionalNotes.isNotEmpty()) lines += additionalNotes + if (recentLog.isNotEmpty()) { + lines += "" + lines += "Recent connection log:" + recentLog.takeLast(MAX_LOG_LINES).forEach { lines += "- $it" } + } + return lines.joinToString("\n") +} + +private const val MAX_LOG_LINES: Int = 25 diff --git a/android/net/src/main/kotlin/com/muxy/net/ConnectionState.kt b/android/net/src/main/kotlin/com/muxy/net/ConnectionState.kt new file mode 100644 index 0000000..9876971 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/ConnectionState.kt @@ -0,0 +1,37 @@ +package com.muxy.net + +import com.muxy.protocol.envelope.MuxyError + +data class ConnectionTarget( + val host: String, + val port: Int, + val deviceName: String, +) + +sealed class ConnectionState { + data object Idle : ConnectionState() + + data class Connecting(val target: ConnectionTarget) : ConnectionState() + + data class Authenticating(val target: ConnectionTarget) : ConnectionState() + + data class AwaitingApproval(val target: ConnectionTarget) : ConnectionState() + + data class Connected(val target: ConnectionTarget) : ConnectionState() + + data class Reconnecting(val target: ConnectionTarget) : ConnectionState() + + data class Failed(val issue: ConnectionIssue, val target: ConnectionTarget?) : ConnectionState() +} + +data class ConnectionIssue( + val message: String, + val operation: String, + val timestamp: String, + val target: ConnectionTarget?, + val requestMethod: String?, + val requestID: String?, + val responseError: MuxyError?, + val underlyingError: String?, + val recentLog: List, +) diff --git a/android/net/src/main/kotlin/com/muxy/net/CryptoBox.kt b/android/net/src/main/kotlin/com/muxy/net/CryptoBox.kt new file mode 100644 index 0000000..e149cd3 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/CryptoBox.kt @@ -0,0 +1,81 @@ +package com.muxy.net + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal data class EncryptedPayload(val iv: ByteArray, val ciphertext: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EncryptedPayload) return false + return iv.contentEquals(other.iv) && ciphertext.contentEquals(other.ciphertext) + } + + override fun hashCode(): Int = 31 * iv.contentHashCode() + ciphertext.contentHashCode() +} + +internal interface CryptoBox { + fun encrypt(plaintext: ByteArray): EncryptedPayload + + fun decrypt(payload: EncryptedPayload): ByteArray + + fun deleteKey() +} + +internal class KeystoreCryptoBox(private val keyAlias: String) : CryptoBox { + override fun encrypt(plaintext: ByteArray): EncryptedPayload { + val key = getOrCreateKey() + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + val ciphertext = cipher.doFinal(plaintext) + return EncryptedPayload(iv = cipher.iv, ciphertext = ciphertext) + } + + override fun decrypt(payload: EncryptedPayload): ByteArray { + val key = loadKey() ?: error("Keystore key '$keyAlias' missing") + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, payload.iv)) + return cipher.doFinal(payload.ciphertext) + } + + override fun deleteKey() { + val keystore = androidKeystore() + if (keystore.containsAlias(keyAlias)) { + keystore.deleteEntry(keyAlias) + } + } + + private fun getOrCreateKey(): SecretKey { + loadKey()?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val spec = + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(AES_KEY_SIZE_BITS) + .build() + generator.init(spec) + return generator.generateKey() + } + + private fun loadKey(): SecretKey? { + val keystore = androidKeystore() + return keystore.getKey(keyAlias, null) as? SecretKey + } + + private fun androidKeystore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + private companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val GCM_TAG_BITS = 128 + const val AES_KEY_SIZE_BITS = 256 + } +} diff --git a/android/net/src/main/kotlin/com/muxy/net/DeviceCredentialsStore.kt b/android/net/src/main/kotlin/com/muxy/net/DeviceCredentialsStore.kt new file mode 100644 index 0000000..101d7b3 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/DeviceCredentialsStore.kt @@ -0,0 +1,119 @@ +package com.muxy.net + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.muxy.protocol.codec.MuxyCodec +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID + +private val Context.deviceCredentialsDataStore: DataStore by preferencesDataStore( + name = "muxy_device_credentials", +) + +class DeviceCredentialsStore internal constructor( + private val dataStore: DataStore, + private val cryptoBox: CryptoBox, + private val random: SecureRandom = SecureRandom(), +) : DeviceCredentialsProvider { + private val mutex = Mutex() + + override suspend fun load(): DeviceCredentials = + mutex.withLock { + readDecrypted()?.let { return@withLock it.toDeviceCredentials() } + val fresh = + StoredCredentials( + deviceID = UUID.randomUUID().toString(), + token = generateToken(), + ) + write(fresh) + fresh.toDeviceCredentials() + } + + suspend fun forget() = + mutex.withLock { + dataStore.edit { it.remove(KEY) } + cryptoBox.deleteKey() + } + + private suspend fun readDecrypted(): StoredCredentials? { + val raw = dataStore.data.first()[KEY] ?: return null + val payload = decodePayload(raw) ?: return null + return runCatching { + val plaintext = cryptoBox.decrypt(payload) + MuxyCodec.json.decodeFromString( + StoredCredentials.serializer(), + plaintext.toString(Charsets.UTF_8), + ) + }.getOrNull() + } + + private suspend fun write(stored: StoredCredentials) { + val plaintext = + MuxyCodec.json + .encodeToString(StoredCredentials.serializer(), stored) + .toByteArray(Charsets.UTF_8) + val payload = cryptoBox.encrypt(plaintext) + val encoded = encodePayload(payload) + dataStore.edit { it[KEY] = encoded } + } + + private fun generateToken(): String { + val bytes = ByteArray(TOKEN_BYTES) + random.nextBytes(bytes) + return Base64.getEncoder().encodeToString(bytes) + } + + @Serializable + private data class StoredCredentials(val deviceID: String, val token: String) { + fun toDeviceCredentials() = + DeviceCredentials( + deviceID = UUID.fromString(deviceID), + token = token, + ) + } + + companion object { + private val KEY = stringPreferencesKey("device_credentials_v1") + private const val DEFAULT_KEY_ALIAS = "muxy.device_credentials.v1" + private const val TOKEN_BYTES = 32 + private const val MAX_IV_LENGTH = 255 + + fun create(context: Context): DeviceCredentialsStore = + DeviceCredentialsStore( + dataStore = context.applicationContext.deviceCredentialsDataStore, + cryptoBox = KeystoreCryptoBox(DEFAULT_KEY_ALIAS), + ) + + internal fun encodePayload(payload: EncryptedPayload): String { + val iv = payload.iv + val ciphertext = payload.ciphertext + require(iv.size in 1..MAX_IV_LENGTH) { "iv length out of range: ${iv.size}" } + val combined = ByteArray(1 + iv.size + ciphertext.size) + combined[0] = iv.size.toByte() + System.arraycopy(iv, 0, combined, 1, iv.size) + System.arraycopy(ciphertext, 0, combined, 1 + iv.size, ciphertext.size) + return Base64.getEncoder().encodeToString(combined) + } + + internal fun decodePayload(encoded: String): EncryptedPayload? = + runCatching { + val combined = Base64.getDecoder().decode(encoded) + if (combined.isEmpty()) return@runCatching null + val ivLength = combined[0].toInt() and 0xFF + if (ivLength == 0 || combined.size < 1 + ivLength) return@runCatching null + EncryptedPayload( + iv = combined.copyOfRange(1, 1 + ivLength), + ciphertext = combined.copyOfRange(1 + ivLength, combined.size), + ) + }.getOrNull() + } +} diff --git a/android/net/src/main/kotlin/com/muxy/net/DeviceTheme.kt b/android/net/src/main/kotlin/com/muxy/net/DeviceTheme.kt new file mode 100644 index 0000000..902d394 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/DeviceTheme.kt @@ -0,0 +1,16 @@ +package com.muxy.net + +data class DeviceTheme( + val fg: UInt, + val bg: UInt, + val palette: List, +) { + val isDark: Boolean + get() { + val r = ((bg shr 16) and 0xFFu).toDouble() / 255.0 + val g = ((bg shr 8) and 0xFFu).toDouble() / 255.0 + val b = (bg and 0xFFu).toDouble() / 255.0 + val luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + return luminance < 0.5 + } +} diff --git a/android/net/src/main/kotlin/com/muxy/net/DiagnosticLog.kt b/android/net/src/main/kotlin/com/muxy/net/DiagnosticLog.kt new file mode 100644 index 0000000..9e462b2 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/DiagnosticLog.kt @@ -0,0 +1,43 @@ +package com.muxy.net + +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField +import java.util.Locale + +class DiagnosticLog(private val capacity: Int = 120) { + private val entries = ArrayDeque(capacity) + private val lock = Any() + + fun append( + message: String, + now: Instant = Instant.now(), + ) { + synchronized(lock) { + entries.addLast("${formatter.format(now)} $message") + while (entries.size > capacity) { + entries.removeFirst() + } + } + } + + fun snapshot(): List = synchronized(lock) { entries.toList() } + + fun clear() = synchronized(lock) { entries.clear() } + + fun lastN(n: Int): List = + synchronized(lock) { + if (entries.size <= n) entries.toList() else entries.toList().takeLast(n) + } + + companion object { + val formatter: DateTimeFormatter = + DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd'T'HH:mm:ss") + .appendFraction(ChronoField.MILLI_OF_SECOND, 3, 3, true) + .appendPattern("XXX") + .toFormatter(Locale.US) + .withZone(java.time.ZoneOffset.UTC) + } +} diff --git a/android/net/src/main/kotlin/com/muxy/net/LastSessionStore.kt b/android/net/src/main/kotlin/com/muxy/net/LastSessionStore.kt new file mode 100644 index 0000000..b097578 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/LastSessionStore.kt @@ -0,0 +1,71 @@ +package com.muxy.net + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.util.UUID + +private val Context.lastSessionDataStore: DataStore by preferencesDataStore( + name = "muxy_last_session", +) + +data class LastSession( + val deviceName: String, + val host: String, + val port: Int, + val activeProjectID: UUID?, +) + +class LastSessionStore(private val dataStore: DataStore) { + val flow: Flow = + dataStore.data.map { prefs -> + val name = prefs[KEY_NAME] ?: return@map null + val host = prefs[KEY_HOST] ?: return@map null + val port = prefs[KEY_PORT] ?: return@map null + val rawProject = prefs[KEY_PROJECT_ID] + val projectID = rawProject?.let { runCatching { UUID.fromString(it) }.getOrNull() } + LastSession(deviceName = name, host = host, port = port, activeProjectID = projectID) + } + + suspend fun read(): LastSession? = flow.first() + + suspend fun saveTarget(target: ConnectionTarget) { + dataStore.edit { prefs -> + prefs[KEY_NAME] = target.deviceName + prefs[KEY_HOST] = target.host + prefs[KEY_PORT] = target.port + prefs.remove(KEY_PROJECT_ID) + } + } + + suspend fun saveActiveProject(projectID: UUID?) { + dataStore.edit { prefs -> + if (projectID == null) prefs.remove(KEY_PROJECT_ID) else prefs[KEY_PROJECT_ID] = projectID.toString() + } + } + + suspend fun clear() { + dataStore.edit { prefs -> + prefs.remove(KEY_NAME) + prefs.remove(KEY_HOST) + prefs.remove(KEY_PORT) + prefs.remove(KEY_PROJECT_ID) + } + } + + companion object { + private val KEY_NAME = stringPreferencesKey("device_name") + private val KEY_HOST = stringPreferencesKey("device_host") + private val KEY_PORT = intPreferencesKey("device_port") + private val KEY_PROJECT_ID = stringPreferencesKey("active_project_id") + + fun create(context: Context): LastSessionStore = LastSessionStore(context.applicationContext.lastSessionDataStore) + } +} diff --git a/android/net/src/main/kotlin/com/muxy/net/MuxyClient.kt b/android/net/src/main/kotlin/com/muxy/net/MuxyClient.kt new file mode 100644 index 0000000..259d923 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/MuxyClient.kt @@ -0,0 +1,821 @@ +package com.muxy.net + +import com.muxy.protocol.codec.MuxyCodec +import com.muxy.protocol.dto.AuthenticateDeviceParams +import com.muxy.protocol.dto.CloseAreaParams +import com.muxy.protocol.dto.CloseTabParams +import com.muxy.protocol.dto.CreateTabParams +import com.muxy.protocol.dto.FocusAreaParams +import com.muxy.protocol.dto.GetProjectLogoParams +import com.muxy.protocol.dto.GetWorkspaceParams +import com.muxy.protocol.dto.ListWorktreesParams +import com.muxy.protocol.dto.MarkNotificationReadParams +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.PairDeviceParams +import com.muxy.protocol.dto.PaneOwnerDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.ReleasePaneParams +import com.muxy.protocol.dto.SelectProjectParams +import com.muxy.protocol.dto.SelectTabParams +import com.muxy.protocol.dto.SelectWorktreeParams +import com.muxy.protocol.dto.SplitAreaParams +import com.muxy.protocol.dto.SplitDirectionDTO +import com.muxy.protocol.dto.SplitPositionDTO +import com.muxy.protocol.dto.TabKindDTO +import com.muxy.protocol.dto.TakeOverPaneParams +import com.muxy.protocol.dto.TerminalInputParams +import com.muxy.protocol.dto.TerminalResizeParams +import com.muxy.protocol.dto.WorkspaceDTO +import com.muxy.protocol.dto.WorktreeDTO +import com.muxy.protocol.envelope.MuxyError +import com.muxy.protocol.envelope.MuxyEvent +import com.muxy.protocol.envelope.MuxyEventData +import com.muxy.protocol.envelope.MuxyMessage +import com.muxy.protocol.envelope.MuxyMethod +import com.muxy.protocol.envelope.MuxyParams +import com.muxy.protocol.envelope.MuxyRequest +import com.muxy.protocol.envelope.MuxyResponse +import com.muxy.protocol.envelope.MuxyResult +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.random.Random +import kotlin.time.Duration + +interface DeviceCredentialsProvider { + suspend fun load(): DeviceCredentials +} + +data class DeviceCredentials(val deviceID: UUID, val token: String) + +class MuxyClient( + private val httpClient: OkHttpClient = defaultClient(), + private val credentialsProvider: DeviceCredentialsProvider, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val backoff: BackoffPolicy = BackoffPolicy.exponentialJitter(), + private val now: () -> Long = { System.currentTimeMillis() }, +) : AutoCloseable { + private val parent = SupervisorJob() + private val scope = CoroutineScope(ioDispatcher + parent) + + private val _state = MutableStateFlow(ConnectionState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() + + private val _paneOwners = MutableStateFlow>(emptyMap()) + val paneOwners: StateFlow> = _paneOwners.asStateFlow() + + private val _myClientID = MutableStateFlow(null) + val myClientID: StateFlow = _myClientID.asStateFlow() + + private val _deviceTheme = MutableStateFlow(null) + val deviceTheme: StateFlow = _deviceTheme.asStateFlow() + + private val _activeProjectID = MutableStateFlow(null) + val activeProjectID: StateFlow = _activeProjectID.asStateFlow() + + private val _projects = MutableStateFlow>(emptyList()) + val projects: StateFlow> = _projects.asStateFlow() + + private val _projectLogos = MutableStateFlow>(emptyMap()) + val projectLogos: StateFlow> = _projectLogos.asStateFlow() + + private val _projectWorktrees = MutableStateFlow>>(emptyMap()) + val projectWorktrees: StateFlow>> = _projectWorktrees.asStateFlow() + + private val _workspace = MutableStateFlow(null) + val workspace: StateFlow = _workspace.asStateFlow() + + private val _notifications = MutableStateFlow>(emptyList()) + val notifications: StateFlow> = _notifications.asStateFlow() + + private val _sessionEpoch = MutableStateFlow(0) + val sessionEpoch: StateFlow = _sessionEpoch.asStateFlow() + + val log: DiagnosticLog = DiagnosticLog() + + private val pendingMutex = Mutex() + private val pending = mutableMapOf>() + private val pendingMethods = mutableMapOf() + + private var webSocket: WebSocket? = null + private var currentTarget: ConnectionTarget? = null + private var connectJob: Job? = null + + private val reconnectMutex = Mutex() + private var isReconnecting: Boolean = false + + @Volatile + var isBackgrounded: Boolean = false + private set + + fun paneIsOwnedBySelf(paneID: UUID): Boolean { + val mine = _myClientID.value ?: return false + val owner = _paneOwners.value[paneID] ?: return false + return owner is PaneOwnerDTO.Remote && owner.deviceID == mine + } + + fun terminalBytes(paneID: UUID): Flow = + _events + .filter { event -> + val data = event.data + (data is MuxyEventData.TerminalOutput && data.value.paneID == paneID) || + (data is MuxyEventData.TerminalSnapshot && data.value.paneID == paneID) + } + .map { event -> + when (val data = event.data) { + is MuxyEventData.TerminalOutput -> data.value.bytes + is MuxyEventData.TerminalSnapshot -> data.value.bytes + else -> ByteArray(0) + } + } + + fun setBackgrounded(value: Boolean) { + isBackgrounded = value + } + + fun suspendForBackground() { + val socket = webSocket ?: return + log.append("Suspending socket for background") + socket.close(1000, "background") + webSocket = null + scope.launch { + cancelAllPending(MuxyError(code = 499, message = "Suspended for background")) + } + } + + fun connect(target: ConnectionTarget) { + connectJob?.cancel() + currentTarget = target + log.append("Connect requested for ${target.deviceName} at ${target.host}:${target.port}") + _paneOwners.value = emptyMap() + _activeProjectID.value = null + _workspace.value = null + _projects.value = emptyList() + _projectLogos.value = emptyMap() + _projectWorktrees.value = emptyMap() + _state.value = ConnectionState.Connecting(target) + _notifications.value = emptyList() + connectJob = + scope.launch { + openSocket(target) + authenticateOrPair(target) + } + } + + fun disconnect() { + log.append("Disconnected") + connectJob?.cancel() + connectJob = null + webSocket?.close(1000, null) + webSocket = null + currentTarget = null + _state.value = ConnectionState.Idle + scope.launch { + cancelAllPending(MuxyError(code = 499, message = "Cancelled")) + } + _paneOwners.value = emptyMap() + _deviceTheme.value = null + _myClientID.value = null + _activeProjectID.value = null + _workspace.value = null + _projects.value = emptyList() + _projectLogos.value = emptyMap() + _projectWorktrees.value = emptyMap() + _notifications.value = emptyList() + } + + suspend fun send( + method: MuxyMethod, + params: MuxyParams? = null, + timeout: Duration = MuxyTimeouts.forMethod(method), + ): MuxyResponse? { + check(method !in MuxyTimeouts.voidMethods) { + "${method.name} is fire-and-forget; use sendFireAndForget" + } + val socket = + webSocket ?: run { + log.append("Send ${method.name} skipped: no socket") + return null + } + val id = UUID.randomUUID().toString() + val request = MuxyRequest(id = id, method = method, params = params) + val message = MuxyMessage.Request(request) + val text = MuxyCodec.encode(message) + val deferred = CompletableDeferred() + pendingMutex.withLock { + pending[id] = deferred + pendingMethods[id] = method + } + log.append("→ ${method.name} [$id]") + val sent = socket.send(text) + if (!sent) { + pendingMutex.withLock { + pending.remove(id) + pendingMethods.remove(id) + } + log.append("× ${method.name} [$id] queue rejected") + return null + } + return withTimeoutOrNull(timeout) { deferred.await() } ?: run { + pendingMutex.withLock { + pending.remove(id) + pendingMethods.remove(id) + } + log.append("× ${method.name} [$id] timed out") + MuxyResponse(id = id, error = MuxyError(code = 408, message = "Timeout")) + } + } + + fun sendFireAndForget( + method: MuxyMethod, + params: MuxyParams, + ) { + val socket = webSocket ?: return + val request = MuxyRequest(id = UUID.randomUUID().toString(), method = method, params = params) + socket.send(MuxyCodec.encode(MuxyMessage.Request(request))) + } + + fun sendTerminalInput( + paneID: UUID, + bytes: ByteArray, + ) { + if (bytes.isEmpty()) return + sendFireAndForget( + MuxyMethod.TERMINAL_INPUT, + MuxyParams.TerminalInput(TerminalInputParams(paneID = paneID, bytes = bytes)), + ) + } + + suspend fun takeOverPane( + paneID: UUID, + cols: UInt, + rows: UInt, + ) { + send( + MuxyMethod.TAKE_OVER_PANE, + MuxyParams.TakeOverPane(TakeOverPaneParams(paneID = paneID, cols = cols, rows = rows)), + ) + } + + suspend fun releasePane(paneID: UUID) { + send(MuxyMethod.RELEASE_PANE, MuxyParams.ReleasePane(ReleasePaneParams(paneID = paneID))) + } + + suspend fun resizeTerminal( + paneID: UUID, + cols: UInt, + rows: UInt, + ) { + send( + MuxyMethod.TERMINAL_RESIZE, + MuxyParams.TerminalResize(TerminalResizeParams(paneID = paneID, cols = cols, rows = rows)), + ) + } + + suspend fun refreshProjects(): Boolean { + val response = send(MuxyMethod.LIST_PROJECTS) ?: return false + if (response.error != null) return false + val result = response.result as? MuxyResult.Projects ?: return false + _projects.value = result.value + for (project in result.value) { + if (project.logo != null) fetchProjectLogo(project.id) + refreshWorktrees(project.id) + } + return true + } + + suspend fun fetchProjectLogo(projectID: UUID): Boolean { + if (_projectLogos.value.containsKey(projectID)) return true + val response = + send( + MuxyMethod.GET_PROJECT_LOGO, + MuxyParams.GetProjectLogo(GetProjectLogoParams(projectID = projectID)), + ) ?: return false + val result = response.result as? MuxyResult.ProjectLogo ?: return false + val data = + runCatching { Base64.getDecoder().decode(result.value.pngData) }.getOrNull() + ?: return false + _projectLogos.value = _projectLogos.value + (projectID to data) + return true + } + + suspend fun selectProject(projectID: UUID): Boolean { + _activeProjectID.value = projectID + _workspace.value = null + _paneOwners.value = emptyMap() + val response = + send( + MuxyMethod.SELECT_PROJECT, + MuxyParams.SelectProject(SelectProjectParams(projectID = projectID)), + ) ?: return false + if (response.error != null) return false + return refreshWorkspace(projectID) + } + + suspend fun refreshWorktrees(projectID: UUID): Boolean { + val response = + send( + MuxyMethod.LIST_WORKTREES, + MuxyParams.ListWorktrees(ListWorktreesParams(projectID = projectID)), + ) ?: return false + if (response.error != null) return false + val result = response.result as? MuxyResult.Worktrees ?: return false + _projectWorktrees.value = _projectWorktrees.value + (projectID to result.value) + return true + } + + suspend fun refreshWorkspace(projectID: UUID): Boolean { + val response = + send( + MuxyMethod.GET_WORKSPACE, + MuxyParams.GetWorkspace(GetWorkspaceParams(projectID = projectID)), + ) ?: return false + if (response.error != null) return false + val result = response.result as? MuxyResult.Workspace ?: return false + _workspace.value = result.value + return true + } + + suspend fun selectWorktree( + projectID: UUID, + worktreeID: UUID, + ): Boolean { + val response = + send( + MuxyMethod.SELECT_WORKTREE, + MuxyParams.SelectWorktree(SelectWorktreeParams(projectID = projectID, worktreeID = worktreeID)), + ) ?: return false + if (response.error != null) return false + return refreshWorkspace(projectID) + } + + suspend fun createTab( + projectID: UUID, + areaID: UUID? = null, + kind: TabKindDTO = TabKindDTO.TERMINAL, + ) { + send( + MuxyMethod.CREATE_TAB, + MuxyParams.CreateTab(CreateTabParams(projectID = projectID, areaID = areaID, kind = kind)), + ) + refreshWorkspace(projectID) + } + + suspend fun closeTab( + projectID: UUID, + areaID: UUID, + tabID: UUID, + ) { + send( + MuxyMethod.CLOSE_TAB, + MuxyParams.CloseTab(CloseTabParams(projectID = projectID, areaID = areaID, tabID = tabID)), + ) + refreshWorkspace(projectID) + } + + suspend fun selectTab( + projectID: UUID, + areaID: UUID, + tabID: UUID, + ) { + send( + MuxyMethod.SELECT_TAB, + MuxyParams.SelectTab(SelectTabParams(projectID = projectID, areaID = areaID, tabID = tabID)), + ) + refreshWorkspace(projectID) + } + + suspend fun focusArea( + projectID: UUID, + areaID: UUID, + ) { + send( + MuxyMethod.FOCUS_AREA, + MuxyParams.FocusArea(FocusAreaParams(projectID = projectID, areaID = areaID)), + ) + refreshWorkspace(projectID) + } + + suspend fun splitArea( + projectID: UUID, + areaID: UUID, + direction: SplitDirectionDTO, + position: SplitPositionDTO, + ) { + send( + MuxyMethod.SPLIT_AREA, + MuxyParams.SplitArea( + SplitAreaParams(projectID = projectID, areaID = areaID, direction = direction, position = position), + ), + ) + refreshWorkspace(projectID) + } + + suspend fun closeArea( + projectID: UUID, + areaID: UUID, + ) { + send( + MuxyMethod.CLOSE_AREA, + MuxyParams.CloseArea(CloseAreaParams(projectID = projectID, areaID = areaID)), + ) + refreshWorkspace(projectID) + } + + suspend fun refreshNotifications(): Boolean { + val response = send(MuxyMethod.LIST_NOTIFICATIONS) ?: return false + if (response.error != null) return false + val result = response.result as? MuxyResult.Notifications ?: return false + _notifications.value = result.value.sortedByDescending { it.timestamp } + return true + } + + suspend fun markNotificationRead(notificationID: UUID): Boolean { + val response = + send( + MuxyMethod.MARK_NOTIFICATION_READ, + MuxyParams.MarkNotificationRead(MarkNotificationReadParams(notificationID = notificationID)), + ) ?: return false + if (response.error != null) return false + _notifications.value = + _notifications.value.map { existing -> + if (existing.id == notificationID) existing.copy(isRead = true) else existing + } + return true + } + + fun verifyConnectionOrReconnect() { + val target = currentTarget ?: return + scope.launch { reconnectSilently(target) } + } + + private suspend fun reconnectSilently(target: ConnectionTarget) { + reconnectMutex.withLock { + if (isReconnecting) return + isReconnecting = true + } + try { + log.append("Silent reconnect to ${target.host}:${target.port}") + _paneOwners.value = emptyMap() + webSocket?.cancel() + webSocket = null + openSocket(target, attemptCount = 1) + authenticateOrPair(target, silent = true) + if (_state.value is ConnectionState.Connected) { + _sessionEpoch.value = _sessionEpoch.value + 1 + val activeID = _activeProjectID.value + if (activeID != null) { + send(MuxyMethod.SELECT_PROJECT, MuxyParams.SelectProject(SelectProjectParams(projectID = activeID))) + refreshWorkspace(activeID) + } + } + } finally { + reconnectMutex.withLock { isReconnecting = false } + } + } + + private suspend fun openSocket( + target: ConnectionTarget, + attemptCount: Int = 0, + ) { + val url = "ws://${target.host}:${target.port}" + log.append("Opening WebSocket to ${target.host}:${target.port}") + val request = Request.Builder().url(url).build() + val listener = SocketListener() + webSocket = httpClient.newWebSocket(request, listener) + if (attemptCount > 0) { + val delayMs = backoff.delayForAttempt(attemptCount) + if (delayMs > 0) delay(delayMs) + } + } + + private suspend fun authenticateOrPair( + target: ConnectionTarget, + silent: Boolean = false, + ) { + val credentials = credentialsProvider.load() + if (!silent) _state.value = ConnectionState.Authenticating(target) + val authParams = + MuxyParams.AuthenticateDevice( + AuthenticateDeviceParams( + deviceID = credentials.deviceID, + deviceName = target.deviceName, + token = credentials.token, + ), + ) + val authResponse = send(MuxyMethod.AUTHENTICATE_DEVICE, authParams) + if (authResponse == null) { + if (!silent) failed("Could not reach device", "Authenticating device", target) + return + } + val authError = authResponse.error + if (authError == null) { + applyPairing(authResponse.result, target) + return + } + if (authError.code != 401) { + failed( + message = "Authentication failed", + operation = "Authenticating device", + target = target, + requestMethod = MuxyMethod.AUTHENTICATE_DEVICE.name, + requestID = authResponse.id, + responseError = authError, + ) + return + } + if (!silent) _state.value = ConnectionState.AwaitingApproval(target) + val pairParams = + MuxyParams.PairDevice( + PairDeviceParams( + deviceID = credentials.deviceID, + deviceName = target.deviceName, + token = credentials.token, + ), + ) + val pairResponse = send(MuxyMethod.PAIR_DEVICE, pairParams) + if (pairResponse == null) { + failed("Could not finish pairing", "Pairing device", target) + return + } + val pairError = pairResponse.error + if (pairError != null) { + val message = if (pairError.code == 403) "Approval denied on Mac" else "Could not finish pairing" + failed( + message = message, + operation = "Pairing device", + target = target, + requestMethod = MuxyMethod.PAIR_DEVICE.name, + requestID = pairResponse.id, + responseError = pairError, + ) + return + } + applyPairing(pairResponse.result, target) + } + + private fun applyPairing( + result: MuxyResult?, + target: ConnectionTarget, + ) { + if (result is MuxyResult.Pairing) { + _myClientID.value = result.value.clientID + val fg = result.value.themeFg + val bg = result.value.themeBg + if (fg != null && bg != null) { + _deviceTheme.value = + DeviceTheme( + fg = fg, + bg = bg, + palette = result.value.themePalette ?: emptyList(), + ) + } + log.append("Authenticated as client ${result.value.clientID}") + _state.value = ConnectionState.Connected(target) + return + } + failed( + message = "Authentication failed", + operation = "Authenticating device", + target = target, + requestMethod = MuxyMethod.AUTHENTICATE_DEVICE.name, + requestID = null, + responseError = null, + ) + } + + private fun failed( + message: String, + operation: String, + target: ConnectionTarget?, + requestMethod: String? = null, + requestID: String? = null, + responseError: MuxyError? = null, + underlyingError: String? = null, + ) { + log.append("Failure during $operation: $message") + if (_state.value is ConnectionState.Idle) return + val issue = + ConnectionIssue( + message = message, + operation = operation, + timestamp = DiagnosticLog.formatter.format(java.time.Instant.ofEpochMilli(now())), + target = target, + requestMethod = requestMethod, + requestID = requestID, + responseError = responseError, + underlyingError = underlyingError, + recentLog = log.lastN(25), + ) + _state.value = ConnectionState.Failed(issue, target) + } + + private fun handleIncomingText(text: String) { + val message = + try { + MuxyCodec.decode(text) + } catch (t: Throwable) { + log.append("Failed to decode incoming message: ${t.message}") + return + } + when (message) { + is MuxyMessage.Response -> handleResponse(message.value) + is MuxyMessage.Event -> handleEvent(message.value) + is MuxyMessage.Request -> Unit + } + } + + private fun handleResponse(response: MuxyResponse) { + val deferred: CompletableDeferred? + val method: MuxyMethod? + synchronized(pending) { + deferred = pending.remove(response.id) + method = pendingMethods.remove(response.id) + } + if (deferred != null) { + log.append("← ${method?.name ?: "?"} [${response.id}] ${summary(response)}") + deferred.complete(response) + } + } + + private fun handleEvent(event: MuxyEvent) { + when (val data = event.data) { + is MuxyEventData.PaneOwnership -> { + _paneOwners.value = _paneOwners.value + (data.value.paneID to data.value.owner) + } + is MuxyEventData.DeviceTheme -> { + _deviceTheme.value = + DeviceTheme( + fg = data.value.fg, + bg = data.value.bg, + palette = data.value.palette ?: emptyList(), + ) + } + else -> Unit + } + scope.launch { _events.emit(event) } + } + + private suspend fun cancelAllPending(error: MuxyError) { + val snapshot = + pendingMutex.withLock { + val copy = pending.toMap() + pending.clear() + pendingMethods.clear() + copy + } + for ((id, deferred) in snapshot) { + deferred.complete(MuxyResponse(id = id, error = error)) + } + } + + override fun close() { + disconnect() + scope.cancel() + httpClient.dispatcher.executorService.shutdown() + httpClient.connectionPool.evictAll() + } + + private inner class SocketListener : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + log.append("WebSocket open") + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + handleIncomingText(text) + } + + override fun onMessage( + webSocket: WebSocket, + bytes: ByteString, + ) { + handleIncomingText(bytes.utf8()) + } + + override fun onFailure( + webSocket: WebSocket, + t: Throwable, + response: Response?, + ) { + log.append("WebSocket failure: ${t.message}") + scope.launch { + cancelAllPending(MuxyError(code = 499, message = "Connection lost")) + } + if (isBackgrounded) { + log.append("Suppressing error: backgrounded") + return + } + failed( + message = "Connection lost", + operation = "WebSocket", + target = currentTarget, + underlyingError = t.message, + ) + } + + override fun onClosing( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + webSocket.close(1000, null) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + log.append("WebSocket closed: $code $reason") + } + } + + private fun summary(response: MuxyResponse): String { + val error = response.error + if (error != null) return "error ${error.code} ${error.message}" + return when (val result = response.result) { + null -> "ok" + is MuxyResult.Ok -> "ok" + is MuxyResult.Projects -> "projects(${result.value.size})" + is MuxyResult.Worktrees -> "worktrees(${result.value.size})" + is MuxyResult.Workspace -> "workspace" + is MuxyResult.Tab -> "tab" + is MuxyResult.TerminalContent -> "terminalContent" + is MuxyResult.TerminalCells -> "terminalCells" + is MuxyResult.DeviceInfo -> "deviceInfo" + is MuxyResult.Pairing -> "pairing" + is MuxyResult.PaneOwner -> "paneOwner" + is MuxyResult.VCSStatus -> "vcsStatus" + is MuxyResult.VCSBranches -> "vcsBranches" + is MuxyResult.VCSPRCreated -> "vcsPRCreated" + is MuxyResult.ProjectLogo -> "projectLogo" + is MuxyResult.Notifications -> "notifications(${result.value.size})" + } + } + + companion object { + fun defaultClient(): OkHttpClient = + OkHttpClient.Builder() + .pingInterval(20, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build() + } +} + +class BackoffPolicy( + private val baseMs: Long, + private val maxMs: Long, + private val jitterMs: Long, + private val random: Random = Random.Default, +) { + fun delayForAttempt(attempt: Int): Long { + if (attempt <= 0) return 0 + val capped = minOf(maxMs, baseMs shl (attempt - 1).coerceAtMost(20)) + val jitter = if (jitterMs <= 0) 0 else random.nextLong(jitterMs) + return capped + jitter + } + + companion object { + fun exponentialJitter( + baseMs: Long = 250, + maxMs: Long = 10_000, + jitterMs: Long = 250, + ): BackoffPolicy = BackoffPolicy(baseMs, maxMs, jitterMs) + } +} diff --git a/android/net/src/main/kotlin/com/muxy/net/MuxyClientVCS.kt b/android/net/src/main/kotlin/com/muxy/net/MuxyClientVCS.kt new file mode 100644 index 0000000..1ff3d23 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/MuxyClientVCS.kt @@ -0,0 +1,204 @@ +package com.muxy.net + +import com.muxy.protocol.dto.GetVCSStatusParams +import com.muxy.protocol.dto.VCSAddWorktreeParams +import com.muxy.protocol.dto.VCSBranchesDTO +import com.muxy.protocol.dto.VCSCommitParams +import com.muxy.protocol.dto.VCSCreateBranchParams +import com.muxy.protocol.dto.VCSCreatePRParams +import com.muxy.protocol.dto.VCSCreatePRResultDTO +import com.muxy.protocol.dto.VCSDiscardFilesParams +import com.muxy.protocol.dto.VCSListBranchesParams +import com.muxy.protocol.dto.VCSPullParams +import com.muxy.protocol.dto.VCSPushParams +import com.muxy.protocol.dto.VCSRemoveWorktreeParams +import com.muxy.protocol.dto.VCSStageFilesParams +import com.muxy.protocol.dto.VCSStatusDTO +import com.muxy.protocol.dto.VCSSwitchBranchParams +import com.muxy.protocol.dto.VCSUnstageFilesParams +import com.muxy.protocol.envelope.MuxyMethod +import com.muxy.protocol.envelope.MuxyParams +import com.muxy.protocol.envelope.MuxyResponse +import com.muxy.protocol.envelope.MuxyResult +import java.util.UUID + +sealed class VCSClientError : Exception() { + data object Timeout : VCSClientError() { + override val message: String = "The request timed out." + + private fun readResolve(): Any = Timeout + } + + data class Server(override val message: String) : VCSClientError() + + data object UnexpectedResponse : VCSClientError() { + override val message: String = "Unexpected response from Mac." + + private fun readResolve(): Any = UnexpectedResponse + } +} + +suspend fun MuxyClient.fetchVCSStatus(projectID: UUID): VCSStatusDTO? { + val response = + send( + MuxyMethod.GET_VCS_STATUS, + MuxyParams.GetVCSStatus(GetVCSStatusParams(projectID = projectID)), + ) ?: return null + if (response.error != null) return null + return (response.result as? MuxyResult.VCSStatus)?.value +} + +suspend fun MuxyClient.stageFiles( + projectID: UUID, + paths: List, +) { + sendThrowingVCS( + MuxyMethod.VCS_STAGE_FILES, + MuxyParams.VCSStageFiles(VCSStageFilesParams(projectID = projectID, paths = paths)), + ) +} + +suspend fun MuxyClient.unstageFiles( + projectID: UUID, + paths: List, +) { + sendThrowingVCS( + MuxyMethod.VCS_UNSTAGE_FILES, + MuxyParams.VCSUnstageFiles(VCSUnstageFilesParams(projectID = projectID, paths = paths)), + ) +} + +suspend fun MuxyClient.discardFiles( + projectID: UUID, + paths: List, + untrackedPaths: List, +) { + sendThrowingVCS( + MuxyMethod.VCS_DISCARD_FILES, + MuxyParams.VCSDiscardFiles( + VCSDiscardFilesParams(projectID = projectID, paths = paths, untrackedPaths = untrackedPaths), + ), + ) +} + +suspend fun MuxyClient.vcsCommit( + projectID: UUID, + message: String, + stageAll: Boolean, +) { + sendThrowingVCS( + MuxyMethod.VCS_COMMIT, + MuxyParams.VCSCommit(VCSCommitParams(projectID = projectID, message = message, stageAll = stageAll)), + ) +} + +suspend fun MuxyClient.vcsPush(projectID: UUID) { + sendThrowingVCS( + MuxyMethod.VCS_PUSH, + MuxyParams.VCSPush(VCSPushParams(projectID = projectID)), + ) +} + +suspend fun MuxyClient.vcsPull(projectID: UUID) { + sendThrowingVCS( + MuxyMethod.VCS_PULL, + MuxyParams.VCSPull(VCSPullParams(projectID = projectID)), + ) +} + +suspend fun MuxyClient.listBranches(projectID: UUID): VCSBranchesDTO { + val response = + send( + MuxyMethod.VCS_LIST_BRANCHES, + MuxyParams.VCSListBranches(VCSListBranchesParams(projectID = projectID)), + ) ?: throw VCSClientError.Timeout + val error = response.error + if (error != null) throw VCSClientError.Server(error.message) + return (response.result as? MuxyResult.VCSBranches)?.value ?: throw VCSClientError.UnexpectedResponse +} + +suspend fun MuxyClient.switchBranch( + projectID: UUID, + branch: String, +) { + sendThrowingVCS( + MuxyMethod.VCS_SWITCH_BRANCH, + MuxyParams.VCSSwitchBranch(VCSSwitchBranchParams(projectID = projectID, branch = branch)), + ) +} + +suspend fun MuxyClient.createBranch( + projectID: UUID, + name: String, +) { + sendThrowingVCS( + MuxyMethod.VCS_CREATE_BRANCH, + MuxyParams.VCSCreateBranch(VCSCreateBranchParams(projectID = projectID, name = name)), + ) +} + +suspend fun MuxyClient.createPullRequest( + projectID: UUID, + title: String, + body: String, + baseBranch: String?, + draft: Boolean, +): VCSCreatePRResultDTO { + val response = + send( + MuxyMethod.VCS_CREATE_PR, + MuxyParams.VCSCreatePR( + VCSCreatePRParams( + projectID = projectID, + title = title, + body = body, + baseBranch = baseBranch, + draft = draft, + ), + ), + ) ?: throw VCSClientError.Timeout + val error = response.error + if (error != null) throw VCSClientError.Server(error.message) + return (response.result as? MuxyResult.VCSPRCreated)?.value ?: throw VCSClientError.UnexpectedResponse +} + +suspend fun MuxyClient.addWorktree( + projectID: UUID, + name: String, + branch: String, + createBranch: Boolean, +) { + sendThrowingVCS( + MuxyMethod.VCS_ADD_WORKTREE, + MuxyParams.VCSAddWorktree( + VCSAddWorktreeParams( + projectID = projectID, + name = name, + branch = branch, + createBranch = createBranch, + ), + ), + ) + refreshWorktrees(projectID) +} + +suspend fun MuxyClient.removeWorktree( + projectID: UUID, + worktreeID: UUID, +) { + sendThrowingVCS( + MuxyMethod.VCS_REMOVE_WORKTREE, + MuxyParams.VCSRemoveWorktree(VCSRemoveWorktreeParams(projectID = projectID, worktreeID = worktreeID)), + ) + refreshWorktrees(projectID) +} + +private suspend fun MuxyClient.sendThrowingVCS( + method: MuxyMethod, + params: MuxyParams, +): MuxyResponse { + val response = send(method, params) ?: throw VCSClientError.Timeout + val error = response.error + if (error != null) throw VCSClientError.Server(error.message) + return response +} diff --git a/android/net/src/main/kotlin/com/muxy/net/MuxyLifecycleBinder.kt b/android/net/src/main/kotlin/com/muxy/net/MuxyLifecycleBinder.kt new file mode 100644 index 0000000..0dc8e64 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/MuxyLifecycleBinder.kt @@ -0,0 +1,58 @@ +package com.muxy.net + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkRequest +import androidx.annotation.RequiresPermission +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner + +class MuxyLifecycleBinder( + private val client: MuxyClient, + private val connectivityManager: ConnectivityManager?, +) : DefaultLifecycleObserver { + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + override fun onStart(owner: LifecycleOwner) { + client.setBackgrounded(false) + client.verifyConnectionOrReconnect() + registerNetworkCallback() + } + + override fun onStop(owner: LifecycleOwner) { + client.setBackgrounded(true) + client.suspendForBackground() + unregisterNetworkCallback() + } + + @SuppressLint("MissingPermission") + @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE) + private fun registerNetworkCallback() { + val cm = connectivityManager ?: return + if (networkCallback != null) return + val request = NetworkRequest.Builder().build() + val callback = + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + client.verifyConnectionOrReconnect() + } + } + runCatching { cm.registerNetworkCallback(request, callback) } + .onSuccess { networkCallback = callback } + } + + private fun unregisterNetworkCallback() { + val cm = connectivityManager ?: return + val callback = networkCallback ?: return + runCatching { cm.unregisterNetworkCallback(callback) } + networkCallback = null + } + + companion object { + fun systemConnectivityManager(context: Context): ConnectivityManager? = + context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + } +} diff --git a/android/net/src/main/kotlin/com/muxy/net/MuxyTimeouts.kt b/android/net/src/main/kotlin/com/muxy/net/MuxyTimeouts.kt new file mode 100644 index 0000000..4bab100 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/MuxyTimeouts.kt @@ -0,0 +1,21 @@ +package com.muxy.net + +import com.muxy.protocol.envelope.MuxyMethod +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +object MuxyTimeouts { + val default: Duration = 10.seconds + + fun forMethod(method: MuxyMethod): Duration = + when (method) { + MuxyMethod.PAIR_DEVICE -> 120.seconds + MuxyMethod.VCS_COMMIT -> 60.seconds + MuxyMethod.VCS_PUSH, MuxyMethod.VCS_PULL, MuxyMethod.VCS_CREATE_PR -> 120.seconds + MuxyMethod.VCS_SWITCH_BRANCH, MuxyMethod.VCS_CREATE_BRANCH -> 30.seconds + MuxyMethod.VCS_ADD_WORKTREE, MuxyMethod.VCS_REMOVE_WORKTREE -> 60.seconds + else -> default + } + + val voidMethods: Set = setOf(MuxyMethod.TERMINAL_INPUT) +} diff --git a/android/net/src/main/kotlin/com/muxy/net/SavedDevice.kt b/android/net/src/main/kotlin/com/muxy/net/SavedDevice.kt new file mode 100644 index 0000000..6463195 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/SavedDevice.kt @@ -0,0 +1,12 @@ +package com.muxy.net + +import kotlinx.serialization.Serializable + +@Serializable +data class SavedDevice( + val name: String, + val host: String, + val port: Int, +) { + val id: String get() = "$host:$port" +} diff --git a/android/net/src/main/kotlin/com/muxy/net/SavedDevicesStore.kt b/android/net/src/main/kotlin/com/muxy/net/SavedDevicesStore.kt new file mode 100644 index 0000000..c5321c0 --- /dev/null +++ b/android/net/src/main/kotlin/com/muxy/net/SavedDevicesStore.kt @@ -0,0 +1,62 @@ +package com.muxy.net + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.muxy.protocol.codec.MuxyCodec +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.builtins.ListSerializer + +private val Context.savedDevicesDataStore: DataStore by preferencesDataStore( + name = "muxy_saved_devices", +) + +class SavedDevicesStore(private val dataStore: DataStore) { + val flow: Flow> = + dataStore.data.map { prefs -> + decode(prefs[KEY]) + } + + suspend fun list(): List = flow.first() + + suspend fun add(device: SavedDevice) { + dataStore.edit { prefs -> + val current = decode(prefs[KEY]).toMutableList() + current.removeAll { it.host == device.host && it.port == device.port } + current.add(0, device) + prefs[KEY] = encode(current) + } + } + + suspend fun remove(device: SavedDevice) { + dataStore.edit { prefs -> + val current = decode(prefs[KEY]).toMutableList() + current.removeAll { it.id == device.id } + prefs[KEY] = encode(current) + } + } + + suspend fun clear() { + dataStore.edit { prefs -> prefs.remove(KEY) } + } + + private fun decode(raw: String?): List { + if (raw.isNullOrEmpty()) return emptyList() + return runCatching { + MuxyCodec.json.decodeFromString(ListSerializer(SavedDevice.serializer()), raw) + }.getOrDefault(emptyList()) + } + + private fun encode(list: List): String = MuxyCodec.json.encodeToString(ListSerializer(SavedDevice.serializer()), list) + + companion object { + private val KEY = stringPreferencesKey("saved_devices_v1") + + fun create(context: Context): SavedDevicesStore = SavedDevicesStore(context.applicationContext.savedDevicesDataStore) + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/BackoffPolicyTest.kt b/android/net/src/test/kotlin/com/muxy/net/BackoffPolicyTest.kt new file mode 100644 index 0000000..87c4adb --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/BackoffPolicyTest.kt @@ -0,0 +1,33 @@ +package com.muxy.net + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +class BackoffPolicyTest { + @Test + fun `attempt 0 is zero`() { + val policy = BackoffPolicy(baseMs = 100, maxMs = 5_000, jitterMs = 0, random = Random(0)) + assertEquals(0, policy.delayForAttempt(0)) + } + + @Test + fun `exponential growth with cap`() { + val policy = BackoffPolicy(baseMs = 100, maxMs = 1_000, jitterMs = 0, random = Random(0)) + assertEquals(100, policy.delayForAttempt(1)) + assertEquals(200, policy.delayForAttempt(2)) + assertEquals(400, policy.delayForAttempt(3)) + assertEquals(800, policy.delayForAttempt(4)) + assertEquals(1_000, policy.delayForAttempt(5)) + } + + @Test + fun `jitter adds random delay within range`() { + val policy = BackoffPolicy(baseMs = 100, maxMs = 5_000, jitterMs = 50, random = Random(42)) + repeat(20) { i -> + val d = policy.delayForAttempt(1) + assertTrue("delay $d out of range", d in 100..149) + } + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/DeviceCredentialsStoreTest.kt b/android/net/src/test/kotlin/com/muxy/net/DeviceCredentialsStoreTest.kt new file mode 100644 index 0000000..8a71f24 --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/DeviceCredentialsStoreTest.kt @@ -0,0 +1,129 @@ +package com.muxy.net + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.security.SecureRandom +import java.util.Base64 + +class DeviceCredentialsStoreTest { + @get:Rule val tempFolder = TemporaryFolder() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private lateinit var prefsFile: File + private lateinit var cryptoBox: FakeCryptoBox + private lateinit var store: DeviceCredentialsStore + + @Before + fun setUp() { + prefsFile = File(tempFolder.newFolder(), "credentials.preferences_pb") + cryptoBox = FakeCryptoBox() + store = + DeviceCredentialsStore( + dataStore = PreferenceDataStoreFactory.create(scope = scope) { prefsFile }, + cryptoBox = cryptoBox, + random = SecureRandom(), + ) + } + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `first load generates persistent credentials`() = + runBlocking { + val first = store.load() + val second = store.load() + + assertEquals(first.deviceID, second.deviceID) + assertEquals(first.token, second.token) + } + + @Test + fun `token is base64 of 32 random bytes`() = + runBlocking { + val credentials = store.load() + val decoded = Base64.getDecoder().decode(credentials.token) + assertEquals(32, decoded.size) + } + + @Test + fun `forget wipes persisted credentials and keystore key`() = + runBlocking { + val first = store.load() + store.forget() + + assertNull(cryptoBox.exportKey()) + val second = store.load() + assertNotEquals(first.deviceID, second.deviceID) + assertNotEquals(first.token, second.token) + assertNotNull(cryptoBox.exportKey()) + } + + @Test + fun `decrypt failure regenerates fresh credentials`() = + runBlocking { + val first = store.load() + + cryptoBox.tamperNextDecrypt = true + val second = store.load() + + assertNotEquals(first.deviceID, second.deviceID) + assertNotEquals(first.token, second.token) + } + + @Test + fun `payload encoding round-trips iv and ciphertext`() { + val payload = + EncryptedPayload( + iv = ByteArray(12).also { SecureRandom().nextBytes(it) }, + ciphertext = ByteArray(64).also { SecureRandom().nextBytes(it) }, + ) + val encoded = DeviceCredentialsStore.encodePayload(payload) + val decoded = DeviceCredentialsStore.decodePayload(encoded) + + assertNotNull(decoded) + assertArrayEquals(payload.iv, decoded!!.iv) + assertArrayEquals(payload.ciphertext, decoded.ciphertext) + } + + @Test + fun `payload decoding rejects malformed input`() { + assertNull(DeviceCredentialsStore.decodePayload("not-base64-!!!")) + assertNull(DeviceCredentialsStore.decodePayload("")) + val emptyIv = Base64.getEncoder().encodeToString(byteArrayOf(0, 1, 2, 3)) + assertNull(DeviceCredentialsStore.decodePayload(emptyIv)) + val ivOverflow = Base64.getEncoder().encodeToString(byteArrayOf(120, 1, 2)) + assertNull(DeviceCredentialsStore.decodePayload(ivOverflow)) + } + + @Test + fun `concurrent loads return identical credentials`() = + runBlocking { + val results = + coroutineScope { + (1..8).map { async { store.load() } }.map { it.await() } + } + val first = results.first() + assertTrue(results.all { it.deviceID == first.deviceID && it.token == first.token }) + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/DiagnosticLogTest.kt b/android/net/src/test/kotlin/com/muxy/net/DiagnosticLogTest.kt new file mode 100644 index 0000000..fa7144a --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/DiagnosticLogTest.kt @@ -0,0 +1,35 @@ +package com.muxy.net + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +class DiagnosticLogTest { + @Test + fun `ring buffer caps at 120 entries`() { + val log = DiagnosticLog(capacity = 120) + repeat(150) { i -> log.append("entry-$i") } + val snapshot = log.snapshot() + assertEquals(120, snapshot.size) + assertTrue(snapshot.first().contains("entry-30")) + assertTrue(snapshot.last().contains("entry-149")) + } + + @Test + fun `lastN returns suffix`() { + val log = DiagnosticLog(capacity = 120) + repeat(50) { i -> log.append("e$i") } + val tail = log.lastN(5) + assertEquals(5, tail.size) + assertTrue(tail.last().contains("e49")) + } + + @Test + fun `entries include ISO 8601 fractional seconds timestamp`() { + val log = DiagnosticLog() + log.append("hi", now = Instant.parse("2026-05-01T12:00:00.250Z")) + val line = log.snapshot().single() + assertTrue(line.startsWith("2026-05-01T12:00:00.250")) + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/FakeCryptoBox.kt b/android/net/src/test/kotlin/com/muxy/net/FakeCryptoBox.kt new file mode 100644 index 0000000..237acda --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/FakeCryptoBox.kt @@ -0,0 +1,53 @@ +package com.muxy.net + +import java.security.SecureRandom + +internal class FakeCryptoBox(initialKey: ByteArray? = null) : CryptoBox { + private val random = SecureRandom() + private var key: ByteArray? = initialKey + + var tamperNextDecrypt: Boolean = false + + override fun encrypt(plaintext: ByteArray): EncryptedPayload { + val keyBytes = + key ?: ByteArray(KEY_LENGTH).also { + random.nextBytes(it) + key = it + } + val iv = ByteArray(IV_LENGTH).also { random.nextBytes(it) } + val ciphertext = xor(plaintext, keyBytes, iv) + return EncryptedPayload(iv = iv, ciphertext = ciphertext) + } + + override fun decrypt(payload: EncryptedPayload): ByteArray { + if (tamperNextDecrypt) { + tamperNextDecrypt = false + error("simulated decryption failure") + } + val keyBytes = key ?: error("Key was deleted") + return xor(payload.ciphertext, keyBytes, payload.iv) + } + + override fun deleteKey() { + key = null + } + + fun exportKey(): ByteArray? = key?.copyOf() + + private fun xor( + input: ByteArray, + key: ByteArray, + iv: ByteArray, + ): ByteArray { + val output = ByteArray(input.size) + for (i in input.indices) { + output[i] = (input[i].toInt() xor key[i % key.size].toInt() xor iv[i % iv.size].toInt()).toByte() + } + return output + } + + private companion object { + const val KEY_LENGTH = 32 + const val IV_LENGTH = 12 + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/FakeMuxyServer.kt b/android/net/src/test/kotlin/com/muxy/net/FakeMuxyServer.kt new file mode 100644 index 0000000..bc46047 --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/FakeMuxyServer.kt @@ -0,0 +1,95 @@ +package com.muxy.net + +import com.muxy.protocol.codec.MuxyCodec +import com.muxy.protocol.envelope.MuxyMessage +import kotlinx.coroutines.flow.MutableSharedFlow +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.ByteString +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class FakeMuxyServer : AutoCloseable { + private val server = MockWebServer() + private val incoming = MutableSharedFlow(extraBufferCapacity = 64) + private val openLatch = CountDownLatch(1) + private val sockets = ConcurrentLinkedQueue() + private val received = ConcurrentLinkedQueue() + private var responder: ((MuxyMessage) -> Unit)? = null + + val host: String get() = server.hostName + val port: Int get() = server.port + + fun start( + maxConnections: Int = 4, + responder: (MuxyMessage) -> Unit, + ) { + this.responder = responder + repeat(maxConnections) { + server.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: Response, + ) { + sockets.add(webSocket) + openLatch.countDown() + } + + override fun onMessage( + webSocket: WebSocket, + text: String, + ) { + val message = MuxyCodec.decode(text) + received.add(message) + this@FakeMuxyServer.responder?.invoke(message) + } + + override fun onMessage( + webSocket: WebSocket, + bytes: ByteString, + ) { + val message = MuxyCodec.decode(bytes.utf8()) + received.add(message) + this@FakeMuxyServer.responder?.invoke(message) + } + + override fun onClosed( + webSocket: WebSocket, + code: Int, + reason: String, + ) { + sockets.remove(webSocket) + } + }, + ), + ) + } + server.start() + } + + fun awaitOpen(timeoutMs: Long = 2_000) { + require(openLatch.await(timeoutMs, TimeUnit.MILLISECONDS)) { "WebSocket never opened" } + } + + fun broadcast(message: MuxyMessage) { + sockets.forEach { it.send(MuxyCodec.encode(message)) } + } + + fun closeAll() { + sockets.forEach { it.cancel() } + sockets.clear() + } + + fun receivedMessages(): List = received.toList() + + override fun close() { + sockets.forEach { it.close(1000, null) } + server.shutdown() + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/MuxyClientNotificationsTest.kt b/android/net/src/test/kotlin/com/muxy/net/MuxyClientNotificationsTest.kt new file mode 100644 index 0000000..d8d7701 --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/MuxyClientNotificationsTest.kt @@ -0,0 +1,171 @@ +package com.muxy.net + +import com.muxy.protocol.dto.MarkNotificationReadParams +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.NotificationSourceDTO +import com.muxy.protocol.dto.PairingResultDTO +import com.muxy.protocol.envelope.MuxyError +import com.muxy.protocol.envelope.MuxyMessage +import com.muxy.protocol.envelope.MuxyMethod +import com.muxy.protocol.envelope.MuxyParams +import com.muxy.protocol.envelope.MuxyRequest +import com.muxy.protocol.envelope.MuxyResponse +import com.muxy.protocol.envelope.MuxyResult +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds + +class MuxyClientNotificationsTest { + private lateinit var server: FakeMuxyServer + private lateinit var client: MuxyClient + private val deviceID = UUID.fromString("AAAAAAAA-1111-1111-1111-111111111111") + private val token = "ZmFrZS10b2tlbg==" + private val clientID = UUID.fromString("BBBBBBBB-2222-2222-2222-222222222222") + + @Before + fun setUp() { + server = FakeMuxyServer() + client = + MuxyClient( + httpClient = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build(), + credentialsProvider = + object : DeviceCredentialsProvider { + override suspend fun load(): DeviceCredentials = DeviceCredentials(deviceID, token) + }, + ) + } + + @After + fun tearDown() { + client.close() + server.close() + } + + private fun fakeNotification( + id: UUID = UUID.randomUUID(), + timestamp: Instant = Instant.parse("2026-01-01T00:00:00Z"), + isRead: Boolean = false, + title: String = "Title", + ) = NotificationDTO( + id = id, + paneID = UUID.randomUUID(), + projectID = UUID.randomUUID(), + worktreeID = UUID.randomUUID(), + areaID = UUID.randomUUID(), + tabID = UUID.randomUUID(), + source = NotificationSourceDTO.Osc, + title = title, + body = "body", + timestamp = timestamp, + isRead = isRead, + ) + + private suspend fun startAndConnect(responder: (MuxyRequest) -> Unit) { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } else { + responder(request) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + } + + @Test + fun `refreshNotifications stores list sorted by timestamp desc`() = + runBlocking { + val older = fakeNotification(timestamp = Instant.parse("2026-01-01T00:00:00Z"), title = "older") + val newer = fakeNotification(timestamp = Instant.parse("2026-02-01T00:00:00Z"), title = "newer") + startAndConnect { request -> + if (request.method == MuxyMethod.LIST_NOTIFICATIONS) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Notifications(listOf(older, newer)), + ), + ), + ) + } + } + val ok = client.refreshNotifications() + assertTrue(ok) + val result = client.notifications.value + assertEquals(2, result.size) + assertEquals("newer", result.first().title) + assertEquals("older", result.last().title) + } + + @Test + fun `refreshNotifications returns false on server error`() = + runBlocking { + startAndConnect { request -> + if (request.method == MuxyMethod.LIST_NOTIFICATIONS) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, error = MuxyError(code = 500, message = "boom")), + ), + ) + } + } + assertFalse(client.refreshNotifications()) + assertTrue(client.notifications.value.isEmpty()) + } + + @Test + fun `markNotificationRead flips isRead in cached list`() = + runBlocking { + val target = fakeNotification(title = "target", isRead = false) + val other = fakeNotification(title = "other", isRead = false) + var seenMarkID: UUID? = null + startAndConnect { request -> + when (request.method) { + MuxyMethod.LIST_NOTIFICATIONS -> + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Notifications(listOf(target, other)), + ), + ), + ) + MuxyMethod.MARK_NOTIFICATION_READ -> { + val params = (request.params as MuxyParams.MarkNotificationRead).value + seenMarkID = (params as MarkNotificationReadParams).notificationID + server.broadcast( + MuxyMessage.Response(MuxyResponse(id = request.id, result = MuxyResult.Ok)), + ) + } + else -> Unit + } + } + assertTrue(client.refreshNotifications()) + assertTrue(client.markNotificationRead(target.id)) + assertEquals(target.id, seenMarkID) + val updated = client.notifications.value + val updatedTarget = updated.first { it.id == target.id } + val updatedOther = updated.first { it.id == other.id } + assertTrue(updatedTarget.isRead) + assertFalse(updatedOther.isRead) + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/MuxyClientTest.kt b/android/net/src/test/kotlin/com/muxy/net/MuxyClientTest.kt new file mode 100644 index 0000000..725c86c --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/MuxyClientTest.kt @@ -0,0 +1,434 @@ +package com.muxy.net + +import com.muxy.protocol.dto.PairingResultDTO +import com.muxy.protocol.dto.PaneOwnerDTO +import com.muxy.protocol.dto.PaneOwnershipEventDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.TerminalInputParams +import com.muxy.protocol.dto.TerminalOutputEventDTO +import com.muxy.protocol.envelope.MuxyError +import com.muxy.protocol.envelope.MuxyEvent +import com.muxy.protocol.envelope.MuxyEventData +import com.muxy.protocol.envelope.MuxyEventKind +import com.muxy.protocol.envelope.MuxyMessage +import com.muxy.protocol.envelope.MuxyMethod +import com.muxy.protocol.envelope.MuxyParams +import com.muxy.protocol.envelope.MuxyResponse +import com.muxy.protocol.envelope.MuxyResult +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateCoroutinesApi::class) +class MuxyClientTest { + private lateinit var server: FakeMuxyServer + private lateinit var client: MuxyClient + private val deviceID = UUID.fromString("AAAAAAAA-1111-1111-1111-111111111111") + private val token = "ZmFrZS10b2tlbg==" + private val clientID = UUID.fromString("BBBBBBBB-2222-2222-2222-222222222222") + + @Before + fun setUp() { + server = FakeMuxyServer() + client = + MuxyClient( + httpClient = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build(), + credentialsProvider = + object : DeviceCredentialsProvider { + override suspend fun load(): DeviceCredentials = DeviceCredentials(deviceID, token) + }, + ) + } + + @After + fun tearDown() { + client.close() + server.close() + } + + @Test + fun `authenticateDevice success transitions to Connected`() = + runBlocking { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + when (request.method) { + MuxyMethod.AUTHENTICATE_DEVICE -> { + val pairing = PairingResultDTO(clientID = clientID, deviceName = "Mac") + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, result = MuxyResult.Pairing(pairing)), + ), + ) + } + else -> Unit + } + } + + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + + withTimeout(2.seconds) { + client.state.first { it is ConnectionState.Connected } + } + assertEquals(clientID, client.myClientID.value) + } + + @Test + fun `401 unauthorized triggers pairing flow then connects`() = + runBlocking { + var seenAuth = false + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + when (request.method) { + MuxyMethod.AUTHENTICATE_DEVICE -> { + seenAuth = true + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, error = MuxyError(code = 401, message = "auth")), + ), + ) + } + MuxyMethod.PAIR_DEVICE -> { + val pairing = PairingResultDTO(clientID = clientID, deviceName = "Mac") + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, result = MuxyResult.Pairing(pairing)), + ), + ) + } + else -> Unit + } + } + + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + + withTimeout(2.seconds) { + client.state.first { it is ConnectionState.Connected } + } + assertTrue(seenAuth) + assertEquals(clientID, client.myClientID.value) + } + + @Test + fun `403 pairingDenied surfaces approval-denied failure`() = + runBlocking { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + when (request.method) { + MuxyMethod.AUTHENTICATE_DEVICE -> + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, error = MuxyError(code = 401, message = "auth")), + ), + ) + MuxyMethod.PAIR_DEVICE -> + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, error = MuxyError(code = 403, message = "denied")), + ), + ) + else -> Unit + } + } + + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + + val state = + withTimeout(2.seconds) { + client.state.first { it is ConnectionState.Failed } + } as ConnectionState.Failed + assertEquals("Approval denied on Mac", state.issue.message) + } + + @Test + fun `terminalInput is fire-and-forget and does not register pending request`() = + runBlocking { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + + val paneID = UUID.randomUUID() + client.sendFireAndForget( + method = MuxyMethod.TERMINAL_INPUT, + params = + MuxyParams.TerminalInput( + TerminalInputParams(paneID = paneID, bytes = "hello".toByteArray()), + ), + ) + delay(150) + val terminalInputs = + server.receivedMessages().filter { msg -> + msg is MuxyMessage.Request && msg.value.method == MuxyMethod.TERMINAL_INPUT + } + assertEquals(1, terminalInputs.size) + } + + @Test + fun `RPC round-trip listProjects returns projects payload`() = + runBlocking { + val project = + ProjectDTO( + id = UUID.randomUUID(), + name = "Muxy", + path = "/x", + sortOrder = 0, + createdAt = Instant.parse("2026-05-01T10:00:00Z"), + ) + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + when (request.method) { + MuxyMethod.AUTHENTICATE_DEVICE -> + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + MuxyMethod.LIST_PROJECTS -> + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Projects(listOf(project)), + ), + ), + ) + else -> Unit + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + + val response = client.send(MuxyMethod.LIST_PROJECTS) + assertNotNull(response) + val result = response!!.result as MuxyResult.Projects + assertEquals(listOf(project), result.value) + } + + @Test + fun `RPC times out and returns 408 when server never responds`() = + runBlocking { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + + val response = client.send(MuxyMethod.LIST_PROJECTS, timeout = 100.milliseconds) + assertNotNull(response) + assertEquals(408, response!!.error!!.code) + } + + @Test + fun `paneOwnershipChanged event updates paneOwners map`() = + runBlocking { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + + val paneID = UUID.randomUUID() + val owner = PaneOwnerDTO.Remote(deviceID = clientID, deviceName = "Pixel") + server.broadcast( + MuxyMessage.Event( + MuxyEvent( + event = MuxyEventKind.PANE_OWNERSHIP_CHANGED, + data = + MuxyEventData.PaneOwnership( + PaneOwnershipEventDTO(paneID = paneID, owner = owner), + ), + ), + ), + ) + + withTimeout(2.seconds) { + client.paneOwners.first { it.containsKey(paneID) } + } + assertEquals(owner, client.paneOwners.value[paneID]) + assertTrue(client.paneIsOwnedBySelf(paneID)) + } + + @Test + fun `terminalOutput and terminalSnapshot both flow through terminalBytes for that pane`() = + runBlocking { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + + val paneID = UUID.randomUUID() + val collected = mutableListOf() + val collector = + GlobalScope.launch(start = CoroutineStart.UNDISPATCHED) { + client.terminalBytes(paneID).collect { bytes -> collected.add(bytes) } + } + + server.broadcast( + MuxyMessage.Event( + MuxyEvent( + event = MuxyEventKind.TERMINAL_SNAPSHOT, + data = + MuxyEventData.TerminalSnapshot( + TerminalOutputEventDTO(paneID, "snap".toByteArray()), + ), + ), + ), + ) + server.broadcast( + MuxyMessage.Event( + MuxyEvent( + event = MuxyEventKind.TERMINAL_OUTPUT, + data = + MuxyEventData.TerminalOutput( + TerminalOutputEventDTO(paneID, "live".toByteArray()), + ), + ), + ), + ) + + withTimeoutOrNull(2.seconds) { + while (collected.size < 2) delay(20) + } + collector.cancel() + assertEquals(2, collected.size) + assertEquals("snap", String(collected[0])) + assertEquals("live", String(collected[1])) + } + + @Test + fun `verifyConnectionOrReconnect re-authenticates and clears paneOwners`() = + runBlocking { + var authCount = 0 + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + authCount += 1 + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + + val paneID = UUID.randomUUID() + server.broadcast( + MuxyMessage.Event( + MuxyEvent( + event = MuxyEventKind.PANE_OWNERSHIP_CHANGED, + data = + MuxyEventData.PaneOwnership( + PaneOwnershipEventDTO(paneID, PaneOwnerDTO.Remote(clientID, "Pixel")), + ), + ), + ), + ) + withTimeout(2.seconds) { client.paneOwners.first { it.containsKey(paneID) } } + + client.verifyConnectionOrReconnect() + + withTimeout(2.seconds) { + while (authCount < 2) delay(20) + } + assertTrue(client.paneOwners.value.isEmpty()) + } + + @Test + fun `disconnect cancels pending requests with cancellation error`() = + runBlocking { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + + val responseDeferred = + GlobalScope.async { + client.send(MuxyMethod.LIST_PROJECTS, timeout = 5.seconds) + } + delay(100) + client.disconnect() + val response = withTimeout(2.seconds) { responseDeferred.await() } + assertNotNull(response) + assertEquals(499, response!!.error!!.code) + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/MuxyClientVCSTest.kt b/android/net/src/test/kotlin/com/muxy/net/MuxyClientVCSTest.kt new file mode 100644 index 0000000..3b87e74 --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/MuxyClientVCSTest.kt @@ -0,0 +1,283 @@ +package com.muxy.net + +import com.muxy.protocol.dto.GitFileDTO +import com.muxy.protocol.dto.GitFileStatusDTO +import com.muxy.protocol.dto.PairingResultDTO +import com.muxy.protocol.dto.VCSBranchesDTO +import com.muxy.protocol.dto.VCSCreatePRResultDTO +import com.muxy.protocol.dto.VCSStatusDTO +import com.muxy.protocol.dto.WorktreeDTO +import com.muxy.protocol.envelope.MuxyError +import com.muxy.protocol.envelope.MuxyMessage +import com.muxy.protocol.envelope.MuxyMethod +import com.muxy.protocol.envelope.MuxyRequest +import com.muxy.protocol.envelope.MuxyResponse +import com.muxy.protocol.envelope.MuxyResult +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds + +class MuxyClientVCSTest { + private lateinit var server: FakeMuxyServer + private lateinit var client: MuxyClient + private val deviceID = UUID.fromString("AAAAAAAA-1111-1111-1111-111111111111") + private val token = "ZmFrZS10b2tlbg==" + private val clientID = UUID.fromString("BBBBBBBB-2222-2222-2222-222222222222") + private val projectID = UUID.fromString("CCCCCCCC-3333-3333-3333-333333333333") + + @Before + fun setUp() { + server = FakeMuxyServer() + client = + MuxyClient( + httpClient = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build(), + credentialsProvider = + object : DeviceCredentialsProvider { + override suspend fun load(): DeviceCredentials = DeviceCredentials(deviceID, token) + }, + ) + } + + @After + fun tearDown() { + client.close() + server.close() + } + + private suspend fun startAndConnect(responder: (MuxyRequest) -> Unit) { + server.start { incoming -> + val request = (incoming as MuxyMessage.Request).value + if (request.method == MuxyMethod.AUTHENTICATE_DEVICE) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = MuxyResult.Pairing(PairingResultDTO(clientID, "Mac")), + ), + ), + ) + } else { + responder(request) + } + } + client.connect(ConnectionTarget(server.host, server.port, "Pixel")) + withTimeout(2.seconds) { client.state.first { it is ConnectionState.Connected } } + } + + @Test + fun `fetchVCSStatus returns parsed payload on success`() = + runBlocking { + val staged = listOf(GitFileDTO(path = "a.txt", status = GitFileStatusDTO.MODIFIED)) + val changed = listOf(GitFileDTO(path = "b.txt", status = GitFileStatusDTO.UNTRACKED, isUntracked = true)) + val expected = + VCSStatusDTO( + branch = "main", + aheadCount = 1, + behindCount = 0, + hasUpstream = true, + stagedFiles = staged, + changedFiles = changed, + ) + startAndConnect { request -> + if (request.method == MuxyMethod.GET_VCS_STATUS) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, result = MuxyResult.VCSStatus(expected)), + ), + ) + } + } + assertEquals(expected, client.fetchVCSStatus(projectID)) + } + + @Test + fun `fetchVCSStatus returns null on server error`() = + runBlocking { + startAndConnect { request -> + if (request.method == MuxyMethod.GET_VCS_STATUS) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, error = MuxyError(code = 500, message = "boom")), + ), + ) + } + } + assertNull(client.fetchVCSStatus(projectID)) + } + + @Test + fun `vcsCommit succeeds when server returns ok`() = + runBlocking { + var seenCommit = false + startAndConnect { request -> + if (request.method == MuxyMethod.VCS_COMMIT) { + seenCommit = true + server.broadcast( + MuxyMessage.Response(MuxyResponse(id = request.id, result = MuxyResult.Ok)), + ) + } + } + client.vcsCommit(projectID, "msg", stageAll = false) + assertTrue(seenCommit) + } + + @Test + fun `stageFiles throws Server error when Mac responds with error`() = + runBlocking { + startAndConnect { request -> + if (request.method == MuxyMethod.VCS_STAGE_FILES) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, error = MuxyError(code = 500, message = "nope")), + ), + ) + } + } + val ex = + assertThrows(VCSClientError.Server::class.java) { + runBlocking { client.stageFiles(projectID, listOf("a.txt")) } + } + assertEquals("nope", ex.message) + } + + @Test + fun `listBranches returns parsed payload`() = + runBlocking { + val expected = VCSBranchesDTO(current = "main", locals = listOf("main", "feat/x")) + startAndConnect { request -> + if (request.method == MuxyMethod.VCS_LIST_BRANCHES) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, result = MuxyResult.VCSBranches(expected)), + ), + ) + } + } + assertEquals(expected, client.listBranches(projectID)) + } + + @Test + fun `createPullRequest returns PR result`() = + runBlocking { + val expected = VCSCreatePRResultDTO(url = "https://example/pr/1", number = 1) + startAndConnect { request -> + if (request.method == MuxyMethod.VCS_CREATE_PR) { + server.broadcast( + MuxyMessage.Response( + MuxyResponse(id = request.id, result = MuxyResult.VCSPRCreated(expected)), + ), + ) + } + } + val result = + client.createPullRequest( + projectID = projectID, + title = "feat", + body = "", + baseBranch = "main", + draft = false, + ) + assertEquals(expected, result) + } + + @Test + fun `addWorktree refreshes worktrees on success`() = + runBlocking { + val worktreeID = UUID.randomUUID() + val createdAt = Instant.parse("2026-05-01T10:00:00Z") + startAndConnect { request -> + when (request.method) { + MuxyMethod.VCS_ADD_WORKTREE -> + server.broadcast( + MuxyMessage.Response(MuxyResponse(id = request.id, result = MuxyResult.Ok)), + ) + MuxyMethod.LIST_WORKTREES -> + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = + MuxyResult.Worktrees( + listOf( + WorktreeDTO( + id = worktreeID, + name = "feature", + path = "/x", + branch = "feat", + isPrimary = false, + createdAt = createdAt, + ), + ), + ), + ), + ), + ) + else -> Unit + } + } + client.addWorktree(projectID, name = "feature", branch = "feat", createBranch = true) + val list = client.projectWorktrees.value[projectID] + assertNotNull(list) + assertEquals(1, list!!.size) + assertEquals(worktreeID, list.first().id) + } + + @Test + fun `selectProject sets activeProjectID and refreshes workspace`() = + runBlocking { + val workspaceProjectID = projectID + val workspaceWorktreeID = UUID.randomUUID() + val areaID = UUID.randomUUID() + startAndConnect { request -> + when (request.method) { + MuxyMethod.SELECT_PROJECT -> + server.broadcast( + MuxyMessage.Response(MuxyResponse(id = request.id, result = MuxyResult.Ok)), + ) + MuxyMethod.GET_WORKSPACE -> + server.broadcast( + MuxyMessage.Response( + MuxyResponse( + id = request.id, + result = + MuxyResult.Workspace( + com.muxy.protocol.dto.WorkspaceDTO( + projectID = workspaceProjectID, + worktreeID = workspaceWorktreeID, + focusedAreaID = areaID, + root = + com.muxy.protocol.dto.SplitNodeDTO.TabArea( + com.muxy.protocol.dto.TabAreaDTO( + id = areaID, + projectPath = "/x", + tabs = emptyList(), + activeTabID = null, + ), + ), + ), + ), + ), + ), + ) + else -> Unit + } + } + val ok = client.selectProject(projectID) + assertTrue(ok) + assertEquals(projectID, client.activeProjectID.value) + assertEquals(workspaceWorktreeID, client.workspace.value!!.worktreeID) + } +} diff --git a/android/net/src/test/kotlin/com/muxy/net/SavedDevicesStoreTest.kt b/android/net/src/test/kotlin/com/muxy/net/SavedDevicesStoreTest.kt new file mode 100644 index 0000000..f33efd2 --- /dev/null +++ b/android/net/src/test/kotlin/com/muxy/net/SavedDevicesStoreTest.kt @@ -0,0 +1,62 @@ +package com.muxy.net + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class SavedDevicesStoreTest { + @get:Rule val tempFolder = TemporaryFolder() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private lateinit var store: SavedDevicesStore + private lateinit var prefsFile: File + + @Before + fun setUp() { + prefsFile = File(tempFolder.newFolder(), "saved.preferences_pb") + val dataStore = PreferenceDataStoreFactory.create(scope = scope) { prefsFile } + store = SavedDevicesStore(dataStore) + } + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun `add prepends and dedupes by host port`() = + runBlocking { + store.add(SavedDevice("Mac", "10.0.0.1", 4865)) + store.add(SavedDevice("Mac2", "10.0.0.2", 4865)) + store.add(SavedDevice("Mac-renamed", "10.0.0.1", 4865)) + + val list = store.list() + assertEquals(2, list.size) + assertEquals("Mac-renamed", list[0].name) + assertEquals("10.0.0.2", list[1].host) + } + + @Test + fun `remove deletes matching device`() = + runBlocking { + val a = SavedDevice("Mac", "10.0.0.1", 4865) + val b = SavedDevice("Linux", "10.0.0.2", 4865) + store.add(a) + store.add(b) + store.remove(a) + + val list = store.list() + assertEquals(1, list.size) + assertEquals("Linux", list[0].name) + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/codec/Base64ByteArraySerializer.kt b/android/protocol/bin/main/com/muxy/protocol/codec/Base64ByteArraySerializer.kt new file mode 100644 index 0000000..b430ee3 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/codec/Base64ByteArraySerializer.kt @@ -0,0 +1,28 @@ +package com.muxy.protocol.codec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.Base64 + +object Base64ByteArraySerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Base64ByteArray", PrimitiveKind.STRING) + + private val base64Encoder = Base64.getEncoder() + private val base64Decoder = Base64.getDecoder() + + override fun serialize( + encoder: Encoder, + value: ByteArray, + ) { + encoder.encodeString(base64Encoder.encodeToString(value)) + } + + override fun deserialize(decoder: Decoder): ByteArray { + return base64Decoder.decode(decoder.decodeString()) + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/codec/InstantSerializer.kt b/android/protocol/bin/main/com/muxy/protocol/codec/InstantSerializer.kt new file mode 100644 index 0000000..0d851e0 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/codec/InstantSerializer.kt @@ -0,0 +1,26 @@ +package com.muxy.protocol.codec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant +import java.time.format.DateTimeFormatter + +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: Instant, + ) { + encoder.encodeString(DateTimeFormatter.ISO_INSTANT.format(value)) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.parse(decoder.decodeString()) + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/codec/MuxyCodec.kt b/android/protocol/bin/main/com/muxy/protocol/codec/MuxyCodec.kt new file mode 100644 index 0000000..57a5607 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/codec/MuxyCodec.kt @@ -0,0 +1,28 @@ +package com.muxy.protocol.codec + +import com.muxy.protocol.envelope.MuxyMessage +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import java.time.Instant +import java.util.UUID + +object MuxyCodec { + private val module = + SerializersModule { + contextual(UUID::class, UuidSerializer) + contextual(Instant::class, InstantSerializer) + } + + val json: Json = + Json { + explicitNulls = false + encodeDefaults = false + ignoreUnknownKeys = true + serializersModule = module + } + + fun encode(message: MuxyMessage): String = json.encodeToString(MuxyMessage.serializer(), message) + + fun decode(text: String): MuxyMessage = json.decodeFromString(MuxyMessage.serializer(), text) +} diff --git a/android/protocol/bin/main/com/muxy/protocol/codec/UuidSerializer.kt b/android/protocol/bin/main/com/muxy/protocol/codec/UuidSerializer.kt new file mode 100644 index 0000000..b8dc102 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/codec/UuidSerializer.kt @@ -0,0 +1,25 @@ +package com.muxy.protocol.codec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.UUID + +object UuidSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.util.UUID", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: UUID, + ) { + encoder.encodeString(value.toString().uppercase()) + } + + override fun deserialize(decoder: Decoder): UUID { + return UUID.fromString(decoder.decodeString()) + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/DeviceDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/DeviceDTO.kt new file mode 100644 index 0000000..d28e8d1 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/DeviceDTO.kt @@ -0,0 +1,29 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PairingResultDTO( + val clientID: @Contextual UUID, + val deviceName: String, + val themeFg: UInt? = null, + val themeBg: UInt? = null, + val themePalette: List? = null, +) + +@Serializable +data class DeviceInfoDTO( + val clientID: @Contextual UUID, + val deviceName: String, + val themeFg: UInt? = null, + val themeBg: UInt? = null, + val themePalette: List? = null, +) + +@Serializable +data class ProjectLogoDTO( + val projectID: @Contextual UUID, + val pngData: String, +) diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/NotificationDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/NotificationDTO.kt new file mode 100644 index 0000000..afe96c1 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/NotificationDTO.kt @@ -0,0 +1,90 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.time.Instant +import java.util.UUID + +@Serializable +data class NotificationDTO( + val id: @Contextual UUID, + val paneID: @Contextual UUID, + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, + val areaID: @Contextual UUID, + val tabID: @Contextual UUID, + val source: NotificationSourceDTO, + val title: String, + val body: String, + val timestamp: @Contextual Instant, + val isRead: Boolean, +) + +@Serializable(with = NotificationSourceSerializer::class) +sealed class NotificationSourceDTO { + object Osc : NotificationSourceDTO() + + object Socket : NotificationSourceDTO() + + data class AiProvider(val provider: String) : NotificationSourceDTO() +} + +object NotificationSourceSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("NotificationSourceDTO") + + override fun serialize( + encoder: Encoder, + value: NotificationSourceDTO, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("NotificationSourceSerializer only supports JSON") + val element = + when (value) { + is NotificationSourceDTO.Osc -> + buildJsonObject { + put("osc", buildJsonObject {}) + } + is NotificationSourceDTO.Socket -> + buildJsonObject { + put("socket", buildJsonObject {}) + } + is NotificationSourceDTO.AiProvider -> + buildJsonObject { + put( + "aiProvider", + buildJsonObject { + put("_0", value.provider) + }, + ) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): NotificationSourceDTO { + val input = + (decoder as? JsonDecoder) + ?: error("NotificationSourceSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + if (obj.containsKey("osc")) return NotificationSourceDTO.Osc + if (obj.containsKey("socket")) return NotificationSourceDTO.Socket + if (obj.containsKey("aiProvider")) { + val inner = obj.getValue("aiProvider").jsonObject + val provider = inner.getValue("_0").jsonPrimitive.content + return NotificationSourceDTO.AiProvider(provider) + } + error("Unknown NotificationSourceDTO shape: ${obj.keys}") + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/PaneOwnerDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/PaneOwnerDTO.kt new file mode 100644 index 0000000..dcce991 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/PaneOwnerDTO.kt @@ -0,0 +1,83 @@ +package com.muxy.protocol.dto + +import com.muxy.protocol.codec.UuidSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.UUID + +@Serializable(with = PaneOwnerSerializer::class) +sealed class PaneOwnerDTO { + abstract val displayName: String + + data class Mac(val deviceName: String) : PaneOwnerDTO() { + override val displayName: String get() = deviceName + } + + data class Remote(val deviceID: UUID, val deviceName: String) : PaneOwnerDTO() { + override val displayName: String get() = deviceName + } +} + +object PaneOwnerSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PaneOwnerDTO") + + override fun serialize( + encoder: Encoder, + value: PaneOwnerDTO, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("PaneOwnerSerializer only supports JSON") + val element = + when (value) { + is PaneOwnerDTO.Mac -> + buildJsonObject { + put( + "mac", + buildJsonObject { + put("deviceName", value.deviceName) + }, + ) + } + is PaneOwnerDTO.Remote -> + buildJsonObject { + put( + "remote", + buildJsonObject { + put("deviceID", output.json.encodeToJsonElement(UuidSerializer, value.deviceID)) + put("deviceName", value.deviceName) + }, + ) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): PaneOwnerDTO { + val input = + (decoder as? JsonDecoder) + ?: error("PaneOwnerSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + if (obj.containsKey("mac")) { + val inner = obj.getValue("mac").jsonObject + return PaneOwnerDTO.Mac(deviceName = inner.getValue("deviceName").jsonPrimitive.content) + } + if (obj.containsKey("remote")) { + val inner = obj.getValue("remote").jsonObject + val deviceID = input.json.decodeFromJsonElement(UuidSerializer, inner.getValue("deviceID")) + val deviceName = inner.getValue("deviceName").jsonPrimitive.content + return PaneOwnerDTO.Remote(deviceID = deviceID, deviceName = deviceName) + } + error("Unknown PaneOwnerDTO shape: ${obj.keys}") + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/ProjectDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/ProjectDTO.kt new file mode 100644 index 0000000..0594d86 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/ProjectDTO.kt @@ -0,0 +1,18 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +@Serializable +data class ProjectDTO( + val id: @Contextual UUID, + val name: String, + val path: String, + val sortOrder: Int, + val createdAt: @Contextual Instant, + val icon: String? = null, + val logo: String? = null, + val iconColor: String? = null, +) diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/ProjectIconColor.kt b/android/protocol/bin/main/com/muxy/protocol/dto/ProjectIconColor.kt new file mode 100644 index 0000000..fc25eee --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/ProjectIconColor.kt @@ -0,0 +1,47 @@ +package com.muxy.protocol.dto + +object ProjectIconColor { + data class Swatch(val id: String, val name: String, val hex: String) { + val prefersDarkForeground: Boolean + get() { + val rgb = rgb(fromHex = hex) ?: return false + val luminance = 0.2126 * rgb.first + 0.7152 * rgb.second + 0.0722 * rgb.third + return luminance > 0.6 + } + } + + val palette: List = + listOf( + Swatch("red", "Red", "#E5484D"), + Swatch("orange", "Orange", "#F76B15"), + Swatch("amber", "Amber", "#F5A623"), + Swatch("yellow", "Yellow", "#EBCB00"), + Swatch("lime", "Lime", "#9BCD1E"), + Swatch("green", "Green", "#30A46C"), + Swatch("teal", "Teal", "#12A594"), + Swatch("cyan", "Cyan", "#05A2C2"), + Swatch("blue", "Blue", "#3E63DD"), + Swatch("indigo", "Indigo", "#5B5BD6"), + Swatch("violet", "Violet", "#8E4EC6"), + Swatch("pink", "Pink", "#D6409F"), + ) + + private val byID: Map = palette.associateBy { it.id } + + fun swatch(forIdentifier: String?): Swatch? { + if (forIdentifier == null) return null + byID[forIdentifier]?.let { return it } + return palette.firstOrNull { it.hex.equals(forIdentifier, ignoreCase = true) } + } + + fun rgb(fromHex: String): Triple? { + var normalized = fromHex.trim() + if (normalized.startsWith("#")) normalized = normalized.removePrefix("#") + if (normalized.length != 6) return null + val value = normalized.toLongOrNull(radix = 16) ?: return null + val red = ((value shr 16) and 0xFF).toDouble() / 255.0 + val green = ((value shr 8) and 0xFF).toDouble() / 255.0 + val blue = (value and 0xFF).toDouble() / 255.0 + return Triple(red, green, blue) + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/ProtocolParams.kt b/android/protocol/bin/main/com/muxy/protocol/dto/ProtocolParams.kt new file mode 100644 index 0000000..cf24d91 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/ProtocolParams.kt @@ -0,0 +1,250 @@ +package com.muxy.protocol.dto + +import com.muxy.protocol.codec.Base64ByteArraySerializer +import com.muxy.protocol.envelope.MuxyEventKind +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class SelectProjectParams(val projectID: @Contextual UUID) + +@Serializable +data class ListWorktreesParams(val projectID: @Contextual UUID) + +@Serializable +data class SelectWorktreeParams( + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, +) + +@Serializable +data class GetWorkspaceParams(val projectID: @Contextual UUID) + +@Serializable +data class CreateTabParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID? = null, + val kind: TabKindDTO, +) + +@Serializable +data class CloseTabParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val tabID: @Contextual UUID, +) + +@Serializable +data class SelectTabParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val tabID: @Contextual UUID, +) + +@Serializable +data class SplitAreaParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val direction: SplitDirectionDTO, + val position: SplitPositionDTO, +) + +@Serializable +data class CloseAreaParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, +) + +@Serializable +data class FocusAreaParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, +) + +@Serializable +data class TerminalInputParams( + val paneID: @Contextual UUID, + val bytes: + @Serializable(with = Base64ByteArraySerializer::class) + ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TerminalInputParams) return false + return paneID == other.paneID && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = paneID.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } +} + +@Serializable +data class TerminalResizeParams( + val paneID: @Contextual UUID, + val cols: UInt, + val rows: UInt, +) + +@Serializable +data class TerminalScrollParams( + val paneID: @Contextual UUID, + val deltaX: Double, + val deltaY: Double, + val precise: Boolean, +) + +@Serializable +data class GetTerminalContentParams(val paneID: @Contextual UUID) + +@Serializable +data class RegisterDeviceParams(val deviceName: String) + +@Serializable +data class PairDeviceParams( + val deviceID: @Contextual UUID, + val deviceName: String, + val token: String, +) + +@Serializable +data class AuthenticateDeviceParams( + val deviceID: @Contextual UUID, + val deviceName: String, + val token: String, +) + +@Serializable +data class TakeOverPaneParams( + val paneID: @Contextual UUID, + val cols: UInt, + val rows: UInt, +) + +@Serializable +data class ReleasePaneParams(val paneID: @Contextual UUID) + +@Serializable +data class PaneOwnershipEventDTO( + val paneID: @Contextual UUID, + val owner: PaneOwnerDTO, +) + +@Serializable +data class DeviceThemeEventDTO( + val fg: UInt, + val bg: UInt, + val palette: List? = null, +) + +@Serializable +data class TabChangeEventDTO( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val tab: TabDTO, + val changeKind: TabChangeKind, +) { + @Serializable + enum class TabChangeKind { + @SerialName("created") + CREATED, + + @SerialName("closed") + CLOSED, + + @SerialName("selected") + SELECTED, + + @SerialName("titleChanged") + TITLE_CHANGED, + } +} + +@Serializable +data class GetVCSStatusParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSCommitParams( + val projectID: @Contextual UUID, + val message: String, + val stageAll: Boolean, +) + +@Serializable +data class VCSPushParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSPullParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSStageFilesParams( + val projectID: @Contextual UUID, + val paths: List, +) + +@Serializable +data class VCSUnstageFilesParams( + val projectID: @Contextual UUID, + val paths: List, +) + +@Serializable +data class VCSDiscardFilesParams( + val projectID: @Contextual UUID, + val paths: List, + val untrackedPaths: List, +) + +@Serializable +data class VCSListBranchesParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSSwitchBranchParams( + val projectID: @Contextual UUID, + val branch: String, +) + +@Serializable +data class VCSCreateBranchParams( + val projectID: @Contextual UUID, + val name: String, +) + +@Serializable +data class VCSCreatePRParams( + val projectID: @Contextual UUID, + val title: String, + val body: String, + val baseBranch: String? = null, + val draft: Boolean, +) + +@Serializable +data class VCSAddWorktreeParams( + val projectID: @Contextual UUID, + val name: String, + val branch: String, + val createBranch: Boolean, +) + +@Serializable +data class VCSRemoveWorktreeParams( + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, +) + +@Serializable +data class GetProjectLogoParams(val projectID: @Contextual UUID) + +@Serializable +data class MarkNotificationReadParams(val notificationID: @Contextual UUID) + +@Serializable +data class SubscribeParams(val events: List) + +@Serializable +data class UnsubscribeParams(val events: List) diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/TerminalDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/TerminalDTO.kt new file mode 100644 index 0000000..15c535b --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/TerminalDTO.kt @@ -0,0 +1,79 @@ +package com.muxy.protocol.dto + +import com.muxy.protocol.codec.Base64ByteArraySerializer +import kotlinx.serialization.Contextual +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import java.util.UUID + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class TerminalContentDTO( + val paneID: @Contextual UUID, + val content: String, + val cols: UInt, + val rows: UInt, +) + +@Serializable +data class TerminalCellDTO( + val codepoint: UInt, + val fg: UInt, + val bg: UInt, + val flags: UShort, +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class TerminalCellsDTO( + val paneID: @Contextual UUID, + val cols: UInt, + val rows: UInt, + val cursorX: UInt, + val cursorY: UInt, + val cursorVisible: Boolean, + val defaultFg: UInt, + val defaultBg: UInt, + val cells: List, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val altScreen: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val cursorKeys: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val bracketedPaste: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val focusEvent: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val mouseEvent: UShort = 0u, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val mouseFormat: UShort = 0u, +) + +object TerminalCellFlag { + val BOLD: UShort = 1u.toUShort() + val ITALIC: UShort = 2u.toUShort() + val FAINT: UShort = 4u.toUShort() + val BLINK: UShort = 8u.toUShort() + val INVERSE: UShort = 16u.toUShort() + val INVISIBLE: UShort = 32u.toUShort() + val STRIKE: UShort = 64u.toUShort() + val UNDERLINE: UShort = 128u.toUShort() + val OVERLINE: UShort = 256u.toUShort() + val WIDE: UShort = 512u.toUShort() + val SPACER: UShort = 1024u.toUShort() +} + +@Serializable +data class TerminalOutputEventDTO( + val paneID: @Contextual UUID, + val bytes: + @Serializable(with = Base64ByteArraySerializer::class) + ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TerminalOutputEventDTO) return false + return paneID == other.paneID && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = paneID.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/VCSDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/VCSDTO.kt new file mode 100644 index 0000000..e557e62 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/VCSDTO.kt @@ -0,0 +1,69 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class VCSStatusDTO( + val branch: String, + val aheadCount: Int, + val behindCount: Int, + val hasUpstream: Boolean, + val stagedFiles: List, + val changedFiles: List, + val defaultBranch: String? = null, + val pullRequest: VCSPullRequestDTO? = null, +) + +@Serializable +data class GitFileDTO( + val path: String, + val status: GitFileStatusDTO, + val isUntracked: Boolean = false, +) + +@Serializable +enum class GitFileStatusDTO { + @SerialName("added") + ADDED, + + @SerialName("modified") + MODIFIED, + + @SerialName("deleted") + DELETED, + + @SerialName("renamed") + RENAMED, + + @SerialName("copied") + COPIED, + + @SerialName("untracked") + UNTRACKED, + + @SerialName("unmerged") + UNMERGED, +} + +@Serializable +data class VCSPullRequestDTO( + val url: String, + val number: Int, + val state: String, + val isDraft: Boolean, + val baseBranch: String, +) + +@Serializable +data class VCSBranchesDTO( + val current: String, + val locals: List, + val defaultBranch: String? = null, +) + +@Serializable +data class VCSCreatePRResultDTO( + val url: String, + val number: Int, +) diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/WorkspaceDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/WorkspaceDTO.kt new file mode 100644 index 0000000..244665a --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/WorkspaceDTO.kt @@ -0,0 +1,140 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.UUID + +@Serializable +data class WorkspaceDTO( + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, + val focusedAreaID: @Contextual UUID? = null, + val root: SplitNodeDTO, +) + +@Serializable +enum class SplitDirectionDTO { + @SerialName("horizontal") + HORIZONTAL, + + @SerialName("vertical") + VERTICAL, +} + +@Serializable +enum class SplitPositionDTO { + @SerialName("first") + FIRST, + + @SerialName("second") + SECOND, +} + +@Serializable(with = SplitNodeSerializer::class) +sealed class SplitNodeDTO { + data class TabArea(val tabArea: TabAreaDTO) : SplitNodeDTO() + + data class Split(val split: SplitBranchDTO) : SplitNodeDTO() +} + +object SplitNodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("SplitNodeDTO") { + element("type") + } + + override fun serialize( + encoder: Encoder, + value: SplitNodeDTO, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("SplitNodeSerializer only supports JSON") + val element = + when (value) { + is SplitNodeDTO.TabArea -> + buildJsonObject { + put("type", "tabArea") + put("tabArea", output.json.encodeToJsonElement(TabAreaDTO.serializer(), value.tabArea)) + } + is SplitNodeDTO.Split -> + buildJsonObject { + put("type", "split") + put("split", output.json.encodeToJsonElement(SplitBranchDTO.serializer(), value.split)) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): SplitNodeDTO { + val input = + (decoder as? JsonDecoder) + ?: error("SplitNodeSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + return when (val type = obj.getValue("type").jsonPrimitive.content) { + "tabArea" -> + SplitNodeDTO.TabArea( + input.json.decodeFromJsonElement(TabAreaDTO.serializer(), obj.getValue("tabArea")), + ) + "split" -> + SplitNodeDTO.Split( + input.json.decodeFromJsonElement(SplitBranchDTO.serializer(), obj.getValue("split")), + ) + else -> error("Unknown SplitNodeDTO type: $type") + } + } +} + +@Serializable +data class SplitBranchDTO( + val id: @Contextual UUID, + val direction: SplitDirectionDTO, + val ratio: Double, + val first: SplitNodeDTO, + val second: SplitNodeDTO, +) + +@Serializable +data class TabAreaDTO( + val id: @Contextual UUID, + val projectPath: String, + val tabs: List, + val activeTabID: @Contextual UUID? = null, +) + +@Serializable +data class TabDTO( + val id: @Contextual UUID, + val kind: TabKindDTO, + val title: String, + val isPinned: Boolean, + val paneID: @Contextual UUID? = null, +) + +@Serializable +enum class TabKindDTO { + @SerialName("terminal") + TERMINAL, + + @SerialName("vcs") + VCS, + + @SerialName("editor") + EDITOR, + + @SerialName("diffViewer") + DIFF_VIEWER, +} diff --git a/android/protocol/bin/main/com/muxy/protocol/dto/WorktreeDTO.kt b/android/protocol/bin/main/com/muxy/protocol/dto/WorktreeDTO.kt new file mode 100644 index 0000000..878e959 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/dto/WorktreeDTO.kt @@ -0,0 +1,21 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class WorktreeDTO( + val id: @Contextual UUID, + val name: String, + val path: String, + val branch: String? = null, + val isPrimary: Boolean, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val canBeRemoved: Boolean = !isPrimary, + val createdAt: @Contextual Instant, +) diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyError.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyError.kt new file mode 100644 index 0000000..91b7a0c --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyError.kt @@ -0,0 +1,17 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyError( + val code: Int, + val message: String, +) { + companion object { + val notFound = MuxyError(code = 404, message = "Not found") + val invalidParams = MuxyError(code = 400, message = "Invalid parameters") + val internalError = MuxyError(code = 500, message = "Internal error") + val unauthorized = MuxyError(code = 401, message = "Authentication required") + val pairingDenied = MuxyError(code = 403, message = "Pairing denied") + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEvent.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEvent.kt new file mode 100644 index 0000000..c7621bf --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEvent.kt @@ -0,0 +1,9 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyEvent( + val event: MuxyEventKind, + val data: MuxyEventData, +) diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEventData.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEventData.kt new file mode 100644 index 0000000..72b5a4d --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEventData.kt @@ -0,0 +1,121 @@ +package com.muxy.protocol.envelope + +import com.muxy.protocol.dto.DeviceThemeEventDTO +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.PaneOwnershipEventDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.TabChangeEventDTO +import com.muxy.protocol.dto.TerminalOutputEventDTO +import com.muxy.protocol.dto.WorkspaceDTO +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyEventDataSerializer::class) +sealed class MuxyEventData { + data class Workspace(val value: WorkspaceDTO) : MuxyEventData() + + data class Tab(val value: TabChangeEventDTO) : MuxyEventData() + + data class TerminalOutput(val value: TerminalOutputEventDTO) : MuxyEventData() + + data class TerminalSnapshot(val value: TerminalOutputEventDTO) : MuxyEventData() + + data class Notification(val value: NotificationDTO) : MuxyEventData() + + data class Projects(val value: List) : MuxyEventData() + + data class PaneOwnership(val value: PaneOwnershipEventDTO) : MuxyEventData() + + data class DeviceTheme(val value: DeviceThemeEventDTO) : MuxyEventData() +} + +object MuxyEventDataSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyEventData") + + override fun serialize( + encoder: Encoder, + value: MuxyEventData, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyEventDataSerializer only supports JSON") + val json = output.json + val element = + when (value) { + is MuxyEventData.Workspace -> + buildJsonObject { + put("type", "workspace") + put("value", json.encodeToJsonElement(WorkspaceDTO.serializer(), value.value)) + } + is MuxyEventData.Tab -> + buildJsonObject { + put("type", "tab") + put("value", json.encodeToJsonElement(TabChangeEventDTO.serializer(), value.value)) + } + is MuxyEventData.TerminalOutput -> + buildJsonObject { + put("type", "terminalOutput") + put("value", json.encodeToJsonElement(TerminalOutputEventDTO.serializer(), value.value)) + } + is MuxyEventData.TerminalSnapshot -> + buildJsonObject { + put("type", "terminalSnapshot") + put("value", json.encodeToJsonElement(TerminalOutputEventDTO.serializer(), value.value)) + } + is MuxyEventData.Notification -> + buildJsonObject { + put("type", "notification") + put("value", json.encodeToJsonElement(NotificationDTO.serializer(), value.value)) + } + is MuxyEventData.Projects -> + buildJsonObject { + put("type", "projects") + put("value", json.encodeToJsonElement(ListSerializer(ProjectDTO.serializer()), value.value)) + } + is MuxyEventData.PaneOwnership -> + buildJsonObject { + put("type", "paneOwnership") + put("value", json.encodeToJsonElement(PaneOwnershipEventDTO.serializer(), value.value)) + } + is MuxyEventData.DeviceTheme -> + buildJsonObject { + put("type", "deviceTheme") + put("value", json.encodeToJsonElement(DeviceThemeEventDTO.serializer(), value.value)) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyEventData { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyEventDataSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + val value: JsonElement = obj.getValue("value") + val json = input.json + return when (type) { + "workspace" -> MuxyEventData.Workspace(json.decodeFromJsonElement(WorkspaceDTO.serializer(), value)) + "tab" -> MuxyEventData.Tab(json.decodeFromJsonElement(TabChangeEventDTO.serializer(), value)) + "terminalOutput" -> MuxyEventData.TerminalOutput(json.decodeFromJsonElement(TerminalOutputEventDTO.serializer(), value)) + "terminalSnapshot" -> MuxyEventData.TerminalSnapshot(json.decodeFromJsonElement(TerminalOutputEventDTO.serializer(), value)) + "notification" -> MuxyEventData.Notification(json.decodeFromJsonElement(NotificationDTO.serializer(), value)) + "projects" -> MuxyEventData.Projects(json.decodeFromJsonElement(ListSerializer(ProjectDTO.serializer()), value)) + "paneOwnership" -> MuxyEventData.PaneOwnership(json.decodeFromJsonElement(PaneOwnershipEventDTO.serializer(), value)) + "deviceTheme" -> MuxyEventData.DeviceTheme(json.decodeFromJsonElement(DeviceThemeEventDTO.serializer(), value)) + else -> error("Unknown MuxyEventData type: $type") + } + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEventKind.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEventKind.kt new file mode 100644 index 0000000..e6e2cdc --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyEventKind.kt @@ -0,0 +1,31 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class MuxyEventKind { + @SerialName("workspaceChanged") + WORKSPACE_CHANGED, + + @SerialName("tabChanged") + TAB_CHANGED, + + @SerialName("terminalOutput") + TERMINAL_OUTPUT, + + @SerialName("terminalSnapshot") + TERMINAL_SNAPSHOT, + + @SerialName("notificationReceived") + NOTIFICATION_RECEIVED, + + @SerialName("projectsChanged") + PROJECTS_CHANGED, + + @SerialName("paneOwnershipChanged") + PANE_OWNERSHIP_CHANGED, + + @SerialName("themeChanged") + THEME_CHANGED, +} diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyMessage.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyMessage.kt new file mode 100644 index 0000000..ebbaad8 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyMessage.kt @@ -0,0 +1,72 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyMessageSerializer::class) +sealed class MuxyMessage { + data class Request(val value: MuxyRequest) : MuxyMessage() + + data class Response(val value: MuxyResponse) : MuxyMessage() + + data class Event(val value: MuxyEvent) : MuxyMessage() +} + +object MuxyMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyMessage") + + override fun serialize( + encoder: Encoder, + value: MuxyMessage, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyMessageSerializer only supports JSON") + val json = output.json + val element = + when (value) { + is MuxyMessage.Request -> + buildJsonObject { + put("type", "request") + put("payload", json.encodeToJsonElement(MuxyRequest.serializer(), value.value)) + } + is MuxyMessage.Response -> + buildJsonObject { + put("type", "response") + put("payload", json.encodeToJsonElement(MuxyResponse.serializer(), value.value)) + } + is MuxyMessage.Event -> + buildJsonObject { + put("type", "event") + put("payload", json.encodeToJsonElement(MuxyEvent.serializer(), value.value)) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyMessage { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyMessageSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + val payload = obj.getValue("payload") + val json = input.json + return when (type) { + "request" -> MuxyMessage.Request(json.decodeFromJsonElement(MuxyRequest.serializer(), payload)) + "response" -> MuxyMessage.Response(json.decodeFromJsonElement(MuxyResponse.serializer(), payload)) + "event" -> MuxyMessage.Event(json.decodeFromJsonElement(MuxyEvent.serializer(), payload)) + else -> error("Unknown MuxyMessage type: $type") + } + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyMethod.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyMethod.kt new file mode 100644 index 0000000..77eb1e1 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyMethod.kt @@ -0,0 +1,121 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class MuxyMethod { + @SerialName("listProjects") + LIST_PROJECTS, + + @SerialName("selectProject") + SELECT_PROJECT, + + @SerialName("listWorktrees") + LIST_WORKTREES, + + @SerialName("selectWorktree") + SELECT_WORKTREE, + + @SerialName("getWorkspace") + GET_WORKSPACE, + + @SerialName("createTab") + CREATE_TAB, + + @SerialName("closeTab") + CLOSE_TAB, + + @SerialName("selectTab") + SELECT_TAB, + + @SerialName("splitArea") + SPLIT_AREA, + + @SerialName("closeArea") + CLOSE_AREA, + + @SerialName("focusArea") + FOCUS_AREA, + + @SerialName("terminalInput") + TERMINAL_INPUT, + + @SerialName("terminalResize") + TERMINAL_RESIZE, + + @SerialName("terminalScroll") + TERMINAL_SCROLL, + + @SerialName("getTerminalContent") + GET_TERMINAL_CONTENT, + + @SerialName("registerDevice") + REGISTER_DEVICE, + + @SerialName("pairDevice") + PAIR_DEVICE, + + @SerialName("authenticateDevice") + AUTHENTICATE_DEVICE, + + @SerialName("takeOverPane") + TAKE_OVER_PANE, + + @SerialName("releasePane") + RELEASE_PANE, + + @SerialName("getVCSStatus") + GET_VCS_STATUS, + + @SerialName("vcsCommit") + VCS_COMMIT, + + @SerialName("vcsPush") + VCS_PUSH, + + @SerialName("vcsPull") + VCS_PULL, + + @SerialName("vcsStageFiles") + VCS_STAGE_FILES, + + @SerialName("vcsUnstageFiles") + VCS_UNSTAGE_FILES, + + @SerialName("vcsDiscardFiles") + VCS_DISCARD_FILES, + + @SerialName("vcsListBranches") + VCS_LIST_BRANCHES, + + @SerialName("vcsSwitchBranch") + VCS_SWITCH_BRANCH, + + @SerialName("vcsCreateBranch") + VCS_CREATE_BRANCH, + + @SerialName("vcsCreatePR") + VCS_CREATE_PR, + + @SerialName("vcsAddWorktree") + VCS_ADD_WORKTREE, + + @SerialName("vcsRemoveWorktree") + VCS_REMOVE_WORKTREE, + + @SerialName("getProjectLogo") + GET_PROJECT_LOGO, + + @SerialName("listNotifications") + LIST_NOTIFICATIONS, + + @SerialName("markNotificationRead") + MARK_NOTIFICATION_READ, + + @SerialName("subscribe") + SUBSCRIBE, + + @SerialName("unsubscribe") + UNSUBSCRIBE, +} diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyParams.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyParams.kt new file mode 100644 index 0000000..1204bbd --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyParams.kt @@ -0,0 +1,251 @@ +package com.muxy.protocol.envelope + +import com.muxy.protocol.dto.AuthenticateDeviceParams +import com.muxy.protocol.dto.CloseAreaParams +import com.muxy.protocol.dto.CloseTabParams +import com.muxy.protocol.dto.CreateTabParams +import com.muxy.protocol.dto.FocusAreaParams +import com.muxy.protocol.dto.GetProjectLogoParams +import com.muxy.protocol.dto.GetTerminalContentParams +import com.muxy.protocol.dto.GetVCSStatusParams +import com.muxy.protocol.dto.GetWorkspaceParams +import com.muxy.protocol.dto.ListWorktreesParams +import com.muxy.protocol.dto.MarkNotificationReadParams +import com.muxy.protocol.dto.PairDeviceParams +import com.muxy.protocol.dto.RegisterDeviceParams +import com.muxy.protocol.dto.ReleasePaneParams +import com.muxy.protocol.dto.SelectProjectParams +import com.muxy.protocol.dto.SelectTabParams +import com.muxy.protocol.dto.SelectWorktreeParams +import com.muxy.protocol.dto.SplitAreaParams +import com.muxy.protocol.dto.SubscribeParams +import com.muxy.protocol.dto.TakeOverPaneParams +import com.muxy.protocol.dto.TerminalInputParams +import com.muxy.protocol.dto.TerminalResizeParams +import com.muxy.protocol.dto.TerminalScrollParams +import com.muxy.protocol.dto.UnsubscribeParams +import com.muxy.protocol.dto.VCSAddWorktreeParams +import com.muxy.protocol.dto.VCSCommitParams +import com.muxy.protocol.dto.VCSCreateBranchParams +import com.muxy.protocol.dto.VCSCreatePRParams +import com.muxy.protocol.dto.VCSDiscardFilesParams +import com.muxy.protocol.dto.VCSListBranchesParams +import com.muxy.protocol.dto.VCSPullParams +import com.muxy.protocol.dto.VCSPushParams +import com.muxy.protocol.dto.VCSRemoveWorktreeParams +import com.muxy.protocol.dto.VCSStageFilesParams +import com.muxy.protocol.dto.VCSSwitchBranchParams +import com.muxy.protocol.dto.VCSUnstageFilesParams +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyParamsSerializer::class) +sealed class MuxyParams { + data class SelectProject(val value: SelectProjectParams) : MuxyParams() + + data class ListWorktrees(val value: ListWorktreesParams) : MuxyParams() + + data class SelectWorktree(val value: SelectWorktreeParams) : MuxyParams() + + data class GetWorkspace(val value: GetWorkspaceParams) : MuxyParams() + + data class CreateTab(val value: CreateTabParams) : MuxyParams() + + data class CloseTab(val value: CloseTabParams) : MuxyParams() + + data class SelectTab(val value: SelectTabParams) : MuxyParams() + + data class SplitArea(val value: SplitAreaParams) : MuxyParams() + + data class CloseArea(val value: CloseAreaParams) : MuxyParams() + + data class FocusArea(val value: FocusAreaParams) : MuxyParams() + + data class TerminalInput(val value: TerminalInputParams) : MuxyParams() + + data class TerminalResize(val value: TerminalResizeParams) : MuxyParams() + + data class TerminalScroll(val value: TerminalScrollParams) : MuxyParams() + + data class GetTerminalContent(val value: GetTerminalContentParams) : MuxyParams() + + data class RegisterDevice(val value: RegisterDeviceParams) : MuxyParams() + + data class PairDevice(val value: PairDeviceParams) : MuxyParams() + + data class AuthenticateDevice(val value: AuthenticateDeviceParams) : MuxyParams() + + data class TakeOverPane(val value: TakeOverPaneParams) : MuxyParams() + + data class ReleasePane(val value: ReleasePaneParams) : MuxyParams() + + data class GetVCSStatus(val value: GetVCSStatusParams) : MuxyParams() + + data class VCSCommit(val value: VCSCommitParams) : MuxyParams() + + data class VCSPush(val value: VCSPushParams) : MuxyParams() + + data class VCSPull(val value: VCSPullParams) : MuxyParams() + + data class VCSStageFiles(val value: VCSStageFilesParams) : MuxyParams() + + data class VCSUnstageFiles(val value: VCSUnstageFilesParams) : MuxyParams() + + data class VCSDiscardFiles(val value: VCSDiscardFilesParams) : MuxyParams() + + data class VCSListBranches(val value: VCSListBranchesParams) : MuxyParams() + + data class VCSSwitchBranch(val value: VCSSwitchBranchParams) : MuxyParams() + + data class VCSCreateBranch(val value: VCSCreateBranchParams) : MuxyParams() + + data class VCSCreatePR(val value: VCSCreatePRParams) : MuxyParams() + + data class VCSAddWorktree(val value: VCSAddWorktreeParams) : MuxyParams() + + data class VCSRemoveWorktree(val value: VCSRemoveWorktreeParams) : MuxyParams() + + data class GetProjectLogo(val value: GetProjectLogoParams) : MuxyParams() + + data class MarkNotificationRead(val value: MarkNotificationReadParams) : MuxyParams() + + data class Subscribe(val value: SubscribeParams) : MuxyParams() + + data class Unsubscribe(val value: UnsubscribeParams) : MuxyParams() +} + +object MuxyParamsSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyParams") + + override fun serialize( + encoder: Encoder, + value: MuxyParams, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyParamsSerializer only supports JSON") + val (typeKey, jsonValue) = serializeBranch(output, value) + val element = + buildJsonObject { + put("type", typeKey) + put("value", jsonValue) + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyParams { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyParamsSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + val value = obj.getValue("value") + return deserializeBranch(input, type, value) + } + + private fun serializeBranch( + output: JsonEncoder, + value: MuxyParams, + ): Pair { + val json = output.json + return when (value) { + is MuxyParams.SelectProject -> "selectProject" to json.encodeToJsonElement(SelectProjectParams.serializer(), value.value) + is MuxyParams.ListWorktrees -> "listWorktrees" to json.encodeToJsonElement(ListWorktreesParams.serializer(), value.value) + is MuxyParams.SelectWorktree -> "selectWorktree" to json.encodeToJsonElement(SelectWorktreeParams.serializer(), value.value) + is MuxyParams.GetWorkspace -> "getWorkspace" to json.encodeToJsonElement(GetWorkspaceParams.serializer(), value.value) + is MuxyParams.CreateTab -> "createTab" to json.encodeToJsonElement(CreateTabParams.serializer(), value.value) + is MuxyParams.CloseTab -> "closeTab" to json.encodeToJsonElement(CloseTabParams.serializer(), value.value) + is MuxyParams.SelectTab -> "selectTab" to json.encodeToJsonElement(SelectTabParams.serializer(), value.value) + is MuxyParams.SplitArea -> "splitArea" to json.encodeToJsonElement(SplitAreaParams.serializer(), value.value) + is MuxyParams.CloseArea -> "closeArea" to json.encodeToJsonElement(CloseAreaParams.serializer(), value.value) + is MuxyParams.FocusArea -> "focusArea" to json.encodeToJsonElement(FocusAreaParams.serializer(), value.value) + is MuxyParams.TerminalInput -> "terminalInput" to json.encodeToJsonElement(TerminalInputParams.serializer(), value.value) + is MuxyParams.TerminalResize -> "terminalResize" to json.encodeToJsonElement(TerminalResizeParams.serializer(), value.value) + is MuxyParams.TerminalScroll -> "terminalScroll" to json.encodeToJsonElement(TerminalScrollParams.serializer(), value.value) + is MuxyParams.GetTerminalContent -> "getTerminalContent" to json.encodeToJsonElement(GetTerminalContentParams.serializer(), value.value) + is MuxyParams.RegisterDevice -> "registerDevice" to json.encodeToJsonElement(RegisterDeviceParams.serializer(), value.value) + is MuxyParams.PairDevice -> "pairDevice" to json.encodeToJsonElement(PairDeviceParams.serializer(), value.value) + is MuxyParams.AuthenticateDevice -> "authenticateDevice" to json.encodeToJsonElement(AuthenticateDeviceParams.serializer(), value.value) + is MuxyParams.TakeOverPane -> "takeOverPane" to json.encodeToJsonElement(TakeOverPaneParams.serializer(), value.value) + is MuxyParams.ReleasePane -> "releasePane" to json.encodeToJsonElement(ReleasePaneParams.serializer(), value.value) + is MuxyParams.GetVCSStatus -> "getVCSStatus" to json.encodeToJsonElement(GetVCSStatusParams.serializer(), value.value) + is MuxyParams.VCSCommit -> "vcsCommit" to json.encodeToJsonElement(VCSCommitParams.serializer(), value.value) + is MuxyParams.VCSPush -> "vcsPush" to json.encodeToJsonElement(VCSPushParams.serializer(), value.value) + is MuxyParams.VCSPull -> "vcsPull" to json.encodeToJsonElement(VCSPullParams.serializer(), value.value) + is MuxyParams.VCSStageFiles -> "vcsStageFiles" to json.encodeToJsonElement(VCSStageFilesParams.serializer(), value.value) + is MuxyParams.VCSUnstageFiles -> "vcsUnstageFiles" to json.encodeToJsonElement(VCSUnstageFilesParams.serializer(), value.value) + is MuxyParams.VCSDiscardFiles -> "vcsDiscardFiles" to json.encodeToJsonElement(VCSDiscardFilesParams.serializer(), value.value) + is MuxyParams.VCSListBranches -> "vcsListBranches" to json.encodeToJsonElement(VCSListBranchesParams.serializer(), value.value) + is MuxyParams.VCSSwitchBranch -> "vcsSwitchBranch" to json.encodeToJsonElement(VCSSwitchBranchParams.serializer(), value.value) + is MuxyParams.VCSCreateBranch -> "vcsCreateBranch" to json.encodeToJsonElement(VCSCreateBranchParams.serializer(), value.value) + is MuxyParams.VCSCreatePR -> "vcsCreatePR" to json.encodeToJsonElement(VCSCreatePRParams.serializer(), value.value) + is MuxyParams.VCSAddWorktree -> "vcsAddWorktree" to json.encodeToJsonElement(VCSAddWorktreeParams.serializer(), value.value) + is MuxyParams.VCSRemoveWorktree -> "vcsRemoveWorktree" to json.encodeToJsonElement(VCSRemoveWorktreeParams.serializer(), value.value) + is MuxyParams.GetProjectLogo -> "getProjectLogo" to json.encodeToJsonElement(GetProjectLogoParams.serializer(), value.value) + is MuxyParams.MarkNotificationRead -> "markNotificationRead" to json.encodeToJsonElement(MarkNotificationReadParams.serializer(), value.value) + is MuxyParams.Subscribe -> "subscribe" to json.encodeToJsonElement(SubscribeParams.serializer(), value.value) + is MuxyParams.Unsubscribe -> "unsubscribe" to json.encodeToJsonElement(UnsubscribeParams.serializer(), value.value) + } + } + + private fun deserializeBranch( + input: JsonDecoder, + type: String, + value: JsonElement, + ): MuxyParams { + val json = input.json + return when (type) { + "selectProject" -> MuxyParams.SelectProject(json.decodeFromJsonElement(SelectProjectParams.serializer(), value)) + "listWorktrees" -> MuxyParams.ListWorktrees(json.decodeFromJsonElement(ListWorktreesParams.serializer(), value)) + "selectWorktree" -> MuxyParams.SelectWorktree(json.decodeFromJsonElement(SelectWorktreeParams.serializer(), value)) + "getWorkspace" -> MuxyParams.GetWorkspace(json.decodeFromJsonElement(GetWorkspaceParams.serializer(), value)) + "createTab" -> MuxyParams.CreateTab(json.decodeFromJsonElement(CreateTabParams.serializer(), value)) + "closeTab" -> MuxyParams.CloseTab(json.decodeFromJsonElement(CloseTabParams.serializer(), value)) + "selectTab" -> MuxyParams.SelectTab(json.decodeFromJsonElement(SelectTabParams.serializer(), value)) + "splitArea" -> MuxyParams.SplitArea(json.decodeFromJsonElement(SplitAreaParams.serializer(), value)) + "closeArea" -> MuxyParams.CloseArea(json.decodeFromJsonElement(CloseAreaParams.serializer(), value)) + "focusArea" -> MuxyParams.FocusArea(json.decodeFromJsonElement(FocusAreaParams.serializer(), value)) + "terminalInput" -> MuxyParams.TerminalInput(json.decodeFromJsonElement(TerminalInputParams.serializer(), value)) + "terminalResize" -> MuxyParams.TerminalResize(json.decodeFromJsonElement(TerminalResizeParams.serializer(), value)) + "terminalScroll" -> MuxyParams.TerminalScroll(json.decodeFromJsonElement(TerminalScrollParams.serializer(), value)) + "getTerminalContent" -> MuxyParams.GetTerminalContent(json.decodeFromJsonElement(GetTerminalContentParams.serializer(), value)) + "registerDevice" -> MuxyParams.RegisterDevice(json.decodeFromJsonElement(RegisterDeviceParams.serializer(), value)) + "pairDevice" -> MuxyParams.PairDevice(json.decodeFromJsonElement(PairDeviceParams.serializer(), value)) + "authenticateDevice" -> MuxyParams.AuthenticateDevice(json.decodeFromJsonElement(AuthenticateDeviceParams.serializer(), value)) + "takeOverPane" -> MuxyParams.TakeOverPane(json.decodeFromJsonElement(TakeOverPaneParams.serializer(), value)) + "releasePane" -> MuxyParams.ReleasePane(json.decodeFromJsonElement(ReleasePaneParams.serializer(), value)) + "getVCSStatus" -> MuxyParams.GetVCSStatus(json.decodeFromJsonElement(GetVCSStatusParams.serializer(), value)) + "vcsCommit" -> MuxyParams.VCSCommit(json.decodeFromJsonElement(VCSCommitParams.serializer(), value)) + "vcsPush" -> MuxyParams.VCSPush(json.decodeFromJsonElement(VCSPushParams.serializer(), value)) + "vcsPull" -> MuxyParams.VCSPull(json.decodeFromJsonElement(VCSPullParams.serializer(), value)) + "vcsStageFiles" -> MuxyParams.VCSStageFiles(json.decodeFromJsonElement(VCSStageFilesParams.serializer(), value)) + "vcsUnstageFiles" -> MuxyParams.VCSUnstageFiles(json.decodeFromJsonElement(VCSUnstageFilesParams.serializer(), value)) + "vcsDiscardFiles" -> MuxyParams.VCSDiscardFiles(json.decodeFromJsonElement(VCSDiscardFilesParams.serializer(), value)) + "vcsListBranches" -> MuxyParams.VCSListBranches(json.decodeFromJsonElement(VCSListBranchesParams.serializer(), value)) + "vcsSwitchBranch" -> MuxyParams.VCSSwitchBranch(json.decodeFromJsonElement(VCSSwitchBranchParams.serializer(), value)) + "vcsCreateBranch" -> MuxyParams.VCSCreateBranch(json.decodeFromJsonElement(VCSCreateBranchParams.serializer(), value)) + "vcsCreatePR" -> MuxyParams.VCSCreatePR(json.decodeFromJsonElement(VCSCreatePRParams.serializer(), value)) + "vcsAddWorktree" -> MuxyParams.VCSAddWorktree(json.decodeFromJsonElement(VCSAddWorktreeParams.serializer(), value)) + "vcsRemoveWorktree" -> MuxyParams.VCSRemoveWorktree(json.decodeFromJsonElement(VCSRemoveWorktreeParams.serializer(), value)) + "getProjectLogo" -> MuxyParams.GetProjectLogo(json.decodeFromJsonElement(GetProjectLogoParams.serializer(), value)) + "markNotificationRead" -> + MuxyParams.MarkNotificationRead( + json.decodeFromJsonElement(MarkNotificationReadParams.serializer(), value), + ) + "subscribe" -> MuxyParams.Subscribe(json.decodeFromJsonElement(SubscribeParams.serializer(), value)) + "unsubscribe" -> MuxyParams.Unsubscribe(json.decodeFromJsonElement(UnsubscribeParams.serializer(), value)) + else -> error("Unknown MuxyParams type: $type") + } + } +} diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyRequest.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyRequest.kt new file mode 100644 index 0000000..fcf0f4f --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyRequest.kt @@ -0,0 +1,10 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyRequest( + val id: String, + val method: MuxyMethod, + val params: MuxyParams? = null, +) diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyResponse.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyResponse.kt new file mode 100644 index 0000000..4a02e63 --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyResponse.kt @@ -0,0 +1,10 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyResponse( + val id: String, + val result: MuxyResult? = null, + val error: MuxyError? = null, +) diff --git a/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyResult.kt b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyResult.kt new file mode 100644 index 0000000..dfd729f --- /dev/null +++ b/android/protocol/bin/main/com/muxy/protocol/envelope/MuxyResult.kt @@ -0,0 +1,183 @@ +package com.muxy.protocol.envelope + +import com.muxy.protocol.dto.DeviceInfoDTO +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.PairingResultDTO +import com.muxy.protocol.dto.PaneOwnerDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.ProjectLogoDTO +import com.muxy.protocol.dto.TabDTO +import com.muxy.protocol.dto.TerminalCellsDTO +import com.muxy.protocol.dto.TerminalContentDTO +import com.muxy.protocol.dto.VCSBranchesDTO +import com.muxy.protocol.dto.VCSCreatePRResultDTO +import com.muxy.protocol.dto.VCSStatusDTO +import com.muxy.protocol.dto.WorkspaceDTO +import com.muxy.protocol.dto.WorktreeDTO +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyResultSerializer::class) +sealed class MuxyResult { + data class Projects(val value: List) : MuxyResult() + + data class Worktrees(val value: List) : MuxyResult() + + data class Workspace(val value: WorkspaceDTO) : MuxyResult() + + data class Tab(val value: TabDTO) : MuxyResult() + + data class TerminalContent(val value: TerminalContentDTO) : MuxyResult() + + data class TerminalCells(val value: TerminalCellsDTO) : MuxyResult() + + data class DeviceInfo(val value: DeviceInfoDTO) : MuxyResult() + + data class Pairing(val value: PairingResultDTO) : MuxyResult() + + data class PaneOwner(val value: PaneOwnerDTO) : MuxyResult() + + data class VCSStatus(val value: VCSStatusDTO) : MuxyResult() + + data class VCSBranches(val value: VCSBranchesDTO) : MuxyResult() + + data class VCSPRCreated(val value: VCSCreatePRResultDTO) : MuxyResult() + + data class ProjectLogo(val value: ProjectLogoDTO) : MuxyResult() + + data class Notifications(val value: List) : MuxyResult() + + object Ok : MuxyResult() +} + +object MuxyResultSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyResult") + + override fun serialize( + encoder: Encoder, + value: MuxyResult, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyResultSerializer only supports JSON") + val json = output.json + val element = + when (value) { + is MuxyResult.Projects -> + buildJsonObject { + put("type", "projects") + put("value", json.encodeToJsonElement(ListSerializer(ProjectDTO.serializer()), value.value)) + } + is MuxyResult.Worktrees -> + buildJsonObject { + put("type", "worktrees") + put("value", json.encodeToJsonElement(ListSerializer(WorktreeDTO.serializer()), value.value)) + } + is MuxyResult.Workspace -> + buildJsonObject { + put("type", "workspace") + put("value", json.encodeToJsonElement(WorkspaceDTO.serializer(), value.value)) + } + is MuxyResult.Tab -> + buildJsonObject { + put("type", "tab") + put("value", json.encodeToJsonElement(TabDTO.serializer(), value.value)) + } + is MuxyResult.TerminalContent -> + buildJsonObject { + put("type", "terminalContent") + put("value", json.encodeToJsonElement(TerminalContentDTO.serializer(), value.value)) + } + is MuxyResult.TerminalCells -> + buildJsonObject { + put("type", "terminalCells") + put("value", json.encodeToJsonElement(TerminalCellsDTO.serializer(), value.value)) + } + is MuxyResult.DeviceInfo -> + buildJsonObject { + put("type", "deviceInfo") + put("value", json.encodeToJsonElement(DeviceInfoDTO.serializer(), value.value)) + } + is MuxyResult.Pairing -> + buildJsonObject { + put("type", "pairing") + put("value", json.encodeToJsonElement(PairingResultDTO.serializer(), value.value)) + } + is MuxyResult.PaneOwner -> + buildJsonObject { + put("type", "paneOwner") + put("value", json.encodeToJsonElement(PaneOwnerDTO.serializer(), value.value)) + } + is MuxyResult.VCSStatus -> + buildJsonObject { + put("type", "vcsStatus") + put("value", json.encodeToJsonElement(VCSStatusDTO.serializer(), value.value)) + } + is MuxyResult.VCSBranches -> + buildJsonObject { + put("type", "vcsBranches") + put("value", json.encodeToJsonElement(VCSBranchesDTO.serializer(), value.value)) + } + is MuxyResult.VCSPRCreated -> + buildJsonObject { + put("type", "vcsPRCreated") + put("value", json.encodeToJsonElement(VCSCreatePRResultDTO.serializer(), value.value)) + } + is MuxyResult.ProjectLogo -> + buildJsonObject { + put("type", "projectLogo") + put("value", json.encodeToJsonElement(ProjectLogoDTO.serializer(), value.value)) + } + is MuxyResult.Notifications -> + buildJsonObject { + put("type", "notifications") + put("value", json.encodeToJsonElement(ListSerializer(NotificationDTO.serializer()), value.value)) + } + is MuxyResult.Ok -> + buildJsonObject { + put("type", "ok") + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyResult { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyResultSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + if (type == "ok") return MuxyResult.Ok + val value: JsonElement = obj.getValue("value") + val json = input.json + return when (type) { + "projects" -> MuxyResult.Projects(json.decodeFromJsonElement(ListSerializer(ProjectDTO.serializer()), value)) + "worktrees" -> MuxyResult.Worktrees(json.decodeFromJsonElement(ListSerializer(WorktreeDTO.serializer()), value)) + "workspace" -> MuxyResult.Workspace(json.decodeFromJsonElement(WorkspaceDTO.serializer(), value)) + "tab" -> MuxyResult.Tab(json.decodeFromJsonElement(TabDTO.serializer(), value)) + "terminalContent" -> MuxyResult.TerminalContent(json.decodeFromJsonElement(TerminalContentDTO.serializer(), value)) + "terminalCells" -> MuxyResult.TerminalCells(json.decodeFromJsonElement(TerminalCellsDTO.serializer(), value)) + "deviceInfo" -> MuxyResult.DeviceInfo(json.decodeFromJsonElement(DeviceInfoDTO.serializer(), value)) + "pairing" -> MuxyResult.Pairing(json.decodeFromJsonElement(PairingResultDTO.serializer(), value)) + "paneOwner" -> MuxyResult.PaneOwner(json.decodeFromJsonElement(PaneOwnerDTO.serializer(), value)) + "vcsStatus" -> MuxyResult.VCSStatus(json.decodeFromJsonElement(VCSStatusDTO.serializer(), value)) + "vcsBranches" -> MuxyResult.VCSBranches(json.decodeFromJsonElement(VCSBranchesDTO.serializer(), value)) + "vcsPRCreated" -> MuxyResult.VCSPRCreated(json.decodeFromJsonElement(VCSCreatePRResultDTO.serializer(), value)) + "projectLogo" -> MuxyResult.ProjectLogo(json.decodeFromJsonElement(ProjectLogoDTO.serializer(), value)) + "notifications" -> MuxyResult.Notifications(json.decodeFromJsonElement(ListSerializer(NotificationDTO.serializer()), value)) + else -> error("Unknown MuxyResult type: $type") + } + } +} diff --git a/android/protocol/bin/test/com/muxy/protocol/MuxyCodecTest.kt b/android/protocol/bin/test/com/muxy/protocol/MuxyCodecTest.kt new file mode 100644 index 0000000..01a8f69 --- /dev/null +++ b/android/protocol/bin/test/com/muxy/protocol/MuxyCodecTest.kt @@ -0,0 +1,481 @@ +package com.muxy.protocol + +import com.muxy.protocol.codec.MuxyCodec +import com.muxy.protocol.dto.AuthenticateDeviceParams +import com.muxy.protocol.dto.GitFileStatusDTO +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.NotificationSourceDTO +import com.muxy.protocol.dto.PairingResultDTO +import com.muxy.protocol.dto.PaneOwnerDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.SelectProjectParams +import com.muxy.protocol.dto.SplitBranchDTO +import com.muxy.protocol.dto.SplitDirectionDTO +import com.muxy.protocol.dto.SplitNodeDTO +import com.muxy.protocol.dto.TabAreaDTO +import com.muxy.protocol.dto.TabDTO +import com.muxy.protocol.dto.TabKindDTO +import com.muxy.protocol.dto.TerminalInputParams +import com.muxy.protocol.dto.VCSStatusDTO +import com.muxy.protocol.dto.WorkspaceDTO +import com.muxy.protocol.dto.WorktreeDTO +import com.muxy.protocol.envelope.MuxyError +import com.muxy.protocol.envelope.MuxyEventData +import com.muxy.protocol.envelope.MuxyEventKind +import com.muxy.protocol.envelope.MuxyMessage +import com.muxy.protocol.envelope.MuxyMethod +import com.muxy.protocol.envelope.MuxyParams +import com.muxy.protocol.envelope.MuxyRequest +import com.muxy.protocol.envelope.MuxyResponse +import com.muxy.protocol.envelope.MuxyResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.util.UUID + +class MuxyCodecTest { + private val timestamp: Instant = Instant.parse("2026-05-01T12:34:56Z") + private val projectID = UUID.fromString("11111111-1111-1111-1111-111111111111") + private val worktreeID = UUID.fromString("22222222-2222-2222-2222-222222222222") + private val areaID = UUID.fromString("33333333-3333-3333-3333-333333333333") + private val tabID = UUID.fromString("44444444-4444-4444-4444-444444444444") + private val paneID = UUID.fromString("55555555-5555-5555-5555-555555555555") + private val deviceID = UUID.fromString("66666666-6666-6666-6666-666666666666") + private val clientID = UUID.fromString("77777777-7777-7777-7777-777777777777") + + @Test + fun `request envelope serializes type and payload`() { + val request = + MuxyRequest( + id = "req-1", + method = MuxyMethod.LIST_PROJECTS, + params = null, + ) + val message = MuxyMessage.Request(request) + val text = MuxyCodec.encode(message) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals("request", obj.getValue("type").jsonPrimitive.content) + val payload = obj.getValue("payload").jsonObject + assertEquals("req-1", payload.getValue("id").jsonPrimitive.content) + assertEquals("listProjects", payload.getValue("method").jsonPrimitive.content) + assertNull(payload["params"]) + } + + @Test + fun `response envelope encodes ok result without value key`() { + val response = MuxyResponse(id = "r-1", result = MuxyResult.Ok) + val message = MuxyMessage.Response(response) + val obj = Json.parseToJsonElement(MuxyCodec.encode(message)).jsonObject + assertEquals("response", obj.getValue("type").jsonPrimitive.content) + val payload = obj.getValue("payload").jsonObject + val result = payload.getValue("result").jsonObject + assertEquals("ok", result.getValue("type").jsonPrimitive.content) + assertNull(result["value"]) + } + + @Test + fun `response envelope decodes ok shape`() { + val raw = + """ + {"type":"response","payload":{"id":"r-2","result":{"type":"ok"}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + assertTrue(message is MuxyMessage.Response) + val response = (message as MuxyMessage.Response).value + assertEquals("r-2", response.id) + assertTrue(response.result is MuxyResult.Ok) + assertNull(response.error) + } + + @Test + fun `error response decodes code and message`() { + val raw = + """ + {"type":"response","payload":{"id":"r-3","error":{"code":401,"message":"Authentication required"}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + val response = (message as MuxyMessage.Response).value + assertEquals(MuxyError(code = 401, message = "Authentication required"), response.error) + assertNull(response.result) + } + + @Test + fun `params shape uses inner type and value keys`() { + val request = + MuxyRequest( + id = "p-1", + method = MuxyMethod.SELECT_PROJECT, + params = MuxyParams.SelectProject(SelectProjectParams(projectID)), + ) + val message = MuxyMessage.Request(request) + val obj = Json.parseToJsonElement(MuxyCodec.encode(message)).jsonObject + val params = obj.getValue("payload").jsonObject.getValue("params").jsonObject + assertEquals("selectProject", params.getValue("type").jsonPrimitive.content) + val value = params.getValue("value").jsonObject + assertEquals(projectID.toString().uppercase(), value.getValue("projectID").jsonPrimitive.content) + } + + @Test + fun `terminalInput params encodes bytes as base64 string`() { + val bytes = byteArrayOf(0x68, 0x69, 0x0A) + val params = MuxyParams.TerminalInput(TerminalInputParams(paneID, bytes)) + val request = MuxyRequest(id = "t-1", method = MuxyMethod.TERMINAL_INPUT, params = params) + val text = MuxyCodec.encode(MuxyMessage.Request(request)) + val obj = Json.parseToJsonElement(text).jsonObject + val value = + obj.getValue("payload").jsonObject + .getValue("params").jsonObject + .getValue("value").jsonObject + val encoded = value.getValue("bytes").jsonPrimitive.content + assertEquals("aGkK", encoded) + + val roundTrip = MuxyCodec.decode(text) + val decoded = ((roundTrip as MuxyMessage.Request).value.params as MuxyParams.TerminalInput).value + assertTrue(decoded.bytes.contentEquals(bytes)) + } + + @Test + fun `auth params round-trip preserves uppercase UUID and string token`() { + val params = + MuxyParams.AuthenticateDevice( + AuthenticateDeviceParams( + deviceID = deviceID, + deviceName = "Pixel 8", + token = "ZmFrZS10b2tlbg==", + ), + ) + val request = MuxyRequest(id = "a-1", method = MuxyMethod.AUTHENTICATE_DEVICE, params = params) + val text = MuxyCodec.encode(MuxyMessage.Request(request)) + val value = + Json.parseToJsonElement(text).jsonObject + .getValue("payload").jsonObject + .getValue("params").jsonObject + .getValue("value").jsonObject + assertEquals(deviceID.toString().uppercase(), value.getValue("deviceID").jsonPrimitive.content) + assertEquals("ZmFrZS10b2tlbg==", value.getValue("token").jsonPrimitive.content) + } + + @Test + fun `pairing result roundtrip preserves theme palette`() { + val pairing = + PairingResultDTO( + clientID = clientID, + deviceName = "Mac", + themeFg = 0xFFEEDDu, + themeBg = 0x101010u, + themePalette = listOf(0u, 1u, 2u, 3u, 0xFFFFFFu), + ) + val response = MuxyResponse(id = "x-1", result = MuxyResult.Pairing(pairing)) + val text = MuxyCodec.encode(MuxyMessage.Response(response)) + val decoded = MuxyCodec.decode(text) + val out = ((decoded as MuxyMessage.Response).value.result as MuxyResult.Pairing).value + assertEquals(pairing, out) + } + + @Test + fun `splitNode tabArea wire shape uses keyed inner field`() { + val tabArea = + TabAreaDTO( + id = areaID, + projectPath = "/p", + tabs = + listOf( + TabDTO(id = tabID, kind = TabKindDTO.TERMINAL, title = "zsh", isPinned = false, paneID = paneID), + ), + activeTabID = tabID, + ) + val node: SplitNodeDTO = SplitNodeDTO.TabArea(tabArea) + val text = MuxyCodec.json.encodeToString(SplitNodeDTO.serializer(), node) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals("tabArea", obj.getValue("type").jsonPrimitive.content) + assertNotNull(obj["tabArea"]) + assertNull(obj["value"]) + } + + @Test + fun `splitNode split wire shape uses keyed inner field`() { + val branch = + SplitBranchDTO( + id = areaID, + direction = SplitDirectionDTO.HORIZONTAL, + ratio = 0.5, + first = SplitNodeDTO.TabArea(emptyArea(areaID)), + second = SplitNodeDTO.TabArea(emptyArea(UUID.fromString("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"))), + ) + val node: SplitNodeDTO = SplitNodeDTO.Split(branch) + val text = MuxyCodec.json.encodeToString(SplitNodeDTO.serializer(), node) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals("split", obj.getValue("type").jsonPrimitive.content) + assertNotNull(obj["split"]) + } + + @Test + fun `workspace round-trips through nested splits`() { + val ws = + WorkspaceDTO( + projectID = projectID, + worktreeID = worktreeID, + focusedAreaID = areaID, + root = + SplitNodeDTO.Split( + SplitBranchDTO( + id = areaID, + direction = SplitDirectionDTO.VERTICAL, + ratio = 0.4, + first = SplitNodeDTO.TabArea(emptyArea(areaID)), + second = SplitNodeDTO.TabArea(emptyArea(tabID)), + ), + ), + ) + val text = MuxyCodec.json.encodeToString(WorkspaceDTO.serializer(), ws) + val parsed = MuxyCodec.json.decodeFromString(WorkspaceDTO.serializer(), text) + assertEquals(ws, parsed) + } + + @Test + fun `paneOwner mac uses single-key wire shape`() { + val owner: PaneOwnerDTO = PaneOwnerDTO.Mac(deviceName = "MacBook") + val text = MuxyCodec.json.encodeToString(PaneOwnerDTO.serializer(), owner) + val obj = Json.parseToJsonElement(text).jsonObject + assertNotNull(obj["mac"]) + assertEquals("MacBook", obj.getValue("mac").jsonObject.getValue("deviceName").jsonPrimitive.content) + val parsed = MuxyCodec.json.decodeFromString(PaneOwnerDTO.serializer(), text) + assertEquals(owner, parsed) + } + + @Test + fun `paneOwner remote serializes UUID and name`() { + val owner: PaneOwnerDTO = PaneOwnerDTO.Remote(deviceID = deviceID, deviceName = "Pixel") + val text = MuxyCodec.json.encodeToString(PaneOwnerDTO.serializer(), owner) + val obj = Json.parseToJsonElement(text).jsonObject + val inner = obj.getValue("remote").jsonObject + assertEquals(deviceID.toString().uppercase(), inner.getValue("deviceID").jsonPrimitive.content) + assertEquals("Pixel", inner.getValue("deviceName").jsonPrimitive.content) + val parsed = MuxyCodec.json.decodeFromString(PaneOwnerDTO.serializer(), text) + assertEquals(owner, parsed) + } + + @Test + fun `pane ownership event decodes from wire shape`() { + val raw = + """ + {"type":"event","payload":{"event":"paneOwnershipChanged","data":{"type":"paneOwnership","value":{"paneID":"55555555-5555-5555-5555-555555555555","owner":{"remote":{"deviceID":"66666666-6666-6666-6666-666666666666","deviceName":"Phone"}}}}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + val event = (message as MuxyMessage.Event).value + assertEquals(MuxyEventKind.PANE_OWNERSHIP_CHANGED, event.event) + val data = event.data as MuxyEventData.PaneOwnership + assertEquals(paneID, data.value.paneID) + assertEquals(PaneOwnerDTO.Remote(deviceID, "Phone"), data.value.owner) + } + + @Test + fun `terminal output event decodes base64 bytes`() { + val raw = + """ + {"type":"event","payload":{"event":"terminalOutput","data":{"type":"terminalOutput","value":{"paneID":"55555555-5555-5555-5555-555555555555","bytes":"aGkK"}}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + val data = (message as MuxyMessage.Event).value.data as MuxyEventData.TerminalOutput + assertTrue(data.value.bytes.contentEquals(byteArrayOf(0x68, 0x69, 0x0A))) + } + + @Test + fun `terminal snapshot event matches output event shape`() { + val raw = + """ + {"type":"event","payload":{"event":"terminalSnapshot","data":{"type":"terminalSnapshot","value":{"paneID":"55555555-5555-5555-5555-555555555555","bytes":"aGVsbG8="}}}} + """.trimIndent() + val event = (MuxyCodec.decode(raw) as MuxyMessage.Event).value + assertEquals(MuxyEventKind.TERMINAL_SNAPSHOT, event.event) + val data = event.data as MuxyEventData.TerminalSnapshot + assertTrue(data.value.bytes.contentEquals("hello".toByteArray())) + } + + @Test + fun `theme changed event maps to deviceTheme data case`() { + val raw = + """ + {"type":"event","payload":{"event":"themeChanged","data":{"type":"deviceTheme","value":{"fg":16777215,"bg":0,"palette":[0,1,2]}}}} + """.trimIndent() + val event = (MuxyCodec.decode(raw) as MuxyMessage.Event).value + assertEquals(MuxyEventKind.THEME_CHANGED, event.event) + val data = event.data as MuxyEventData.DeviceTheme + assertEquals(0xFFFFFFu, data.value.fg) + assertEquals(0u, data.value.bg) + assertEquals(listOf(0u, 1u, 2u), data.value.palette) + } + + @Test + fun `notification source aiProvider uses _0 unlabeled key`() { + val notification = + NotificationDTO( + id = UUID.randomUUID(), + paneID = paneID, + projectID = projectID, + worktreeID = worktreeID, + areaID = areaID, + tabID = tabID, + source = NotificationSourceDTO.AiProvider("openai"), + title = "Title", + body = "Body", + timestamp = timestamp, + isRead = false, + ) + val text = MuxyCodec.json.encodeToString(NotificationDTO.serializer(), notification) + val source = Json.parseToJsonElement(text).jsonObject.getValue("source").jsonObject + val provider = source.getValue("aiProvider").jsonObject.getValue("_0").jsonPrimitive.content + assertEquals("openai", provider) + val parsed = MuxyCodec.json.decodeFromString(NotificationDTO.serializer(), text) + assertEquals(notification, parsed) + } + + @Test + fun `notification source osc and socket are object-empty`() { + val sources = listOf(NotificationSourceDTO.Osc, NotificationSourceDTO.Socket) + for (source in sources) { + val text = MuxyCodec.json.encodeToString(NotificationSourceDTO.serializer(), source) + val obj = Json.parseToJsonElement(text).jsonObject + val key = if (source is NotificationSourceDTO.Osc) "osc" else "socket" + assertNotNull(obj[key]) + val parsed = MuxyCodec.json.decodeFromString(NotificationSourceDTO.serializer(), text) + assertEquals(source, parsed) + } + } + + @Test + fun `worktree without canBeRemoved field decodes to negation of isPrimary`() { + val raw = + """ + {"id":"22222222-2222-2222-2222-222222222222","name":"main","path":"/p","isPrimary":true,"createdAt":"2026-05-01T12:34:56Z"} + """.trimIndent() + val parsed = MuxyCodec.json.decodeFromString(WorktreeDTO.serializer(), raw) + assertEquals(false, parsed.canBeRemoved) + assertEquals(true, parsed.isPrimary) + assertNull(parsed.branch) + } + + @Test + fun `worktree always emits canBeRemoved on encode`() { + val tree = + WorktreeDTO( + id = worktreeID, + name = "main", + path = "/p", + branch = "main", + isPrimary = true, + canBeRemoved = false, + createdAt = timestamp, + ) + val text = MuxyCodec.json.encodeToString(WorktreeDTO.serializer(), tree) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals(false, obj.getValue("canBeRemoved").jsonPrimitive.boolean) + assertEquals("main", obj.getValue("branch").jsonPrimitive.content) + } + + @Test + fun `worktree omits null branch on encode`() { + val tree = + WorktreeDTO( + id = worktreeID, + name = "main", + path = "/p", + branch = null, + isPrimary = true, + createdAt = timestamp, + ) + val text = MuxyCodec.json.encodeToString(WorktreeDTO.serializer(), tree) + val obj = Json.parseToJsonElement(text).jsonObject + assertNull(obj["branch"]) + } + + @Test + fun `project DTO round-trips with optional logo and color`() { + val project = + ProjectDTO( + id = projectID, + name = "Muxy", + path = "/Users/me/dev/muxy", + sortOrder = 0, + createdAt = timestamp, + icon = "folder", + logo = "L", + iconColor = "blue", + ) + val text = MuxyCodec.json.encodeToString(ProjectDTO.serializer(), project) + val parsed = MuxyCodec.json.decodeFromString(ProjectDTO.serializer(), text) + assertEquals(project, parsed) + } + + @Test + fun `vcs status with pull request decodes`() { + val raw = + """ + { + "branch":"main", + "aheadCount":1, + "behindCount":0, + "hasUpstream":true, + "stagedFiles":[{"path":"a.swift","status":"modified","isUntracked":false}], + "changedFiles":[], + "defaultBranch":"main", + "pullRequest":{"url":"https://github.com/x/y/pull/1","number":1,"state":"open","isDraft":false,"baseBranch":"main"} + } + """.trimIndent() + val parsed = MuxyCodec.json.decodeFromString(VCSStatusDTO.serializer(), raw) + assertEquals(1, parsed.aheadCount) + assertEquals(0, parsed.behindCount) + assertEquals(GitFileStatusDTO.MODIFIED, parsed.stagedFiles.first().status) + assertEquals("https://github.com/x/y/pull/1", parsed.pullRequest?.url) + } + + @Test + fun `subscribe params encodes events as method strings`() { + val request = + MuxyRequest( + id = "s-1", + method = MuxyMethod.SUBSCRIBE, + params = + MuxyParams.Subscribe( + com.muxy.protocol.dto.SubscribeParams( + events = listOf(MuxyEventKind.WORKSPACE_CHANGED, MuxyEventKind.TERMINAL_OUTPUT), + ), + ), + ) + val text = MuxyCodec.encode(MuxyMessage.Request(request)) + val list = + Json.parseToJsonElement(text).jsonObject + .getValue("payload").jsonObject + .getValue("params").jsonObject + .getValue("value").jsonObject + .getValue("events").jsonArray + assertEquals("workspaceChanged", list[0].jsonPrimitive.content) + assertEquals("terminalOutput", list[1].jsonPrimitive.content) + } + + @Test + fun `unknown keys in incoming payloads are ignored`() { + val raw = + """ + {"type":"response","payload":{"id":"u-1","result":{"type":"ok"},"extraField":42}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + assertTrue((message as MuxyMessage.Response).value.result is MuxyResult.Ok) + } + + private fun emptyArea(id: UUID) = + TabAreaDTO( + id = id, + projectPath = "/x", + tabs = emptyList(), + activeTabID = null, + ) +} diff --git a/android/protocol/build.gradle.kts b/android/protocol/build.gradle.kts new file mode 100644 index 0000000..e8ecee9 --- /dev/null +++ b/android/protocol/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/codec/Base64ByteArraySerializer.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/Base64ByteArraySerializer.kt new file mode 100644 index 0000000..b430ee3 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/Base64ByteArraySerializer.kt @@ -0,0 +1,28 @@ +package com.muxy.protocol.codec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.Base64 + +object Base64ByteArraySerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Base64ByteArray", PrimitiveKind.STRING) + + private val base64Encoder = Base64.getEncoder() + private val base64Decoder = Base64.getDecoder() + + override fun serialize( + encoder: Encoder, + value: ByteArray, + ) { + encoder.encodeString(base64Encoder.encodeToString(value)) + } + + override fun deserialize(decoder: Decoder): ByteArray { + return base64Decoder.decode(decoder.decodeString()) + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/codec/InstantSerializer.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/InstantSerializer.kt new file mode 100644 index 0000000..0d851e0 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/InstantSerializer.kt @@ -0,0 +1,26 @@ +package com.muxy.protocol.codec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.Instant +import java.time.format.DateTimeFormatter + +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.time.Instant", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: Instant, + ) { + encoder.encodeString(DateTimeFormatter.ISO_INSTANT.format(value)) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.parse(decoder.decodeString()) + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/codec/MuxyCodec.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/MuxyCodec.kt new file mode 100644 index 0000000..57a5607 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/MuxyCodec.kt @@ -0,0 +1,28 @@ +package com.muxy.protocol.codec + +import com.muxy.protocol.envelope.MuxyMessage +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.contextual +import java.time.Instant +import java.util.UUID + +object MuxyCodec { + private val module = + SerializersModule { + contextual(UUID::class, UuidSerializer) + contextual(Instant::class, InstantSerializer) + } + + val json: Json = + Json { + explicitNulls = false + encodeDefaults = false + ignoreUnknownKeys = true + serializersModule = module + } + + fun encode(message: MuxyMessage): String = json.encodeToString(MuxyMessage.serializer(), message) + + fun decode(text: String): MuxyMessage = json.decodeFromString(MuxyMessage.serializer(), text) +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/codec/UuidSerializer.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/UuidSerializer.kt new file mode 100644 index 0000000..b8dc102 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/codec/UuidSerializer.kt @@ -0,0 +1,25 @@ +package com.muxy.protocol.codec + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.util.UUID + +object UuidSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("java.util.UUID", PrimitiveKind.STRING) + + override fun serialize( + encoder: Encoder, + value: UUID, + ) { + encoder.encodeString(value.toString().uppercase()) + } + + override fun deserialize(decoder: Decoder): UUID { + return UUID.fromString(decoder.decodeString()) + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/DeviceDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/DeviceDTO.kt new file mode 100644 index 0000000..d28e8d1 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/DeviceDTO.kt @@ -0,0 +1,29 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class PairingResultDTO( + val clientID: @Contextual UUID, + val deviceName: String, + val themeFg: UInt? = null, + val themeBg: UInt? = null, + val themePalette: List? = null, +) + +@Serializable +data class DeviceInfoDTO( + val clientID: @Contextual UUID, + val deviceName: String, + val themeFg: UInt? = null, + val themeBg: UInt? = null, + val themePalette: List? = null, +) + +@Serializable +data class ProjectLogoDTO( + val projectID: @Contextual UUID, + val pngData: String, +) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/NotificationDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/NotificationDTO.kt new file mode 100644 index 0000000..afe96c1 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/NotificationDTO.kt @@ -0,0 +1,90 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.time.Instant +import java.util.UUID + +@Serializable +data class NotificationDTO( + val id: @Contextual UUID, + val paneID: @Contextual UUID, + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, + val areaID: @Contextual UUID, + val tabID: @Contextual UUID, + val source: NotificationSourceDTO, + val title: String, + val body: String, + val timestamp: @Contextual Instant, + val isRead: Boolean, +) + +@Serializable(with = NotificationSourceSerializer::class) +sealed class NotificationSourceDTO { + object Osc : NotificationSourceDTO() + + object Socket : NotificationSourceDTO() + + data class AiProvider(val provider: String) : NotificationSourceDTO() +} + +object NotificationSourceSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("NotificationSourceDTO") + + override fun serialize( + encoder: Encoder, + value: NotificationSourceDTO, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("NotificationSourceSerializer only supports JSON") + val element = + when (value) { + is NotificationSourceDTO.Osc -> + buildJsonObject { + put("osc", buildJsonObject {}) + } + is NotificationSourceDTO.Socket -> + buildJsonObject { + put("socket", buildJsonObject {}) + } + is NotificationSourceDTO.AiProvider -> + buildJsonObject { + put( + "aiProvider", + buildJsonObject { + put("_0", value.provider) + }, + ) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): NotificationSourceDTO { + val input = + (decoder as? JsonDecoder) + ?: error("NotificationSourceSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + if (obj.containsKey("osc")) return NotificationSourceDTO.Osc + if (obj.containsKey("socket")) return NotificationSourceDTO.Socket + if (obj.containsKey("aiProvider")) { + val inner = obj.getValue("aiProvider").jsonObject + val provider = inner.getValue("_0").jsonPrimitive.content + return NotificationSourceDTO.AiProvider(provider) + } + error("Unknown NotificationSourceDTO shape: ${obj.keys}") + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/PaneOwnerDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/PaneOwnerDTO.kt new file mode 100644 index 0000000..dcce991 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/PaneOwnerDTO.kt @@ -0,0 +1,83 @@ +package com.muxy.protocol.dto + +import com.muxy.protocol.codec.UuidSerializer +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.UUID + +@Serializable(with = PaneOwnerSerializer::class) +sealed class PaneOwnerDTO { + abstract val displayName: String + + data class Mac(val deviceName: String) : PaneOwnerDTO() { + override val displayName: String get() = deviceName + } + + data class Remote(val deviceID: UUID, val deviceName: String) : PaneOwnerDTO() { + override val displayName: String get() = deviceName + } +} + +object PaneOwnerSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("PaneOwnerDTO") + + override fun serialize( + encoder: Encoder, + value: PaneOwnerDTO, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("PaneOwnerSerializer only supports JSON") + val element = + when (value) { + is PaneOwnerDTO.Mac -> + buildJsonObject { + put( + "mac", + buildJsonObject { + put("deviceName", value.deviceName) + }, + ) + } + is PaneOwnerDTO.Remote -> + buildJsonObject { + put( + "remote", + buildJsonObject { + put("deviceID", output.json.encodeToJsonElement(UuidSerializer, value.deviceID)) + put("deviceName", value.deviceName) + }, + ) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): PaneOwnerDTO { + val input = + (decoder as? JsonDecoder) + ?: error("PaneOwnerSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + if (obj.containsKey("mac")) { + val inner = obj.getValue("mac").jsonObject + return PaneOwnerDTO.Mac(deviceName = inner.getValue("deviceName").jsonPrimitive.content) + } + if (obj.containsKey("remote")) { + val inner = obj.getValue("remote").jsonObject + val deviceID = input.json.decodeFromJsonElement(UuidSerializer, inner.getValue("deviceID")) + val deviceName = inner.getValue("deviceName").jsonPrimitive.content + return PaneOwnerDTO.Remote(deviceID = deviceID, deviceName = deviceName) + } + error("Unknown PaneOwnerDTO shape: ${obj.keys}") + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProjectDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProjectDTO.kt new file mode 100644 index 0000000..0594d86 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProjectDTO.kt @@ -0,0 +1,18 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +@Serializable +data class ProjectDTO( + val id: @Contextual UUID, + val name: String, + val path: String, + val sortOrder: Int, + val createdAt: @Contextual Instant, + val icon: String? = null, + val logo: String? = null, + val iconColor: String? = null, +) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProjectIconColor.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProjectIconColor.kt new file mode 100644 index 0000000..fc25eee --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProjectIconColor.kt @@ -0,0 +1,47 @@ +package com.muxy.protocol.dto + +object ProjectIconColor { + data class Swatch(val id: String, val name: String, val hex: String) { + val prefersDarkForeground: Boolean + get() { + val rgb = rgb(fromHex = hex) ?: return false + val luminance = 0.2126 * rgb.first + 0.7152 * rgb.second + 0.0722 * rgb.third + return luminance > 0.6 + } + } + + val palette: List = + listOf( + Swatch("red", "Red", "#E5484D"), + Swatch("orange", "Orange", "#F76B15"), + Swatch("amber", "Amber", "#F5A623"), + Swatch("yellow", "Yellow", "#EBCB00"), + Swatch("lime", "Lime", "#9BCD1E"), + Swatch("green", "Green", "#30A46C"), + Swatch("teal", "Teal", "#12A594"), + Swatch("cyan", "Cyan", "#05A2C2"), + Swatch("blue", "Blue", "#3E63DD"), + Swatch("indigo", "Indigo", "#5B5BD6"), + Swatch("violet", "Violet", "#8E4EC6"), + Swatch("pink", "Pink", "#D6409F"), + ) + + private val byID: Map = palette.associateBy { it.id } + + fun swatch(forIdentifier: String?): Swatch? { + if (forIdentifier == null) return null + byID[forIdentifier]?.let { return it } + return palette.firstOrNull { it.hex.equals(forIdentifier, ignoreCase = true) } + } + + fun rgb(fromHex: String): Triple? { + var normalized = fromHex.trim() + if (normalized.startsWith("#")) normalized = normalized.removePrefix("#") + if (normalized.length != 6) return null + val value = normalized.toLongOrNull(radix = 16) ?: return null + val red = ((value shr 16) and 0xFF).toDouble() / 255.0 + val green = ((value shr 8) and 0xFF).toDouble() / 255.0 + val blue = (value and 0xFF).toDouble() / 255.0 + return Triple(red, green, blue) + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProtocolParams.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProtocolParams.kt new file mode 100644 index 0000000..cf24d91 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/ProtocolParams.kt @@ -0,0 +1,250 @@ +package com.muxy.protocol.dto + +import com.muxy.protocol.codec.Base64ByteArraySerializer +import com.muxy.protocol.envelope.MuxyEventKind +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class SelectProjectParams(val projectID: @Contextual UUID) + +@Serializable +data class ListWorktreesParams(val projectID: @Contextual UUID) + +@Serializable +data class SelectWorktreeParams( + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, +) + +@Serializable +data class GetWorkspaceParams(val projectID: @Contextual UUID) + +@Serializable +data class CreateTabParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID? = null, + val kind: TabKindDTO, +) + +@Serializable +data class CloseTabParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val tabID: @Contextual UUID, +) + +@Serializable +data class SelectTabParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val tabID: @Contextual UUID, +) + +@Serializable +data class SplitAreaParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val direction: SplitDirectionDTO, + val position: SplitPositionDTO, +) + +@Serializable +data class CloseAreaParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, +) + +@Serializable +data class FocusAreaParams( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, +) + +@Serializable +data class TerminalInputParams( + val paneID: @Contextual UUID, + val bytes: + @Serializable(with = Base64ByteArraySerializer::class) + ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TerminalInputParams) return false + return paneID == other.paneID && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = paneID.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } +} + +@Serializable +data class TerminalResizeParams( + val paneID: @Contextual UUID, + val cols: UInt, + val rows: UInt, +) + +@Serializable +data class TerminalScrollParams( + val paneID: @Contextual UUID, + val deltaX: Double, + val deltaY: Double, + val precise: Boolean, +) + +@Serializable +data class GetTerminalContentParams(val paneID: @Contextual UUID) + +@Serializable +data class RegisterDeviceParams(val deviceName: String) + +@Serializable +data class PairDeviceParams( + val deviceID: @Contextual UUID, + val deviceName: String, + val token: String, +) + +@Serializable +data class AuthenticateDeviceParams( + val deviceID: @Contextual UUID, + val deviceName: String, + val token: String, +) + +@Serializable +data class TakeOverPaneParams( + val paneID: @Contextual UUID, + val cols: UInt, + val rows: UInt, +) + +@Serializable +data class ReleasePaneParams(val paneID: @Contextual UUID) + +@Serializable +data class PaneOwnershipEventDTO( + val paneID: @Contextual UUID, + val owner: PaneOwnerDTO, +) + +@Serializable +data class DeviceThemeEventDTO( + val fg: UInt, + val bg: UInt, + val palette: List? = null, +) + +@Serializable +data class TabChangeEventDTO( + val projectID: @Contextual UUID, + val areaID: @Contextual UUID, + val tab: TabDTO, + val changeKind: TabChangeKind, +) { + @Serializable + enum class TabChangeKind { + @SerialName("created") + CREATED, + + @SerialName("closed") + CLOSED, + + @SerialName("selected") + SELECTED, + + @SerialName("titleChanged") + TITLE_CHANGED, + } +} + +@Serializable +data class GetVCSStatusParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSCommitParams( + val projectID: @Contextual UUID, + val message: String, + val stageAll: Boolean, +) + +@Serializable +data class VCSPushParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSPullParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSStageFilesParams( + val projectID: @Contextual UUID, + val paths: List, +) + +@Serializable +data class VCSUnstageFilesParams( + val projectID: @Contextual UUID, + val paths: List, +) + +@Serializable +data class VCSDiscardFilesParams( + val projectID: @Contextual UUID, + val paths: List, + val untrackedPaths: List, +) + +@Serializable +data class VCSListBranchesParams(val projectID: @Contextual UUID) + +@Serializable +data class VCSSwitchBranchParams( + val projectID: @Contextual UUID, + val branch: String, +) + +@Serializable +data class VCSCreateBranchParams( + val projectID: @Contextual UUID, + val name: String, +) + +@Serializable +data class VCSCreatePRParams( + val projectID: @Contextual UUID, + val title: String, + val body: String, + val baseBranch: String? = null, + val draft: Boolean, +) + +@Serializable +data class VCSAddWorktreeParams( + val projectID: @Contextual UUID, + val name: String, + val branch: String, + val createBranch: Boolean, +) + +@Serializable +data class VCSRemoveWorktreeParams( + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, +) + +@Serializable +data class GetProjectLogoParams(val projectID: @Contextual UUID) + +@Serializable +data class MarkNotificationReadParams(val notificationID: @Contextual UUID) + +@Serializable +data class SubscribeParams(val events: List) + +@Serializable +data class UnsubscribeParams(val events: List) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/TerminalDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/TerminalDTO.kt new file mode 100644 index 0000000..15c535b --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/TerminalDTO.kt @@ -0,0 +1,79 @@ +package com.muxy.protocol.dto + +import com.muxy.protocol.codec.Base64ByteArraySerializer +import kotlinx.serialization.Contextual +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import java.util.UUID + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class TerminalContentDTO( + val paneID: @Contextual UUID, + val content: String, + val cols: UInt, + val rows: UInt, +) + +@Serializable +data class TerminalCellDTO( + val codepoint: UInt, + val fg: UInt, + val bg: UInt, + val flags: UShort, +) + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class TerminalCellsDTO( + val paneID: @Contextual UUID, + val cols: UInt, + val rows: UInt, + val cursorX: UInt, + val cursorY: UInt, + val cursorVisible: Boolean, + val defaultFg: UInt, + val defaultBg: UInt, + val cells: List, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val altScreen: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val cursorKeys: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val bracketedPaste: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val focusEvent: Boolean = false, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val mouseEvent: UShort = 0u, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) val mouseFormat: UShort = 0u, +) + +object TerminalCellFlag { + val BOLD: UShort = 1u.toUShort() + val ITALIC: UShort = 2u.toUShort() + val FAINT: UShort = 4u.toUShort() + val BLINK: UShort = 8u.toUShort() + val INVERSE: UShort = 16u.toUShort() + val INVISIBLE: UShort = 32u.toUShort() + val STRIKE: UShort = 64u.toUShort() + val UNDERLINE: UShort = 128u.toUShort() + val OVERLINE: UShort = 256u.toUShort() + val WIDE: UShort = 512u.toUShort() + val SPACER: UShort = 1024u.toUShort() +} + +@Serializable +data class TerminalOutputEventDTO( + val paneID: @Contextual UUID, + val bytes: + @Serializable(with = Base64ByteArraySerializer::class) + ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TerminalOutputEventDTO) return false + return paneID == other.paneID && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = paneID.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/VCSDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/VCSDTO.kt new file mode 100644 index 0000000..e557e62 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/VCSDTO.kt @@ -0,0 +1,69 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class VCSStatusDTO( + val branch: String, + val aheadCount: Int, + val behindCount: Int, + val hasUpstream: Boolean, + val stagedFiles: List, + val changedFiles: List, + val defaultBranch: String? = null, + val pullRequest: VCSPullRequestDTO? = null, +) + +@Serializable +data class GitFileDTO( + val path: String, + val status: GitFileStatusDTO, + val isUntracked: Boolean = false, +) + +@Serializable +enum class GitFileStatusDTO { + @SerialName("added") + ADDED, + + @SerialName("modified") + MODIFIED, + + @SerialName("deleted") + DELETED, + + @SerialName("renamed") + RENAMED, + + @SerialName("copied") + COPIED, + + @SerialName("untracked") + UNTRACKED, + + @SerialName("unmerged") + UNMERGED, +} + +@Serializable +data class VCSPullRequestDTO( + val url: String, + val number: Int, + val state: String, + val isDraft: Boolean, + val baseBranch: String, +) + +@Serializable +data class VCSBranchesDTO( + val current: String, + val locals: List, + val defaultBranch: String? = null, +) + +@Serializable +data class VCSCreatePRResultDTO( + val url: String, + val number: Int, +) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/WorkspaceDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/WorkspaceDTO.kt new file mode 100644 index 0000000..244665a --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/WorkspaceDTO.kt @@ -0,0 +1,140 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.UUID + +@Serializable +data class WorkspaceDTO( + val projectID: @Contextual UUID, + val worktreeID: @Contextual UUID, + val focusedAreaID: @Contextual UUID? = null, + val root: SplitNodeDTO, +) + +@Serializable +enum class SplitDirectionDTO { + @SerialName("horizontal") + HORIZONTAL, + + @SerialName("vertical") + VERTICAL, +} + +@Serializable +enum class SplitPositionDTO { + @SerialName("first") + FIRST, + + @SerialName("second") + SECOND, +} + +@Serializable(with = SplitNodeSerializer::class) +sealed class SplitNodeDTO { + data class TabArea(val tabArea: TabAreaDTO) : SplitNodeDTO() + + data class Split(val split: SplitBranchDTO) : SplitNodeDTO() +} + +object SplitNodeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("SplitNodeDTO") { + element("type") + } + + override fun serialize( + encoder: Encoder, + value: SplitNodeDTO, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("SplitNodeSerializer only supports JSON") + val element = + when (value) { + is SplitNodeDTO.TabArea -> + buildJsonObject { + put("type", "tabArea") + put("tabArea", output.json.encodeToJsonElement(TabAreaDTO.serializer(), value.tabArea)) + } + is SplitNodeDTO.Split -> + buildJsonObject { + put("type", "split") + put("split", output.json.encodeToJsonElement(SplitBranchDTO.serializer(), value.split)) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): SplitNodeDTO { + val input = + (decoder as? JsonDecoder) + ?: error("SplitNodeSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + return when (val type = obj.getValue("type").jsonPrimitive.content) { + "tabArea" -> + SplitNodeDTO.TabArea( + input.json.decodeFromJsonElement(TabAreaDTO.serializer(), obj.getValue("tabArea")), + ) + "split" -> + SplitNodeDTO.Split( + input.json.decodeFromJsonElement(SplitBranchDTO.serializer(), obj.getValue("split")), + ) + else -> error("Unknown SplitNodeDTO type: $type") + } + } +} + +@Serializable +data class SplitBranchDTO( + val id: @Contextual UUID, + val direction: SplitDirectionDTO, + val ratio: Double, + val first: SplitNodeDTO, + val second: SplitNodeDTO, +) + +@Serializable +data class TabAreaDTO( + val id: @Contextual UUID, + val projectPath: String, + val tabs: List, + val activeTabID: @Contextual UUID? = null, +) + +@Serializable +data class TabDTO( + val id: @Contextual UUID, + val kind: TabKindDTO, + val title: String, + val isPinned: Boolean, + val paneID: @Contextual UUID? = null, +) + +@Serializable +enum class TabKindDTO { + @SerialName("terminal") + TERMINAL, + + @SerialName("vcs") + VCS, + + @SerialName("editor") + EDITOR, + + @SerialName("diffViewer") + DIFF_VIEWER, +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/dto/WorktreeDTO.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/WorktreeDTO.kt new file mode 100644 index 0000000..878e959 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/dto/WorktreeDTO.kt @@ -0,0 +1,21 @@ +package com.muxy.protocol.dto + +import kotlinx.serialization.Contextual +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import java.time.Instant +import java.util.UUID + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class WorktreeDTO( + val id: @Contextual UUID, + val name: String, + val path: String, + val branch: String? = null, + val isPrimary: Boolean, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) + val canBeRemoved: Boolean = !isPrimary, + val createdAt: @Contextual Instant, +) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyError.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyError.kt new file mode 100644 index 0000000..91b7a0c --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyError.kt @@ -0,0 +1,17 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyError( + val code: Int, + val message: String, +) { + companion object { + val notFound = MuxyError(code = 404, message = "Not found") + val invalidParams = MuxyError(code = 400, message = "Invalid parameters") + val internalError = MuxyError(code = 500, message = "Internal error") + val unauthorized = MuxyError(code = 401, message = "Authentication required") + val pairingDenied = MuxyError(code = 403, message = "Pairing denied") + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEvent.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEvent.kt new file mode 100644 index 0000000..c7621bf --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEvent.kt @@ -0,0 +1,9 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyEvent( + val event: MuxyEventKind, + val data: MuxyEventData, +) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEventData.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEventData.kt new file mode 100644 index 0000000..72b5a4d --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEventData.kt @@ -0,0 +1,121 @@ +package com.muxy.protocol.envelope + +import com.muxy.protocol.dto.DeviceThemeEventDTO +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.PaneOwnershipEventDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.TabChangeEventDTO +import com.muxy.protocol.dto.TerminalOutputEventDTO +import com.muxy.protocol.dto.WorkspaceDTO +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyEventDataSerializer::class) +sealed class MuxyEventData { + data class Workspace(val value: WorkspaceDTO) : MuxyEventData() + + data class Tab(val value: TabChangeEventDTO) : MuxyEventData() + + data class TerminalOutput(val value: TerminalOutputEventDTO) : MuxyEventData() + + data class TerminalSnapshot(val value: TerminalOutputEventDTO) : MuxyEventData() + + data class Notification(val value: NotificationDTO) : MuxyEventData() + + data class Projects(val value: List) : MuxyEventData() + + data class PaneOwnership(val value: PaneOwnershipEventDTO) : MuxyEventData() + + data class DeviceTheme(val value: DeviceThemeEventDTO) : MuxyEventData() +} + +object MuxyEventDataSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyEventData") + + override fun serialize( + encoder: Encoder, + value: MuxyEventData, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyEventDataSerializer only supports JSON") + val json = output.json + val element = + when (value) { + is MuxyEventData.Workspace -> + buildJsonObject { + put("type", "workspace") + put("value", json.encodeToJsonElement(WorkspaceDTO.serializer(), value.value)) + } + is MuxyEventData.Tab -> + buildJsonObject { + put("type", "tab") + put("value", json.encodeToJsonElement(TabChangeEventDTO.serializer(), value.value)) + } + is MuxyEventData.TerminalOutput -> + buildJsonObject { + put("type", "terminalOutput") + put("value", json.encodeToJsonElement(TerminalOutputEventDTO.serializer(), value.value)) + } + is MuxyEventData.TerminalSnapshot -> + buildJsonObject { + put("type", "terminalSnapshot") + put("value", json.encodeToJsonElement(TerminalOutputEventDTO.serializer(), value.value)) + } + is MuxyEventData.Notification -> + buildJsonObject { + put("type", "notification") + put("value", json.encodeToJsonElement(NotificationDTO.serializer(), value.value)) + } + is MuxyEventData.Projects -> + buildJsonObject { + put("type", "projects") + put("value", json.encodeToJsonElement(ListSerializer(ProjectDTO.serializer()), value.value)) + } + is MuxyEventData.PaneOwnership -> + buildJsonObject { + put("type", "paneOwnership") + put("value", json.encodeToJsonElement(PaneOwnershipEventDTO.serializer(), value.value)) + } + is MuxyEventData.DeviceTheme -> + buildJsonObject { + put("type", "deviceTheme") + put("value", json.encodeToJsonElement(DeviceThemeEventDTO.serializer(), value.value)) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyEventData { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyEventDataSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + val value: JsonElement = obj.getValue("value") + val json = input.json + return when (type) { + "workspace" -> MuxyEventData.Workspace(json.decodeFromJsonElement(WorkspaceDTO.serializer(), value)) + "tab" -> MuxyEventData.Tab(json.decodeFromJsonElement(TabChangeEventDTO.serializer(), value)) + "terminalOutput" -> MuxyEventData.TerminalOutput(json.decodeFromJsonElement(TerminalOutputEventDTO.serializer(), value)) + "terminalSnapshot" -> MuxyEventData.TerminalSnapshot(json.decodeFromJsonElement(TerminalOutputEventDTO.serializer(), value)) + "notification" -> MuxyEventData.Notification(json.decodeFromJsonElement(NotificationDTO.serializer(), value)) + "projects" -> MuxyEventData.Projects(json.decodeFromJsonElement(ListSerializer(ProjectDTO.serializer()), value)) + "paneOwnership" -> MuxyEventData.PaneOwnership(json.decodeFromJsonElement(PaneOwnershipEventDTO.serializer(), value)) + "deviceTheme" -> MuxyEventData.DeviceTheme(json.decodeFromJsonElement(DeviceThemeEventDTO.serializer(), value)) + else -> error("Unknown MuxyEventData type: $type") + } + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEventKind.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEventKind.kt new file mode 100644 index 0000000..e6e2cdc --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyEventKind.kt @@ -0,0 +1,31 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class MuxyEventKind { + @SerialName("workspaceChanged") + WORKSPACE_CHANGED, + + @SerialName("tabChanged") + TAB_CHANGED, + + @SerialName("terminalOutput") + TERMINAL_OUTPUT, + + @SerialName("terminalSnapshot") + TERMINAL_SNAPSHOT, + + @SerialName("notificationReceived") + NOTIFICATION_RECEIVED, + + @SerialName("projectsChanged") + PROJECTS_CHANGED, + + @SerialName("paneOwnershipChanged") + PANE_OWNERSHIP_CHANGED, + + @SerialName("themeChanged") + THEME_CHANGED, +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyMessage.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyMessage.kt new file mode 100644 index 0000000..ebbaad8 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyMessage.kt @@ -0,0 +1,72 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyMessageSerializer::class) +sealed class MuxyMessage { + data class Request(val value: MuxyRequest) : MuxyMessage() + + data class Response(val value: MuxyResponse) : MuxyMessage() + + data class Event(val value: MuxyEvent) : MuxyMessage() +} + +object MuxyMessageSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyMessage") + + override fun serialize( + encoder: Encoder, + value: MuxyMessage, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyMessageSerializer only supports JSON") + val json = output.json + val element = + when (value) { + is MuxyMessage.Request -> + buildJsonObject { + put("type", "request") + put("payload", json.encodeToJsonElement(MuxyRequest.serializer(), value.value)) + } + is MuxyMessage.Response -> + buildJsonObject { + put("type", "response") + put("payload", json.encodeToJsonElement(MuxyResponse.serializer(), value.value)) + } + is MuxyMessage.Event -> + buildJsonObject { + put("type", "event") + put("payload", json.encodeToJsonElement(MuxyEvent.serializer(), value.value)) + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyMessage { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyMessageSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + val payload = obj.getValue("payload") + val json = input.json + return when (type) { + "request" -> MuxyMessage.Request(json.decodeFromJsonElement(MuxyRequest.serializer(), payload)) + "response" -> MuxyMessage.Response(json.decodeFromJsonElement(MuxyResponse.serializer(), payload)) + "event" -> MuxyMessage.Event(json.decodeFromJsonElement(MuxyEvent.serializer(), payload)) + else -> error("Unknown MuxyMessage type: $type") + } + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyMethod.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyMethod.kt new file mode 100644 index 0000000..77eb1e1 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyMethod.kt @@ -0,0 +1,121 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class MuxyMethod { + @SerialName("listProjects") + LIST_PROJECTS, + + @SerialName("selectProject") + SELECT_PROJECT, + + @SerialName("listWorktrees") + LIST_WORKTREES, + + @SerialName("selectWorktree") + SELECT_WORKTREE, + + @SerialName("getWorkspace") + GET_WORKSPACE, + + @SerialName("createTab") + CREATE_TAB, + + @SerialName("closeTab") + CLOSE_TAB, + + @SerialName("selectTab") + SELECT_TAB, + + @SerialName("splitArea") + SPLIT_AREA, + + @SerialName("closeArea") + CLOSE_AREA, + + @SerialName("focusArea") + FOCUS_AREA, + + @SerialName("terminalInput") + TERMINAL_INPUT, + + @SerialName("terminalResize") + TERMINAL_RESIZE, + + @SerialName("terminalScroll") + TERMINAL_SCROLL, + + @SerialName("getTerminalContent") + GET_TERMINAL_CONTENT, + + @SerialName("registerDevice") + REGISTER_DEVICE, + + @SerialName("pairDevice") + PAIR_DEVICE, + + @SerialName("authenticateDevice") + AUTHENTICATE_DEVICE, + + @SerialName("takeOverPane") + TAKE_OVER_PANE, + + @SerialName("releasePane") + RELEASE_PANE, + + @SerialName("getVCSStatus") + GET_VCS_STATUS, + + @SerialName("vcsCommit") + VCS_COMMIT, + + @SerialName("vcsPush") + VCS_PUSH, + + @SerialName("vcsPull") + VCS_PULL, + + @SerialName("vcsStageFiles") + VCS_STAGE_FILES, + + @SerialName("vcsUnstageFiles") + VCS_UNSTAGE_FILES, + + @SerialName("vcsDiscardFiles") + VCS_DISCARD_FILES, + + @SerialName("vcsListBranches") + VCS_LIST_BRANCHES, + + @SerialName("vcsSwitchBranch") + VCS_SWITCH_BRANCH, + + @SerialName("vcsCreateBranch") + VCS_CREATE_BRANCH, + + @SerialName("vcsCreatePR") + VCS_CREATE_PR, + + @SerialName("vcsAddWorktree") + VCS_ADD_WORKTREE, + + @SerialName("vcsRemoveWorktree") + VCS_REMOVE_WORKTREE, + + @SerialName("getProjectLogo") + GET_PROJECT_LOGO, + + @SerialName("listNotifications") + LIST_NOTIFICATIONS, + + @SerialName("markNotificationRead") + MARK_NOTIFICATION_READ, + + @SerialName("subscribe") + SUBSCRIBE, + + @SerialName("unsubscribe") + UNSUBSCRIBE, +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyParams.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyParams.kt new file mode 100644 index 0000000..1204bbd --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyParams.kt @@ -0,0 +1,251 @@ +package com.muxy.protocol.envelope + +import com.muxy.protocol.dto.AuthenticateDeviceParams +import com.muxy.protocol.dto.CloseAreaParams +import com.muxy.protocol.dto.CloseTabParams +import com.muxy.protocol.dto.CreateTabParams +import com.muxy.protocol.dto.FocusAreaParams +import com.muxy.protocol.dto.GetProjectLogoParams +import com.muxy.protocol.dto.GetTerminalContentParams +import com.muxy.protocol.dto.GetVCSStatusParams +import com.muxy.protocol.dto.GetWorkspaceParams +import com.muxy.protocol.dto.ListWorktreesParams +import com.muxy.protocol.dto.MarkNotificationReadParams +import com.muxy.protocol.dto.PairDeviceParams +import com.muxy.protocol.dto.RegisterDeviceParams +import com.muxy.protocol.dto.ReleasePaneParams +import com.muxy.protocol.dto.SelectProjectParams +import com.muxy.protocol.dto.SelectTabParams +import com.muxy.protocol.dto.SelectWorktreeParams +import com.muxy.protocol.dto.SplitAreaParams +import com.muxy.protocol.dto.SubscribeParams +import com.muxy.protocol.dto.TakeOverPaneParams +import com.muxy.protocol.dto.TerminalInputParams +import com.muxy.protocol.dto.TerminalResizeParams +import com.muxy.protocol.dto.TerminalScrollParams +import com.muxy.protocol.dto.UnsubscribeParams +import com.muxy.protocol.dto.VCSAddWorktreeParams +import com.muxy.protocol.dto.VCSCommitParams +import com.muxy.protocol.dto.VCSCreateBranchParams +import com.muxy.protocol.dto.VCSCreatePRParams +import com.muxy.protocol.dto.VCSDiscardFilesParams +import com.muxy.protocol.dto.VCSListBranchesParams +import com.muxy.protocol.dto.VCSPullParams +import com.muxy.protocol.dto.VCSPushParams +import com.muxy.protocol.dto.VCSRemoveWorktreeParams +import com.muxy.protocol.dto.VCSStageFilesParams +import com.muxy.protocol.dto.VCSSwitchBranchParams +import com.muxy.protocol.dto.VCSUnstageFilesParams +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyParamsSerializer::class) +sealed class MuxyParams { + data class SelectProject(val value: SelectProjectParams) : MuxyParams() + + data class ListWorktrees(val value: ListWorktreesParams) : MuxyParams() + + data class SelectWorktree(val value: SelectWorktreeParams) : MuxyParams() + + data class GetWorkspace(val value: GetWorkspaceParams) : MuxyParams() + + data class CreateTab(val value: CreateTabParams) : MuxyParams() + + data class CloseTab(val value: CloseTabParams) : MuxyParams() + + data class SelectTab(val value: SelectTabParams) : MuxyParams() + + data class SplitArea(val value: SplitAreaParams) : MuxyParams() + + data class CloseArea(val value: CloseAreaParams) : MuxyParams() + + data class FocusArea(val value: FocusAreaParams) : MuxyParams() + + data class TerminalInput(val value: TerminalInputParams) : MuxyParams() + + data class TerminalResize(val value: TerminalResizeParams) : MuxyParams() + + data class TerminalScroll(val value: TerminalScrollParams) : MuxyParams() + + data class GetTerminalContent(val value: GetTerminalContentParams) : MuxyParams() + + data class RegisterDevice(val value: RegisterDeviceParams) : MuxyParams() + + data class PairDevice(val value: PairDeviceParams) : MuxyParams() + + data class AuthenticateDevice(val value: AuthenticateDeviceParams) : MuxyParams() + + data class TakeOverPane(val value: TakeOverPaneParams) : MuxyParams() + + data class ReleasePane(val value: ReleasePaneParams) : MuxyParams() + + data class GetVCSStatus(val value: GetVCSStatusParams) : MuxyParams() + + data class VCSCommit(val value: VCSCommitParams) : MuxyParams() + + data class VCSPush(val value: VCSPushParams) : MuxyParams() + + data class VCSPull(val value: VCSPullParams) : MuxyParams() + + data class VCSStageFiles(val value: VCSStageFilesParams) : MuxyParams() + + data class VCSUnstageFiles(val value: VCSUnstageFilesParams) : MuxyParams() + + data class VCSDiscardFiles(val value: VCSDiscardFilesParams) : MuxyParams() + + data class VCSListBranches(val value: VCSListBranchesParams) : MuxyParams() + + data class VCSSwitchBranch(val value: VCSSwitchBranchParams) : MuxyParams() + + data class VCSCreateBranch(val value: VCSCreateBranchParams) : MuxyParams() + + data class VCSCreatePR(val value: VCSCreatePRParams) : MuxyParams() + + data class VCSAddWorktree(val value: VCSAddWorktreeParams) : MuxyParams() + + data class VCSRemoveWorktree(val value: VCSRemoveWorktreeParams) : MuxyParams() + + data class GetProjectLogo(val value: GetProjectLogoParams) : MuxyParams() + + data class MarkNotificationRead(val value: MarkNotificationReadParams) : MuxyParams() + + data class Subscribe(val value: SubscribeParams) : MuxyParams() + + data class Unsubscribe(val value: UnsubscribeParams) : MuxyParams() +} + +object MuxyParamsSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyParams") + + override fun serialize( + encoder: Encoder, + value: MuxyParams, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyParamsSerializer only supports JSON") + val (typeKey, jsonValue) = serializeBranch(output, value) + val element = + buildJsonObject { + put("type", typeKey) + put("value", jsonValue) + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyParams { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyParamsSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + val value = obj.getValue("value") + return deserializeBranch(input, type, value) + } + + private fun serializeBranch( + output: JsonEncoder, + value: MuxyParams, + ): Pair { + val json = output.json + return when (value) { + is MuxyParams.SelectProject -> "selectProject" to json.encodeToJsonElement(SelectProjectParams.serializer(), value.value) + is MuxyParams.ListWorktrees -> "listWorktrees" to json.encodeToJsonElement(ListWorktreesParams.serializer(), value.value) + is MuxyParams.SelectWorktree -> "selectWorktree" to json.encodeToJsonElement(SelectWorktreeParams.serializer(), value.value) + is MuxyParams.GetWorkspace -> "getWorkspace" to json.encodeToJsonElement(GetWorkspaceParams.serializer(), value.value) + is MuxyParams.CreateTab -> "createTab" to json.encodeToJsonElement(CreateTabParams.serializer(), value.value) + is MuxyParams.CloseTab -> "closeTab" to json.encodeToJsonElement(CloseTabParams.serializer(), value.value) + is MuxyParams.SelectTab -> "selectTab" to json.encodeToJsonElement(SelectTabParams.serializer(), value.value) + is MuxyParams.SplitArea -> "splitArea" to json.encodeToJsonElement(SplitAreaParams.serializer(), value.value) + is MuxyParams.CloseArea -> "closeArea" to json.encodeToJsonElement(CloseAreaParams.serializer(), value.value) + is MuxyParams.FocusArea -> "focusArea" to json.encodeToJsonElement(FocusAreaParams.serializer(), value.value) + is MuxyParams.TerminalInput -> "terminalInput" to json.encodeToJsonElement(TerminalInputParams.serializer(), value.value) + is MuxyParams.TerminalResize -> "terminalResize" to json.encodeToJsonElement(TerminalResizeParams.serializer(), value.value) + is MuxyParams.TerminalScroll -> "terminalScroll" to json.encodeToJsonElement(TerminalScrollParams.serializer(), value.value) + is MuxyParams.GetTerminalContent -> "getTerminalContent" to json.encodeToJsonElement(GetTerminalContentParams.serializer(), value.value) + is MuxyParams.RegisterDevice -> "registerDevice" to json.encodeToJsonElement(RegisterDeviceParams.serializer(), value.value) + is MuxyParams.PairDevice -> "pairDevice" to json.encodeToJsonElement(PairDeviceParams.serializer(), value.value) + is MuxyParams.AuthenticateDevice -> "authenticateDevice" to json.encodeToJsonElement(AuthenticateDeviceParams.serializer(), value.value) + is MuxyParams.TakeOverPane -> "takeOverPane" to json.encodeToJsonElement(TakeOverPaneParams.serializer(), value.value) + is MuxyParams.ReleasePane -> "releasePane" to json.encodeToJsonElement(ReleasePaneParams.serializer(), value.value) + is MuxyParams.GetVCSStatus -> "getVCSStatus" to json.encodeToJsonElement(GetVCSStatusParams.serializer(), value.value) + is MuxyParams.VCSCommit -> "vcsCommit" to json.encodeToJsonElement(VCSCommitParams.serializer(), value.value) + is MuxyParams.VCSPush -> "vcsPush" to json.encodeToJsonElement(VCSPushParams.serializer(), value.value) + is MuxyParams.VCSPull -> "vcsPull" to json.encodeToJsonElement(VCSPullParams.serializer(), value.value) + is MuxyParams.VCSStageFiles -> "vcsStageFiles" to json.encodeToJsonElement(VCSStageFilesParams.serializer(), value.value) + is MuxyParams.VCSUnstageFiles -> "vcsUnstageFiles" to json.encodeToJsonElement(VCSUnstageFilesParams.serializer(), value.value) + is MuxyParams.VCSDiscardFiles -> "vcsDiscardFiles" to json.encodeToJsonElement(VCSDiscardFilesParams.serializer(), value.value) + is MuxyParams.VCSListBranches -> "vcsListBranches" to json.encodeToJsonElement(VCSListBranchesParams.serializer(), value.value) + is MuxyParams.VCSSwitchBranch -> "vcsSwitchBranch" to json.encodeToJsonElement(VCSSwitchBranchParams.serializer(), value.value) + is MuxyParams.VCSCreateBranch -> "vcsCreateBranch" to json.encodeToJsonElement(VCSCreateBranchParams.serializer(), value.value) + is MuxyParams.VCSCreatePR -> "vcsCreatePR" to json.encodeToJsonElement(VCSCreatePRParams.serializer(), value.value) + is MuxyParams.VCSAddWorktree -> "vcsAddWorktree" to json.encodeToJsonElement(VCSAddWorktreeParams.serializer(), value.value) + is MuxyParams.VCSRemoveWorktree -> "vcsRemoveWorktree" to json.encodeToJsonElement(VCSRemoveWorktreeParams.serializer(), value.value) + is MuxyParams.GetProjectLogo -> "getProjectLogo" to json.encodeToJsonElement(GetProjectLogoParams.serializer(), value.value) + is MuxyParams.MarkNotificationRead -> "markNotificationRead" to json.encodeToJsonElement(MarkNotificationReadParams.serializer(), value.value) + is MuxyParams.Subscribe -> "subscribe" to json.encodeToJsonElement(SubscribeParams.serializer(), value.value) + is MuxyParams.Unsubscribe -> "unsubscribe" to json.encodeToJsonElement(UnsubscribeParams.serializer(), value.value) + } + } + + private fun deserializeBranch( + input: JsonDecoder, + type: String, + value: JsonElement, + ): MuxyParams { + val json = input.json + return when (type) { + "selectProject" -> MuxyParams.SelectProject(json.decodeFromJsonElement(SelectProjectParams.serializer(), value)) + "listWorktrees" -> MuxyParams.ListWorktrees(json.decodeFromJsonElement(ListWorktreesParams.serializer(), value)) + "selectWorktree" -> MuxyParams.SelectWorktree(json.decodeFromJsonElement(SelectWorktreeParams.serializer(), value)) + "getWorkspace" -> MuxyParams.GetWorkspace(json.decodeFromJsonElement(GetWorkspaceParams.serializer(), value)) + "createTab" -> MuxyParams.CreateTab(json.decodeFromJsonElement(CreateTabParams.serializer(), value)) + "closeTab" -> MuxyParams.CloseTab(json.decodeFromJsonElement(CloseTabParams.serializer(), value)) + "selectTab" -> MuxyParams.SelectTab(json.decodeFromJsonElement(SelectTabParams.serializer(), value)) + "splitArea" -> MuxyParams.SplitArea(json.decodeFromJsonElement(SplitAreaParams.serializer(), value)) + "closeArea" -> MuxyParams.CloseArea(json.decodeFromJsonElement(CloseAreaParams.serializer(), value)) + "focusArea" -> MuxyParams.FocusArea(json.decodeFromJsonElement(FocusAreaParams.serializer(), value)) + "terminalInput" -> MuxyParams.TerminalInput(json.decodeFromJsonElement(TerminalInputParams.serializer(), value)) + "terminalResize" -> MuxyParams.TerminalResize(json.decodeFromJsonElement(TerminalResizeParams.serializer(), value)) + "terminalScroll" -> MuxyParams.TerminalScroll(json.decodeFromJsonElement(TerminalScrollParams.serializer(), value)) + "getTerminalContent" -> MuxyParams.GetTerminalContent(json.decodeFromJsonElement(GetTerminalContentParams.serializer(), value)) + "registerDevice" -> MuxyParams.RegisterDevice(json.decodeFromJsonElement(RegisterDeviceParams.serializer(), value)) + "pairDevice" -> MuxyParams.PairDevice(json.decodeFromJsonElement(PairDeviceParams.serializer(), value)) + "authenticateDevice" -> MuxyParams.AuthenticateDevice(json.decodeFromJsonElement(AuthenticateDeviceParams.serializer(), value)) + "takeOverPane" -> MuxyParams.TakeOverPane(json.decodeFromJsonElement(TakeOverPaneParams.serializer(), value)) + "releasePane" -> MuxyParams.ReleasePane(json.decodeFromJsonElement(ReleasePaneParams.serializer(), value)) + "getVCSStatus" -> MuxyParams.GetVCSStatus(json.decodeFromJsonElement(GetVCSStatusParams.serializer(), value)) + "vcsCommit" -> MuxyParams.VCSCommit(json.decodeFromJsonElement(VCSCommitParams.serializer(), value)) + "vcsPush" -> MuxyParams.VCSPush(json.decodeFromJsonElement(VCSPushParams.serializer(), value)) + "vcsPull" -> MuxyParams.VCSPull(json.decodeFromJsonElement(VCSPullParams.serializer(), value)) + "vcsStageFiles" -> MuxyParams.VCSStageFiles(json.decodeFromJsonElement(VCSStageFilesParams.serializer(), value)) + "vcsUnstageFiles" -> MuxyParams.VCSUnstageFiles(json.decodeFromJsonElement(VCSUnstageFilesParams.serializer(), value)) + "vcsDiscardFiles" -> MuxyParams.VCSDiscardFiles(json.decodeFromJsonElement(VCSDiscardFilesParams.serializer(), value)) + "vcsListBranches" -> MuxyParams.VCSListBranches(json.decodeFromJsonElement(VCSListBranchesParams.serializer(), value)) + "vcsSwitchBranch" -> MuxyParams.VCSSwitchBranch(json.decodeFromJsonElement(VCSSwitchBranchParams.serializer(), value)) + "vcsCreateBranch" -> MuxyParams.VCSCreateBranch(json.decodeFromJsonElement(VCSCreateBranchParams.serializer(), value)) + "vcsCreatePR" -> MuxyParams.VCSCreatePR(json.decodeFromJsonElement(VCSCreatePRParams.serializer(), value)) + "vcsAddWorktree" -> MuxyParams.VCSAddWorktree(json.decodeFromJsonElement(VCSAddWorktreeParams.serializer(), value)) + "vcsRemoveWorktree" -> MuxyParams.VCSRemoveWorktree(json.decodeFromJsonElement(VCSRemoveWorktreeParams.serializer(), value)) + "getProjectLogo" -> MuxyParams.GetProjectLogo(json.decodeFromJsonElement(GetProjectLogoParams.serializer(), value)) + "markNotificationRead" -> + MuxyParams.MarkNotificationRead( + json.decodeFromJsonElement(MarkNotificationReadParams.serializer(), value), + ) + "subscribe" -> MuxyParams.Subscribe(json.decodeFromJsonElement(SubscribeParams.serializer(), value)) + "unsubscribe" -> MuxyParams.Unsubscribe(json.decodeFromJsonElement(UnsubscribeParams.serializer(), value)) + else -> error("Unknown MuxyParams type: $type") + } + } +} diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyRequest.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyRequest.kt new file mode 100644 index 0000000..fcf0f4f --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyRequest.kt @@ -0,0 +1,10 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyRequest( + val id: String, + val method: MuxyMethod, + val params: MuxyParams? = null, +) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyResponse.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyResponse.kt new file mode 100644 index 0000000..4a02e63 --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyResponse.kt @@ -0,0 +1,10 @@ +package com.muxy.protocol.envelope + +import kotlinx.serialization.Serializable + +@Serializable +data class MuxyResponse( + val id: String, + val result: MuxyResult? = null, + val error: MuxyError? = null, +) diff --git a/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyResult.kt b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyResult.kt new file mode 100644 index 0000000..dfd729f --- /dev/null +++ b/android/protocol/src/main/kotlin/com/muxy/protocol/envelope/MuxyResult.kt @@ -0,0 +1,183 @@ +package com.muxy.protocol.envelope + +import com.muxy.protocol.dto.DeviceInfoDTO +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.PairingResultDTO +import com.muxy.protocol.dto.PaneOwnerDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.ProjectLogoDTO +import com.muxy.protocol.dto.TabDTO +import com.muxy.protocol.dto.TerminalCellsDTO +import com.muxy.protocol.dto.TerminalContentDTO +import com.muxy.protocol.dto.VCSBranchesDTO +import com.muxy.protocol.dto.VCSCreatePRResultDTO +import com.muxy.protocol.dto.VCSStatusDTO +import com.muxy.protocol.dto.WorkspaceDTO +import com.muxy.protocol.dto.WorktreeDTO +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable(with = MuxyResultSerializer::class) +sealed class MuxyResult { + data class Projects(val value: List) : MuxyResult() + + data class Worktrees(val value: List) : MuxyResult() + + data class Workspace(val value: WorkspaceDTO) : MuxyResult() + + data class Tab(val value: TabDTO) : MuxyResult() + + data class TerminalContent(val value: TerminalContentDTO) : MuxyResult() + + data class TerminalCells(val value: TerminalCellsDTO) : MuxyResult() + + data class DeviceInfo(val value: DeviceInfoDTO) : MuxyResult() + + data class Pairing(val value: PairingResultDTO) : MuxyResult() + + data class PaneOwner(val value: PaneOwnerDTO) : MuxyResult() + + data class VCSStatus(val value: VCSStatusDTO) : MuxyResult() + + data class VCSBranches(val value: VCSBranchesDTO) : MuxyResult() + + data class VCSPRCreated(val value: VCSCreatePRResultDTO) : MuxyResult() + + data class ProjectLogo(val value: ProjectLogoDTO) : MuxyResult() + + data class Notifications(val value: List) : MuxyResult() + + object Ok : MuxyResult() +} + +object MuxyResultSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("MuxyResult") + + override fun serialize( + encoder: Encoder, + value: MuxyResult, + ) { + val output = + (encoder as? JsonEncoder) + ?: error("MuxyResultSerializer only supports JSON") + val json = output.json + val element = + when (value) { + is MuxyResult.Projects -> + buildJsonObject { + put("type", "projects") + put("value", json.encodeToJsonElement(ListSerializer(ProjectDTO.serializer()), value.value)) + } + is MuxyResult.Worktrees -> + buildJsonObject { + put("type", "worktrees") + put("value", json.encodeToJsonElement(ListSerializer(WorktreeDTO.serializer()), value.value)) + } + is MuxyResult.Workspace -> + buildJsonObject { + put("type", "workspace") + put("value", json.encodeToJsonElement(WorkspaceDTO.serializer(), value.value)) + } + is MuxyResult.Tab -> + buildJsonObject { + put("type", "tab") + put("value", json.encodeToJsonElement(TabDTO.serializer(), value.value)) + } + is MuxyResult.TerminalContent -> + buildJsonObject { + put("type", "terminalContent") + put("value", json.encodeToJsonElement(TerminalContentDTO.serializer(), value.value)) + } + is MuxyResult.TerminalCells -> + buildJsonObject { + put("type", "terminalCells") + put("value", json.encodeToJsonElement(TerminalCellsDTO.serializer(), value.value)) + } + is MuxyResult.DeviceInfo -> + buildJsonObject { + put("type", "deviceInfo") + put("value", json.encodeToJsonElement(DeviceInfoDTO.serializer(), value.value)) + } + is MuxyResult.Pairing -> + buildJsonObject { + put("type", "pairing") + put("value", json.encodeToJsonElement(PairingResultDTO.serializer(), value.value)) + } + is MuxyResult.PaneOwner -> + buildJsonObject { + put("type", "paneOwner") + put("value", json.encodeToJsonElement(PaneOwnerDTO.serializer(), value.value)) + } + is MuxyResult.VCSStatus -> + buildJsonObject { + put("type", "vcsStatus") + put("value", json.encodeToJsonElement(VCSStatusDTO.serializer(), value.value)) + } + is MuxyResult.VCSBranches -> + buildJsonObject { + put("type", "vcsBranches") + put("value", json.encodeToJsonElement(VCSBranchesDTO.serializer(), value.value)) + } + is MuxyResult.VCSPRCreated -> + buildJsonObject { + put("type", "vcsPRCreated") + put("value", json.encodeToJsonElement(VCSCreatePRResultDTO.serializer(), value.value)) + } + is MuxyResult.ProjectLogo -> + buildJsonObject { + put("type", "projectLogo") + put("value", json.encodeToJsonElement(ProjectLogoDTO.serializer(), value.value)) + } + is MuxyResult.Notifications -> + buildJsonObject { + put("type", "notifications") + put("value", json.encodeToJsonElement(ListSerializer(NotificationDTO.serializer()), value.value)) + } + is MuxyResult.Ok -> + buildJsonObject { + put("type", "ok") + } + } + output.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): MuxyResult { + val input = + (decoder as? JsonDecoder) + ?: error("MuxyResultSerializer only supports JSON") + val obj = input.decodeJsonElement().jsonObject + val type = obj.getValue("type").jsonPrimitive.content + if (type == "ok") return MuxyResult.Ok + val value: JsonElement = obj.getValue("value") + val json = input.json + return when (type) { + "projects" -> MuxyResult.Projects(json.decodeFromJsonElement(ListSerializer(ProjectDTO.serializer()), value)) + "worktrees" -> MuxyResult.Worktrees(json.decodeFromJsonElement(ListSerializer(WorktreeDTO.serializer()), value)) + "workspace" -> MuxyResult.Workspace(json.decodeFromJsonElement(WorkspaceDTO.serializer(), value)) + "tab" -> MuxyResult.Tab(json.decodeFromJsonElement(TabDTO.serializer(), value)) + "terminalContent" -> MuxyResult.TerminalContent(json.decodeFromJsonElement(TerminalContentDTO.serializer(), value)) + "terminalCells" -> MuxyResult.TerminalCells(json.decodeFromJsonElement(TerminalCellsDTO.serializer(), value)) + "deviceInfo" -> MuxyResult.DeviceInfo(json.decodeFromJsonElement(DeviceInfoDTO.serializer(), value)) + "pairing" -> MuxyResult.Pairing(json.decodeFromJsonElement(PairingResultDTO.serializer(), value)) + "paneOwner" -> MuxyResult.PaneOwner(json.decodeFromJsonElement(PaneOwnerDTO.serializer(), value)) + "vcsStatus" -> MuxyResult.VCSStatus(json.decodeFromJsonElement(VCSStatusDTO.serializer(), value)) + "vcsBranches" -> MuxyResult.VCSBranches(json.decodeFromJsonElement(VCSBranchesDTO.serializer(), value)) + "vcsPRCreated" -> MuxyResult.VCSPRCreated(json.decodeFromJsonElement(VCSCreatePRResultDTO.serializer(), value)) + "projectLogo" -> MuxyResult.ProjectLogo(json.decodeFromJsonElement(ProjectLogoDTO.serializer(), value)) + "notifications" -> MuxyResult.Notifications(json.decodeFromJsonElement(ListSerializer(NotificationDTO.serializer()), value)) + else -> error("Unknown MuxyResult type: $type") + } + } +} diff --git a/android/protocol/src/test/kotlin/com/muxy/protocol/MuxyCodecTest.kt b/android/protocol/src/test/kotlin/com/muxy/protocol/MuxyCodecTest.kt new file mode 100644 index 0000000..01a8f69 --- /dev/null +++ b/android/protocol/src/test/kotlin/com/muxy/protocol/MuxyCodecTest.kt @@ -0,0 +1,481 @@ +package com.muxy.protocol + +import com.muxy.protocol.codec.MuxyCodec +import com.muxy.protocol.dto.AuthenticateDeviceParams +import com.muxy.protocol.dto.GitFileStatusDTO +import com.muxy.protocol.dto.NotificationDTO +import com.muxy.protocol.dto.NotificationSourceDTO +import com.muxy.protocol.dto.PairingResultDTO +import com.muxy.protocol.dto.PaneOwnerDTO +import com.muxy.protocol.dto.ProjectDTO +import com.muxy.protocol.dto.SelectProjectParams +import com.muxy.protocol.dto.SplitBranchDTO +import com.muxy.protocol.dto.SplitDirectionDTO +import com.muxy.protocol.dto.SplitNodeDTO +import com.muxy.protocol.dto.TabAreaDTO +import com.muxy.protocol.dto.TabDTO +import com.muxy.protocol.dto.TabKindDTO +import com.muxy.protocol.dto.TerminalInputParams +import com.muxy.protocol.dto.VCSStatusDTO +import com.muxy.protocol.dto.WorkspaceDTO +import com.muxy.protocol.dto.WorktreeDTO +import com.muxy.protocol.envelope.MuxyError +import com.muxy.protocol.envelope.MuxyEventData +import com.muxy.protocol.envelope.MuxyEventKind +import com.muxy.protocol.envelope.MuxyMessage +import com.muxy.protocol.envelope.MuxyMethod +import com.muxy.protocol.envelope.MuxyParams +import com.muxy.protocol.envelope.MuxyRequest +import com.muxy.protocol.envelope.MuxyResponse +import com.muxy.protocol.envelope.MuxyResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.util.UUID + +class MuxyCodecTest { + private val timestamp: Instant = Instant.parse("2026-05-01T12:34:56Z") + private val projectID = UUID.fromString("11111111-1111-1111-1111-111111111111") + private val worktreeID = UUID.fromString("22222222-2222-2222-2222-222222222222") + private val areaID = UUID.fromString("33333333-3333-3333-3333-333333333333") + private val tabID = UUID.fromString("44444444-4444-4444-4444-444444444444") + private val paneID = UUID.fromString("55555555-5555-5555-5555-555555555555") + private val deviceID = UUID.fromString("66666666-6666-6666-6666-666666666666") + private val clientID = UUID.fromString("77777777-7777-7777-7777-777777777777") + + @Test + fun `request envelope serializes type and payload`() { + val request = + MuxyRequest( + id = "req-1", + method = MuxyMethod.LIST_PROJECTS, + params = null, + ) + val message = MuxyMessage.Request(request) + val text = MuxyCodec.encode(message) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals("request", obj.getValue("type").jsonPrimitive.content) + val payload = obj.getValue("payload").jsonObject + assertEquals("req-1", payload.getValue("id").jsonPrimitive.content) + assertEquals("listProjects", payload.getValue("method").jsonPrimitive.content) + assertNull(payload["params"]) + } + + @Test + fun `response envelope encodes ok result without value key`() { + val response = MuxyResponse(id = "r-1", result = MuxyResult.Ok) + val message = MuxyMessage.Response(response) + val obj = Json.parseToJsonElement(MuxyCodec.encode(message)).jsonObject + assertEquals("response", obj.getValue("type").jsonPrimitive.content) + val payload = obj.getValue("payload").jsonObject + val result = payload.getValue("result").jsonObject + assertEquals("ok", result.getValue("type").jsonPrimitive.content) + assertNull(result["value"]) + } + + @Test + fun `response envelope decodes ok shape`() { + val raw = + """ + {"type":"response","payload":{"id":"r-2","result":{"type":"ok"}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + assertTrue(message is MuxyMessage.Response) + val response = (message as MuxyMessage.Response).value + assertEquals("r-2", response.id) + assertTrue(response.result is MuxyResult.Ok) + assertNull(response.error) + } + + @Test + fun `error response decodes code and message`() { + val raw = + """ + {"type":"response","payload":{"id":"r-3","error":{"code":401,"message":"Authentication required"}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + val response = (message as MuxyMessage.Response).value + assertEquals(MuxyError(code = 401, message = "Authentication required"), response.error) + assertNull(response.result) + } + + @Test + fun `params shape uses inner type and value keys`() { + val request = + MuxyRequest( + id = "p-1", + method = MuxyMethod.SELECT_PROJECT, + params = MuxyParams.SelectProject(SelectProjectParams(projectID)), + ) + val message = MuxyMessage.Request(request) + val obj = Json.parseToJsonElement(MuxyCodec.encode(message)).jsonObject + val params = obj.getValue("payload").jsonObject.getValue("params").jsonObject + assertEquals("selectProject", params.getValue("type").jsonPrimitive.content) + val value = params.getValue("value").jsonObject + assertEquals(projectID.toString().uppercase(), value.getValue("projectID").jsonPrimitive.content) + } + + @Test + fun `terminalInput params encodes bytes as base64 string`() { + val bytes = byteArrayOf(0x68, 0x69, 0x0A) + val params = MuxyParams.TerminalInput(TerminalInputParams(paneID, bytes)) + val request = MuxyRequest(id = "t-1", method = MuxyMethod.TERMINAL_INPUT, params = params) + val text = MuxyCodec.encode(MuxyMessage.Request(request)) + val obj = Json.parseToJsonElement(text).jsonObject + val value = + obj.getValue("payload").jsonObject + .getValue("params").jsonObject + .getValue("value").jsonObject + val encoded = value.getValue("bytes").jsonPrimitive.content + assertEquals("aGkK", encoded) + + val roundTrip = MuxyCodec.decode(text) + val decoded = ((roundTrip as MuxyMessage.Request).value.params as MuxyParams.TerminalInput).value + assertTrue(decoded.bytes.contentEquals(bytes)) + } + + @Test + fun `auth params round-trip preserves uppercase UUID and string token`() { + val params = + MuxyParams.AuthenticateDevice( + AuthenticateDeviceParams( + deviceID = deviceID, + deviceName = "Pixel 8", + token = "ZmFrZS10b2tlbg==", + ), + ) + val request = MuxyRequest(id = "a-1", method = MuxyMethod.AUTHENTICATE_DEVICE, params = params) + val text = MuxyCodec.encode(MuxyMessage.Request(request)) + val value = + Json.parseToJsonElement(text).jsonObject + .getValue("payload").jsonObject + .getValue("params").jsonObject + .getValue("value").jsonObject + assertEquals(deviceID.toString().uppercase(), value.getValue("deviceID").jsonPrimitive.content) + assertEquals("ZmFrZS10b2tlbg==", value.getValue("token").jsonPrimitive.content) + } + + @Test + fun `pairing result roundtrip preserves theme palette`() { + val pairing = + PairingResultDTO( + clientID = clientID, + deviceName = "Mac", + themeFg = 0xFFEEDDu, + themeBg = 0x101010u, + themePalette = listOf(0u, 1u, 2u, 3u, 0xFFFFFFu), + ) + val response = MuxyResponse(id = "x-1", result = MuxyResult.Pairing(pairing)) + val text = MuxyCodec.encode(MuxyMessage.Response(response)) + val decoded = MuxyCodec.decode(text) + val out = ((decoded as MuxyMessage.Response).value.result as MuxyResult.Pairing).value + assertEquals(pairing, out) + } + + @Test + fun `splitNode tabArea wire shape uses keyed inner field`() { + val tabArea = + TabAreaDTO( + id = areaID, + projectPath = "/p", + tabs = + listOf( + TabDTO(id = tabID, kind = TabKindDTO.TERMINAL, title = "zsh", isPinned = false, paneID = paneID), + ), + activeTabID = tabID, + ) + val node: SplitNodeDTO = SplitNodeDTO.TabArea(tabArea) + val text = MuxyCodec.json.encodeToString(SplitNodeDTO.serializer(), node) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals("tabArea", obj.getValue("type").jsonPrimitive.content) + assertNotNull(obj["tabArea"]) + assertNull(obj["value"]) + } + + @Test + fun `splitNode split wire shape uses keyed inner field`() { + val branch = + SplitBranchDTO( + id = areaID, + direction = SplitDirectionDTO.HORIZONTAL, + ratio = 0.5, + first = SplitNodeDTO.TabArea(emptyArea(areaID)), + second = SplitNodeDTO.TabArea(emptyArea(UUID.fromString("AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA"))), + ) + val node: SplitNodeDTO = SplitNodeDTO.Split(branch) + val text = MuxyCodec.json.encodeToString(SplitNodeDTO.serializer(), node) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals("split", obj.getValue("type").jsonPrimitive.content) + assertNotNull(obj["split"]) + } + + @Test + fun `workspace round-trips through nested splits`() { + val ws = + WorkspaceDTO( + projectID = projectID, + worktreeID = worktreeID, + focusedAreaID = areaID, + root = + SplitNodeDTO.Split( + SplitBranchDTO( + id = areaID, + direction = SplitDirectionDTO.VERTICAL, + ratio = 0.4, + first = SplitNodeDTO.TabArea(emptyArea(areaID)), + second = SplitNodeDTO.TabArea(emptyArea(tabID)), + ), + ), + ) + val text = MuxyCodec.json.encodeToString(WorkspaceDTO.serializer(), ws) + val parsed = MuxyCodec.json.decodeFromString(WorkspaceDTO.serializer(), text) + assertEquals(ws, parsed) + } + + @Test + fun `paneOwner mac uses single-key wire shape`() { + val owner: PaneOwnerDTO = PaneOwnerDTO.Mac(deviceName = "MacBook") + val text = MuxyCodec.json.encodeToString(PaneOwnerDTO.serializer(), owner) + val obj = Json.parseToJsonElement(text).jsonObject + assertNotNull(obj["mac"]) + assertEquals("MacBook", obj.getValue("mac").jsonObject.getValue("deviceName").jsonPrimitive.content) + val parsed = MuxyCodec.json.decodeFromString(PaneOwnerDTO.serializer(), text) + assertEquals(owner, parsed) + } + + @Test + fun `paneOwner remote serializes UUID and name`() { + val owner: PaneOwnerDTO = PaneOwnerDTO.Remote(deviceID = deviceID, deviceName = "Pixel") + val text = MuxyCodec.json.encodeToString(PaneOwnerDTO.serializer(), owner) + val obj = Json.parseToJsonElement(text).jsonObject + val inner = obj.getValue("remote").jsonObject + assertEquals(deviceID.toString().uppercase(), inner.getValue("deviceID").jsonPrimitive.content) + assertEquals("Pixel", inner.getValue("deviceName").jsonPrimitive.content) + val parsed = MuxyCodec.json.decodeFromString(PaneOwnerDTO.serializer(), text) + assertEquals(owner, parsed) + } + + @Test + fun `pane ownership event decodes from wire shape`() { + val raw = + """ + {"type":"event","payload":{"event":"paneOwnershipChanged","data":{"type":"paneOwnership","value":{"paneID":"55555555-5555-5555-5555-555555555555","owner":{"remote":{"deviceID":"66666666-6666-6666-6666-666666666666","deviceName":"Phone"}}}}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + val event = (message as MuxyMessage.Event).value + assertEquals(MuxyEventKind.PANE_OWNERSHIP_CHANGED, event.event) + val data = event.data as MuxyEventData.PaneOwnership + assertEquals(paneID, data.value.paneID) + assertEquals(PaneOwnerDTO.Remote(deviceID, "Phone"), data.value.owner) + } + + @Test + fun `terminal output event decodes base64 bytes`() { + val raw = + """ + {"type":"event","payload":{"event":"terminalOutput","data":{"type":"terminalOutput","value":{"paneID":"55555555-5555-5555-5555-555555555555","bytes":"aGkK"}}}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + val data = (message as MuxyMessage.Event).value.data as MuxyEventData.TerminalOutput + assertTrue(data.value.bytes.contentEquals(byteArrayOf(0x68, 0x69, 0x0A))) + } + + @Test + fun `terminal snapshot event matches output event shape`() { + val raw = + """ + {"type":"event","payload":{"event":"terminalSnapshot","data":{"type":"terminalSnapshot","value":{"paneID":"55555555-5555-5555-5555-555555555555","bytes":"aGVsbG8="}}}} + """.trimIndent() + val event = (MuxyCodec.decode(raw) as MuxyMessage.Event).value + assertEquals(MuxyEventKind.TERMINAL_SNAPSHOT, event.event) + val data = event.data as MuxyEventData.TerminalSnapshot + assertTrue(data.value.bytes.contentEquals("hello".toByteArray())) + } + + @Test + fun `theme changed event maps to deviceTheme data case`() { + val raw = + """ + {"type":"event","payload":{"event":"themeChanged","data":{"type":"deviceTheme","value":{"fg":16777215,"bg":0,"palette":[0,1,2]}}}} + """.trimIndent() + val event = (MuxyCodec.decode(raw) as MuxyMessage.Event).value + assertEquals(MuxyEventKind.THEME_CHANGED, event.event) + val data = event.data as MuxyEventData.DeviceTheme + assertEquals(0xFFFFFFu, data.value.fg) + assertEquals(0u, data.value.bg) + assertEquals(listOf(0u, 1u, 2u), data.value.palette) + } + + @Test + fun `notification source aiProvider uses _0 unlabeled key`() { + val notification = + NotificationDTO( + id = UUID.randomUUID(), + paneID = paneID, + projectID = projectID, + worktreeID = worktreeID, + areaID = areaID, + tabID = tabID, + source = NotificationSourceDTO.AiProvider("openai"), + title = "Title", + body = "Body", + timestamp = timestamp, + isRead = false, + ) + val text = MuxyCodec.json.encodeToString(NotificationDTO.serializer(), notification) + val source = Json.parseToJsonElement(text).jsonObject.getValue("source").jsonObject + val provider = source.getValue("aiProvider").jsonObject.getValue("_0").jsonPrimitive.content + assertEquals("openai", provider) + val parsed = MuxyCodec.json.decodeFromString(NotificationDTO.serializer(), text) + assertEquals(notification, parsed) + } + + @Test + fun `notification source osc and socket are object-empty`() { + val sources = listOf(NotificationSourceDTO.Osc, NotificationSourceDTO.Socket) + for (source in sources) { + val text = MuxyCodec.json.encodeToString(NotificationSourceDTO.serializer(), source) + val obj = Json.parseToJsonElement(text).jsonObject + val key = if (source is NotificationSourceDTO.Osc) "osc" else "socket" + assertNotNull(obj[key]) + val parsed = MuxyCodec.json.decodeFromString(NotificationSourceDTO.serializer(), text) + assertEquals(source, parsed) + } + } + + @Test + fun `worktree without canBeRemoved field decodes to negation of isPrimary`() { + val raw = + """ + {"id":"22222222-2222-2222-2222-222222222222","name":"main","path":"/p","isPrimary":true,"createdAt":"2026-05-01T12:34:56Z"} + """.trimIndent() + val parsed = MuxyCodec.json.decodeFromString(WorktreeDTO.serializer(), raw) + assertEquals(false, parsed.canBeRemoved) + assertEquals(true, parsed.isPrimary) + assertNull(parsed.branch) + } + + @Test + fun `worktree always emits canBeRemoved on encode`() { + val tree = + WorktreeDTO( + id = worktreeID, + name = "main", + path = "/p", + branch = "main", + isPrimary = true, + canBeRemoved = false, + createdAt = timestamp, + ) + val text = MuxyCodec.json.encodeToString(WorktreeDTO.serializer(), tree) + val obj = Json.parseToJsonElement(text).jsonObject + assertEquals(false, obj.getValue("canBeRemoved").jsonPrimitive.boolean) + assertEquals("main", obj.getValue("branch").jsonPrimitive.content) + } + + @Test + fun `worktree omits null branch on encode`() { + val tree = + WorktreeDTO( + id = worktreeID, + name = "main", + path = "/p", + branch = null, + isPrimary = true, + createdAt = timestamp, + ) + val text = MuxyCodec.json.encodeToString(WorktreeDTO.serializer(), tree) + val obj = Json.parseToJsonElement(text).jsonObject + assertNull(obj["branch"]) + } + + @Test + fun `project DTO round-trips with optional logo and color`() { + val project = + ProjectDTO( + id = projectID, + name = "Muxy", + path = "/Users/me/dev/muxy", + sortOrder = 0, + createdAt = timestamp, + icon = "folder", + logo = "L", + iconColor = "blue", + ) + val text = MuxyCodec.json.encodeToString(ProjectDTO.serializer(), project) + val parsed = MuxyCodec.json.decodeFromString(ProjectDTO.serializer(), text) + assertEquals(project, parsed) + } + + @Test + fun `vcs status with pull request decodes`() { + val raw = + """ + { + "branch":"main", + "aheadCount":1, + "behindCount":0, + "hasUpstream":true, + "stagedFiles":[{"path":"a.swift","status":"modified","isUntracked":false}], + "changedFiles":[], + "defaultBranch":"main", + "pullRequest":{"url":"https://github.com/x/y/pull/1","number":1,"state":"open","isDraft":false,"baseBranch":"main"} + } + """.trimIndent() + val parsed = MuxyCodec.json.decodeFromString(VCSStatusDTO.serializer(), raw) + assertEquals(1, parsed.aheadCount) + assertEquals(0, parsed.behindCount) + assertEquals(GitFileStatusDTO.MODIFIED, parsed.stagedFiles.first().status) + assertEquals("https://github.com/x/y/pull/1", parsed.pullRequest?.url) + } + + @Test + fun `subscribe params encodes events as method strings`() { + val request = + MuxyRequest( + id = "s-1", + method = MuxyMethod.SUBSCRIBE, + params = + MuxyParams.Subscribe( + com.muxy.protocol.dto.SubscribeParams( + events = listOf(MuxyEventKind.WORKSPACE_CHANGED, MuxyEventKind.TERMINAL_OUTPUT), + ), + ), + ) + val text = MuxyCodec.encode(MuxyMessage.Request(request)) + val list = + Json.parseToJsonElement(text).jsonObject + .getValue("payload").jsonObject + .getValue("params").jsonObject + .getValue("value").jsonObject + .getValue("events").jsonArray + assertEquals("workspaceChanged", list[0].jsonPrimitive.content) + assertEquals("terminalOutput", list[1].jsonPrimitive.content) + } + + @Test + fun `unknown keys in incoming payloads are ignored`() { + val raw = + """ + {"type":"response","payload":{"id":"u-1","result":{"type":"ok"},"extraField":42}} + """.trimIndent() + val message = MuxyCodec.decode(raw) + assertTrue((message as MuxyMessage.Response).value.result is MuxyResult.Ok) + } + + private fun emptyArea(id: UUID) = + TabAreaDTO( + id = id, + projectPath = "/x", + tabs = emptyList(), + activeTabID = null, + ) +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 68bacf3..f0a1b32 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,5 +20,9 @@ dependencyResolutionManagement { } } -rootProject.name = "MuxyMobile" +rootProject.name = "Muxy" + include(":app") +include(":protocol") +include(":net") +include(":terminal") diff --git a/android/store/listing.md b/android/store/listing.md new file mode 100644 index 0000000..846212e --- /dev/null +++ b/android/store/listing.md @@ -0,0 +1,66 @@ +# Muxy Android — store listing copy + +These are working copy stubs for any future Play Store / F-Droid submission. v1 +ships via GitHub Releases (sideload only); update before any store submission. + +## Title + +Muxy + +## Short description (80 chars max) + +Remote-control Muxy, the macOS terminal multiplexer, from your Android device. + +## Full description + +Muxy for Android is a remote-control client for the Muxy desktop app on macOS. +Pair once over Tailscale or any trusted network, and you can: + +- Browse projects, worktrees, and tabs from your phone or tablet +- Take over a terminal pane and see live output rendered with the Termux + terminal core +- Type into the pane with a custom accessory bar tuned for terminal use: + Esc, Tab, Ctrl/Shift/Alt/Cmd modifiers, paste, and an analog D-pad +- Run a full git workflow against the active project: stage, commit, push, + pull, branches, worktrees, and pull-request creation +- Get a notification feed pulled from Muxy's notification store + +You still run all your terminals on the Mac. Muxy for Android is a thin +remote, not a local terminal — your zsh/tmux/nvim stays on macOS where it +belongs. + +## Privacy + +Muxy for Android stores its pairing token encrypted by an Android Keystore +key on this device only. Nothing is uploaded to a Muxy server because +there is no Muxy server — every byte of terminal output flows directly +from your Mac over the network you control. + +The desktop server speaks plain WebSocket (no TLS). Use only on a trusted +network: a VPN, Tailscale, or a private LAN you control. + +## Categories + +- Productivity / Developer Tools + +## Tags + +terminal, ssh, tmux, remote, developer, android, mac, macos, ghostty, termux + +## Screenshots (TODO) + +- 1080×2400 phone: connect screen, project list, terminal with accessory + bar visible, VCS sheet, branches sheet, error report sheet +- 2560×1600 tablet: workspace with terminal, settings sheet + +## Promo video (TODO) + +90-second walkthrough: pair → open project → take over pane → type a git +command → push → switch branch. + +## License notes (Play Store / F-Droid) + +The Android binary is GPL-3.0 because it vendors Termux's terminal-emulator +and terminal-view. Source code is at github.com/muxy-app/muxy under the +`android/` directory. Releases attach the source tag (or commit) used to +build the APK alongside the artifact. diff --git a/android/terminal/build.gradle.kts b/android/terminal/build.gradle.kts new file mode 100644 index 0000000..9c00635 --- /dev/null +++ b/android/terminal/build.gradle.kts @@ -0,0 +1,83 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.muxy.terminal" + compileSdk = 35 + + defaultConfig { + minSdk = 31 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + sourceSets { + named("main") { + java.srcDirs( + "src/main/java", + "vendor/terminal-emulator/src/main/java", + "vendor/terminal-view/src/main/java", + ) + res.srcDirs( + "src/main/res", + "vendor/terminal-view/src/main/res", + ) + } + named("test") { + java.srcDirs("src/test/kotlin") + } + } + + lint { + disable += + setOf( + "DefaultLocale", + "ObsoleteSdkInt", + "WrongConstant", + "ClickableViewAccessibility", + "RtlHardcoded", + "InflateParams", + "UseCompatLoadingForDrawables", + "Recycle", + "UseCompatTextViewDrawableXml", + "PrivateApi", + ) + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + api(project(":protocol")) + api(project(":net")) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.annotation) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + + testImplementation(libs.junit) + + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/terminal/src/main/assets/fonts/JetBrainsMonoNerdFontMono-Bold.ttf b/android/terminal/src/main/assets/fonts/JetBrainsMonoNerdFontMono-Bold.ttf new file mode 100644 index 0000000..ccfa8a2 Binary files /dev/null and b/android/terminal/src/main/assets/fonts/JetBrainsMonoNerdFontMono-Bold.ttf differ diff --git a/android/terminal/src/main/assets/fonts/JetBrainsMonoNerdFontMono-Regular.ttf b/android/terminal/src/main/assets/fonts/JetBrainsMonoNerdFontMono-Regular.ttf new file mode 100644 index 0000000..99f2e9b Binary files /dev/null and b/android/terminal/src/main/assets/fonts/JetBrainsMonoNerdFontMono-Regular.ttf differ diff --git a/android/terminal/src/main/java/com/muxy/terminal/AccessoryAction.kt b/android/terminal/src/main/java/com/muxy/terminal/AccessoryAction.kt new file mode 100644 index 0000000..9394049 --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/AccessoryAction.kt @@ -0,0 +1,9 @@ +package com.muxy.terminal + +interface AccessoryActions { + fun sendText(text: String) + + fun pasteFromClipboard() + + fun toggleKeyboard() +} diff --git a/android/terminal/src/main/java/com/muxy/terminal/ArmedModifier.kt b/android/terminal/src/main/java/com/muxy/terminal/ArmedModifier.kt new file mode 100644 index 0000000..87fc9ae --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/ArmedModifier.kt @@ -0,0 +1,35 @@ +package com.muxy.terminal + +enum class ArmedModifier(val displayName: String, val glyph: String) { + CTRL("control", "⌃"), + SHIFT("shift", "⇧"), + ALT("option", "⌥"), + CMD("command", "⌘"), +} + +object ModifierTransform { + private const val ESC: String = "\u001B" + private const val NUL: String = "\u0000" + + fun transform( + text: String, + modifier: ArmedModifier, + ): String? = + when (modifier) { + ArmedModifier.CTRL -> ctrlTransform(text) + ArmedModifier.SHIFT -> text.uppercase() + ArmedModifier.ALT -> ESC + text + ArmedModifier.CMD -> text + } + + private fun ctrlTransform(text: String): String? { + if (text.length != 1) return null + val value = text[0].code + return when (value) { + in 0x40..0x5F -> (value - 0x40).toChar().toString() + in 0x61..0x7A -> (value - 0x60).toChar().toString() + 0x20 -> NUL + else -> null + } + } +} diff --git a/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalSession.kt b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalSession.kt new file mode 100644 index 0000000..596e53a --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalSession.kt @@ -0,0 +1,41 @@ +package com.muxy.terminal + +import com.muxy.net.MuxyClient +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient +import java.util.UUID + +class MuxyTerminalSession( + private val client: MuxyClient, + val paneID: UUID, + sessionClient: TerminalSessionClient, + transcriptRows: Int? = DEFAULT_TRANSCRIPT_ROWS, +) : TerminalSession(transcriptRows, sessionClient) { + override fun write( + data: ByteArray, + offset: Int, + count: Int, + ) { + if (count <= 0) return + val payload = + if (offset == 0 && count == data.size) { + data.copyOf() + } else { + data.copyOfRange(offset, offset + count) + } + client.sendTerminalInput(paneID = paneID, bytes = payload) + } + + fun acceptRemoteOutput(bytes: ByteArray) { + if (bytes.isEmpty()) return + feedRemoteOutput(bytes, bytes.size) + } + + fun resetEmulatorScreen() { + getEmulator()?.reset() + } + + companion object { + const val DEFAULT_TRANSCRIPT_ROWS: Int = 2000 + } +} diff --git a/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalSessionClient.kt b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalSessionClient.kt new file mode 100644 index 0000000..dd69dc8 --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalSessionClient.kt @@ -0,0 +1,109 @@ +package com.muxy.terminal + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.util.Log +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient + +internal class MuxyTerminalSessionClient( + private val context: Context, +) : TerminalSessionClient { + var onTextChanged: (() -> Unit)? = null + var onTitleChanged: (() -> Unit)? = null + var onColorsChanged: (() -> Unit)? = null + var onPasteRequested: (() -> Unit)? = null + + override fun onTextChanged(changedSession: TerminalSession) { + onTextChanged?.invoke() + } + + override fun onTitleChanged(changedSession: TerminalSession) { + onTitleChanged?.invoke() + } + + override fun onSessionFinished(finishedSession: TerminalSession) = Unit + + override fun onCopyTextToClipboard( + session: TerminalSession, + text: String?, + ) { + if (text.isNullOrEmpty()) return + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText("muxy-terminal", text)) + } + + override fun onPasteTextFromClipboard(session: TerminalSession?) { + onPasteRequested?.invoke() + } + + override fun onBell(session: TerminalSession) = Unit + + override fun onColorsChanged(session: TerminalSession) { + onColorsChanged?.invoke() + } + + override fun onTerminalCursorStateChange(state: Boolean) = Unit + + override fun setTerminalShellPid( + session: TerminalSession, + pid: Int, + ) = Unit + + override fun getTerminalCursorStyle(): Int? = null + + override fun logError( + tag: String?, + message: String?, + ) { + Log.e(tag ?: TAG, message ?: "") + } + + override fun logWarn( + tag: String?, + message: String?, + ) { + Log.w(tag ?: TAG, message ?: "") + } + + override fun logInfo( + tag: String?, + message: String?, + ) { + Log.i(tag ?: TAG, message ?: "") + } + + override fun logDebug( + tag: String?, + message: String?, + ) { + Log.d(tag ?: TAG, message ?: "") + } + + override fun logVerbose( + tag: String?, + message: String?, + ) { + Log.v(tag ?: TAG, message ?: "") + } + + override fun logStackTraceWithMessage( + tag: String?, + message: String?, + e: Exception?, + ) { + Log.e(tag ?: TAG, message ?: "", e) + } + + override fun logStackTrace( + tag: String?, + e: Exception?, + ) { + Log.e(tag ?: TAG, "", e) + } + + private companion object { + const val TAG = "MuxyTerminal" + } +} diff --git a/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalView.kt b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalView.kt new file mode 100644 index 0000000..899c2d8 --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalView.kt @@ -0,0 +1,330 @@ +package com.muxy.terminal + +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Typeface +import android.view.View +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.muxy.net.DeviceTheme +import com.muxy.net.MuxyClient +import com.muxy.protocol.dto.PaneOwnerDTO +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TextStyle +import com.termux.view.TerminalView +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.UUID + +@Composable +fun MuxyTerminalView( + client: MuxyClient, + paneID: UUID, + fontSizeSp: Int = 12, + useNerdFont: Boolean = false, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val theme by client.deviceTheme.collectAsStateOrNull() + val owners by client.paneOwners.collectAsStateOrNull() + val myClientID by client.myClientID.collectAsStateOrNull() + val sessionEpoch by client.sessionEpoch.collectAsStateOrNull() + val typeface = remember(useNerdFont) { resolveTypeface(context, useNerdFont) } + + val foreground = theme?.let { rgbColor(it.fg) } ?: Color.White + val background = theme?.let { rgbColor(it.bg) } ?: Color.Black + + var armed by remember { mutableStateOf(null) } + var activeModifier by remember { mutableStateOf(ArmedModifier.CTRL) } + var keyboardVisible by remember { mutableStateOf(false) } + var reportedCols by remember { mutableStateOf(null) } + var reportedRows by remember { mutableStateOf(null) } + var autoTakenPaneID by remember { mutableStateOf(null) } + + val sessionClient = remember(context) { MuxyTerminalSessionClient(context) } + val viewClient = remember { MuxyTerminalViewClient() } + val session = + remember(client, paneID) { + MuxyTerminalSession(client = client, paneID = paneID, sessionClient = sessionClient) + } + val terminalViewRef = remember { mutableStateOf(null) } + val sizeReporter = + remember(client, paneID) { + SizeReporter(client = client, paneID = paneID, scope = scope) { cols, rows -> + reportedCols = cols + reportedRows = rows + } + } + + viewClient.modifierProvider = { armed } + sessionClient.onPasteRequested = { + pasteClipboardThrough(context, session) + } + + LaunchedEffect(client, paneID) { + client.terminalBytes(paneID).collectLatest { bytes -> + session.acceptRemoteOutput(bytes) + terminalViewRef.value?.onScreenUpdated() + } + } + + LaunchedEffect(sessionEpoch) { + autoTakenPaneID = null + } + + LaunchedEffect(paneID, reportedCols, reportedRows, sessionEpoch) { + val cols = reportedCols ?: return@LaunchedEffect + val rows = reportedRows ?: return@LaunchedEffect + if (autoTakenPaneID == paneID) return@LaunchedEffect + autoTakenPaneID = paneID + session.resetEmulatorScreen() + client.takeOverPane(paneID = paneID, cols = cols, rows = rows) + } + + DisposableEffect(client, paneID) { + onDispose { + scope.launch { client.releasePane(paneID) } + session.finishIfRunning() + } + } + + val actions = + remember(session, terminalViewRef, context) { + object : AccessoryActions { + override fun sendText(text: String) { + if (text.isEmpty()) return + val transformed = armed?.let { ModifierTransform.transform(text, it) } ?: text + if (armed != null) armed = null + if (transformed.isNotEmpty()) { + val bytes = transformed.toByteArray(Charsets.UTF_8) + client.sendTerminalInput(paneID = paneID, bytes = bytes) + } + } + + override fun pasteFromClipboard() { + pasteClipboardThrough(context, session) + } + + override fun toggleKeyboard() { + val view = terminalViewRef.value ?: return + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager ?: return + keyboardVisible = !keyboardVisible + if (keyboardVisible) { + view.isFocusable = true + view.isFocusableInTouchMode = true + view.requestFocus() + view.post { + imm.showSoftInput(view, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) + } + } else { + imm.hideSoftInputFromWindow(view.windowToken, 0) + } + } + } + } + + val ownerName = + remember(owners, paneID) { + when (val owner = owners?.get(paneID)) { + is PaneOwnerDTO.Mac -> owner.deviceName + is PaneOwnerDTO.Remote -> owner.deviceName + null -> "Mac" + } + } + val isOwnedBySelf = + remember(owners, myClientID, paneID) { + val mine = myClientID ?: return@remember false + val owner = owners?.get(paneID) ?: return@remember false + owner is PaneOwnerDTO.Remote && owner.deviceID == mine + } + + Column( + modifier = + modifier + .fillMaxSize() + .background(background) + .imePadding(), + ) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + TerminalView(ctx, null).apply { + setTerminalViewClient(viewClient) + setTextSize(spToPx(ctx, fontSizeSp).toInt()) + setTypeface(typeface) + attachSession(session) + applyTheme(theme, this) + sizeReporter.attach(this) + terminalViewRef.value = this + } + }, + update = { view -> + view.attachSession(session) + applyTheme(theme, view) + sizeReporter.attach(view) + if (view.mEmulator != null) { + view.setTextSize(spToPx(context, fontSizeSp).toInt()) + view.setTypeface(typeface) + } + view.alpha = if (isOwnedBySelf) 1f else 0f + view.isFocusable = isOwnedBySelf + view.isFocusableInTouchMode = isOwnedBySelf + }, + ) + + if (!isOwnedBySelf) { + TakeOverOverlay( + ownerName = ownerName, + foreground = foreground, + background = background, + onTakeOver = { + val cols = reportedCols + val rows = reportedRows + if (cols != null && rows != null) { + scope.launch { + session.resetEmulatorScreen() + client.takeOverPane(paneID = paneID, cols = cols, rows = rows) + } + } + }, + ) + } + } + + TerminalAccessoryBar( + actions = actions, + armedModifier = armed, + activeModifier = activeModifier, + onToggleArm = { + armed = if (armed == null) activeModifier else null + }, + onSelectModifier = { picked -> + activeModifier = picked + if (armed != null) armed = null + }, + foreground = foreground, + background = background, + keyboardVisible = keyboardVisible, + ) + } +} + +private fun pasteClipboardThrough( + context: Context, + session: MuxyTerminalSession, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + val text = clipboard.primaryClip?.getItemAt(0)?.coerceToText(context)?.toString() ?: return + if (text.isEmpty()) return + val bytes = text.toByteArray(Charsets.UTF_8) + session.write(bytes, 0, bytes.size) +} + +private fun applyTheme( + theme: DeviceTheme?, + view: TerminalView, +) { + val emulator: TerminalEmulator = view.mEmulator ?: return + val fg = (theme?.fg ?: 0xFFFFFFu).toInt() or 0xFF000000.toInt() + val bg = (theme?.bg ?: 0x000000u).toInt() or 0xFF000000.toInt() + emulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_FOREGROUND] = fg + emulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_BACKGROUND] = bg + emulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] = fg + val palette = theme?.palette + if (palette != null && palette.size == 16) { + for (i in 0 until 16) { + emulator.mColors.mCurrentColors[i] = palette[i].toInt() or 0xFF000000.toInt() + } + } + view.setBackgroundColor(bg) + view.invalidate() +} + +private class SizeReporter( + private val client: MuxyClient, + private val paneID: UUID, + private val scope: kotlinx.coroutines.CoroutineScope, + private val onSize: (UInt, UInt) -> Unit, +) { + private var lastCols: Int = 0 + private var lastRows: Int = 0 + private var attached: View? = null + private val listener = + View.OnLayoutChangeListener { v, _, _, _, _, _, _, _, _ -> + report(v as TerminalView) + } + + fun attach(view: TerminalView) { + if (attached === view) { + report(view) + return + } + attached?.removeOnLayoutChangeListener(listener) + view.removeOnLayoutChangeListener(listener) + view.addOnLayoutChangeListener(listener) + attached = view + report(view) + } + + private fun report(view: TerminalView) { + val emulator = view.mEmulator ?: return + val cols = emulator.mColumns + val rows = emulator.mRows + if (cols <= 0 || rows <= 0) return + if (cols == lastCols && rows == lastRows) return + lastCols = cols + lastRows = rows + onSize(cols.toUInt(), rows.toUInt()) + scope.launch { client.resizeTerminal(paneID = paneID, cols = cols.toUInt(), rows = rows.toUInt()) } + } +} + +private fun TerminalView.onScreenUpdated() { + invalidate() +} + +@Composable +private fun StateFlow.collectAsStateOrNull(): State = collectAsState() + +private fun rgbColor(rgb: UInt): Color { + val r = ((rgb shr 16) and 0xFFu).toInt() + val g = ((rgb shr 8) and 0xFFu).toInt() + val b = (rgb and 0xFFu).toInt() + return Color(red = r, green = g, blue = b) +} + +private fun spToPx( + context: Context, + sp: Int, +): Float = sp * context.resources.displayMetrics.scaledDensity + +private fun resolveTypeface( + context: Context, + useNerdFont: Boolean, +): Typeface { + if (!useNerdFont) return Typeface.MONOSPACE + return runCatching { + Typeface.createFromAsset(context.assets, "fonts/JetBrainsMonoNerdFontMono-Regular.ttf") + }.getOrDefault(Typeface.MONOSPACE) +} diff --git a/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalViewClient.kt b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalViewClient.kt new file mode 100644 index 0000000..60d4dc4 --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/MuxyTerminalViewClient.kt @@ -0,0 +1,108 @@ +package com.muxy.terminal + +import android.util.Log +import android.view.KeyEvent +import android.view.MotionEvent +import com.termux.terminal.TerminalSession +import com.termux.view.TerminalViewClient + +internal class MuxyTerminalViewClient : TerminalViewClient { + var modifierProvider: (() -> ArmedModifier?)? = null + + override fun onScale(scale: Float): Float = 1f + + override fun onSingleTapUp(e: MotionEvent?) = Unit + + override fun shouldBackButtonBeMappedToEscape(): Boolean = false + + override fun shouldEnforceCharBasedInput(): Boolean = true + + override fun shouldUseCtrlSpaceWorkaround(): Boolean = false + + override fun isTerminalViewSelected(): Boolean = true + + override fun copyModeChanged(copyMode: Boolean) = Unit + + override fun onKeyDown( + keyCode: Int, + e: KeyEvent?, + session: TerminalSession?, + ): Boolean = false + + override fun onKeyUp( + keyCode: Int, + e: KeyEvent?, + ): Boolean = false + + override fun onLongPress(event: MotionEvent?): Boolean = false + + override fun readControlKey(): Boolean = modifierProvider?.invoke() == ArmedModifier.CTRL + + override fun readAltKey(): Boolean = modifierProvider?.invoke() == ArmedModifier.ALT + + override fun readShiftKey(): Boolean = modifierProvider?.invoke() == ArmedModifier.SHIFT + + override fun readFnKey(): Boolean = false + + override fun onCodePoint( + codePoint: Int, + ctrlDown: Boolean, + session: TerminalSession?, + ): Boolean = false + + override fun onEmulatorSet() = Unit + + override fun logError( + tag: String?, + message: String?, + ) { + Log.e(tag ?: TAG, message ?: "") + } + + override fun logWarn( + tag: String?, + message: String?, + ) { + Log.w(tag ?: TAG, message ?: "") + } + + override fun logInfo( + tag: String?, + message: String?, + ) { + Log.i(tag ?: TAG, message ?: "") + } + + override fun logDebug( + tag: String?, + message: String?, + ) { + Log.d(tag ?: TAG, message ?: "") + } + + override fun logVerbose( + tag: String?, + message: String?, + ) { + Log.v(tag ?: TAG, message ?: "") + } + + override fun logStackTraceWithMessage( + tag: String?, + message: String?, + e: Exception?, + ) { + Log.e(tag ?: TAG, message ?: "", e) + } + + override fun logStackTrace( + tag: String?, + e: Exception?, + ) { + Log.e(tag ?: TAG, "", e) + } + + private companion object { + const val TAG = "MuxyTerminalView" + } +} diff --git a/android/terminal/src/main/java/com/muxy/terminal/TakeOverOverlay.kt b/android/terminal/src/main/java/com/muxy/terminal/TakeOverOverlay.kt new file mode 100644 index 0000000..0cb8c5d --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/TakeOverOverlay.kt @@ -0,0 +1,89 @@ +package com.muxy.terminal + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.DesktopWindows +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun TakeOverOverlay( + ownerName: String, + foreground: Color, + background: Color, + onTakeOver: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxSize() + .background(background.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center, + ) { + Surface( + color = foreground.copy(alpha = 0.08f), + contentColor = foreground, + shape = RoundedCornerShape(20.dp), + modifier = + Modifier + .padding(horizontal = 24.dp) + .widthIn(max = 360.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.Outlined.DesktopWindows, contentDescription = null, tint = foreground) + Text( + text = "Controlled on $ownerName", + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = foreground, + ) + Text( + text = "This terminal is currently being used on $ownerName. Take over to control it from here.", + fontSize = 13.sp, + color = foreground.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(4.dp)) + Button( + onClick = onTakeOver, + colors = + ButtonDefaults.buttonColors( + containerColor = foreground, + contentColor = background, + ), + ) { + Text("Take Over", fontWeight = FontWeight.SemiBold) + } + } + } + } +} diff --git a/android/terminal/src/main/java/com/muxy/terminal/TerminalAccessoryBar.kt b/android/terminal/src/main/java/com/muxy/terminal/TerminalAccessoryBar.kt new file mode 100644 index 0000000..bd2d2cd --- /dev/null +++ b/android/terminal/src/main/java/com/muxy/terminal/TerminalAccessoryBar.kt @@ -0,0 +1,408 @@ +package com.muxy.terminal + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentPaste +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.KeyboardHide +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.hypot + +private const val ARROW_UP = "\u001B[A" +private const val ARROW_DOWN = "\u001B[B" +private const val ARROW_LEFT = "\u001B[D" +private const val ARROW_RIGHT = "\u001B[C" +private const val ESC_PAYLOAD = "\u001B" +private const val TAB_PAYLOAD = "\t" + +@Composable +fun TerminalAccessoryBar( + actions: AccessoryActions, + armedModifier: ArmedModifier?, + activeModifier: ArmedModifier, + onToggleArm: () -> Unit, + onSelectModifier: (ArmedModifier) -> Unit, + foreground: Color, + background: Color, + keyboardVisible: Boolean, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth().background(background.copy(alpha = 0.94f))) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = + Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + AccessoryKey("esc", foreground) { actions.sendText(ESC_PAYLOAD) } + ModifierKey( + active = activeModifier, + armed = armedModifier != null, + foreground = foreground, + background = background, + onTap = onToggleArm, + onSelect = onSelectModifier, + ) + AccessoryKey("tab", foreground) { actions.sendText(TAB_PAYLOAD) } + AccessoryIcon(Icons.Outlined.ContentPaste, "Paste", foreground = foreground) { + actions.pasteFromClipboard() + } + AccessoryKey("~", foreground) { actions.sendText("~") } + AccessoryKey("|", foreground) { actions.sendText("|") } + AccessoryKey("/", foreground) { actions.sendText("/") } + AccessoryKey("-", foreground) { actions.sendText("-") } + } + KeyboardToggle(visible = keyboardVisible, foreground = foreground, onClick = actions::toggleKeyboard) + DPad(foreground = foreground) { payload -> actions.sendText(payload) } + } + } +} + +@Composable +private fun AccessoryKey( + label: String, + foreground: Color, + onClick: () -> Unit, +) { + Surface( + color = Color.Transparent, + contentColor = foreground, + shape = RoundedCornerShape(8.dp), + modifier = + Modifier + .height(36.dp) + .semantics { + contentDescription = "Send $label" + role = Role.Button + this.onClick(label = "Send $label") { + onClick() + true + } + } + .pointerInput(label) { + detectTapGestures(onTap = { onClick() }) + } + .padding(horizontal = 10.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth().height(36.dp)) { + Text(label, fontSize = 14.sp, color = foreground) + } + } +} + +@Composable +private fun AccessoryIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + foreground: Color, + onClick: () -> Unit, +) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(36.dp) + .semantics { + contentDescription = description + role = Role.Button + this.onClick(label = description) { + onClick() + true + } + } + .pointerInput(description) { + detectTapGestures(onTap = { onClick() }) + }, + ) { + Icon(icon, contentDescription = null, tint = foreground) + } +} + +@Composable +private fun KeyboardToggle( + visible: Boolean, + foreground: Color, + onClick: () -> Unit, +) { + val description = if (visible) "Hide keyboard" else "Show keyboard" + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(40.dp) + .semantics { + contentDescription = description + role = Role.Button + this.onClick(label = description) { + onClick() + true + } + } + .pointerInput(visible) { + detectTapGestures(onTap = { onClick() }) + }, + ) { + val icon = if (visible) Icons.Outlined.KeyboardHide else Icons.Outlined.Keyboard + Icon(icon, contentDescription = null, tint = foreground) + } +} + +@Composable +private fun ModifierKey( + active: ArmedModifier, + armed: Boolean, + foreground: Color, + background: Color, + onTap: () -> Unit, + onSelect: (ArmedModifier) -> Unit, +) { + var pickerVisible by remember { mutableStateOf(false) } + val armedLabel = if (armed) "armed" else "off" + Box(contentAlignment = Alignment.Center) { + Surface( + color = if (armed) foreground else Color.Transparent, + contentColor = if (armed) background else foreground, + shape = RoundedCornerShape(18.dp), + modifier = + Modifier + .height(36.dp) + .semantics { + contentDescription = "${active.displayName} modifier $armedLabel. Long-press to choose modifier." + role = Role.Button + this.onClick(label = "Toggle ${active.displayName}") { + onTap() + true + } + } + .pointerInput(active, armed) { + detectTapGestures( + onTap = { onTap() }, + onLongPress = { pickerVisible = true }, + ) + } + .padding(horizontal = 12.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.height(36.dp)) { + Text(active.displayName, fontSize = 14.sp) + } + } + AnimatedVisibility( + visible = pickerVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + ModifierPicker( + active = active, + onPick = { picked -> + onSelect(picked) + pickerVisible = false + }, + onDismiss = { pickerVisible = false }, + foreground = foreground, + background = background, + ) + } + } +} + +@Composable +private fun ModifierPicker( + active: ArmedModifier, + onPick: (ArmedModifier) -> Unit, + onDismiss: () -> Unit, + foreground: Color, + background: Color, +) { + Surface( + color = background.copy(alpha = 0.95f), + contentColor = foreground, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.padding(8.dp), + ) { + Column(modifier = Modifier.padding(8.dp)) { + ArmedModifier.values().forEach { modifier -> + val disabled = modifier == active + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .pointerInput(modifier, disabled) { + if (!disabled) detectTapGestures(onTap = { onPick(modifier) }) + }, + ) { + Text( + modifier.glyph, + fontSize = 16.sp, + color = if (disabled) foreground.copy(alpha = 0.4f) else foreground, + ) + Spacer(Modifier.width(10.dp)) + Text( + modifier.displayName, + fontSize = 14.sp, + color = if (disabled) foreground.copy(alpha = 0.4f) else foreground, + ) + } + } + } + } + LaunchedEffect(Unit) { + // Auto-dismiss safety: if user lifts away, ensure we close. + delay(8000) + onDismiss() + } +} + +@Composable +private fun DPad( + foreground: Color, + onDirection: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var thumbOffset by remember { mutableStateOf(Offset.Zero) } + var activeDirection by remember { mutableStateOf(null) } + var repeatJob by remember { mutableStateOf(null) } + + fun stopRepeating() { + repeatJob?.cancel() + repeatJob = null + activeDirection = null + } + + fun startRepeating(direction: DPadDirection) { + repeatJob?.cancel() + onDirection(direction.payload) + repeatJob = + scope.launch { + delay(300) + while (true) { + onDirection(direction.payload) + delay(60) + } + } + } + + DisposableEffect(Unit) { + onDispose { stopRepeating() } + } + + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(48.dp) + .background(Color.Black.copy(alpha = 0.35f), CircleShape) + .semantics { + contentDescription = "Arrow key D-pad. Drag in a direction to send arrow keys." + } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { thumbOffset = Offset.Zero }, + onDragEnd = { + thumbOffset = Offset.Zero + stopRepeating() + }, + onDragCancel = { + thumbOffset = Offset.Zero + stopRepeating() + }, + ) { change, drag -> + val next = thumbOffset + drag + val mag = hypot(next.x.toDouble(), next.y.toDouble()).toFloat() + val deadZone = 5f + if (mag <= deadZone) { + if (activeDirection != null) stopRepeating() + thumbOffset = Offset.Zero + change.consume() + return@detectDragGestures + } + val direction = + if (abs(next.x) > abs(next.y)) { + if (next.x > 0) DPadDirection.RIGHT else DPadDirection.LEFT + } else { + if (next.y > 0) DPadDirection.DOWN else DPadDirection.UP + } + val maxReach = 12f + thumbOffset = + when (direction) { + DPadDirection.UP -> Offset(0f, -maxReach) + DPadDirection.DOWN -> Offset(0f, maxReach) + DPadDirection.LEFT -> Offset(-maxReach, 0f) + DPadDirection.RIGHT -> Offset(maxReach, 0f) + } + if (direction != activeDirection) { + activeDirection = direction + startRepeating(direction) + } + change.consume() + } + }, + ) { + Canvas(modifier = Modifier.size(16.dp)) { + drawCircle( + color = foreground.copy(alpha = 0.55f), + center = Offset(size.width / 2 + thumbOffset.x, size.height / 2 + thumbOffset.y), + ) + } + } +} + +private enum class DPadDirection(val payload: String) { + UP(ARROW_UP), + DOWN(ARROW_DOWN), + LEFT(ARROW_LEFT), + RIGHT(ARROW_RIGHT), +} diff --git a/android/terminal/src/test/kotlin/com/muxy/terminal/ModifierTransformTest.kt b/android/terminal/src/test/kotlin/com/muxy/terminal/ModifierTransformTest.kt new file mode 100644 index 0000000..30ddccb --- /dev/null +++ b/android/terminal/src/test/kotlin/com/muxy/terminal/ModifierTransformTest.kt @@ -0,0 +1,49 @@ +package com.muxy.terminal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ModifierTransformTest { + @Test + fun ctrlLowerLetterMapsToControlByte() { + assertEquals("\u0001", ModifierTransform.transform("a", ArmedModifier.CTRL)) + assertEquals("\u0003", ModifierTransform.transform("c", ArmedModifier.CTRL)) + assertEquals("\u001A", ModifierTransform.transform("z", ArmedModifier.CTRL)) + } + + @Test + fun ctrlUpperLetterMapsToControlByte() { + assertEquals("\u0001", ModifierTransform.transform("A", ArmedModifier.CTRL)) + assertEquals("\u0003", ModifierTransform.transform("C", ArmedModifier.CTRL)) + assertEquals("\u001F", ModifierTransform.transform("_", ArmedModifier.CTRL)) + } + + @Test + fun ctrlSpaceMapsToNul() { + assertEquals("\u0000", ModifierTransform.transform(" ", ArmedModifier.CTRL)) + } + + @Test + fun ctrlOtherCharactersReturnNull() { + assertNull(ModifierTransform.transform("ab", ArmedModifier.CTRL)) + assertNull(ModifierTransform.transform("1", ArmedModifier.CTRL)) + } + + @Test + fun shiftUppercases() { + assertEquals("FOO", ModifierTransform.transform("foo", ArmedModifier.SHIFT)) + assertEquals("X", ModifierTransform.transform("x", ArmedModifier.SHIFT)) + } + + @Test + fun altPrependsEsc() { + assertEquals("\u001Bb", ModifierTransform.transform("b", ArmedModifier.ALT)) + assertEquals("\u001Bff", ModifierTransform.transform("ff", ArmedModifier.ALT)) + } + + @Test + fun cmdPassesThrough() { + assertEquals("z", ModifierTransform.transform("z", ArmedModifier.CMD)) + } +} diff --git a/android/terminal/vendor/LICENSE.md b/android/terminal/vendor/LICENSE.md new file mode 100644 index 0000000..4b66170 --- /dev/null +++ b/android/terminal/vendor/LICENSE.md @@ -0,0 +1,6 @@ +The `termux/termux-app` repository is released under [GPLv3 only](https://www.gnu.org/licenses/gpl-3.0.html) license. + +### Exceptions + +- [Terminal Emulator for Android](https://github.com/jackpal/Android-Terminal-Emulator) code is used which is released under [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0) license. Check [`terminal-view`](terminal-view) and [`terminal-emulator`](terminal-emulator) libraries. +- Check [`termux-shared/LICENSE.md`](termux-shared/LICENSE.md) for `termux-shared` library related exceptions. diff --git a/android/terminal/vendor/UPSTREAM b/android/terminal/vendor/UPSTREAM new file mode 100644 index 0000000..2686c14 --- /dev/null +++ b/android/terminal/vendor/UPSTREAM @@ -0,0 +1,27 @@ +Source: https://github.com/termux/termux-app +Pinned commit: 30ebb2dee381d292ade0f2868cfde0f9f20b89fe (master, 2026-04-06) +Vendored modules: + - terminal-emulator/src/main/java/com/termux/terminal/ (excluding JNI.java) + - terminal-view/src/main/java/com/termux/view/ + - terminal-view/src/main/res/ + +Per termux-app's LICENSE.md, the `terminal-emulator` and `terminal-view` +libraries are derivative works of Jack Palevich's Android-Terminal-Emulator +(https://github.com/jackpal/Android-Terminal-Emulator) and are released +under the Apache 2.0 license; the rest of the termux-app repository is +GPL-3.0. Only the two libraries above are vendored here. + +Local modifications applied after copy (see git history under +android/terminal/vendor/ for diffs): + - TerminalSession: removed `final` modifier; removed JNI/PTY dependency; + added a remote-mode constructor and `feedRemoteOutput` API so output + bytes from the Mac drive `TerminalEmulator.append`. The original + local-PTY code path is removed because we never spawn a local shell. + - JNI.java is not included. + +Update procedure: + 1. Pick a new Termux commit, update the SHA above. + 2. Re-copy files from the upstream tarball. + 3. Re-apply the modifications. Diff against the previous vendor snapshot + to spot upstream changes that touch our patch points. + 4. Re-run :terminal tests. diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java new file mode 100644 index 0000000..adfdfa8 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/ByteQueue.java @@ -0,0 +1,108 @@ +package com.termux.terminal; + +/** A circular byte buffer allowing one producer and one consumer thread. */ +final class ByteQueue { + + private final byte[] mBuffer; + private int mHead; + private int mStoredBytes; + private boolean mOpen = true; + + public ByteQueue(int size) { + mBuffer = new byte[size]; + } + + public synchronized void close() { + mOpen = false; + notify(); + } + + public synchronized int read(byte[] buffer, boolean block) { + while (mStoredBytes == 0 && mOpen) { + if (block) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } else { + return 0; + } + } + if (!mOpen) return -1; + + int totalRead = 0; + int bufferLength = mBuffer.length; + boolean wasFull = bufferLength == mStoredBytes; + int length = buffer.length; + int offset = 0; + while (length > 0 && mStoredBytes > 0) { + int oneRun = Math.min(bufferLength - mHead, mStoredBytes); + int bytesToCopy = Math.min(length, oneRun); + System.arraycopy(mBuffer, mHead, buffer, offset, bytesToCopy); + mHead += bytesToCopy; + if (mHead >= bufferLength) mHead = 0; + mStoredBytes -= bytesToCopy; + length -= bytesToCopy; + offset += bytesToCopy; + totalRead += bytesToCopy; + } + if (wasFull) notify(); + return totalRead; + } + + /** + * Attempt to write the specified portion of the provided buffer to the queue. + *

+ * Returns whether the output was totally written, false if it was closed before. + */ + public boolean write(byte[] buffer, int offset, int lengthToWrite) { + if (lengthToWrite + offset > buffer.length) { + throw new IllegalArgumentException("length + offset > buffer.length"); + } else if (lengthToWrite <= 0) { + throw new IllegalArgumentException("length <= 0"); + } + + final int bufferLength = mBuffer.length; + + synchronized (this) { + while (lengthToWrite > 0) { + while (bufferLength == mStoredBytes && mOpen) { + try { + wait(); + } catch (InterruptedException e) { + // Ignore. + } + } + if (!mOpen) return false; + final boolean wasEmpty = mStoredBytes == 0; + int bytesToWriteBeforeWaiting = Math.min(lengthToWrite, bufferLength - mStoredBytes); + lengthToWrite -= bytesToWriteBeforeWaiting; + + while (bytesToWriteBeforeWaiting > 0) { + int tail = mHead + mStoredBytes; + int oneRun; + if (tail >= bufferLength) { + // Buffer: [.............] + // ________________H_______T + // => + // Buffer: [.............] + // ___________T____H + // onRun= _____----_ + tail = tail - bufferLength; + oneRun = mHead - tail; + } else { + oneRun = bufferLength - tail; + } + int bytesToCopy = Math.min(oneRun, bytesToWriteBeforeWaiting); + System.arraycopy(buffer, offset, mBuffer, tail, bytesToCopy); + offset += bytesToCopy; + bytesToWriteBeforeWaiting -= bytesToCopy; + mStoredBytes += bytesToCopy; + } + if (wasEmpty) notify(); + } + } + return true; + } +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java new file mode 100644 index 0000000..8ecfb63 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/KeyHandler.java @@ -0,0 +1,373 @@ +package com.termux.terminal; + +import java.util.HashMap; +import java.util.Map; + +import static android.view.KeyEvent.KEYCODE_BACK; +import static android.view.KeyEvent.KEYCODE_BREAK; +import static android.view.KeyEvent.KEYCODE_DEL; +import static android.view.KeyEvent.KEYCODE_DPAD_CENTER; +import static android.view.KeyEvent.KEYCODE_DPAD_DOWN; +import static android.view.KeyEvent.KEYCODE_DPAD_LEFT; +import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT; +import static android.view.KeyEvent.KEYCODE_DPAD_UP; +import static android.view.KeyEvent.KEYCODE_ENTER; +import static android.view.KeyEvent.KEYCODE_ESCAPE; +import static android.view.KeyEvent.KEYCODE_F1; +import static android.view.KeyEvent.KEYCODE_F10; +import static android.view.KeyEvent.KEYCODE_F11; +import static android.view.KeyEvent.KEYCODE_F12; +import static android.view.KeyEvent.KEYCODE_F2; +import static android.view.KeyEvent.KEYCODE_F3; +import static android.view.KeyEvent.KEYCODE_F4; +import static android.view.KeyEvent.KEYCODE_F5; +import static android.view.KeyEvent.KEYCODE_F6; +import static android.view.KeyEvent.KEYCODE_F7; +import static android.view.KeyEvent.KEYCODE_F8; +import static android.view.KeyEvent.KEYCODE_F9; +import static android.view.KeyEvent.KEYCODE_FORWARD_DEL; +import static android.view.KeyEvent.KEYCODE_INSERT; +import static android.view.KeyEvent.KEYCODE_MOVE_END; +import static android.view.KeyEvent.KEYCODE_MOVE_HOME; +import static android.view.KeyEvent.KEYCODE_NUMPAD_0; +import static android.view.KeyEvent.KEYCODE_NUMPAD_1; +import static android.view.KeyEvent.KEYCODE_NUMPAD_2; +import static android.view.KeyEvent.KEYCODE_NUMPAD_3; +import static android.view.KeyEvent.KEYCODE_NUMPAD_4; +import static android.view.KeyEvent.KEYCODE_NUMPAD_5; +import static android.view.KeyEvent.KEYCODE_NUMPAD_6; +import static android.view.KeyEvent.KEYCODE_NUMPAD_7; +import static android.view.KeyEvent.KEYCODE_NUMPAD_8; +import static android.view.KeyEvent.KEYCODE_NUMPAD_9; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ADD; +import static android.view.KeyEvent.KEYCODE_NUMPAD_COMMA; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DIVIDE; +import static android.view.KeyEvent.KEYCODE_NUMPAD_DOT; +import static android.view.KeyEvent.KEYCODE_NUMPAD_ENTER; +import static android.view.KeyEvent.KEYCODE_NUMPAD_EQUALS; +import static android.view.KeyEvent.KEYCODE_NUMPAD_MULTIPLY; +import static android.view.KeyEvent.KEYCODE_NUMPAD_SUBTRACT; +import static android.view.KeyEvent.KEYCODE_NUM_LOCK; +import static android.view.KeyEvent.KEYCODE_PAGE_DOWN; +import static android.view.KeyEvent.KEYCODE_PAGE_UP; +import static android.view.KeyEvent.KEYCODE_SPACE; +import static android.view.KeyEvent.KEYCODE_SYSRQ; +import static android.view.KeyEvent.KEYCODE_TAB; + +public final class KeyHandler { + + public static final int KEYMOD_ALT = 0x80000000; + public static final int KEYMOD_CTRL = 0x40000000; + public static final int KEYMOD_SHIFT = 0x20000000; + public static final int KEYMOD_NUM_LOCK = 0x10000000; + + private static final Map TERMCAP_TO_KEYCODE = new HashMap<>(); + + static { + // terminfo: http://pubs.opengroup.org/onlinepubs/7990989799/xcurses/terminfo.html + // termcap: http://man7.org/linux/man-pages/man5/termcap.5.html + TERMCAP_TO_KEYCODE.put("%i", KEYMOD_SHIFT | KEYCODE_DPAD_RIGHT); + TERMCAP_TO_KEYCODE.put("#2", KEYMOD_SHIFT | KEYCODE_MOVE_HOME); // Shifted home + TERMCAP_TO_KEYCODE.put("#4", KEYMOD_SHIFT | KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("*7", KEYMOD_SHIFT | KEYCODE_MOVE_END); // Shifted end key + + TERMCAP_TO_KEYCODE.put("k1", KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("k2", KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("k3", KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("k4", KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("k5", KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("k6", KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("k7", KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("k8", KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("k9", KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("k;", KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("F1", KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("F2", KEYCODE_F12); + TERMCAP_TO_KEYCODE.put("F3", KEYMOD_SHIFT | KEYCODE_F1); + TERMCAP_TO_KEYCODE.put("F4", KEYMOD_SHIFT | KEYCODE_F2); + TERMCAP_TO_KEYCODE.put("F5", KEYMOD_SHIFT | KEYCODE_F3); + TERMCAP_TO_KEYCODE.put("F6", KEYMOD_SHIFT | KEYCODE_F4); + TERMCAP_TO_KEYCODE.put("F7", KEYMOD_SHIFT | KEYCODE_F5); + TERMCAP_TO_KEYCODE.put("F8", KEYMOD_SHIFT | KEYCODE_F6); + TERMCAP_TO_KEYCODE.put("F9", KEYMOD_SHIFT | KEYCODE_F7); + TERMCAP_TO_KEYCODE.put("FA", KEYMOD_SHIFT | KEYCODE_F8); + TERMCAP_TO_KEYCODE.put("FB", KEYMOD_SHIFT | KEYCODE_F9); + TERMCAP_TO_KEYCODE.put("FC", KEYMOD_SHIFT | KEYCODE_F10); + TERMCAP_TO_KEYCODE.put("FD", KEYMOD_SHIFT | KEYCODE_F11); + TERMCAP_TO_KEYCODE.put("FE", KEYMOD_SHIFT | KEYCODE_F12); + + TERMCAP_TO_KEYCODE.put("kb", KEYCODE_DEL); // backspace key + + TERMCAP_TO_KEYCODE.put("kd", KEYCODE_DPAD_DOWN); // terminfo=kcud1, down-arrow key + TERMCAP_TO_KEYCODE.put("kh", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("kl", KEYCODE_DPAD_LEFT); + TERMCAP_TO_KEYCODE.put("kr", KEYCODE_DPAD_RIGHT); + + // K1=Upper left of keypad: + // t_K1 keypad home key + // t_K3 keypad page-up key + // t_K4 keypad end key + // t_K5 keypad page-down key + TERMCAP_TO_KEYCODE.put("K1", KEYCODE_MOVE_HOME); + TERMCAP_TO_KEYCODE.put("K3", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("K4", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("K5", KEYCODE_PAGE_DOWN); + + TERMCAP_TO_KEYCODE.put("ku", KEYCODE_DPAD_UP); + + TERMCAP_TO_KEYCODE.put("kB", KEYMOD_SHIFT | KEYCODE_TAB); // termcap=kB, terminfo=kcbt: Back-tab + TERMCAP_TO_KEYCODE.put("kD", KEYCODE_FORWARD_DEL); // terminfo=kdch1, delete-character key + TERMCAP_TO_KEYCODE.put("kDN", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // non-standard shifted arrow down + TERMCAP_TO_KEYCODE.put("kF", KEYMOD_SHIFT | KEYCODE_DPAD_DOWN); // terminfo=kind, scroll-forward key + TERMCAP_TO_KEYCODE.put("kI", KEYCODE_INSERT); + TERMCAP_TO_KEYCODE.put("kP", KEYCODE_PAGE_UP); + TERMCAP_TO_KEYCODE.put("kN", KEYCODE_PAGE_DOWN); + TERMCAP_TO_KEYCODE.put("kR", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // terminfo=kri, scroll-backward key + TERMCAP_TO_KEYCODE.put("kUP", KEYMOD_SHIFT | KEYCODE_DPAD_UP); // non-standard shifted up + + TERMCAP_TO_KEYCODE.put("@7", KEYCODE_MOVE_END); + TERMCAP_TO_KEYCODE.put("@8", KEYCODE_NUMPAD_ENTER); + } + + static String getCodeFromTermcap(String termcap, boolean cursorKeysApplication, boolean keypadApplication) { + Integer keyCodeAndMod = TERMCAP_TO_KEYCODE.get(termcap); + if (keyCodeAndMod == null) return null; + int keyCode = keyCodeAndMod; + int keyMod = 0; + if ((keyCode & KEYMOD_SHIFT) != 0) { + keyMod |= KEYMOD_SHIFT; + keyCode &= ~KEYMOD_SHIFT; + } + if ((keyCode & KEYMOD_CTRL) != 0) { + keyMod |= KEYMOD_CTRL; + keyCode &= ~KEYMOD_CTRL; + } + if ((keyCode & KEYMOD_ALT) != 0) { + keyMod |= KEYMOD_ALT; + keyCode &= ~KEYMOD_ALT; + } + if ((keyCode & KEYMOD_NUM_LOCK) != 0) { + keyMod |= KEYMOD_NUM_LOCK; + keyCode &= ~KEYMOD_NUM_LOCK; + } + return getCode(keyCode, keyMod, cursorKeysApplication, keypadApplication); + } + + public static String getCode(int keyCode, int keyMode, boolean cursorApp, boolean keypadApplication) { + boolean numLockOn = (keyMode & KEYMOD_NUM_LOCK) != 0; + keyMode &= ~KEYMOD_NUM_LOCK; + switch (keyCode) { + case KEYCODE_DPAD_CENTER: + return "\015"; + + case KEYCODE_DPAD_UP: + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + case KEYCODE_DPAD_DOWN: + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + case KEYCODE_DPAD_RIGHT: + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + case KEYCODE_DPAD_LEFT: + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + + case KEYCODE_MOVE_HOME: + // Note that KEYCODE_HOME is handled by the system and never delivered to applications. + // On a Logitech k810 keyboard KEYCODE_MOVE_HOME is sent by FN+LeftArrow. + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + case KEYCODE_MOVE_END: + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + + // An xterm can send function keys F1 to F4 in two modes: vt100 compatible or + // not. Because Vim may not know what the xterm is sending, both types of keys + // are recognized. The same happens for the and keys. + // normal vt100 ~ + // t_k1 [11~ OP *-xterm* + // t_k2 [12~ OQ *-xterm* + // t_k3 [13~ OR *-xterm* + // t_k4 [14~ OS *-xterm* + // t_kh [7~ OH *-xterm* + // t_@7 [4~ OF *-xterm* + case KEYCODE_F1: + return (keyMode == 0) ? "\033OP" : transformForModifiers("\033[1", keyMode, 'P'); + case KEYCODE_F2: + return (keyMode == 0) ? "\033OQ" : transformForModifiers("\033[1", keyMode, 'Q'); + case KEYCODE_F3: + return (keyMode == 0) ? "\033OR" : transformForModifiers("\033[1", keyMode, 'R'); + case KEYCODE_F4: + return (keyMode == 0) ? "\033OS" : transformForModifiers("\033[1", keyMode, 'S'); + case KEYCODE_F5: + return transformForModifiers("\033[15", keyMode, '~'); + case KEYCODE_F6: + return transformForModifiers("\033[17", keyMode, '~'); + case KEYCODE_F7: + return transformForModifiers("\033[18", keyMode, '~'); + case KEYCODE_F8: + return transformForModifiers("\033[19", keyMode, '~'); + case KEYCODE_F9: + return transformForModifiers("\033[20", keyMode, '~'); + case KEYCODE_F10: + return transformForModifiers("\033[21", keyMode, '~'); + case KEYCODE_F11: + return transformForModifiers("\033[23", keyMode, '~'); + case KEYCODE_F12: + return transformForModifiers("\033[24", keyMode, '~'); + + case KEYCODE_SYSRQ: + return "\033[32~"; // Sys Request / Print + // Is this Scroll lock? case Cancel: return "\033[33~"; + case KEYCODE_BREAK: + return "\033[34~"; // Pause/Break + + case KEYCODE_ESCAPE: + case KEYCODE_BACK: + return "\033"; + + case KEYCODE_INSERT: + return transformForModifiers("\033[2", keyMode, '~'); + case KEYCODE_FORWARD_DEL: + return transformForModifiers("\033[3", keyMode, '~'); + + case KEYCODE_PAGE_UP: + return transformForModifiers("\033[5", keyMode, '~'); + case KEYCODE_PAGE_DOWN: + return transformForModifiers("\033[6", keyMode, '~'); + case KEYCODE_DEL: + String prefix = ((keyMode & KEYMOD_ALT) == 0) ? "" : "\033"; + // Just do what xterm and gnome-terminal does: + return prefix + (((keyMode & KEYMOD_CTRL) == 0) ? "\u007F" : "\u0008"); + case KEYCODE_NUM_LOCK: + if (keypadApplication) { + return "\033OP"; + } else { + return null; + } + case KEYCODE_SPACE: + // If ctrl is not down, return null so that it goes through normal input processing (which may e.g. cause a + // combining accent to be written): + return ((keyMode & KEYMOD_CTRL) == 0) ? null : "\0"; + case KEYCODE_TAB: + // This is back-tab when shifted: + return (keyMode & KEYMOD_SHIFT) == 0 ? "\011" : "\033[Z"; + case KEYCODE_ENTER: + return ((keyMode & KEYMOD_ALT) == 0) ? "\r" : "\033\r"; + + case KEYCODE_NUMPAD_ENTER: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'M') : "\n"; + case KEYCODE_NUMPAD_MULTIPLY: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'j') : "*"; + case KEYCODE_NUMPAD_ADD: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'k') : "+"; + case KEYCODE_NUMPAD_COMMA: + return ","; + case KEYCODE_NUMPAD_DOT: + if (numLockOn) { + return keypadApplication ? "\033On" : "."; + } else { + // DELETE + return transformForModifiers("\033[3", keyMode, '~'); + } + case KEYCODE_NUMPAD_SUBTRACT: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'm') : "-"; + case KEYCODE_NUMPAD_DIVIDE: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'o') : "/"; + case KEYCODE_NUMPAD_0: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'p') : "0"; + } else { + // INSERT + return transformForModifiers("\033[2", keyMode, '~'); + } + case KEYCODE_NUMPAD_1: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'q') : "1"; + } else { + // END + return (keyMode == 0) ? (cursorApp ? "\033OF" : "\033[F") : transformForModifiers("\033[1", keyMode, 'F'); + } + case KEYCODE_NUMPAD_2: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'r') : "2"; + } else { + // DOWN + return (keyMode == 0) ? (cursorApp ? "\033OB" : "\033[B") : transformForModifiers("\033[1", keyMode, 'B'); + } + case KEYCODE_NUMPAD_3: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 's') : "3"; + } else { + // PGDN + return "\033[6~"; + } + case KEYCODE_NUMPAD_4: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 't') : "4"; + } else { + // LEFT + return (keyMode == 0) ? (cursorApp ? "\033OD" : "\033[D") : transformForModifiers("\033[1", keyMode, 'D'); + } + case KEYCODE_NUMPAD_5: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'u') : "5"; + case KEYCODE_NUMPAD_6: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'v') : "6"; + } else { + // RIGHT + return (keyMode == 0) ? (cursorApp ? "\033OC" : "\033[C") : transformForModifiers("\033[1", keyMode, 'C'); + } + case KEYCODE_NUMPAD_7: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'w') : "7"; + } else { + // HOME + return (keyMode == 0) ? (cursorApp ? "\033OH" : "\033[H") : transformForModifiers("\033[1", keyMode, 'H'); + } + case KEYCODE_NUMPAD_8: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'x') : "8"; + } else { + // UP + return (keyMode == 0) ? (cursorApp ? "\033OA" : "\033[A") : transformForModifiers("\033[1", keyMode, 'A'); + } + case KEYCODE_NUMPAD_9: + if (numLockOn) { + return keypadApplication ? transformForModifiers("\033O", keyMode, 'y') : "9"; + } else { + // PGUP + return "\033[5~"; + } + case KEYCODE_NUMPAD_EQUALS: + return keypadApplication ? transformForModifiers("\033O", keyMode, 'X') : "="; + } + + return null; + } + + private static String transformForModifiers(String start, int keymod, char lastChar) { + int modifier; + switch (keymod) { + case KEYMOD_SHIFT: + modifier = 2; + break; + case KEYMOD_ALT: + modifier = 3; + break; + case (KEYMOD_SHIFT | KEYMOD_ALT): + modifier = 4; + break; + case KEYMOD_CTRL: + modifier = 5; + break; + case KEYMOD_SHIFT | KEYMOD_CTRL: + modifier = 6; + break; + case KEYMOD_ALT | KEYMOD_CTRL: + modifier = 7; + break; + case KEYMOD_SHIFT | KEYMOD_ALT | KEYMOD_CTRL: + modifier = 8; + break; + default: + return start + lastChar; + } + return start + (";" + modifier) + lastChar; + } +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/Logger.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/Logger.java new file mode 100644 index 0000000..d4d502e --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/Logger.java @@ -0,0 +1,80 @@ +package com.termux.terminal; + +import android.util.Log; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +public class Logger { + + public static void logError(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logError(logTag, message); + else + Log.e(logTag, message); + } + + public static void logWarn(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logWarn(logTag, message); + else + Log.w(logTag, message); + } + + public static void logInfo(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logInfo(logTag, message); + else + Log.i(logTag, message); + } + + public static void logDebug(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logDebug(logTag, message); + else + Log.d(logTag, message); + } + + public static void logVerbose(TerminalSessionClient client, String logTag, String message) { + if (client != null) + client.logVerbose(logTag, message); + else + Log.v(logTag, message); + } + + public static void logStackTraceWithMessage(TerminalSessionClient client, String tag, String message, Throwable throwable) { + logError(client, tag, getMessageAndStackTraceString(message, throwable)); + } + + public static String getMessageAndStackTraceString(String message, Throwable throwable) { + if (message == null && throwable == null) + return null; + else if (message != null && throwable != null) + return message + ":\n" + getStackTraceString(throwable); + else if (throwable == null) + return message; + else + return getStackTraceString(throwable); + } + + public static String getStackTraceString(Throwable throwable) { + if (throwable == null) return null; + + String stackTraceString = null; + + try { + StringWriter errors = new StringWriter(); + PrintWriter pw = new PrintWriter(errors); + throwable.printStackTrace(pw); + pw.close(); + stackTraceString = errors.toString(); + errors.close(); + } catch (IOException e) { + e.printStackTrace(); + } + + return stackTraceString; + } + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java new file mode 100644 index 0000000..21d6518 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalBuffer.java @@ -0,0 +1,497 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * A circular buffer of {@link TerminalRow}:s which keeps notes about what is visible on a logical screen and the scroll + * history. + *

+ * See {@link #externalToInternalRow(int)} for how to map from logical screen rows to array indices. + */ +public final class TerminalBuffer { + + TerminalRow[] mLines; + /** The length of {@link #mLines}. */ + int mTotalRows; + /** The number of rows and columns visible on the screen. */ + int mScreenRows, mColumns; + /** The number of rows kept in history. */ + private int mActiveTranscriptRows = 0; + /** The index in the circular buffer where the visible screen starts. */ + private int mScreenFirstRow = 0; + + /** + * Create a transcript screen. + * + * @param columns the width of the screen in characters. + * @param totalRows the height of the entire text area, in rows of text. + * @param screenRows the height of just the screen, not including the transcript that holds lines that have scrolled off + * the top of the screen. + */ + public TerminalBuffer(int columns, int totalRows, int screenRows) { + mColumns = columns; + mTotalRows = totalRows; + mScreenRows = screenRows; + mLines = new TerminalRow[totalRows]; + + blockSet(0, 0, columns, screenRows, ' ', TextStyle.NORMAL); + } + + public String getTranscriptText() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows).trim(); + } + + public String getTranscriptTextWithoutJoinedLines() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, false).trim(); + } + + public String getTranscriptTextWithFullLinesJoined() { + return getSelectedText(0, -getActiveTranscriptRows(), mColumns, mScreenRows, true, true).trim(); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2) { + return getSelectedText(selX1, selY1, selX2, selY2, true); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines) { + return getSelectedText(selX1, selY1, selX2, selY2, joinBackLines, false); + } + + public String getSelectedText(int selX1, int selY1, int selX2, int selY2, boolean joinBackLines, boolean joinFullLines) { + final StringBuilder builder = new StringBuilder(); + final int columns = mColumns; + + if (selY1 < -getActiveTranscriptRows()) selY1 = -getActiveTranscriptRows(); + if (selY2 >= mScreenRows) selY2 = mScreenRows - 1; + + for (int row = selY1; row <= selY2; row++) { + int x1 = (row == selY1) ? selX1 : 0; + int x2; + if (row == selY2) { + x2 = selX2 + 1; + if (x2 > columns) x2 = columns; + } else { + x2 = columns; + } + TerminalRow lineObject = mLines[externalToInternalRow(row)]; + int x1Index = lineObject.findStartOfColumn(x1); + int x2Index = (x2 < mColumns) ? lineObject.findStartOfColumn(x2) : lineObject.getSpaceUsed(); + if (x2Index == x1Index) { + // Selected the start of a wide character. + x2Index = lineObject.findStartOfColumn(x2 + 1); + } + char[] line = lineObject.mText; + int lastPrintingCharIndex = -1; + int i; + boolean rowLineWrap = getLineWrap(row); + if (rowLineWrap && x2 == columns) { + // If the line was wrapped, we shouldn't lose trailing space: + lastPrintingCharIndex = x2Index - 1; + } else { + for (i = x1Index; i < x2Index; ++i) { + char c = line[i]; + if (c != ' ') lastPrintingCharIndex = i; + } + } + + int len = lastPrintingCharIndex - x1Index + 1; + if (lastPrintingCharIndex != -1 && len > 0) + builder.append(line, x1Index, len); + + boolean lineFillsWidth = lastPrintingCharIndex == x2Index - 1; + if ((!joinBackLines || !rowLineWrap) && (!joinFullLines || !lineFillsWidth) + && row < selY2 && row < mScreenRows - 1) builder.append('\n'); + } + return builder.toString(); + } + + public String getWordAtLocation(int x, int y) { + // Set y1 and y2 to the lines where the wrapped line starts and ends. + // I.e. if a line that is wrapped to 3 lines starts at line 4, and this + // is called with y=5, then y1 would be set to 4 and y2 would be set to 6. + int y1 = y; + int y2 = y; + while (y1 > 0 && !getSelectedText(0, y1 - 1, mColumns, y, true, true).contains("\n")) { + y1--; + } + while (y2 < mScreenRows && !getSelectedText(0, y, mColumns, y2 + 1, true, true).contains("\n")) { + y2++; + } + + // Get the text for the whole wrapped line + String text = getSelectedText(0, y1, mColumns, y2, true, true); + // The index of x in text + int textOffset = (y - y1) * mColumns + x; + + if (textOffset >= text.length()) { + // The click was to the right of the last word on the line, so + // there's no word to return + return ""; + } + + // Set x1 and x2 to the indices of the last space before x and the + // first space after x in text respectively + int x1 = text.lastIndexOf(' ', textOffset); + int x2 = text.indexOf(' ', textOffset); + if (x2 == -1) { + x2 = text.length(); + } + + if (x1 == x2) { + // The click was on a space, so there's no word to return + return ""; + } + return text.substring(x1 + 1, x2); + } + + public int getActiveTranscriptRows() { + return mActiveTranscriptRows; + } + + public int getActiveRows() { + return mActiveTranscriptRows + mScreenRows; + } + + /** + * Convert a row value from the public external coordinate system to our internal private coordinate system. + * + *

+     * - External coordinate system: -mActiveTranscriptRows to mScreenRows-1, with the screen being 0..mScreenRows-1.
+     * - Internal coordinate system: the mScreenRows lines starting at mScreenFirstRow comprise the screen, while the
+     *   mActiveTranscriptRows lines ending at mScreenFirstRow-1 form the transcript (as a circular buffer).
+     *
+     * External ↔ Internal:
+     *
+     * [ ...                            ]     [ ...                                     ]
+     * [ -mActiveTranscriptRows         ]     [ mScreenFirstRow - mActiveTranscriptRows ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ 0 (visible screen starts here) ]  ↔  [ mScreenFirstRow                         ]
+     * [ ...                            ]     [ ...                                     ]
+     * [ mScreenRows-1                  ]     [ mScreenFirstRow + mScreenRows-1         ]
+     * 
+ * + * @param externalRow a row in the external coordinate system. + * @return The row corresponding to the input argument in the private coordinate system. + */ + public int externalToInternalRow(int externalRow) { + if (externalRow < -mActiveTranscriptRows || externalRow > mScreenRows) + throw new IllegalArgumentException("extRow=" + externalRow + ", mScreenRows=" + mScreenRows + ", mActiveTranscriptRows=" + mActiveTranscriptRows); + final int internalRow = mScreenFirstRow + externalRow; + return (internalRow < 0) ? (mTotalRows + internalRow) : (internalRow % mTotalRows); + } + + public void setLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = true; + } + + public boolean getLineWrap(int row) { + return mLines[externalToInternalRow(row)].mLineWrap; + } + + public void clearLineWrap(int row) { + mLines[externalToInternalRow(row)].mLineWrap = false; + } + + /** + * Resize the screen which this transcript backs. Currently, this only works if the number of columns does not + * change or the rows expand (that is, it only works when shrinking the number of rows). + * + * @param newColumns The number of columns the screen should have. + * @param newRows The number of rows the screen should have. + * @param cursor An int[2] containing the (column, row) cursor location. + */ + public void resize(int newColumns, int newRows, int newTotalRows, int[] cursor, long currentStyle, boolean altScreen) { + // newRows > mTotalRows should not normally happen since mTotalRows is TRANSCRIPT_ROWS (10000): + if (newColumns == mColumns && newRows <= mTotalRows) { + // Fast resize where just the rows changed. + int shiftDownOfTopRow = mScreenRows - newRows; + if (shiftDownOfTopRow > 0 && shiftDownOfTopRow < mScreenRows) { + // Shrinking. Check if we can skip blank rows at bottom below cursor. + for (int i = mScreenRows - 1; i > 0; i--) { + if (cursor[1] >= i) break; + int r = externalToInternalRow(i); + if (mLines[r] == null || mLines[r].isBlank()) { + if (--shiftDownOfTopRow == 0) break; + } + } + } else if (shiftDownOfTopRow < 0) { + // Negative shift down = expanding. Only move screen up if there is transcript to show: + int actualShift = Math.max(shiftDownOfTopRow, -mActiveTranscriptRows); + if (shiftDownOfTopRow != actualShift) { + // The new lines revealed by the resizing are not all from the transcript. Blank the below ones. + for (int i = 0; i < actualShift - shiftDownOfTopRow; i++) + allocateFullLineIfNecessary((mScreenFirstRow + mScreenRows + i) % mTotalRows).clear(currentStyle); + shiftDownOfTopRow = actualShift; + } + } + mScreenFirstRow += shiftDownOfTopRow; + mScreenFirstRow = (mScreenFirstRow < 0) ? (mScreenFirstRow + mTotalRows) : (mScreenFirstRow % mTotalRows); + mTotalRows = newTotalRows; + mActiveTranscriptRows = altScreen ? 0 : Math.max(0, mActiveTranscriptRows + shiftDownOfTopRow); + cursor[1] -= shiftDownOfTopRow; + mScreenRows = newRows; + } else { + // Copy away old state and update new: + TerminalRow[] oldLines = mLines; + mLines = new TerminalRow[newTotalRows]; + for (int i = 0; i < newTotalRows; i++) + mLines[i] = new TerminalRow(newColumns, currentStyle); + + final int oldActiveTranscriptRows = mActiveTranscriptRows; + final int oldScreenFirstRow = mScreenFirstRow; + final int oldScreenRows = mScreenRows; + final int oldTotalRows = mTotalRows; + mTotalRows = newTotalRows; + mScreenRows = newRows; + mActiveTranscriptRows = mScreenFirstRow = 0; + mColumns = newColumns; + + int newCursorRow = -1; + int newCursorColumn = -1; + int oldCursorRow = cursor[1]; + int oldCursorColumn = cursor[0]; + boolean newCursorPlaced = false; + + int currentOutputExternalRow = 0; + int currentOutputExternalColumn = 0; + + // Loop over every character in the initial state. + // Blank lines should be skipped only if at end of transcript (just as is done in the "fast" resize), so we + // keep track how many blank lines we have skipped if we later on find a non-blank line. + int skippedBlankLines = 0; + for (int externalOldRow = -oldActiveTranscriptRows; externalOldRow < oldScreenRows; externalOldRow++) { + // Do what externalToInternalRow() does but for the old state: + int internalOldRow = oldScreenFirstRow + externalOldRow; + internalOldRow = (internalOldRow < 0) ? (oldTotalRows + internalOldRow) : (internalOldRow % oldTotalRows); + + TerminalRow oldLine = oldLines[internalOldRow]; + boolean cursorAtThisRow = externalOldRow == oldCursorRow; + // The cursor may only be on a non-null line, which we should not skip: + if (oldLine == null || (!(!newCursorPlaced && cursorAtThisRow)) && oldLine.isBlank()) { + skippedBlankLines++; + continue; + } else if (skippedBlankLines > 0) { + // After skipping some blank lines we encounter a non-blank line. Insert the skipped blank lines. + for (int i = 0; i < skippedBlankLines; i++) { + if (currentOutputExternalRow == mScreenRows - 1) { + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + skippedBlankLines = 0; + } + + int lastNonSpaceIndex = 0; + boolean justToCursor = false; + if (cursorAtThisRow || oldLine.mLineWrap) { + // Take the whole line, either because of cursor on it, or if line wrapping. + lastNonSpaceIndex = oldLine.getSpaceUsed(); + if (cursorAtThisRow) justToCursor = true; + } else { + for (int i = 0; i < oldLine.getSpaceUsed(); i++) + // NEWLY INTRODUCED BUG! Should not index oldLine.mStyle with char indices + if (oldLine.mText[i] != ' '/* || oldLine.mStyle[i] != currentStyle */) + lastNonSpaceIndex = i + 1; + } + + int currentOldCol = 0; + long styleAtCol = 0; + for (int i = 0; i < lastNonSpaceIndex; i++) { + // Note that looping over java character, not cells. + char c = oldLine.mText[i]; + int codePoint = (Character.isHighSurrogate(c)) ? Character.toCodePoint(c, oldLine.mText[++i]) : c; + int displayWidth = WcWidth.width(codePoint); + // Use the last style if this is a zero-width character: + if (displayWidth > 0) styleAtCol = oldLine.getStyle(currentOldCol); + + // Line wrap as necessary: + if (currentOutputExternalColumn + displayWidth > mColumns) { + setLineWrap(currentOutputExternalRow); + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && currentOutputExternalColumn > 0) ? 1 : 0); + int outputColumn = currentOutputExternalColumn - offsetDueToCombiningChar; + setChar(outputColumn, currentOutputExternalRow, codePoint, styleAtCol); + + if (displayWidth > 0) { + if (oldCursorRow == externalOldRow && oldCursorColumn == currentOldCol) { + newCursorColumn = currentOutputExternalColumn; + newCursorRow = currentOutputExternalRow; + newCursorPlaced = true; + } + currentOldCol += displayWidth; + currentOutputExternalColumn += displayWidth; + if (justToCursor && newCursorPlaced) break; + } + } + // Old row has been copied. Check if we need to insert newline if old line was not wrapping: + if (externalOldRow != (oldScreenRows - 1) && !oldLine.mLineWrap) { + if (currentOutputExternalRow == mScreenRows - 1) { + if (newCursorPlaced) newCursorRow--; + scrollDownOneLine(0, mScreenRows, currentStyle); + } else { + currentOutputExternalRow++; + } + currentOutputExternalColumn = 0; + } + } + + cursor[0] = newCursorColumn; + cursor[1] = newCursorRow; + } + + // Handle cursor scrolling off screen: + if (cursor[0] < 0 || cursor[1] < 0) cursor[0] = cursor[1] = 0; + } + + /** + * Block copy lines and associated metadata from one location to another in the circular buffer, taking wraparound + * into account. + * + * @param srcInternal The first line to be copied. + * @param len The number of lines to be copied. + */ + private void blockCopyLinesDown(int srcInternal, int len) { + if (len == 0) return; + int totalRows = mTotalRows; + + int start = len - 1; + // Save away line to be overwritten: + TerminalRow lineToBeOverWritten = mLines[(srcInternal + start + 1) % totalRows]; + // Do the copy from bottom to top. + for (int i = start; i >= 0; --i) + mLines[(srcInternal + i + 1) % totalRows] = mLines[(srcInternal + i) % totalRows]; + // Put back overwritten line, now above the block: + mLines[(srcInternal) % totalRows] = lineToBeOverWritten; + } + + /** + * Scroll the screen down one line. To scroll the whole screen of a 24 line screen, the arguments would be (0, 24). + * + * @param topMargin First line that is scrolled. + * @param bottomMargin One line after the last line that is scrolled. + * @param style the style for the newly exposed line. + */ + public void scrollDownOneLine(int topMargin, int bottomMargin, long style) { + if (topMargin > bottomMargin - 1 || topMargin < 0 || bottomMargin > mScreenRows) + throw new IllegalArgumentException("topMargin=" + topMargin + ", bottomMargin=" + bottomMargin + ", mScreenRows=" + mScreenRows); + + // Copy the fixed topMargin lines one line down so that they remain on screen in same position: + blockCopyLinesDown(mScreenFirstRow, topMargin); + // Copy the fixed mScreenRows-bottomMargin lines one line down so that they remain on screen in same + // position: + blockCopyLinesDown(externalToInternalRow(bottomMargin), mScreenRows - bottomMargin); + + // Update the screen location in the ring buffer: + mScreenFirstRow = (mScreenFirstRow + 1) % mTotalRows; + // Note that the history has grown if not already full: + if (mActiveTranscriptRows < mTotalRows - mScreenRows) mActiveTranscriptRows++; + + // Blank the newly revealed line above the bottom margin: + int blankRow = externalToInternalRow(bottomMargin - 1); + if (mLines[blankRow] == null) { + mLines[blankRow] = new TerminalRow(mColumns, style); + } else { + mLines[blankRow].clear(style); + } + } + + /** + * Block copy characters from one position in the screen to another. The two positions can overlap. All characters + * of the source and destination must be within the bounds of the screen, or else an InvalidParameterException will + * be thrown. + * + * @param sx source X coordinate + * @param sy source Y coordinate + * @param w width + * @param h height + * @param dx destination X coordinate + * @param dy destination Y coordinate + */ + public void blockCopy(int sx, int sy, int w, int h, int dx, int dy) { + if (w == 0) return; + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows || dx < 0 || dx + w > mColumns || dy < 0 || dy + h > mScreenRows) + throw new IllegalArgumentException(); + boolean copyingUp = sy > dy; + for (int y = 0; y < h; y++) { + int y2 = copyingUp ? y : (h - (y + 1)); + TerminalRow sourceRow = allocateFullLineIfNecessary(externalToInternalRow(sy + y2)); + allocateFullLineIfNecessary(externalToInternalRow(dy + y2)).copyInterval(sourceRow, sx, sx + w, dx); + } + } + + /** + * Block set characters. All characters must be within the bounds of the screen, or else and + * InvalidParemeterException will be thrown. Typically this is called with a "val" argument of 32 to clear a block + * of characters. + */ + public void blockSet(int sx, int sy, int w, int h, int val, long style) { + if (sx < 0 || sx + w > mColumns || sy < 0 || sy + h > mScreenRows) { + throw new IllegalArgumentException( + "Illegal arguments! blockSet(" + sx + ", " + sy + ", " + w + ", " + h + ", " + val + ", " + mColumns + ", " + mScreenRows + ")"); + } + for (int y = 0; y < h; y++) + for (int x = 0; x < w; x++) + setChar(sx + x, sy + y, val, style); + } + + public TerminalRow allocateFullLineIfNecessary(int row) { + return (mLines[row] == null) ? (mLines[row] = new TerminalRow(mColumns, 0)) : mLines[row]; + } + + public void setChar(int column, int row, int codePoint, long style) { + if (row < 0 || row >= mScreenRows || column < 0 || column >= mColumns) + throw new IllegalArgumentException("TerminalBuffer.setChar(): row=" + row + ", column=" + column + ", mScreenRows=" + mScreenRows + ", mColumns=" + mColumns); + row = externalToInternalRow(row); + allocateFullLineIfNecessary(row).setChar(column, codePoint, style); + } + + public long getStyleAt(int externalRow, int column) { + return allocateFullLineIfNecessary(externalToInternalRow(externalRow)).getStyle(column); + } + + /** Support for http://vt100.net/docs/vt510-rm/DECCARA and http://vt100.net/docs/vt510-rm/DECCARA */ + public void setOrClearEffect(int bits, boolean setOrClear, boolean reverse, boolean rectangular, int leftMargin, int rightMargin, int top, int left, + int bottom, int right) { + for (int y = top; y < bottom; y++) { + TerminalRow line = mLines[externalToInternalRow(y)]; + int startOfLine = (rectangular || y == top) ? left : leftMargin; + int endOfLine = (rectangular || y + 1 == bottom) ? right : rightMargin; + for (int x = startOfLine; x < endOfLine; x++) { + long currentStyle = line.getStyle(x); + int foreColor = TextStyle.decodeForeColor(currentStyle); + int backColor = TextStyle.decodeBackColor(currentStyle); + int effect = TextStyle.decodeEffect(currentStyle); + if (reverse) { + // Clear out the bits to reverse and add them back in reversed: + effect = (effect & ~bits) | (bits & ~effect); + } else if (setOrClear) { + effect |= bits; + } else { + effect &= ~bits; + } + line.mStyle[x] = TextStyle.encode(foreColor, backColor, effect); + } + } + } + + public void clearTranscript() { + if (mScreenFirstRow < mActiveTranscriptRows) { + Arrays.fill(mLines, mTotalRows + mScreenFirstRow - mActiveTranscriptRows, mTotalRows, null); + Arrays.fill(mLines, 0, mScreenFirstRow, null); + } else { + Arrays.fill(mLines, mScreenFirstRow - mActiveTranscriptRows, mScreenFirstRow, null); + } + mActiveTranscriptRows = 0; + } + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java new file mode 100644 index 0000000..4088781 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalColorScheme.java @@ -0,0 +1,126 @@ +package com.termux.terminal; + +import java.util.Map; +import java.util.Properties; + +/** + * Color scheme for a terminal with default colors, which may be overridden (and then reset) from the shell using + * Operating System Control (OSC) sequences. + * + * @see TerminalColors + */ +public final class TerminalColorScheme { + + /** http://upload.wikimedia.org/wikipedia/en/1/15/Xterm_256color_chart.svg, but with blue color brighter. */ + private static final int[] DEFAULT_COLORSCHEME = { + // 16 original colors. First 8 are dim. + 0xff000000, // black + 0xffcd0000, // dim red + 0xff00cd00, // dim green + 0xffcdcd00, // dim yellow + 0xff6495ed, // dim blue + 0xffcd00cd, // dim magenta + 0xff00cdcd, // dim cyan + 0xffe5e5e5, // dim white + // Second 8 are bright: + 0xff7f7f7f, // medium grey + 0xffff0000, // bright red + 0xff00ff00, // bright green + 0xffffff00, // bright yellow + 0xff5c5cff, // light blue + 0xffff00ff, // bright magenta + 0xff00ffff, // bright cyan + 0xffffffff, // bright white + + // 216 color cube, six shades of each color: + 0xff000000, 0xff00005f, 0xff000087, 0xff0000af, 0xff0000d7, 0xff0000ff, 0xff005f00, 0xff005f5f, 0xff005f87, 0xff005faf, 0xff005fd7, 0xff005fff, + 0xff008700, 0xff00875f, 0xff008787, 0xff0087af, 0xff0087d7, 0xff0087ff, 0xff00af00, 0xff00af5f, 0xff00af87, 0xff00afaf, 0xff00afd7, 0xff00afff, + 0xff00d700, 0xff00d75f, 0xff00d787, 0xff00d7af, 0xff00d7d7, 0xff00d7ff, 0xff00ff00, 0xff00ff5f, 0xff00ff87, 0xff00ffaf, 0xff00ffd7, 0xff00ffff, + 0xff5f0000, 0xff5f005f, 0xff5f0087, 0xff5f00af, 0xff5f00d7, 0xff5f00ff, 0xff5f5f00, 0xff5f5f5f, 0xff5f5f87, 0xff5f5faf, 0xff5f5fd7, 0xff5f5fff, + 0xff5f8700, 0xff5f875f, 0xff5f8787, 0xff5f87af, 0xff5f87d7, 0xff5f87ff, 0xff5faf00, 0xff5faf5f, 0xff5faf87, 0xff5fafaf, 0xff5fafd7, 0xff5fafff, + 0xff5fd700, 0xff5fd75f, 0xff5fd787, 0xff5fd7af, 0xff5fd7d7, 0xff5fd7ff, 0xff5fff00, 0xff5fff5f, 0xff5fff87, 0xff5fffaf, 0xff5fffd7, 0xff5fffff, + 0xff870000, 0xff87005f, 0xff870087, 0xff8700af, 0xff8700d7, 0xff8700ff, 0xff875f00, 0xff875f5f, 0xff875f87, 0xff875faf, 0xff875fd7, 0xff875fff, + 0xff878700, 0xff87875f, 0xff878787, 0xff8787af, 0xff8787d7, 0xff8787ff, 0xff87af00, 0xff87af5f, 0xff87af87, 0xff87afaf, 0xff87afd7, 0xff87afff, + 0xff87d700, 0xff87d75f, 0xff87d787, 0xff87d7af, 0xff87d7d7, 0xff87d7ff, 0xff87ff00, 0xff87ff5f, 0xff87ff87, 0xff87ffaf, 0xff87ffd7, 0xff87ffff, + 0xffaf0000, 0xffaf005f, 0xffaf0087, 0xffaf00af, 0xffaf00d7, 0xffaf00ff, 0xffaf5f00, 0xffaf5f5f, 0xffaf5f87, 0xffaf5faf, 0xffaf5fd7, 0xffaf5fff, + 0xffaf8700, 0xffaf875f, 0xffaf8787, 0xffaf87af, 0xffaf87d7, 0xffaf87ff, 0xffafaf00, 0xffafaf5f, 0xffafaf87, 0xffafafaf, 0xffafafd7, 0xffafafff, + 0xffafd700, 0xffafd75f, 0xffafd787, 0xffafd7af, 0xffafd7d7, 0xffafd7ff, 0xffafff00, 0xffafff5f, 0xffafff87, 0xffafffaf, 0xffafffd7, 0xffafffff, + 0xffd70000, 0xffd7005f, 0xffd70087, 0xffd700af, 0xffd700d7, 0xffd700ff, 0xffd75f00, 0xffd75f5f, 0xffd75f87, 0xffd75faf, 0xffd75fd7, 0xffd75fff, + 0xffd78700, 0xffd7875f, 0xffd78787, 0xffd787af, 0xffd787d7, 0xffd787ff, 0xffd7af00, 0xffd7af5f, 0xffd7af87, 0xffd7afaf, 0xffd7afd7, 0xffd7afff, + 0xffd7d700, 0xffd7d75f, 0xffd7d787, 0xffd7d7af, 0xffd7d7d7, 0xffd7d7ff, 0xffd7ff00, 0xffd7ff5f, 0xffd7ff87, 0xffd7ffaf, 0xffd7ffd7, 0xffd7ffff, + 0xffff0000, 0xffff005f, 0xffff0087, 0xffff00af, 0xffff00d7, 0xffff00ff, 0xffff5f00, 0xffff5f5f, 0xffff5f87, 0xffff5faf, 0xffff5fd7, 0xffff5fff, + 0xffff8700, 0xffff875f, 0xffff8787, 0xffff87af, 0xffff87d7, 0xffff87ff, 0xffffaf00, 0xffffaf5f, 0xffffaf87, 0xffffafaf, 0xffffafd7, 0xffffafff, + 0xffffd700, 0xffffd75f, 0xffffd787, 0xffffd7af, 0xffffd7d7, 0xffffd7ff, 0xffffff00, 0xffffff5f, 0xffffff87, 0xffffffaf, 0xffffffd7, 0xffffffff, + + // 24 grey scale ramp: + 0xff080808, 0xff121212, 0xff1c1c1c, 0xff262626, 0xff303030, 0xff3a3a3a, 0xff444444, 0xff4e4e4e, 0xff585858, 0xff626262, 0xff6c6c6c, 0xff767676, + 0xff808080, 0xff8a8a8a, 0xff949494, 0xff9e9e9e, 0xffa8a8a8, 0xffb2b2b2, 0xffbcbcbc, 0xffc6c6c6, 0xffd0d0d0, 0xffdadada, 0xffe4e4e4, 0xffeeeeee, + + // COLOR_INDEX_DEFAULT_FOREGROUND, COLOR_INDEX_DEFAULT_BACKGROUND and COLOR_INDEX_DEFAULT_CURSOR: + 0xffffffff, 0xff000000, 0xffffffff}; + + public final int[] mDefaultColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + public TerminalColorScheme() { + reset(); + } + + private void reset() { + System.arraycopy(DEFAULT_COLORSCHEME, 0, mDefaultColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + public void updateWith(Properties props) { + reset(); + boolean cursorPropExists = false; + for (Map.Entry entries : props.entrySet()) { + String key = (String) entries.getKey(); + String value = (String) entries.getValue(); + int colorIndex; + + if (key.equals("foreground")) { + colorIndex = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (key.equals("background")) { + colorIndex = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (key.equals("cursor")) { + colorIndex = TextStyle.COLOR_INDEX_CURSOR; + cursorPropExists = true; + } else if (key.startsWith("color")) { + try { + colorIndex = Integer.parseInt(key.substring(5)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + } else { + throw new IllegalArgumentException("Invalid property: '" + key + "'"); + } + + int colorValue = TerminalColors.parse(value); + if (colorValue == 0) + throw new IllegalArgumentException("Property '" + key + "' has invalid color: '" + value + "'"); + + mDefaultColors[colorIndex] = colorValue; + } + + if (!cursorPropExists) + setCursorColorForBackground(); + } + + /** + * If the "cursor" color is not set by user, we need to decide on the appropriate color that will + * be visible on the current terminal background. White will not be visible on light backgrounds + * and black won't be visible on dark backgrounds. So we find the perceived brightness of the + * background color and if its below the threshold (too dark), we use white cursor and if its + * above (too bright), we use black cursor. + */ + public void setCursorColorForBackground() { + int backgroundColor = mDefaultColors[TextStyle.COLOR_INDEX_BACKGROUND]; + int brightness = TerminalColors.getPerceivedBrightnessOfColor(backgroundColor); + if (brightness > 0) { + if (brightness < 130) + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xffffffff; + else + mDefaultColors[TextStyle.COLOR_INDEX_CURSOR] = 0xff000000; + } + } + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java new file mode 100644 index 0000000..25135a2 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalColors.java @@ -0,0 +1,96 @@ +package com.termux.terminal; + +import android.graphics.Color; + +/** Current terminal colors (if different from default). */ +public final class TerminalColors { + + /** Static data - a bit ugly but ok for now. */ + public static final TerminalColorScheme COLOR_SCHEME = new TerminalColorScheme(); + + /** + * The current terminal colors, which are normally set from the color theme, but may be set dynamically with the OSC + * 4 control sequence. + */ + public final int[] mCurrentColors = new int[TextStyle.NUM_INDEXED_COLORS]; + + /** Create a new instance with default colors from the theme. */ + public TerminalColors() { + reset(); + } + + /** Reset a particular indexed color with the default color from the color theme. */ + public void reset(int index) { + mCurrentColors[index] = COLOR_SCHEME.mDefaultColors[index]; + } + + /** Reset all indexed colors with the default color from the color theme. */ + public void reset() { + System.arraycopy(COLOR_SCHEME.mDefaultColors, 0, mCurrentColors, 0, TextStyle.NUM_INDEXED_COLORS); + } + + /** + * Parse color according to http://manpages.ubuntu.com/manpages/intrepid/man3/XQueryColor.3.html + *

+ * Highest bit is set if successful, so return value is 0xFF${R}${G}${B}. Return 0 if failed. + */ + static int parse(String c) { + try { + int skipInitial, skipBetween; + if (c.charAt(0) == '#') { + // #RGB, #RRGGBB, #RRRGGGBBB or #RRRRGGGGBBBB. Most significant bits. + skipInitial = 1; + skipBetween = 0; + } else if (c.startsWith("rgb:")) { + // rgb:// where , , := h | hh | hhh | hhhh. Scaled. + skipInitial = 4; + skipBetween = 1; + } else { + return 0; + } + int charsForColors = c.length() - skipInitial - 2 * skipBetween; + if (charsForColors % 3 != 0) return 0; // Unequal lengths. + int componentLength = charsForColors / 3; + double mult = 255 / (Math.pow(2, componentLength * 4) - 1); + + int currentPosition = skipInitial; + String rString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String gString = c.substring(currentPosition, currentPosition + componentLength); + currentPosition += componentLength + skipBetween; + String bString = c.substring(currentPosition, currentPosition + componentLength); + + int r = (int) (Integer.parseInt(rString, 16) * mult); + int g = (int) (Integer.parseInt(gString, 16) * mult); + int b = (int) (Integer.parseInt(bString, 16) * mult); + return 0xFF << 24 | r << 16 | g << 8 | b; + } catch (NumberFormatException | IndexOutOfBoundsException e) { + return 0; + } + } + + /** Try parse a color from a text parameter and into a specified index. */ + public void tryParseColor(int intoIndex, String textParameter) { + int c = parse(textParameter); + if (c != 0) mCurrentColors[intoIndex] = c; + } + + /** + * Get the perceived brightness of the color based on its RGB components. + * + * https://www.nbdtech.com/Blog/archive/2008/04/27/Calculating-the-Perceived-Brightness-of-a-Color.aspx + * http://alienryderflex.com/hsp.html + * + * @param color The color code int. + * @return Returns value between 0-255. + */ + public static int getPerceivedBrightnessOfColor(int color) { + return (int) + Math.floor(Math.sqrt( + Math.pow(Color.red(color), 2) * 0.241 + + Math.pow(Color.green(color), 2) * 0.691 + + Math.pow(Color.blue(color), 2) * 0.068 + )); + } + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java new file mode 100644 index 0000000..b0be6f3 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalEmulator.java @@ -0,0 +1,2617 @@ +package com.termux.terminal; + +import android.util.Base64; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.Stack; + +/** + * Renders text into a screen. Contains all the terminal-specific knowledge and state. Emulates a subset of the X Window + * System xterm terminal, which in turn is an emulator for a subset of the Digital Equipment Corporation vt100 terminal. + *

+ * References: + *

    + *
  • http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
  • + *
  • http://en.wikipedia.org/wiki/ANSI_escape_code
  • + *
  • http://man.he.net/man4/console_codes
  • + *
  • http://bazaar.launchpad.net/~leonerd/libvterm/trunk/view/head:/src/state.c
  • + *
  • http://www.columbia.edu/~kermit/k95manual/iso2022.html
  • + *
  • http://www.vt100.net/docs/vt510-rm/chapter4
  • + *
  • http://en.wikipedia.org/wiki/ISO/IEC_2022 - for 7-bit and 8-bit GL GR explanation
  • + *
  • http://bjh21.me.uk/all-escapes/all-escapes.txt - extensive!
  • + *
  • http://woldlab.caltech.edu/~diane/kde4.10/workingdir/kubuntu/konsole/doc/developer/old-documents/VT100/techref. + * html - document for konsole - accessible!
  • + *
+ */ +public final class TerminalEmulator { + + /** Log unknown or unimplemented escape sequences received from the shell process. */ + private static final boolean LOG_ESCAPE_SEQUENCES = false; + + public static final int MOUSE_LEFT_BUTTON = 0; + + /** Mouse moving while having left mouse button pressed. */ + public static final int MOUSE_LEFT_BUTTON_MOVED = 32; + public static final int MOUSE_WHEELUP_BUTTON = 64; + public static final int MOUSE_WHEELDOWN_BUTTON = 65; + + /** Used for invalid data - http://en.wikipedia.org/wiki/Replacement_character#Replacement_character */ + public static final int UNICODE_REPLACEMENT_CHAR = 0xFFFD; + + /** Escape processing: Not currently in an escape sequence. */ + private static final int ESC_NONE = 0; + /** Escape processing: Have seen an ESC character - proceed to {@link #doEsc(int)} */ + private static final int ESC = 1; + /** Escape processing: Have seen ESC POUND */ + private static final int ESC_POUND = 2; + /** Escape processing: Have seen ESC and a character-set-select ( char */ + private static final int ESC_SELECT_LEFT_PAREN = 3; + /** Escape processing: Have seen ESC and a character-set-select ) char */ + private static final int ESC_SELECT_RIGHT_PAREN = 4; + /** Escape processing: "ESC [" or CSI (Control Sequence Introducer). */ + private static final int ESC_CSI = 6; + /** Escape processing: ESC [ ? */ + private static final int ESC_CSI_QUESTIONMARK = 7; + /** Escape processing: ESC [ $ */ + private static final int ESC_CSI_DOLLAR = 8; + /** Escape processing: ESC % */ + private static final int ESC_PERCENT = 9; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) */ + private static final int ESC_OSC = 10; + /** Escape processing: ESC ] (AKA OSC - Operating System Controls) ESC */ + private static final int ESC_OSC_ESC = 11; + /** Escape processing: ESC [ > */ + private static final int ESC_CSI_BIGGERTHAN = 12; + /** Escape procession: "ESC P" or Device Control String (DCS) */ + private static final int ESC_P = 13; + /** Escape processing: CSI > */ + private static final int ESC_CSI_QUESTIONMARK_ARG_DOLLAR = 14; + /** Escape processing: CSI $ARGS ' ' */ + private static final int ESC_CSI_ARGS_SPACE = 15; + /** Escape processing: CSI $ARGS '*' */ + private static final int ESC_CSI_ARGS_ASTERIX = 16; + /** Escape processing: CSI " */ + private static final int ESC_CSI_DOUBLE_QUOTE = 17; + /** Escape processing: CSI ' */ + private static final int ESC_CSI_SINGLE_QUOTE = 18; + /** Escape processing: CSI ! */ + private static final int ESC_CSI_EXCLAMATION = 19; + /** Escape processing: "ESC _" or Application Program Command (APC). */ + private static final int ESC_APC = 20; + /** Escape processing: "ESC _" or Application Program Command (APC), followed by Escape. */ + private static final int ESC_APC_ESCAPE = 21; + /** Escape processing: ESC [ */ + private static final int ESC_CSI_UNSUPPORTED_PARAMETER_BYTE = 22; + /** Escape processing: ESC [ */ + private static final int ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE = 23; + + /** The number of parameter arguments including colon separated sub-parameters. */ + private static final int MAX_ESCAPE_PARAMETERS = 32; + + /** Needs to be large enough to contain reasonable OSC 52 pastes. */ + private static final int MAX_OSC_STRING_LENGTH = 8192; + + /** DECSET 1 - application cursor keys. */ + private static final int DECSET_BIT_APPLICATION_CURSOR_KEYS = 1; + private static final int DECSET_BIT_REVERSE_VIDEO = 1 << 1; + /** + * http://www.vt100.net/docs/vt510-rm/DECOM: "When DECOM is set, the home cursor position is at the upper-left + * corner of the screen, within the margins. The starting point for line numbers depends on the current top margin + * setting. The cursor cannot move outside of the margins. When DECOM is reset, the home cursor position is at the + * upper-left corner of the screen. The starting point for line numbers is independent of the margins. The cursor + * can move outside of the margins." + */ + private static final int DECSET_BIT_ORIGIN_MODE = 1 << 2; + /** + * http://www.vt100.net/docs/vt510-rm/DECAWM: "If the DECAWM function is set, then graphic characters received when + * the cursor is at the right border of the page appear at the beginning of the next line. Any text on the page + * scrolls up if the cursor is at the end of the scrolling region. If the DECAWM function is reset, then graphic + * characters received when the cursor is at the right border of the page replace characters already on the page." + */ + private static final int DECSET_BIT_AUTOWRAP = 1 << 3; + /** DECSET 25 - if the cursor should be enabled, {@link #isCursorEnabled()}. */ + private static final int DECSET_BIT_CURSOR_ENABLED = 1 << 4; + private static final int DECSET_BIT_APPLICATION_KEYPAD = 1 << 5; + /** DECSET 1000 - if to report mouse press&release events. */ + private static final int DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE = 1 << 6; + /** DECSET 1002 - like 1000, but report moving mouse while pressed. */ + private static final int DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT = 1 << 7; + /** DECSET 1004 - NOT implemented. */ + private static final int DECSET_BIT_SEND_FOCUS_EVENTS = 1 << 8; + /** DECSET 1006 - SGR-like mouse protocol (the modern sane choice). */ + private static final int DECSET_BIT_MOUSE_PROTOCOL_SGR = 1 << 9; + /** DECSET 2004 - see {@link #paste(String)} */ + private static final int DECSET_BIT_BRACKETED_PASTE_MODE = 1 << 10; + /** Toggled with DECLRMM - http://www.vt100.net/docs/vt510-rm/DECLRMM */ + private static final int DECSET_BIT_LEFTRIGHT_MARGIN_MODE = 1 << 11; + /** Not really DECSET bit... - http://www.vt100.net/docs/vt510-rm/DECSACE */ + private static final int DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE = 1 << 12; + + + private String mTitle; + private final Stack mTitleStack = new Stack<>(); + + /** The cursor position. Between (0,0) and (mRows-1, mColumns-1). */ + private int mCursorRow, mCursorCol; + + /** The number of character rows and columns in the terminal screen. */ + public int mRows, mColumns; + + /** Size of a terminal cell in pixels. */ + private int mCellWidthPixels, mCellHeightPixels; + + /** The number of terminal transcript rows that can be scrolled back to. */ + public static final int TERMINAL_TRANSCRIPT_ROWS_MIN = 100; + public static final int TERMINAL_TRANSCRIPT_ROWS_MAX = 50000; + public static final int DEFAULT_TERMINAL_TRANSCRIPT_ROWS = 2000; + + + /* The supported terminal cursor styles. */ + + public static final int TERMINAL_CURSOR_STYLE_BLOCK = 0; + public static final int TERMINAL_CURSOR_STYLE_UNDERLINE = 1; + public static final int TERMINAL_CURSOR_STYLE_BAR = 2; + public static final int DEFAULT_TERMINAL_CURSOR_STYLE = TERMINAL_CURSOR_STYLE_BLOCK; + public static final Integer[] TERMINAL_CURSOR_STYLES_LIST = new Integer[]{TERMINAL_CURSOR_STYLE_BLOCK, TERMINAL_CURSOR_STYLE_UNDERLINE, TERMINAL_CURSOR_STYLE_BAR}; + + /** The terminal cursor styles. */ + private int mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + + + /** The normal screen buffer. Stores the characters that appear on the screen of the emulated terminal. */ + private final TerminalBuffer mMainBuffer; + /** + * The alternate screen buffer, exactly as large as the display and contains no additional saved lines (so that when + * the alternate screen buffer is active, you cannot scroll back to view saved lines). + *

+ * See http://www.xfree86.org/current/ctlseqs.html#The%20Alternate%20Screen%20Buffer + */ + final TerminalBuffer mAltBuffer; + /** The current screen buffer, pointing at either {@link #mMainBuffer} or {@link #mAltBuffer}. */ + private TerminalBuffer mScreen; + + /** The terminal session this emulator is bound to. */ + private final TerminalOutput mSession; + + TerminalSessionClient mClient; + + /** Keeps track of the current argument of the current escape sequence. Ranges from 0 to MAX_ESCAPE_PARAMETERS-1. */ + private int mArgIndex; + /** Holds the arguments of the current escape sequence. */ + private final int[] mArgs = new int[MAX_ESCAPE_PARAMETERS]; + /** Holds the bit flags which arguments are sub parameters (after a colon) - bit N is set if mArgs[N] is a sub parameter. */ + private int mArgsSubParamsBitSet = 0; + + /** Holds OSC and device control arguments, which can be strings. */ + private final StringBuilder mOSCOrDeviceControlArgs = new StringBuilder(); + + /** + * True if the current escape sequence should continue, false if the current escape sequence should be terminated. + * Used when parsing a single character. + */ + private boolean mContinueSequence; + + /** The current state of the escape sequence state machine. One of the ESC_* constants. */ + private int mEscapeState; + + private final SavedScreenState mSavedStateMain = new SavedScreenState(); + private final SavedScreenState mSavedStateAlt = new SavedScreenState(); + + /** http://www.vt100.net/docs/vt102-ug/table5-15.html */ + private boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + + /** + * @see TerminalEmulator#mapDecSetBitToInternalBit(int) + */ + private int mCurrentDecSetFlags, mSavedDecSetFlags; + + /** + * If insert mode (as opposed to replace mode) is active. In insert mode new characters are inserted, pushing + * existing text to the right. Characters moved past the right margin are lost. + */ + private boolean mInsertMode; + + /** An array of tab stops. mTabStop[i] is true if there is a tab stop set for column i. */ + private boolean[] mTabStop; + + /** + * Top margin of screen for scrolling ranges from 0 to mRows-2. Bottom margin ranges from mTopMargin + 2 to mRows + * (Defines the first row after the scrolling region). Left/right margin in [0, mColumns]. + */ + private int mTopMargin, mBottomMargin, mLeftMargin, mRightMargin; + + /** + * If the next character to be emitted will be automatically wrapped to the next line. Used to disambiguate the case + * where the cursor is positioned on the last column (mColumns-1). When standing there, a written character will be + * output in the last column, the cursor not moving but this flag will be set. When outputting another character + * this will move to the next line. + */ + private boolean mAboutToAutoWrap; + + /** + * If the cursor blinking is enabled. It requires cursor itself to be enabled, which is controlled + * byt whether {@link #DECSET_BIT_CURSOR_ENABLED} bit is set or not. + */ + private boolean mCursorBlinkingEnabled; + + /** + * If currently cursor should be in a visible state or not if {@link #mCursorBlinkingEnabled} + * is {@code true}. + */ + private boolean mCursorBlinkState; + + /** + * Current foreground, background and underline colors. Can either be a color index in [0,259] or a truecolor (24-bit) value. + * For a 24-bit value the top byte (0xff000000) is set. + * + *

Note that the underline color is currently parsed but not yet used during rendering. + * + * @see TextStyle + */ + int mForeColor, mBackColor, mUnderlineColor; + + /** Current {@link TextStyle} effect. */ + int mEffect; + + /** + * The number of scrolled lines since last calling {@link #clearScrollCounter()}. Used for moving selection up along + * with the scrolling text. + */ + private int mScrollCounter = 0; + + /** If automatic scrolling of terminal is disabled */ + private boolean mAutoScrollDisabled; + + private byte mUtf8ToFollow, mUtf8Index; + private final byte[] mUtf8InputBuffer = new byte[4]; + private int mLastEmittedCodePoint = -1; + + public final TerminalColors mColors = new TerminalColors(); + + private static final String LOG_TAG = "TerminalEmulator"; + + private boolean isDecsetInternalBitSet(int bit) { + return (mCurrentDecSetFlags & bit) != 0; + } + + private void setDecsetinternalBit(int internalBit, boolean set) { + if (set) { + // The mouse modes are mutually exclusive. + if (internalBit == DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT, false); + } else if (internalBit == DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT) { + setDecsetinternalBit(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE, false); + } + } + if (set) { + mCurrentDecSetFlags |= internalBit; + } else { + mCurrentDecSetFlags &= ~internalBit; + } + } + + static int mapDecSetBitToInternalBit(int decsetBit) { + switch (decsetBit) { + case 1: + return DECSET_BIT_APPLICATION_CURSOR_KEYS; + case 5: + return DECSET_BIT_REVERSE_VIDEO; + case 6: + return DECSET_BIT_ORIGIN_MODE; + case 7: + return DECSET_BIT_AUTOWRAP; + case 25: + return DECSET_BIT_CURSOR_ENABLED; + case 66: + return DECSET_BIT_APPLICATION_KEYPAD; + case 69: + return DECSET_BIT_LEFTRIGHT_MARGIN_MODE; + case 1000: + return DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE; + case 1002: + return DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT; + case 1004: + return DECSET_BIT_SEND_FOCUS_EVENTS; + case 1006: + return DECSET_BIT_MOUSE_PROTOCOL_SGR; + case 2004: + return DECSET_BIT_BRACKETED_PASTE_MODE; + default: + return -1; + // throw new IllegalArgumentException("Unsupported decset: " + decsetBit); + } + } + + public TerminalEmulator(TerminalOutput session, int columns, int rows, int cellWidthPixels, int cellHeightPixels, Integer transcriptRows, TerminalSessionClient client) { + mSession = session; + mScreen = mMainBuffer = new TerminalBuffer(columns, getTerminalTranscriptRows(transcriptRows), rows); + mAltBuffer = new TerminalBuffer(columns, rows, rows); + mClient = client; + mRows = rows; + mColumns = columns; + mCellWidthPixels = cellWidthPixels; + mCellHeightPixels = cellHeightPixels; + mTabStop = new boolean[mColumns]; + reset(); + } + + public void updateTerminalSessionClient(TerminalSessionClient client) { + mClient = client; + setCursorStyle(); + setCursorBlinkState(true); + } + + public TerminalBuffer getScreen() { + return mScreen; + } + + public boolean isAlternateBufferActive() { + return mScreen == mAltBuffer; + } + + private int getTerminalTranscriptRows(Integer transcriptRows) { + if (transcriptRows == null || transcriptRows < TERMINAL_TRANSCRIPT_ROWS_MIN || transcriptRows > TERMINAL_TRANSCRIPT_ROWS_MAX) + return DEFAULT_TERMINAL_TRANSCRIPT_ROWS; + else + return transcriptRows; + } + + /** + * @param mouseButton one of the MOUSE_* constants of this class. + */ + public void sendMouseEvent(int mouseButton, int column, int row, boolean pressed) { + if (column < 1) column = 1; + if (column > mColumns) column = mColumns; + if (row < 1) row = 1; + if (row > mRows) row = mRows; + + if (mouseButton == MOUSE_LEFT_BUTTON_MOVED && !isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT)) { + // Do not send tracking. + } else if (isDecsetInternalBitSet(DECSET_BIT_MOUSE_PROTOCOL_SGR)) { + mSession.write(String.format("\033[<%d;%d;%d" + (pressed ? 'M' : 'm'), mouseButton, column, row)); + } else { + mouseButton = pressed ? mouseButton : 3; // 3 for release of all buttons. + // Clip to screen, and clip to the limits of 8-bit data. + boolean out_of_bounds = column > 255 - 32 || row > 255 - 32; + if (!out_of_bounds) { + byte[] data = {'\033', '[', 'M', (byte) (32 + mouseButton), (byte) (32 + column), (byte) (32 + row)}; + mSession.write(data, 0, data.length); + } + } + } + + public void resize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + this.mCellWidthPixels = cellWidthPixels; + this.mCellHeightPixels = cellHeightPixels; + + if (mRows == rows && mColumns == columns) { + return; + } else if (columns < 2 || rows < 2) { + throw new IllegalArgumentException("rows=" + rows + ", columns=" + columns); + } + + if (mRows != rows) { + mRows = rows; + mTopMargin = 0; + mBottomMargin = mRows; + } + if (mColumns != columns) { + int oldColumns = mColumns; + mColumns = columns; + boolean[] oldTabStop = mTabStop; + mTabStop = new boolean[mColumns]; + setDefaultTabStops(); + int toTransfer = Math.min(oldColumns, columns); + System.arraycopy(oldTabStop, 0, mTabStop, 0, toTransfer); + mLeftMargin = 0; + mRightMargin = mColumns; + } + + resizeScreen(); + } + + private void resizeScreen() { + final int[] cursor = {mCursorCol, mCursorRow}; + int newTotalRows = (mScreen == mAltBuffer) ? mRows : mMainBuffer.mTotalRows; + mScreen.resize(mColumns, mRows, newTotalRows, cursor, getStyle(), isAlternateBufferActive()); + mCursorCol = cursor[0]; + mCursorRow = cursor[1]; + } + + public int getCursorRow() { + return mCursorRow; + } + + public int getCursorCol() { + return mCursorCol; + } + + /** Get the terminal cursor style. It will be one of {@link #TERMINAL_CURSOR_STYLES_LIST} */ + public int getCursorStyle() { + return mCursorStyle; + } + + /** Set the terminal cursor style. */ + public void setCursorStyle() { + Integer cursorStyle = null; + + if (mClient != null) + cursorStyle = mClient.getTerminalCursorStyle(); + + if (cursorStyle == null || !Arrays.asList(TERMINAL_CURSOR_STYLES_LIST).contains(cursorStyle)) + mCursorStyle = DEFAULT_TERMINAL_CURSOR_STYLE; + else + mCursorStyle = cursorStyle; + } + + public boolean isReverseVideo() { + return isDecsetInternalBitSet(DECSET_BIT_REVERSE_VIDEO); + } + + + + public boolean isCursorEnabled() { + return isDecsetInternalBitSet(DECSET_BIT_CURSOR_ENABLED); + } + public boolean shouldCursorBeVisible() { + if (!isCursorEnabled()) + return false; + else + return mCursorBlinkingEnabled ? mCursorBlinkState : true; + } + + public void setCursorBlinkingEnabled(boolean cursorBlinkingEnabled) { + this.mCursorBlinkingEnabled = cursorBlinkingEnabled; + } + + public void setCursorBlinkState(boolean cursorBlinkState) { + this.mCursorBlinkState = cursorBlinkState; + } + + + + public boolean isKeypadApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD); + } + + public boolean isCursorKeysApplicationMode() { + return isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS); + } + + /** If mouse events are being sent as escape codes to the terminal. */ + public boolean isMouseTrackingActive() { + return isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_PRESS_RELEASE) || isDecsetInternalBitSet(DECSET_BIT_MOUSE_TRACKING_BUTTON_EVENT); + } + + private void setDefaultTabStops() { + for (int i = 0; i < mColumns; i++) + mTabStop[i] = (i & 7) == 0 && i != 0; + } + + /** + * Accept bytes (typically from the pseudo-teletype) and process them. + * + * @param buffer a byte array containing the bytes to be processed + * @param length the number of bytes in the array to process + */ + public void append(byte[] buffer, int length) { + for (int i = 0; i < length; i++) + processByte(buffer[i]); + } + + private void processByte(byte byteToProcess) { + if (mUtf8ToFollow > 0) { + if ((byteToProcess & 0b11000000) == 0b10000000) { + // 10xxxxxx, a continuation byte. + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + if (--mUtf8ToFollow == 0) { + byte firstByteMask = (byte) (mUtf8Index == 2 ? 0b00011111 : (mUtf8Index == 3 ? 0b00001111 : 0b00000111)); + int codePoint = (mUtf8InputBuffer[0] & firstByteMask); + for (int i = 1; i < mUtf8Index; i++) + codePoint = ((codePoint << 6) | (mUtf8InputBuffer[i] & 0b00111111)); + if (((codePoint <= 0b1111111) && mUtf8Index > 1) || (codePoint < 0b11111111111 && mUtf8Index > 2) + || (codePoint < 0b1111111111111111 && mUtf8Index > 3)) { + // Overlong encoding. + codePoint = UNICODE_REPLACEMENT_CHAR; + } + + mUtf8Index = mUtf8ToFollow = 0; + + if (codePoint >= 0x80 && codePoint <= 0x9F) { + // Sequence decoded to a C1 control character which we ignore. They are + // not used nowadays and increases the risk of messing up the terminal state + // on binary input. XTerm does not allow them in utf-8: + // "It is not possible to use a C1 control obtained from decoding the + // UTF-8 text" - http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + } else { + switch (Character.getType(codePoint)) { + case Character.UNASSIGNED: + case Character.SURROGATE: + codePoint = UNICODE_REPLACEMENT_CHAR; + } + processCodePoint(codePoint); + } + } + } else { + // Not a UTF-8 continuation byte so replace the entire sequence up to now with the replacement char: + mUtf8Index = mUtf8ToFollow = 0; + emitCodePoint(UNICODE_REPLACEMENT_CHAR); + // The Unicode Standard Version 6.2 – Core Specification + // (http://www.unicode.org/versions/Unicode6.2.0/ch03.pdf): + // "If the converter encounters an ill-formed UTF-8 code unit sequence which starts with a valid first + // byte, but which does not continue with valid successor bytes (see Table 3-7), it must not consume the + // successor bytes as part of the ill-formed subsequence + // whenever those successor bytes themselves constitute part of a well-formed UTF-8 code unit + // subsequence." + processByte(byteToProcess); + } + } else { + if ((byteToProcess & 0b10000000) == 0) { // The leading bit is not set so it is a 7-bit ASCII character. + processCodePoint(byteToProcess); + return; + } else if ((byteToProcess & 0b11100000) == 0b11000000) { // 110xxxxx, a two-byte sequence. + mUtf8ToFollow = 1; + } else if ((byteToProcess & 0b11110000) == 0b11100000) { // 1110xxxx, a three-byte sequence. + mUtf8ToFollow = 2; + } else if ((byteToProcess & 0b11111000) == 0b11110000) { // 11110xxx, a four-byte sequence. + mUtf8ToFollow = 3; + } else { + // Not a valid UTF-8 sequence start, signal invalid data: + processCodePoint(UNICODE_REPLACEMENT_CHAR); + return; + } + mUtf8InputBuffer[mUtf8Index++] = byteToProcess; + } + } + + public void processCodePoint(int b) { + // The Application Program-Control (APC) string might be arbitrary non-printable characters, so handle that early. + if (mEscapeState == ESC_APC) { + doApc(b); + return; + } else if (mEscapeState == ESC_APC_ESCAPE) { + doApcEscape(b); + return; + } + + switch (b) { + case 0: // Null character (NUL, ^@). Do nothing. + break; + case 7: // Bell (BEL, ^G, \a). If in an OSC sequence, BEL may terminate a string; otherwise signal bell. + if (mEscapeState == ESC_OSC) + doOsc(b); + else + mSession.onBell(); + break; + case 8: // Backspace (BS, ^H). + if (mLeftMargin == mCursorCol) { + // Jump to previous line if it was auto-wrapped. + int previousRow = mCursorRow - 1; + if (previousRow >= 0 && mScreen.getLineWrap(previousRow)) { + mScreen.clearLineWrap(previousRow); + setCursorRowCol(previousRow, mRightMargin - 1); + } + } else { + setCursorCol(mCursorCol - 1); + } + break; + case 9: // Horizontal tab (HT, \t) - move to next tab stop, but not past edge of screen + // XXX: Should perhaps use color if writing to new cells. Try with + // printf "\033[41m\tXX\033[0m\n" + // The OSX Terminal.app colors the spaces from the tab red, but xterm does not. + // Note that Terminal.app only colors on new cells, in e.g. + // printf "\033[41m\t\r\033[42m\tXX\033[0m\n" + // the first cells are created with a red background, but when tabbing over + // them again with a green background they are not overwritten. + mCursorCol = nextTabStop(1); + break; + case 10: // Line feed (LF, \n). + case 11: // Vertical tab (VT, \v). + case 12: // Form feed (FF, \f). + doLinefeed(); + break; + case 13: // Carriage return (CR, \r). + setCursorCol(mLeftMargin); + break; + case 14: // Shift Out (Ctrl-N, SO) → Switch to Alternate Character Set. This invokes the G1 character set. + mUseLineDrawingUsesG0 = false; + break; + case 15: // Shift In (Ctrl-O, SI) → Switch to Standard Character Set. This invokes the G0 character set. + mUseLineDrawingUsesG0 = true; + break; + case 24: // CAN. + case 26: // SUB. + if (mEscapeState != ESC_NONE) { + // FIXME: What is this?? + mEscapeState = ESC_NONE; + emitCodePoint(127); + } + break; + case 27: // ESC + // Starts an escape sequence unless we're parsing a string + if (mEscapeState == ESC_P) { + // XXX: Ignore escape when reading device control sequence, since it may be part of string terminator. + return; + } else if (mEscapeState != ESC_OSC) { + startEscapeSequence(); + } else { + doOsc(b); + } + break; + default: + mContinueSequence = false; + switch (mEscapeState) { + case ESC_NONE: + if (b >= 32) emitCodePoint(b); + break; + case ESC: + doEsc(b); + break; + case ESC_POUND: + doEscPound(b); + break; + case ESC_SELECT_LEFT_PAREN: // Designate G0 Character Set (ISO 2022, VT100). + mUseLineDrawingG0 = (b == '0'); + break; + case ESC_SELECT_RIGHT_PAREN: // Designate G1 Character Set (ISO 2022, VT100). + mUseLineDrawingG1 = (b == '0'); + break; + case ESC_CSI: + doCsi(b); + break; + case ESC_CSI_UNSUPPORTED_PARAMETER_BYTE: + case ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE: + doCsiUnsupportedParameterOrIntermediateByte(b); + break; + case ESC_CSI_EXCLAMATION: + if (b == 'p') { // Soft terminal reset (DECSTR, http://vt100.net/docs/vt510-rm/DECSTR). + reset(); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_QUESTIONMARK: + doCsiQuestionMark(b); + break; + case ESC_CSI_BIGGERTHAN: + doCsiBiggerThan(b); + break; + case ESC_CSI_DOLLAR: + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + switch (b) { + case 'v': // ${CSI}${SRC_TOP}${SRC_LEFT}${SRC_BOTTOM}${SRC_RIGHT}${SRC_PAGE}${DST_TOP}${DST_LEFT}${DST_PAGE}$v" + // Copy rectangular area (DECCRA - http://vt100.net/docs/vt510-rm/DECCRA): + // "If Pbs is greater than Pts, or Pls is greater than Prs, the terminal ignores DECCRA. + // The coordinates of the rectangular area are affected by the setting of origin mode (DECOM). + // DECCRA is not affected by the page margins. + // The copied text takes on the line attributes of the destination area. + // If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, then the value + // is treated as the width or height of that page. + // If the destination area is partially off the page, then DECCRA clips the off-page data. + // DECCRA does not change the active cursor position." + int topSource = Math.min(getArg(0, 1, true) - 1 + effectiveTopMargin, mRows); + int leftSource = Math.min(getArg(1, 1, true) - 1 + effectiveLeftMargin, mColumns); + // Inclusive, so do not subtract one: + int bottomSource = Math.min(Math.max(getArg(2, mRows, true) + effectiveTopMargin, topSource), mRows); + int rightSource = Math.min(Math.max(getArg(3, mColumns, true) + effectiveLeftMargin, leftSource), mColumns); + // int sourcePage = getArg(4, 1, true); + int destionationTop = Math.min(getArg(5, 1, true) - 1 + effectiveTopMargin, mRows); + int destinationLeft = Math.min(getArg(6, 1, true) - 1 + effectiveLeftMargin, mColumns); + // int destinationPage = getArg(7, 1, true); + int heightToCopy = Math.min(mRows - destionationTop, bottomSource - topSource); + int widthToCopy = Math.min(mColumns - destinationLeft, rightSource - leftSource); + mScreen.blockCopy(leftSource, topSource, widthToCopy, heightToCopy, destinationLeft, destionationTop); + break; + case '{': // ${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${" + // Selective erase rectangular area (DECSERA - http://www.vt100.net/docs/vt510-rm/DECSERA). + case 'x': // ${CSI}${CHAR};${TOP}${LEFT}${BOTTOM}${RIGHT}$x" + // Fill rectangular area (DECFRA - http://www.vt100.net/docs/vt510-rm/DECFRA). + case 'z': // ${CSI}$${TOP}${LEFT}${BOTTOM}${RIGHT}$z" + // Erase rectangular area (DECERA - http://www.vt100.net/docs/vt510-rm/DECERA). + boolean erase = b != 'x'; + boolean selective = b == '{'; + // Only DECSERA keeps visual attributes, DECERA does not: + boolean keepVisualAttributes = erase && selective; + int argIndex = 0; + int fillChar = erase ? ' ' : getArg(argIndex++, -1, true); + // "Pch can be any value from 32 to 126 or from 160 to 255. If Pch is not in this range, then the + // terminal ignores the DECFRA command": + if ((fillChar >= 32 && fillChar <= 126) || (fillChar >= 160 && fillChar <= 255)) { + // "If the value of Pt, Pl, Pb, or Pr exceeds the width or height of the active page, the value + // is treated as the width or height of that page." + int top = Math.min(getArg(argIndex++, 1, true) + effectiveTopMargin, effectiveBottomMargin + 1); + int left = Math.min(getArg(argIndex++, 1, true) + effectiveLeftMargin, effectiveRightMargin + 1); + int bottom = Math.min(getArg(argIndex++, mRows, true) + effectiveTopMargin, effectiveBottomMargin); + int right = Math.min(getArg(argIndex, mColumns, true) + effectiveLeftMargin, effectiveRightMargin); + long style = getStyle(); + for (int row = top - 1; row < bottom; row++) + for (int col = left - 1; col < right; col++) + if (!selective || (TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, keepVisualAttributes ? mScreen.getStyleAt(row, col) : style); + } + break; + case 'r': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$r" + // Change attributes in rectangular area (DECCARA - http://vt100.net/docs/vt510-rm/DECCARA). + case 't': // "${CSI}${TOP}${LEFT}${BOTTOM}${RIGHT}${ATTRIBUTES}$t" + // Reverse attributes in rectangular area (DECRARA - http://www.vt100.net/docs/vt510-rm/DECRARA). + boolean reverse = b == 't'; + // FIXME: "coordinates of the rectangular area are affected by the setting of origin mode (DECOM)". + int top = Math.min(getArg(0, 1, true) - 1, effectiveBottomMargin) + effectiveTopMargin; + int left = Math.min(getArg(1, 1, true) - 1, effectiveRightMargin) + effectiveLeftMargin; + int bottom = Math.min(getArg(2, mRows, true) + 1, effectiveBottomMargin - 1) + effectiveTopMargin; + int right = Math.min(getArg(3, mColumns, true) + 1, effectiveRightMargin - 1) + effectiveLeftMargin; + if (mArgIndex >= 4) { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 4; i <= mArgIndex; i++) { + int bits = 0; + boolean setOrClear = true; // True if setting, false if clearing. + switch (getArg(i, 0, false)) { + case 0: // Attributes off (no bold, no underline, no blink, positive image). + bits = (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE | TextStyle.CHARACTER_ATTRIBUTE_BLINK + | TextStyle.CHARACTER_ATTRIBUTE_INVERSE); + if (!reverse) setOrClear = false; + break; + case 1: // Bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + break; + case 4: // Underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + break; + case 5: // Blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + break; + case 7: // Negative image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + break; + case 22: // No bold. + bits = TextStyle.CHARACTER_ATTRIBUTE_BOLD; + setOrClear = false; + break; + case 24: // No underline. + bits = TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + setOrClear = false; + break; + case 25: // No blink. + bits = TextStyle.CHARACTER_ATTRIBUTE_BLINK; + setOrClear = false; + break; + case 27: // Positive image. + bits = TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + setOrClear = false; + break; + } + if (reverse && !setOrClear) { + // Reverse attributes in rectangular area ignores non-(1,4,5,7) bits. + } else { + mScreen.setOrClearEffect(bits, setOrClear, reverse, isDecsetInternalBitSet(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE), + effectiveLeftMargin, effectiveRightMargin, top, left, bottom, right); + } + } + } else { + // Do nothing. + } + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_DOUBLE_QUOTE: + if (b == 'q') { + // http://www.vt100.net/docs/vt510-rm/DECSCA + int arg = getArg0(0); + if (arg == 0 || arg == 2) { + // DECSED and DECSEL can erase characters. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else if (arg == 1) { + // DECSED and DECSEL cannot erase characters. + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_PROTECTED; + } else { + unknownSequence(b); + } + } else { + unknownSequence(b); + } + break; + case ESC_CSI_SINGLE_QUOTE: + if (b == '}') { // Insert Ps Column(s) (default = 1) (DECIC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToInsert = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToInsert; + mScreen.blockCopy(mCursorCol, 0, columnsToMove, mRows, mCursorCol + columnsToInsert, 0); + blockClear(mCursorCol, 0, columnsToInsert, mRows); + } else if (b == '~') { // Delete Ps Column(s) (default = 1) (DECDC), VT420 and up. + int columnsAfterCursor = mRightMargin - mCursorCol; + int columnsToDelete = Math.min(getArg0(1), columnsAfterCursor); + int columnsToMove = columnsAfterCursor - columnsToDelete; + mScreen.blockCopy(mCursorCol + columnsToDelete, 0, columnsToMove, mRows, mCursorCol, 0); + } else { + unknownSequence(b); + } + break; + case ESC_PERCENT: + break; + case ESC_OSC: + doOsc(b); + break; + case ESC_OSC_ESC: + doOscEsc(b); + break; + case ESC_P: + doDeviceControl(b); + break; + case ESC_CSI_QUESTIONMARK_ARG_DOLLAR: + if (b == 'p') { + // Request DEC private mode (DECRQM). + int mode = getArg0(0); + int value; + if (mode == 47 || mode == 1047 || mode == 1049) { + // This state is carried by mScreen pointer. + value = (mScreen == mAltBuffer) ? 1 : 2; + } else { + int internalBit = mapDecSetBitToInternalBit(mode); + if (internalBit != -1) { + value = isDecsetInternalBitSet(internalBit) ? 1 : 2; // 1=set, 2=reset. + } else { + Logger.logError(mClient, LOG_TAG, "Got DECRQM for unrecognized private DEC mode=" + mode); + value = 0; // 0=not recognized, 3=permanently set, 4=permanently reset + } + } + mSession.write(String.format(Locale.US, "\033[?%d;%d$y", mode, value)); + } else { + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_SPACE: + int arg = getArg0(0); + switch (b) { + case 'q': // "${CSI}${STYLE} q" - set cursor style (http://www.vt100.net/docs/vt510-rm/DECSCUSR). + switch (arg) { + case 0: // Blinking block. + case 1: // Blinking block. + case 2: // Steady block. + mCursorStyle = TERMINAL_CURSOR_STYLE_BLOCK; + break; + case 3: // Blinking underline. + case 4: // Steady underline. + mCursorStyle = TERMINAL_CURSOR_STYLE_UNDERLINE; + break; + case 5: // Blinking bar (xterm addition). + case 6: // Steady bar (xterm addition). + mCursorStyle = TERMINAL_CURSOR_STYLE_BAR; + break; + } + break; + case 't': + case 'u': + // Set margin-bell volume - ignore. + break; + default: + unknownSequence(b); + } + break; + case ESC_CSI_ARGS_ASTERIX: + int attributeChangeExtent = getArg0(0); + if (b == 'x' && (attributeChangeExtent >= 0 && attributeChangeExtent <= 2)) { + // Select attribute change extent (DECSACE - http://www.vt100.net/docs/vt510-rm/DECSACE). + setDecsetinternalBit(DECSET_BIT_RECTANGULAR_CHANGEATTRIBUTE, attributeChangeExtent == 2); + } else { + unknownSequence(b); + } + break; + default: + unknownSequence(b); + break; + } + if (!mContinueSequence) mEscapeState = ESC_NONE; + break; + } + } + + /** When in {@link #ESC_P} ("device control") sequence. */ + private void doDeviceControl(int b) { + switch (b) { + case (byte) '\\': // End of ESC \ string Terminator + { + String dcs = mOSCOrDeviceControlArgs.toString(); + // DCS $ q P t ST. Request Status String (DECRQSS) + if (dcs.startsWith("$q")) { + if (dcs.equals("$q\"p")) { + // DECSCL, conformance level, http://www.vt100.net/docs/vt510-rm/DECSCL: + String csiString = "64;1\"p"; + mSession.write("\033P1$r" + csiString + "\033\\"); + } else { + finishSequenceAndLogError("Unrecognized DECRQSS string: '" + dcs + "'"); + } + } else if (dcs.startsWith("+q")) { + // Request Termcap/Terminfo String. The string following the "q" is a list of names encoded in + // hexadecimal (2 digits per character) separated by ; which correspond to termcap or terminfo key + // names. + // Two special features are also recognized, which are not key names: Co for termcap colors (or colors + // for terminfo colors), and TN for termcap name (or name for terminfo name). + // xterm responds with DCS 1 + r P t ST for valid requests, adding to P t an = , and the value of the + // corresponding string that xterm would send, or DCS 0 + r P t ST for invalid requests. The strings are + // encoded in hexadecimal (2 digits per character). + // Example: + // :kr=\EOC: ks=\E[?1h\E=: ku=\EOA: le=^H:mb=\E[5m:md=\E[1m:\ + // where + // kd=down-arrow key + // kl=left-arrow key + // kr=right-arrow key + // ku=up-arrow key + // #2=key_shome, "shifted home" + // #4=key_sleft, "shift arrow left" + // %i=key_sright, "shift arrow right" + // *7=key_send, "shifted end" + // k1=F1 function key + + // Example: Request for ku is "ESC P + q 6 b 7 5 ESC \", where 6b7d=ku in hexadecimal. + // Xterm response in normal cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x5B 0x41 = 27 91 65 = ESC [ A + // Xterm response in application cursor mode: + // "<27> P 1 + r 6 b 7 5 = 1 B 5 B 4 1" where 0x1B 0x4F 0x41 = 27 91 65 = ESC 0 A + + // #4 is "shift arrow left": + // *** Device Control (DCS) for '#4'- 'ESC P + q 23 34 ESC \' + // Response: <27> P 1 + r 2 3 3 4 = 1 B 5 B 3 1 3 B 3 2 4 4 <27> \ + // where 0x1B 0x5B 0x31 0x3B 0x32 0x44 = ESC [ 1 ; 2 D + // which we find in: TermKeyListener.java: KEY_MAP.put(KEYMOD_SHIFT | KEYCODE_DPAD_LEFT, "\033[1;2D"); + + // See http://h30097.www3.hp.com/docs/base_doc/DOCUMENTATION/V40G_HTML/MAN/MAN4/0178____.HTM for what to + // respond, as well as http://www.freebsd.org/cgi/man.cgi?query=termcap&sektion=5#CAPABILITIES for + // the meaning of e.g. "ku", "kd", "kr", "kl" + + for (String part : dcs.substring(2).split(";")) { + if (part.length() % 2 == 0) { + StringBuilder transBuffer = new StringBuilder(); + char c; + for (int i = 0; i < part.length(); i += 2) { + try { + c = (char) Long.decode("0x" + part.charAt(i) + "" + part.charAt(i + 1)).longValue(); + } catch (NumberFormatException e) { + Logger.logStackTraceWithMessage(mClient, LOG_TAG, "Invalid device termcap/terminfo encoded name \"" + part + "\"", e); + continue; + } + transBuffer.append(c); + } + + String trans = transBuffer.toString(); + String responseValue; + switch (trans) { + case "Co": + case "colors": + responseValue = "256"; // Number of colors. + break; + case "TN": + case "name": + responseValue = "xterm"; + break; + default: + responseValue = KeyHandler.getCodeFromTermcap(trans, isDecsetInternalBitSet(DECSET_BIT_APPLICATION_CURSOR_KEYS), + isDecsetInternalBitSet(DECSET_BIT_APPLICATION_KEYPAD)); + break; + } + if (responseValue == null) { + switch (trans) { + case "%1": // Help key - ignore + case "&8": // Undo key - ignore. + break; + default: + Logger.logWarn(mClient, LOG_TAG, "Unhandled termcap/terminfo name: '" + trans + "'"); + } + // Respond with invalid request: + mSession.write("\033P0+r" + part + "\033\\"); + } else { + StringBuilder hexEncoded = new StringBuilder(); + for (int j = 0; j < responseValue.length(); j++) { + hexEncoded.append(String.format("%02X", (int) responseValue.charAt(j))); + } + mSession.write("\033P1+r" + part + "=" + hexEncoded + "\033\\"); + } + } else { + Logger.logError(mClient, LOG_TAG, "Invalid device termcap/terminfo name of odd length: " + part); + } + } + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logError(mClient, LOG_TAG, "Unrecognized device control string: " + dcs); + } + finishSequence(); + } + break; + default: + if (mOSCOrDeviceControlArgs.length() > MAX_OSC_STRING_LENGTH) { + // Too long. + mOSCOrDeviceControlArgs.setLength(0); + finishSequence(); + } else { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } + } + } + + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApc(int b) { + if (b == 27) { + continueSequence(ESC_APC_ESCAPE); + } + // Eat APC sequences silently for now. + } + + /** + * When in {@link #ESC_APC} (APC, Application Program Command) sequence. + */ + private void doApcEscape(int b) { + if (b == '\\') { + // A String Terminator (ST), ending the APC escape sequence. + finishSequence(); + } else { + // The Escape character was not the start of a String Terminator (ST), + // but instead just data inside of the APC escape sequence. + continueSequence(ESC_APC); + } + } + + private int nextTabStop(int numTabs) { + for (int i = mCursorCol + 1; i < mColumns; i++) + if (mTabStop[i] && --numTabs == 0) return Math.min(i, mRightMargin); + return mRightMargin - 1; + } + + /** + * Process byte while in the {@link #ESC_CSI_UNSUPPORTED_PARAMETER_BYTE} or + * {@link #ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE} escape state. + * + * Parse unsupported parameter, intermediate and final bytes but ignore them. + * + * > For Control Sequence Introducer, ... the ESC [ is followed by + * > - any number (including none) of "parameter bytes" in the range 0x30–0x3F (ASCII 0–9:;<=>?), + * > - then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), + * > - then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~). + * + * - https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands + * - https://invisible-island.net/xterm/ecma-48-parameter-format.html#section5.4 + */ + private void doCsiUnsupportedParameterOrIntermediateByte(int b) { + if (mEscapeState == ESC_CSI_UNSUPPORTED_PARAMETER_BYTE && b >= 0x30 && b <= 0x3F) { + // Supported `0–9:;>?` or unsupported `<=` parameter byte after an + // initial unsupported parameter byte in `doCsi()`, or a sequential parameter byte. + continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + } else if (b >= 0x20 && b <= 0x2F) { + // Optional intermediate byte `!"#$%&'()*+,-./` after parameter or intermediate byte. + continueSequence(ESC_CSI_UNSUPPORTED_INTERMEDIATE_BYTE); + } else if (b >= 0x40 && b <= 0x7E) { + // Final byte `@A–Z[\]^_`a–z{|}~` after parameter or intermediate byte. + // Calling `unknownSequence()` would log an error with only a final byte, so ignore it for now. + finishSequence(); + } else { + unknownSequence(b); + } + } + + /** Process byte while in the {@link #ESC_CSI_QUESTIONMARK} escape state. */ + private void doCsiQuestionMark(int b) { + switch (b) { + case 'J': // Selective erase in display (DECSED) - http://www.vt100.net/docs/vt510-rm/DECSED. + case 'K': // Selective erase in line (DECSEL) - http://vt100.net/docs/vt510-rm/DECSEL. + mAboutToAutoWrap = false; + int fillChar = ' '; + int startCol = -1; + int startRow = -1; + int endCol = -1; + int endRow = -1; + boolean justRow = (b == 'K'); + switch (getArg0(0)) { + case 0: // Erase from the active position to the end, inclusive (default). + startCol = mCursorCol; + startRow = mCursorRow; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + case 1: // Erase from start to the active position, inclusive. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mCursorCol + 1; + endRow = mCursorRow + 1; + break; + case 2: // Erase all of the display/line. + startCol = 0; + startRow = justRow ? mCursorRow : 0; + endCol = mColumns; + endRow = justRow ? (mCursorRow + 1) : mRows; + break; + default: + unknownSequence(b); + break; + } + long style = getStyle(); + for (int row = startRow; row < endRow; row++) { + for (int col = startCol; col < endCol; col++) { + if ((TextStyle.decodeEffect(mScreen.getStyleAt(row, col)) & TextStyle.CHARACTER_ATTRIBUTE_PROTECTED) == 0) + mScreen.setChar(col, row, fillChar, style); + } + } + break; + case 'h': + case 'l': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) + doDecSetOrReset(b == 'h', mArgs[i]); + break; + case 'n': // Device Status Report (DSR, DEC-specific). + switch (getArg0(-1)) { + case 6: + // Extended Cursor Position (DECXCPR - http://www.vt100.net/docs/vt510-rm/DECXCPR). Page=1. + mSession.write(String.format(Locale.US, "\033[?%d;%d;1R", mCursorRow + 1, mCursorCol + 1)); + break; + default: + finishSequence(); + return; + } + break; + case 'r': + case 's': + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int externalBit = mArgs[i]; + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit == -1) { + Logger.logWarn(mClient, LOG_TAG, "Ignoring request to save/recall decset bit=" + externalBit); + } else { + if (b == 's') { + mSavedDecSetFlags |= internalBit; + } else { + doDecSetOrReset((mSavedDecSetFlags & internalBit) != 0, externalBit); + } + } + } + break; + case '$': + continueSequence(ESC_CSI_QUESTIONMARK_ARG_DOLLAR); + return; + default: + parseArg(b); + } + } + + public void doDecSetOrReset(boolean setting, int externalBit) { + int internalBit = mapDecSetBitToInternalBit(externalBit); + if (internalBit != -1) { + setDecsetinternalBit(internalBit, setting); + } + switch (externalBit) { + case 1: // Application Cursor Keys (DECCKM). + break; + case 3: // Set: 132 column mode (. Reset: 80 column mode. ANSI name: DECCOLM. + // We don't actually set/reset 132 cols, but we do want the side effects + // (FIXME: Should only do this if the 95 DECSET bit (DECNCSM) is set, and if changing value?): + // Sets the left, right, top and bottom scrolling margins to their default positions, which is important for + // the "reset" utility to really reset the terminal: + mLeftMargin = mTopMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + // "DECCOLM resets vertical split screen mode (DECLRMM) to unavailable": + setDecsetinternalBit(DECSET_BIT_LEFTRIGHT_MARGIN_MODE, false); + // "Erases all data in page memory": + blockClear(0, 0, mColumns, mRows); + setCursorRowCol(0, 0); + break; + case 4: // DECSCLM-Scrolling Mode. Ignore. + break; + case 5: // Reverse video. No action. + break; + case 6: // Set: Origin Mode. Reset: Normal Cursor Mode. Ansi name: DECOM. + if (setting) setCursorPosition(0, 0); + break; + case 7: // Wrap-around bit, not specific action. + case 8: // Auto-repeat Keys (DECARM). Do not implement. + case 9: // X10 mouse reporting - outdated. Do not implement. + case 12: // Control cursor blinking - ignore. + case 25: // Hide/show cursor - no action needed, renderer will check with shouldCursorBeVisible(). + if (mClient != null) + mClient.onTerminalCursorStateChange(setting); + break; + case 40: // Allow 80 => 132 Mode, ignore. + case 45: // TODO: Reverse wrap-around. Implement??? + case 66: // Application keypad (DECNKM). + break; + case 69: // Left and right margin mode (DECLRMM). + if (!setting) { + mLeftMargin = 0; + mRightMargin = mColumns; + } + break; + case 1000: + case 1001: + case 1002: + case 1003: + case 1004: + case 1005: // UTF-8 mouse mode, ignore. + case 1006: // SGR Mouse Mode + case 1015: + case 1034: // Interpret "meta" key, sets eighth bit. + break; + case 1048: // Set: Save cursor as in DECSC. Reset: Restore cursor as in DECRC. + if (setting) + saveCursor(); + else + restoreCursor(); + break; + case 47: + case 1047: + case 1049: { + // Set: Save cursor as in DECSC and use Alternate Screen Buffer, clearing it first. + // Reset: Use Normal Screen Buffer and restore cursor as in DECRC. + TerminalBuffer newScreen = setting ? mAltBuffer : mMainBuffer; + if (newScreen != mScreen) { + boolean resized = !(newScreen.mColumns == mColumns && newScreen.mScreenRows == mRows); + if (setting) saveCursor(); + mScreen = newScreen; + if (!setting) { + int col = mSavedStateMain.mSavedCursorCol; + int row = mSavedStateMain.mSavedCursorRow; + restoreCursor(); + if (resized) { + // Restore cursor position _not_ clipped to current screen (let resizeScreen() handle that): + mCursorCol = col; + mCursorRow = row; + } + } + // Check if buffer size needs to be updated: + if (resized) resizeScreen(); + // Clear new screen if alt buffer: + if (newScreen == mAltBuffer) + newScreen.blockSet(0, 0, mColumns, mRows, ' ', getStyle()); + } + break; + } + case 2004: + // Bracketed paste mode - setting bit is enough. + break; + default: + unknownParameter(externalBit); + break; + } + } + + private void doCsiBiggerThan(int b) { + switch (b) { + case 'c': // "${CSI}>c" or "${CSI}>c". Secondary Device Attributes (DA2). + // Originally this was used for the terminal to respond with "identification code, firmware version level, + // and hardware options" (http://vt100.net/docs/vt510-rm/DA2), with the first "41" meaning the VT420 + // terminal type. This is not used anymore, but the second version level field has been changed by xterm + // to mean it's release number ("patch numbers" listed at http://invisible-island.net/xterm/xterm.log.html), + // and some applications use it as a feature check: + // * tmux used to have a "xterm won't reach version 500 for a while so set that as the upper limit" check, + // and then check "xterm_version > 270" if rectangular area operations such as DECCRA could be used. + // * vim checks xterm version number >140 for "Request termcap/terminfo string" functionality >276 for SGR + // mouse report. + // The third number is a keyboard identifier not used nowadays. + mSession.write("\033[>41;320;0c"); + break; + case 'm': + // https://bugs.launchpad.net/gnome-terminal/+bug/96676/comments/25 + // Depending on the first number parameter, this can set one of the xterm resources + // modifyKeyboard, modifyCursorKeys, modifyFunctionKeys and modifyOtherKeys. + // http://invisible-island.net/xterm/manpage/xterm.html#RESOURCES + + // * modifyKeyboard (parameter=1): + // Normally xterm makes a special case regarding modifiers (shift, control, etc.) to handle special keyboard + // layouts (legacy and vt220). This is done to provide compatible keyboards for DEC VT220 and related + // terminals that implement user-defined keys (UDK). + // The bits of the resource value selectively enable modification of the given category when these keyboards + // are selected. The default is "0": + // (0) The legacy/vt220 keyboards interpret only the Control-modifier when constructing numbered + // function-keys. Other special keys are not modified. + // (1) allows modification of the numeric keypad + // (2) allows modification of the editing keypad + // (4) allows modification of function-keys, overrides use of Shift-modifier for UDK. + // (8) allows modification of other special keys + + // * modifyCursorKeys (parameter=2): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a cursor-key. The default is "2". + // - Set it to -1 to disable it. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + + // * modifyFunctionKeys (parameter=3): + // Tells how to handle the special case where Control-, Shift-, Alt- or Meta-modifiers are used to add a + // parameter to the escape sequence returned by a (numbered) function- + // key. The default is "2". The resource values are similar to modifyCursorKeys: + // Set it to -1 to permit the user to use shift- and control-modifiers to construct function-key strings + // using the normal encoding scheme. + // - Set it to 0 to use the old/obsolete behavior. + // - Set it to 1 to prefix modified sequences with CSI. + // - Set it to 2 to force the modifier to be the second parameter if it would otherwise be the first. + // - Set it to 3 to mark the sequence with a ">" to hint that it is private. + // If modifyFunctionKeys is zero, xterm uses Control- and Shift-modifiers to allow the user to construct + // numbered function-keys beyond the set provided by the keyboard: + // (Control) adds the value given by the ctrlFKeys resource. + // (Shift) adds twice the value given by the ctrlFKeys resource. + // (Control/Shift) adds three times the value given by the ctrlFKeys resource. + // + // As a special case, legacy (when oldFunctionKeys is true) or vt220 (when sunKeyboard is true) + // keyboards interpret only the Control-modifier when constructing numbered function-keys. + // This is done to provide compatible keyboards for DEC VT220 and related terminals that + // implement user-defined keys (UDK). + + // * modifyOtherKeys (parameter=4): + // Like modifyCursorKeys, tells xterm to construct an escape sequence for other keys (such as "2") when + // modified by Control-, Alt- or Meta-modifiers. This feature does not apply to function keys and + // well-defined keys such as ESC or the control keys. The default is "0". + // (0) disables this feature. + // (1) enables this feature for keys except for those with well-known behavior, e.g., Tab, Backarrow and + // some special control character cases, e.g., Control-Space to make a NUL. + // (2) enables this feature for keys including the exceptions listed. + Logger.logError(mClient, LOG_TAG, "(ignored) CSI > MODIFY RESOURCE: " + getArg0(-1) + " to " + getArg1(-1)); + break; + default: + parseArg(b); + break; + } + } + + private void startEscapeSequence() { + mEscapeState = ESC; + mArgIndex = 0; + Arrays.fill(mArgs, -1); + mArgsSubParamsBitSet = 0; + } + + private void doLinefeed() { + boolean belowScrollingRegion = mCursorRow >= mBottomMargin; + int newCursorRow = mCursorRow + 1; + if (belowScrollingRegion) { + // Move down (but not scroll) as long as we are above the last row. + if (mCursorRow != mRows - 1) { + setCursorRow(newCursorRow); + } + } else { + if (newCursorRow == mBottomMargin) { + scrollDownOneLine(); + newCursorRow = mBottomMargin - 1; + } + setCursorRow(newCursorRow); + } + } + + private void continueSequence(int state) { + mEscapeState = state; + mContinueSequence = true; + } + + private void doEscPound(int b) { + switch (b) { + case '8': // Esc # 8 - DEC screen alignment test - fill screen with E's. + mScreen.blockSet(0, 0, mColumns, mRows, 'E', getStyle()); + break; + default: + unknownSequence(b); + break; + } + } + + /** Encountering a character in the {@link #ESC} state. */ + private void doEsc(int b) { + switch (b) { + case '#': + continueSequence(ESC_POUND); + break; + case '(': + continueSequence(ESC_SELECT_LEFT_PAREN); + break; + case ')': + continueSequence(ESC_SELECT_RIGHT_PAREN); + break; + case '6': // Back index (http://www.vt100.net/docs/vt510-rm/DECBI). Move left, insert blank column if start. + if (mCursorCol > mLeftMargin) { + mCursorCol--; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin + 1, mTopMargin); + mScreen.blockSet(mLeftMargin, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case '7': // DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC + saveCursor(); + break; + case '8': // DECRC restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC + restoreCursor(); + break; + case '9': // Forward Index (http://www.vt100.net/docs/vt510-rm/DECFI). Move right, insert blank column if end. + if (mCursorCol < mRightMargin - 1) { + mCursorCol++; + } else { + int rows = mBottomMargin - mTopMargin; + mScreen.blockCopy(mLeftMargin + 1, mTopMargin, mRightMargin - mLeftMargin - 1, rows, mLeftMargin, mTopMargin); + mScreen.blockSet(mRightMargin - 1, mTopMargin, 1, rows, ' ', TextStyle.encode(mForeColor, mBackColor, 0)); + } + break; + case 'c': // RIS - Reset to Initial State (http://vt100.net/docs/vt510-rm/RIS). + reset(); + mMainBuffer.clearTranscript(); + blockClear(0, 0, mColumns, mRows); + setCursorPosition(0, 0); + break; + case 'D': // INDEX + doLinefeed(); + break; + case 'E': // Next line (http://www.vt100.net/docs/vt510-rm/NEL). + setCursorCol(isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE) ? mLeftMargin : 0); + doLinefeed(); + break; + case 'F': // Cursor to lower-left corner of screen + setCursorRowCol(0, mBottomMargin - 1); + break; + case 'H': // Tab set + mTabStop[mCursorCol] = true; + break; + case 'M': // "${ESC}M" - reverse index (RI). + // http://www.vt100.net/docs/vt100-ug/chapter3.html: "Move the active position to the same horizontal + // position on the preceding line. If the active position is at the top margin, a scroll down is performed". + if (mCursorRow <= mTopMargin) { + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, mBottomMargin - (mTopMargin + 1), mLeftMargin, mTopMargin + 1); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin); + } else { + mCursorRow--; + } + break; + case 'N': // SS2, ignore. + case '0': // SS3, ignore. + break; + case 'P': // Device control string + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_P); + break; + case '[': + continueSequence(ESC_CSI); + break; + case '=': // DECKPAM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, true); + break; + case ']': // OSC + mOSCOrDeviceControlArgs.setLength(0); + continueSequence(ESC_OSC); + break; + case '>': // DECKPNM + setDecsetinternalBit(DECSET_BIT_APPLICATION_KEYPAD, false); + break; + case '_': // APC - Application Program Command. + continueSequence(ESC_APC); + break; + default: + unknownSequence(b); + break; + } + } + + /** DECSC save cursor - http://www.vt100.net/docs/vt510-rm/DECSC . See {@link #restoreCursor()}. */ + private void saveCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + state.mSavedCursorRow = mCursorRow; + state.mSavedCursorCol = mCursorCol; + state.mSavedEffect = mEffect; + state.mSavedForeColor = mForeColor; + state.mSavedBackColor = mBackColor; + state.mSavedDecFlags = mCurrentDecSetFlags; + state.mUseLineDrawingG0 = mUseLineDrawingG0; + state.mUseLineDrawingG1 = mUseLineDrawingG1; + state.mUseLineDrawingUsesG0 = mUseLineDrawingUsesG0; + } + + /** DECRS restore cursor - http://www.vt100.net/docs/vt510-rm/DECRC. See {@link #saveCursor()}. */ + private void restoreCursor() { + SavedScreenState state = (mScreen == mMainBuffer) ? mSavedStateMain : mSavedStateAlt; + setCursorRowCol(state.mSavedCursorRow, state.mSavedCursorCol); + mEffect = state.mSavedEffect; + mForeColor = state.mSavedForeColor; + mBackColor = state.mSavedBackColor; + int mask = (DECSET_BIT_AUTOWRAP | DECSET_BIT_ORIGIN_MODE); + mCurrentDecSetFlags = (mCurrentDecSetFlags & ~mask) | (state.mSavedDecFlags & mask); + mUseLineDrawingG0 = state.mUseLineDrawingG0; + mUseLineDrawingG1 = state.mUseLineDrawingG1; + mUseLineDrawingUsesG0 = state.mUseLineDrawingUsesG0; + } + + /** Following a CSI - Control Sequence Introducer, "\033[". {@link #ESC_CSI}. */ + private void doCsi(int b) { + switch (b) { + case '!': + continueSequence(ESC_CSI_EXCLAMATION); + break; + case '"': + continueSequence(ESC_CSI_DOUBLE_QUOTE); + break; + case '\'': + continueSequence(ESC_CSI_SINGLE_QUOTE); + break; + case '$': + continueSequence(ESC_CSI_DOLLAR); + break; + case '*': + continueSequence(ESC_CSI_ARGS_ASTERIX); + break; + case '@': { + // "CSI{n}@" - Insert ${n} space characters (ICH) - http://www.vt100.net/docs/vt510-rm/ICH. + mAboutToAutoWrap = false; + int columnsAfterCursor = mColumns - mCursorCol; + int spacesToInsert = Math.min(getArg0(1), columnsAfterCursor); + int charsToMove = columnsAfterCursor - spacesToInsert; + mScreen.blockCopy(mCursorCol, mCursorRow, charsToMove, 1, mCursorCol + spacesToInsert, mCursorRow); + blockClear(mCursorCol, mCursorRow, spacesToInsert); + } + break; + case 'A': // "CSI${n}A" - Cursor up (CUU) ${n} rows. + setCursorRow(Math.max(0, mCursorRow - getArg0(1))); + break; + case 'B': // "CSI${n}B" - Cursor down (CUD) ${n} rows. + setCursorRow(Math.min(mRows - 1, mCursorRow + getArg0(1))); + break; + case 'C': // "CSI${n}C" - Cursor forward (CUF). + case 'a': // "CSI${n}a" - Horizontal position relative (HPR). From ISO-6428/ECMA-48. + setCursorCol(Math.min(mRightMargin - 1, mCursorCol + getArg0(1))); + break; + case 'D': // "CSI${n}D" - Cursor backward (CUB) ${n} columns. + setCursorCol(Math.max(mLeftMargin, mCursorCol - getArg0(1))); + break; + case 'E': // "CSI{n}E - Cursor Next Line (CNL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow + getArg0(1)); + break; + case 'F': // "CSI{n}F - Cursor Previous Line (CPL). From ISO-6428/ECMA-48. + setCursorPosition(0, mCursorRow - getArg0(1)); + break; + case 'G': // "CSI${n}G" - Cursor horizontal absolute (CHA) to column ${n}. + setCursorCol(Math.min(Math.max(1, getArg0(1)), mColumns) - 1); + break; + case 'H': // "${CSI}${ROW};${COLUMN}H" - Cursor position (CUP). + case 'f': // "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). + setCursorPosition(getArg1(1) - 1, getArg0(1) - 1); + break; + case 'I': // Cursor Horizontal Forward Tabulation (CHT). Move the active position n tabs forward. + setCursorCol(nextTabStop(getArg0(1))); + break; + case 'J': // "${CSI}${0,1,2,3}J" - Erase in Display (ED) + // ED ignores the scrolling margins. + switch (getArg0(0)) { + case 0: // Erase from the active position to the end of the screen, inclusive (default). + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + blockClear(0, mCursorRow + 1, mColumns, mRows - (mCursorRow + 1)); + break; + case 1: // Erase from start of the screen to the active position, inclusive. + blockClear(0, 0, mColumns, mCursorRow); + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the display - all lines are erased, changed to single-width, and the cursor does not + // move.. + blockClear(0, 0, mColumns, mRows); + break; + case 3: // Delete all lines saved in the scrollback buffer (xterm etc) + mMainBuffer.clearTranscript(); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'K': // "CSI{n}K" - Erase in line (EL). + switch (getArg0(0)) { + case 0: // Erase from the cursor to the end of the line, inclusive (default) + blockClear(mCursorCol, mCursorRow, mColumns - mCursorCol); + break; + case 1: // Erase from the start of the screen to the cursor, inclusive. + blockClear(0, mCursorRow, mCursorCol + 1); + break; + case 2: // Erase all of the line. + blockClear(0, mCursorRow, mColumns); + break; + default: + unknownSequence(b); + return; + } + mAboutToAutoWrap = false; + break; + case 'L': // "${CSI}{N}L" - insert ${N} lines (IL). + { + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToInsert = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToInsert; + mScreen.blockCopy(0, mCursorRow, mColumns, linesToMove, 0, mCursorRow + linesToInsert); + blockClear(0, mCursorRow, mColumns, linesToInsert); + } + break; + case 'M': // "${CSI}${N}M" - delete N lines (DL). + { + mAboutToAutoWrap = false; + int linesAfterCursor = mBottomMargin - mCursorRow; + int linesToDelete = Math.min(getArg0(1), linesAfterCursor); + int linesToMove = linesAfterCursor - linesToDelete; + mScreen.blockCopy(0, mCursorRow + linesToDelete, mColumns, linesToMove, 0, mCursorRow); + blockClear(0, mCursorRow + linesToMove, mColumns, linesToDelete); + } + break; + case 'P': // "${CSI}{N}P" - delete ${N} characters (DCH). + { + // http://www.vt100.net/docs/vt510-rm/DCH: "If ${N} is greater than the number of characters between the + // cursor and the right margin, then DCH only deletes the remaining characters. + // As characters are deleted, the remaining characters between the cursor and right margin move to the left. + // Character attributes move with the characters. The terminal adds blank spaces with no visual character + // attributes at the right margin. DCH has no effect outside the scrolling margins." + mAboutToAutoWrap = false; + int cellsAfterCursor = mColumns - mCursorCol; + int cellsToDelete = Math.min(getArg0(1), cellsAfterCursor); + int cellsToMove = cellsAfterCursor - cellsToDelete; + mScreen.blockCopy(mCursorCol + cellsToDelete, mCursorRow, cellsToMove, 1, mCursorCol, mCursorRow); + blockClear(mCursorCol + cellsToMove, mCursorRow, cellsToDelete); + } + break; + case 'S': { // "${CSI}${N}S" - scroll up ${N} lines (default = 1) (SU). + final int linesToScroll = getArg0(1); + for (int i = 0; i < linesToScroll; i++) + scrollDownOneLine(); + break; + } + case 'T': + if (mArgIndex == 0) { + // "${CSI}${N}T" - Scroll down N lines (default = 1) (SD). + // http://vt100.net/docs/vt510-rm/SD: "N is the number of lines to move the user window up in page + // memory. N new lines appear at the top of the display. N old lines disappear at the bottom of the + // display. You cannot pan past the top margin of the current page". + final int linesToScrollArg = getArg0(1); + final int linesBetweenTopAndBottomMargins = mBottomMargin - mTopMargin; + final int linesToScroll = Math.min(linesBetweenTopAndBottomMargins, linesToScrollArg); + mScreen.blockCopy(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesBetweenTopAndBottomMargins - linesToScroll, mLeftMargin, mTopMargin + linesToScroll); + blockClear(mLeftMargin, mTopMargin, mRightMargin - mLeftMargin, linesToScroll); + } else { + // "${CSI}${func};${startx};${starty};${firstrow};${lastrow}T" - initiate highlight mouse tracking. + unimplementedSequence(b); + } + break; + case 'X': // "${CSI}${N}X" - Erase ${N:=1} character(s) (ECH). FIXME: Clears character attributes? + mAboutToAutoWrap = false; + mScreen.blockSet(mCursorCol, mCursorRow, Math.min(getArg0(1), mColumns - mCursorCol), 1, ' ', getStyle()); + break; + case 'Z': // Cursor Backward Tabulation (CBT). Move the active position n tabs backward. + int numberOfTabs = getArg0(1); + int newCol = mLeftMargin; + for (int i = mCursorCol - 1; i >= 0; i--) + if (mTabStop[i]) { + if (--numberOfTabs == 0) { + newCol = Math.max(i, mLeftMargin); + break; + } + } + mCursorCol = newCol; + break; + case '?': // Esc [ ? -- start of a private parameter byte + continueSequence(ESC_CSI_QUESTIONMARK); + break; + case '>': // "Esc [ >" -- start of a private parameter byte + continueSequence(ESC_CSI_BIGGERTHAN); + break; + case '<': // "Esc [ <" -- start of a private parameter byte + case '=': // "Esc [ =" -- start of a private parameter byte + continueSequence(ESC_CSI_UNSUPPORTED_PARAMETER_BYTE); + break; + case '`': // Horizontal position absolute (HPA - http://www.vt100.net/docs/vt510-rm/HPA). + setCursorColRespectingOriginMode(getArg0(1) - 1); + break; + case 'b': // Repeat the preceding graphic character Ps times (REP). + if (mLastEmittedCodePoint == -1) break; + final int numRepeat = getArg0(1); + for (int i = 0; i < numRepeat; i++) emitCodePoint(mLastEmittedCodePoint); + break; + case 'c': // Primary Device Attributes (http://www.vt100.net/docs/vt510-rm/DA1) if argument is missing or zero. + // The important part that may still be used by some (tmux stores this value but does not currently use it) + // is the first response parameter identifying the terminal service class, where we send 64 for "vt420". + // This is followed by a list of attributes which is probably unused by applications. Send like xterm. + if (getArg0(0) == 0) mSession.write("\033[?64;1;2;6;9;15;18;21;22c"); + break; + case 'd': // ESC [ Pn d - Vert Position Absolute + setCursorRow(Math.min(Math.max(1, getArg0(1)), mRows) - 1); + break; + case 'e': // Vertical Position Relative (VPR). From ISO-6429 (ECMA-48). + setCursorPosition(mCursorCol, mCursorRow + getArg0(1)); + break; + // case 'f': "${CSI}${ROW};${COLUMN}f" - Horizontal and Vertical Position (HVP). Grouped with case 'H'. + case 'g': // Clear tab stop + switch (getArg0(0)) { + case 0: + mTabStop[mCursorCol] = false; + break; + case 3: + for (int i = 0; i < mColumns; i++) { + mTabStop[i] = false; + } + break; + default: + // Specified to have no effect. + break; + } + break; + case 'h': // Set Mode + doSetMode(true); + break; + case 'l': // Reset Mode + doSetMode(false); + break; + case 'm': // Esc [ Pn m - character attributes. (can have up to 16 numerical arguments) + selectGraphicRendition(); + break; + case 'n': // Esc [ Pn n - ECMA-48 Status Report Commands + // sendDeviceAttributes() + switch (getArg0(0)) { + case 5: // Device status report (DSR): + // Answer is ESC [ 0 n (Terminal OK). + byte[] dsr = {(byte) 27, (byte) '[', (byte) '0', (byte) 'n'}; + mSession.write(dsr, 0, dsr.length); + break; + case 6: // Cursor position report (CPR): + // Answer is ESC [ y ; x R, where x,y is + // the cursor location. + mSession.write(String.format(Locale.US, "\033[%d;%dR", mCursorRow + 1, mCursorCol + 1)); + break; + default: + break; + } + break; + case 'r': // "CSI${top};${bottom}r" - set top and bottom Margins (DECSTBM). + { + // https://vt100.net/docs/vt510-rm/DECSTBM.html + // The top margin defaults to 1, the bottom margin defaults to mRows. + // The escape sequence numbers top 1..23, but we number top 0..22. + // The escape sequence numbers bottom 2..24, and so do we (because we use a zero based numbering + // scheme, but we store the first line below the bottom-most scrolling line. + // As a result, we adjust the top line by -1, but we leave the bottom line alone. + // Also require that top + 2 <= bottom. + mTopMargin = Math.max(0, Math.min(getArg0(1) - 1, mRows - 2)); + mBottomMargin = Math.max(mTopMargin + 2, Math.min(getArg1(mRows), mRows)); + + // DECSTBM moves the cursor to column 1, line 1 of the page respecting origin mode. + setCursorPosition(0, 0); + } + break; + case 's': + if (isDecsetInternalBitSet(DECSET_BIT_LEFTRIGHT_MARGIN_MODE)) { + // Set left and right margins (DECSLRM - http://www.vt100.net/docs/vt510-rm/DECSLRM). + mLeftMargin = Math.min(getArg0(1) - 1, mColumns - 2); + mRightMargin = Math.max(mLeftMargin + 1, Math.min(getArg1(mColumns), mColumns)); + // DECSLRM moves the cursor to column 1, line 1 of the page. + setCursorPosition(0, 0); + } else { + // Save cursor (ANSI.SYS), available only when DECLRMM is disabled. + saveCursor(); + } + break; + case 't': // Window manipulation (from dtterm, as well as extensions) + switch (getArg0(0)) { + case 11: // Report xterm window state. If the xterm window is open (non-iconified), it returns CSI 1 t . + mSession.write("\033[1t"); + break; + case 13: // Report xterm window position. Result is CSI 3 ; x ; y t + mSession.write("\033[3;0;0t"); + break; + case 14: // Report xterm window in pixels. Result is CSI 4 ; height ; width t + mSession.write(String.format(Locale.US, "\033[4;%d;%dt", mRows * mCellHeightPixels, mColumns * mCellWidthPixels)); + break; + case 16: // Report xterm character cell size in pixels. Result is CSI 6 ; height ; width t + mSession.write(String.format(Locale.US, "\033[6;%d;%dt", mCellHeightPixels, mCellWidthPixels)); + break; + case 18: // Report the size of the text area in characters. Result is CSI 8 ; height ; width t + mSession.write(String.format(Locale.US, "\033[8;%d;%dt", mRows, mColumns)); + break; + case 19: // Report the size of the screen in characters. Result is CSI 9 ; height ; width t + // We report the same size as the view, since it's the view really isn't resizable from the shell. + mSession.write(String.format(Locale.US, "\033[9;%d;%dt", mRows, mColumns)); + break; + case 20: // Report xterm windows icon label. Result is OSC L label ST. Disabled due to security concerns: + mSession.write("\033]LIconLabel\033\\"); + break; + case 21: // Report xterm windows title. Result is OSC l label ST. Disabled due to security concerns: + mSession.write("\033]l\033\\"); + break; + case 22: + // 22;0 -> Save xterm icon and window title on stack. + // 22;1 -> Save xterm icon title on stack. + // 22;2 -> Save xterm window title on stack. + mTitleStack.push(mTitle); + if (mTitleStack.size() > 20) { + // Limit size + mTitleStack.remove(0); + } + break; + case 23: // Like 22 above but restore from stack. + if (!mTitleStack.isEmpty()) setTitle(mTitleStack.pop()); + break; + default: + // Ignore window manipulation. + break; + } + break; + case 'u': // Restore cursor (ANSI.SYS). + restoreCursor(); + break; + case ' ': + continueSequence(ESC_CSI_ARGS_SPACE); + break; + default: + parseArg(b); + break; + } + } + + /** Select Graphic Rendition (SGR) - see http://en.wikipedia.org/wiki/ANSI_escape_code#graphics. */ + private void selectGraphicRendition() { + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + // Skip leading sub parameters: + if ((mArgsSubParamsBitSet & (1 << i)) != 0) { + continue; + } + + int code = getArg(i, 0, false); + if (code < 0) { + if (mArgIndex > 0) { + continue; + } else { + code = 0; + } + } + if (code == 0) { // reset + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + mEffect = 0; + } else if (code == 1) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BOLD; + } else if (code == 2) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_DIM; + } else if (code == 3) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 4) { + if (i + 1 <= mArgIndex && ((mArgsSubParamsBitSet & (1 << (i + 1))) != 0)) { + // Sub parameter, see https://sw.kovidgoyal.net/kitty/underlines/ + i++; + if (mArgs[i] == 0) { + // No underline. + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else { + // Different variations of underlines: https://sw.kovidgoyal.net/kitty/underlines/ + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } + } else { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } + } else if (code == 5) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 7) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 8) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 9) { + mEffect |= TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code == 10) { + // Exit alt charset (TERM=linux) - ignore. + } else if (code == 11) { + // Enter alt charset (TERM=linux) - ignore. + } else if (code == 22) { // Normal color or intensity, neither bright, bold nor faint. + mEffect &= ~(TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_DIM); + } else if (code == 23) { // not italic, but rarely used as such; clears standout with TERM=screen + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_ITALIC; + } else if (code == 24) { // underline: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE; + } else if (code == 25) { // blink: none + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_BLINK; + } else if (code == 27) { // image: positive + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVERSE; + } else if (code == 28) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE; + } else if (code == 29) { + mEffect &= ~TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH; + } else if (code >= 30 && code <= 37) { + mForeColor = code - 30; + } else if (code == 38 || code == 48 || code == 58) { + // Extended set foreground(38)/background(48)/underline(58) color. + // This is followed by either "2;$R;$G;$B" to set a 24-bit color or + // "5;$INDEX" to set an indexed color. + if (i + 2 > mArgIndex) continue; + int firstArg = mArgs[i + 1]; + if (firstArg == 2) { + if (i + 4 > mArgIndex) { + Logger.logWarn(mClient, LOG_TAG, "Too few CSI" + code + ";2 RGB arguments"); + } else { + int red = getArg(i + 2, 0, false); + int green = getArg(i + 3, 0, false); + int blue = getArg(i + 4, 0, false); + + if (red < 0 || green < 0 || blue < 0 || red > 255 || green > 255 || blue > 255) { + finishSequenceAndLogError("Invalid RGB: " + red + "," + green + "," + blue); + } else { + int argbColor = 0xff_00_00_00 | (red << 16) | (green << 8) | blue; + switch (code) { + case 38: mForeColor = argbColor; break; + case 48: mBackColor = argbColor; break; + case 58: mUnderlineColor = argbColor; break; + } + } + i += 4; // "2;P_r;P_g;P_r" + } + } else if (firstArg == 5) { + int color = getArg(i + 2, 0, false); + i += 2; // "5;P_s" + if (color >= 0 && color < TextStyle.NUM_INDEXED_COLORS) { + switch (code) { + case 38: mForeColor = color; break; + case 48: mBackColor = color; break; + case 58: mUnderlineColor = color; break; + } + } else { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, "Invalid color index: " + color); + } + } else { + finishSequenceAndLogError("Invalid ISO-8613-3 SGR first argument: " + firstArg); + } + } else if (code == 39) { // Set default foreground color. + mForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 40 && code <= 47) { // Set background color. + mBackColor = code - 40; + } else if (code == 49) { // Set default background color. + mBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + } else if (code == 59) { // Set default underline color. + mUnderlineColor = TextStyle.COLOR_INDEX_FOREGROUND; + } else if (code >= 90 && code <= 97) { // Bright foreground colors (aixterm codes). + mForeColor = code - 90 + 8; + } else if (code >= 100 && code <= 107) { // Bright background color (aixterm codes). + mBackColor = code - 100 + 8; + } else { + if (LOG_ESCAPE_SEQUENCES) + Logger.logWarn(mClient, LOG_TAG, String.format("SGR unknown code %d", code)); + } + } + } + + private void doOsc(int b) { + switch (b) { + case 7: // Bell. + doOscSetTextParameters("\007"); + break; + case 27: // Escape. + continueSequence(ESC_OSC_ESC); + break; + default: + collectOSCArgs(b); + break; + } + } + + private void doOscEsc(int b) { + switch (b) { + case '\\': + doOscSetTextParameters("\033\\"); + break; + default: + // The ESC character was not followed by a \, so insert the ESC and + // the current character in arg buffer. + collectOSCArgs(27); + collectOSCArgs(b); + continueSequence(ESC_OSC); + break; + } + } + + /** An Operating System Controls (OSC) Set Text Parameters. May come here from BEL or ST. */ + private void doOscSetTextParameters(String bellOrStringTerminator) { + int value = -1; + String textParameter = ""; + // Extract initial $value from initial "$value;..." string. + for (int mOSCArgTokenizerIndex = 0; mOSCArgTokenizerIndex < mOSCOrDeviceControlArgs.length(); mOSCArgTokenizerIndex++) { + char b = mOSCOrDeviceControlArgs.charAt(mOSCArgTokenizerIndex); + if (b == ';') { + textParameter = mOSCOrDeviceControlArgs.substring(mOSCArgTokenizerIndex + 1); + break; + } else if (b >= '0' && b <= '9') { + value = ((value < 0) ? 0 : value * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + } + + switch (value) { + case 0: // Change icon name and window title to T. + case 1: // Change icon name to T. + case 2: // Change window title to T. + setTitle(textParameter); + break; + case 4: + // P s = 4 ; c ; spec → Change Color Number c to the color specified by spec. This can be a name or RGB + // specification as per XParseColor. Any number of c name pairs may be given. The color numbers correspond + // to the ANSI colors 0-7, their bright versions 8-15, and if supported, the remainder of the 88-color or + // 256-color table. + // If a "?" is given rather than a name or RGB specification, xterm replies with a control sequence of the + // same form which can be used to set the corresponding color. Because more than one pair of color number + // and specification can be given in one control sequence, xterm can make more than one reply. + int colorIndex = -1; + int parsingPairStart = -1; + for (int i = 0; ; i++) { + boolean endOfInput = i == textParameter.length(); + char b = endOfInput ? ';' : textParameter.charAt(i); + if (b == ';') { + if (parsingPairStart < 0) { + parsingPairStart = i + 1; + } else { + if (colorIndex < 0 || colorIndex > 255) { + unknownSequence(b); + return; + } else { + mColors.tryParseColor(colorIndex, textParameter.substring(parsingPairStart, i)); + mSession.onColorsChanged(); + colorIndex = -1; + parsingPairStart = -1; + } + } + } else if (parsingPairStart >= 0) { + // We have passed a color index and are now going through color spec. + } else if (parsingPairStart < 0 && (b >= '0' && b <= '9')) { + colorIndex = ((colorIndex < 0) ? 0 : colorIndex * 10) + (b - '0'); + } else { + unknownSequence(b); + return; + } + if (endOfInput) break; + } + break; + case 10: // Set foreground color. + case 11: // Set background color. + case 12: // Set cursor color. + int specialIndex = TextStyle.COLOR_INDEX_FOREGROUND + (value - 10); + int lastSemiIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + String colorSpec = textParameter.substring(lastSemiIndex, charIndex); + if ("?".equals(colorSpec)) { + // Report current color in the same format xterm and gnome-terminal does. + int rgb = mColors.mCurrentColors[specialIndex]; + int r = (65535 * ((rgb & 0x00FF0000) >> 16)) / 255; + int g = (65535 * ((rgb & 0x0000FF00) >> 8)) / 255; + int b = (65535 * ((rgb & 0x000000FF))) / 255; + mSession.write("\033]" + value + ";rgb:" + String.format(Locale.US, "%04x", r) + "/" + String.format(Locale.US, "%04x", g) + "/" + + String.format(Locale.US, "%04x", b) + bellOrStringTerminator); + } else { + mColors.tryParseColor(specialIndex, colorSpec); + mSession.onColorsChanged(); + } + specialIndex++; + if (endOfInput || (specialIndex > TextStyle.COLOR_INDEX_CURSOR) || ++charIndex >= textParameter.length()) + break; + lastSemiIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + break; + case 52: // Manipulate Selection Data. Skip the optional first selection parameter(s). + int startIndex = textParameter.indexOf(";") + 1; + try { + String clipboardText = new String(Base64.decode(textParameter.substring(startIndex), 0), StandardCharsets.UTF_8); + mSession.onCopyTextToClipboard(clipboardText); + } catch (Exception e) { + Logger.logError(mClient, LOG_TAG, "OSC Manipulate selection, invalid string '" + textParameter + ""); + } + break; + case 104: + // "104;$c" → Reset Color Number $c. It is reset to the color specified by the corresponding X + // resource. Any number of c parameters may be given. These parameters correspond to the ANSI colors 0-7, + // their bright versions 8-15, and if supported, the remainder of the 88-color or 256-color table. If no + // parameters are given, the entire table will be reset. + if (textParameter.isEmpty()) { + mColors.reset(); + mSession.onColorsChanged(); + } else { + int lastIndex = 0; + for (int charIndex = 0; ; charIndex++) { + boolean endOfInput = charIndex == textParameter.length(); + if (endOfInput || textParameter.charAt(charIndex) == ';') { + try { + int colorToReset = Integer.parseInt(textParameter.substring(lastIndex, charIndex)); + mColors.reset(colorToReset); + mSession.onColorsChanged(); + if (endOfInput) break; + charIndex++; + lastIndex = charIndex; + } catch (NumberFormatException e) { + // Ignore. + } + } + } + } + break; + case 110: // Reset foreground color. + case 111: // Reset background color. + case 112: // Reset cursor color. + mColors.reset(TextStyle.COLOR_INDEX_FOREGROUND + (value - 110)); + mSession.onColorsChanged(); + break; + case 119: // Reset highlight color. + break; + default: + unknownParameter(value); + break; + } + finishSequence(); + } + + private void blockClear(int sx, int sy, int w) { + blockClear(sx, sy, w, 1); + } + + private void blockClear(int sx, int sy, int w, int h) { + mScreen.blockSet(sx, sy, w, h, ' ', getStyle()); + } + + private long getStyle() { + return TextStyle.encode(mForeColor, mBackColor, mEffect); + } + + /** "CSI P_m h" for set or "CSI P_m l" for reset ANSI mode. */ + private void doSetMode(boolean newValue) { + int modeBit = getArg0(0); + switch (modeBit) { + case 4: // Set="Insert Mode". Reset="Replace Mode". (IRM). + mInsertMode = newValue; + break; + case 20: // Normal Linefeed (LNM). + unknownParameter(modeBit); + // http://www.vt100.net/docs/vt510-rm/LNM + break; + case 34: + // Normal cursor visibility - when using TERM=screen, see + // http://www.gnu.org/software/screen/manual/html_node/Control-Sequences.html + break; + default: + unknownParameter(modeBit); + break; + } + } + + /** + * NOTE: The parameters of this function respect the {@link #DECSET_BIT_ORIGIN_MODE}. Use + * {@link #setCursorRowCol(int, int)} for absolute pos. + */ + private void setCursorPosition(int x, int y) { + boolean originMode = isDecsetInternalBitSet(DECSET_BIT_ORIGIN_MODE); + int effectiveTopMargin = originMode ? mTopMargin : 0; + int effectiveBottomMargin = originMode ? mBottomMargin : mRows; + int effectiveLeftMargin = originMode ? mLeftMargin : 0; + int effectiveRightMargin = originMode ? mRightMargin : mColumns; + int newRow = Math.max(effectiveTopMargin, Math.min(effectiveTopMargin + y, effectiveBottomMargin - 1)); + int newCol = Math.max(effectiveLeftMargin, Math.min(effectiveLeftMargin + x, effectiveRightMargin - 1)); + setCursorRowCol(newRow, newCol); + } + + private void scrollDownOneLine() { + mScrollCounter++; + long currentStyle = getStyle(); + if (mLeftMargin != 0 || mRightMargin != mColumns) { + // Horizontal margin: Do not put anything into scroll history, just non-margin part of screen up. + mScreen.blockCopy(mLeftMargin, mTopMargin + 1, mRightMargin - mLeftMargin, mBottomMargin - mTopMargin - 1, mLeftMargin, mTopMargin); + // .. and blank bottom row between margins: + mScreen.blockSet(mLeftMargin, mBottomMargin - 1, mRightMargin - mLeftMargin, 1, ' ', currentStyle); + } else { + mScreen.scrollDownOneLine(mTopMargin, mBottomMargin, currentStyle); + } + } + + /** + * Process the next ASCII character of a parameter. + * + *

You must use the ; character to separate parameters and : to separate sub-parameters. + * + *

Parameter characters modify the action or interpretation of the sequence. Originally + * you can use up to 16 parameters per sequence, but following at least xterm and alacritty + * we use a common space for parameters and sub-parameters, allowing 32 in total. + * + *

All parameters are unsigned, positive decimal integers, with the most significant + * digit sent first. Any parameter greater than 9999 (decimal) is set to 9999 + * (decimal). If you do not specify a value, a 0 value is assumed. A 0 value + * or omitted parameter indicates a default value for the sequence. For most + * sequences, the default value is 1. + * + *

References: + * VT510 Video Terminal Programmer Information: Control Sequences + * alacritty/vte: Implement colon separated CSI parameters + * */ + private void parseArg(int b) { + if (b >= '0' && b <= '9') { + if (mArgIndex < mArgs.length) { + int oldValue = mArgs[mArgIndex]; + int thisDigit = b - '0'; + int value; + if (oldValue >= 0) { + value = oldValue * 10 + thisDigit; + } else { + value = thisDigit; + } + if (value > 9999) + value = 9999; + mArgs[mArgIndex] = value; + } + continueSequence(mEscapeState); + } else if (b == ';' || b == ':') { + if (mArgIndex + 1 < mArgs.length) { + mArgIndex++; + if (b == ':') { + mArgsSubParamsBitSet |= 1 << mArgIndex; + } + } else { + logError("Too many parameters when in state: " + mEscapeState); + } + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private int getArg0(int defaultValue) { + return getArg(0, defaultValue, true); + } + + private int getArg1(int defaultValue) { + return getArg(1, defaultValue, true); + } + + private int getArg(int index, int defaultValue, boolean treatZeroAsDefault) { + int result = mArgs[index]; + if (result < 0 || (result == 0 && treatZeroAsDefault)) { + result = defaultValue; + } + return result; + } + + private void collectOSCArgs(int b) { + if (mOSCOrDeviceControlArgs.length() < MAX_OSC_STRING_LENGTH) { + mOSCOrDeviceControlArgs.appendCodePoint(b); + continueSequence(mEscapeState); + } else { + unknownSequence(b); + } + } + + private void unimplementedSequence(int b) { + logError("Unimplemented sequence char '" + (char) b + "' (U+" + String.format("%04x", b) + ")"); + finishSequence(); + } + + private void unknownSequence(int b) { + logError("Unknown sequence char '" + (char) b + "' (numeric value=" + b + ")"); + finishSequence(); + } + + private void unknownParameter(int parameter) { + logError("Unknown parameter: " + parameter); + finishSequence(); + } + + private void logError(String errorType) { + if (LOG_ESCAPE_SEQUENCES) { + StringBuilder buf = new StringBuilder(); + buf.append(errorType); + buf.append(", escapeState="); + buf.append(mEscapeState); + boolean firstArg = true; + if (mArgIndex >= mArgs.length) mArgIndex = mArgs.length - 1; + for (int i = 0; i <= mArgIndex; i++) { + int value = mArgs[i]; + if (value >= 0) { + if (firstArg) { + firstArg = false; + buf.append(", args={"); + } else { + buf.append(','); + } + buf.append(value); + } + } + if (!firstArg) buf.append('}'); + finishSequenceAndLogError(buf.toString()); + } + } + + private void finishSequenceAndLogError(String error) { + if (LOG_ESCAPE_SEQUENCES) Logger.logWarn(mClient, LOG_TAG, error); + finishSequence(); + } + + private void finishSequence() { + mEscapeState = ESC_NONE; + } + + /** + * Send a Unicode code point to the screen. + * + * @param codePoint The code point of the character to display + */ + private void emitCodePoint(int codePoint) { + mLastEmittedCodePoint = codePoint; + if (mUseLineDrawingUsesG0 ? mUseLineDrawingG0 : mUseLineDrawingG1) { + // http://www.vt100.net/docs/vt102-ug/table5-15.html. + switch (codePoint) { + case '_': + codePoint = ' '; // Blank. + break; + case '`': + codePoint = '◆'; // Diamond. + break; + case '0': + codePoint = '█'; // Solid block; + break; + case 'a': + codePoint = '▒'; // Checker board. + break; + case 'b': + codePoint = '␉'; // Horizontal tab. + break; + case 'c': + codePoint = '␌'; // Form feed. + break; + case 'd': + codePoint = '\r'; // Carriage return. + break; + case 'e': + codePoint = '␊'; // Linefeed. + break; + case 'f': + codePoint = '°'; // Degree. + break; + case 'g': + codePoint = '±'; // Plus-minus. + break; + case 'h': + codePoint = '\n'; // Newline. + break; + case 'i': + codePoint = '␋'; // Vertical tab. + break; + case 'j': + codePoint = '┘'; // Lower right corner. + break; + case 'k': + codePoint = '┐'; // Upper right corner. + break; + case 'l': + codePoint = '┌'; // Upper left corner. + break; + case 'm': + codePoint = '└'; // Left left corner. + break; + case 'n': + codePoint = '┼'; // Crossing lines. + break; + case 'o': + codePoint = '⎺'; // Horizontal line - scan 1. + break; + case 'p': + codePoint = '⎻'; // Horizontal line - scan 3. + break; + case 'q': + codePoint = '─'; // Horizontal line - scan 5. + break; + case 'r': + codePoint = '⎼'; // Horizontal line - scan 7. + break; + case 's': + codePoint = '⎽'; // Horizontal line - scan 9. + break; + case 't': + codePoint = '├'; // T facing rightwards. + break; + case 'u': + codePoint = '┤'; // T facing leftwards. + break; + case 'v': + codePoint = '┴'; // T facing upwards. + break; + case 'w': + codePoint = '┬'; // T facing downwards. + break; + case 'x': + codePoint = '│'; // Vertical line. + break; + case 'y': + codePoint = '≤'; // Less than or equal to. + break; + case 'z': + codePoint = '≥'; // Greater than or equal to. + break; + case '{': + codePoint = 'π'; // Pi. + break; + case '|': + codePoint = '≠'; // Not equal to. + break; + case '}': + codePoint = '£'; // UK pound. + break; + case '~': + codePoint = '·'; // Centered dot. + break; + } + } + + final boolean autoWrap = isDecsetInternalBitSet(DECSET_BIT_AUTOWRAP); + final int displayWidth = WcWidth.width(codePoint); + final boolean cursorInLastColumn = mCursorCol == mRightMargin - 1; + + if (autoWrap) { + if (cursorInLastColumn && ((mAboutToAutoWrap && displayWidth == 1) || displayWidth == 2)) { + mScreen.setLineWrap(mCursorRow); + mCursorCol = mLeftMargin; + if (mCursorRow + 1 < mBottomMargin) { + mCursorRow++; + } else { + scrollDownOneLine(); + } + } + } else if (cursorInLastColumn && displayWidth == 2) { + // The behaviour when a wide character is output with cursor in the last column when + // autowrap is disabled is not obvious - it's ignored here. + return; + } + + if (mInsertMode && displayWidth > 0) { + // Move character to right one space. + int destCol = mCursorCol + displayWidth; + if (destCol < mRightMargin) + mScreen.blockCopy(mCursorCol, mCursorRow, mRightMargin - destCol, 1, destCol, mCursorRow); + } + + int offsetDueToCombiningChar = ((displayWidth <= 0 && mCursorCol > 0 && !mAboutToAutoWrap) ? 1 : 0); + int column = mCursorCol - offsetDueToCombiningChar; + + // Fix TerminalRow.setChar() ArrayIndexOutOfBoundsException index=-1 exception reported + // The offsetDueToCombiningChar would never be 1 if mCursorCol was 0 to get column/index=-1, + // so was mCursorCol changed after the offsetDueToCombiningChar conditional by another thread? + // TODO: Check if there are thread synchronization issues with mCursorCol and mCursorRow, possibly causing others bugs too. + if (column < 0) column = 0; + mScreen.setChar(column, mCursorRow, codePoint, getStyle()); + + if (autoWrap && displayWidth > 0) + mAboutToAutoWrap = (mCursorCol == mRightMargin - displayWidth); + + mCursorCol = Math.min(mCursorCol + displayWidth, mRightMargin - 1); + } + + private void setCursorRow(int row) { + mCursorRow = row; + mAboutToAutoWrap = false; + } + + private void setCursorCol(int col) { + mCursorCol = col; + mAboutToAutoWrap = false; + } + + /** Set the cursor mode, but limit it to margins if {@link #DECSET_BIT_ORIGIN_MODE} is enabled. */ + private void setCursorColRespectingOriginMode(int col) { + setCursorPosition(col, mCursorRow); + } + + /** TODO: Better name, distinguished from {@link #setCursorPosition(int, int)} by not regarding origin mode. */ + private void setCursorRowCol(int row, int col) { + mCursorRow = Math.max(0, Math.min(row, mRows - 1)); + mCursorCol = Math.max(0, Math.min(col, mColumns - 1)); + mAboutToAutoWrap = false; + } + + public int getScrollCounter() { + return mScrollCounter; + } + + public void clearScrollCounter() { + mScrollCounter = 0; + } + + public boolean isAutoScrollDisabled() { + return mAutoScrollDisabled; + } + + public void toggleAutoScrollDisabled() { + mAutoScrollDisabled = !mAutoScrollDisabled; + } + + + /** Reset terminal state so user can interact with it regardless of present state. */ + public void reset() { + setCursorStyle(); + mArgIndex = 0; + mContinueSequence = false; + mEscapeState = ESC_NONE; + mInsertMode = false; + mTopMargin = mLeftMargin = 0; + mBottomMargin = mRows; + mRightMargin = mColumns; + mAboutToAutoWrap = false; + mForeColor = mSavedStateMain.mSavedForeColor = mSavedStateAlt.mSavedForeColor = TextStyle.COLOR_INDEX_FOREGROUND; + mBackColor = mSavedStateMain.mSavedBackColor = mSavedStateAlt.mSavedBackColor = TextStyle.COLOR_INDEX_BACKGROUND; + setDefaultTabStops(); + + mUseLineDrawingG0 = mUseLineDrawingG1 = false; + mUseLineDrawingUsesG0 = true; + + mSavedStateMain.mSavedCursorRow = mSavedStateMain.mSavedCursorCol = mSavedStateMain.mSavedEffect = mSavedStateMain.mSavedDecFlags = 0; + mSavedStateAlt.mSavedCursorRow = mSavedStateAlt.mSavedCursorCol = mSavedStateAlt.mSavedEffect = mSavedStateAlt.mSavedDecFlags = 0; + mCurrentDecSetFlags = 0; + // Initial wrap-around is not accurate but makes terminal more useful, especially on a small screen: + setDecsetinternalBit(DECSET_BIT_AUTOWRAP, true); + setDecsetinternalBit(DECSET_BIT_CURSOR_ENABLED, true); + mSavedDecSetFlags = mSavedStateMain.mSavedDecFlags = mSavedStateAlt.mSavedDecFlags = mCurrentDecSetFlags; + + // XXX: Should we set terminal driver back to IUTF8 with termios? + mUtf8Index = mUtf8ToFollow = 0; + + mColors.reset(); + mSession.onColorsChanged(); + } + + public String getSelectedText(int x1, int y1, int x2, int y2) { + return mScreen.getSelectedText(x1, y1, x2, y2); + } + + /** Get the terminal session's title (null if not set). */ + public String getTitle() { + return mTitle; + } + + /** Change the terminal session's title. */ + private void setTitle(String newTitle) { + String oldTitle = mTitle; + mTitle = newTitle; + if (!Objects.equals(oldTitle, newTitle)) { + mSession.titleChanged(oldTitle, newTitle); + } + } + + /** If DECSET 2004 is set, prefix paste with "\033[200~" and suffix with "\033[201~". */ + public void paste(String text) { + // First: Always remove escape key and C1 control characters [0x80,0x9F]: + text = text.replaceAll("(\u001B|[\u0080-\u009F])", ""); + // Second: Replace all newlines (\n) or CRLF (\r\n) with carriage returns (\r). + text = text.replaceAll("\r?\n", "\r"); + + // Then: Implement bracketed paste mode if enabled: + boolean bracketed = isDecsetInternalBitSet(DECSET_BIT_BRACKETED_PASTE_MODE); + if (bracketed) mSession.write("\033[200~"); + mSession.write(text); + if (bracketed) mSession.write("\033[201~"); + } + + /** http://www.vt100.net/docs/vt510-rm/DECSC */ + static final class SavedScreenState { + /** Saved state of the cursor position, Used to implement the save/restore cursor position escape sequences. */ + int mSavedCursorRow, mSavedCursorCol; + int mSavedEffect, mSavedForeColor, mSavedBackColor; + int mSavedDecFlags; + boolean mUseLineDrawingG0, mUseLineDrawingG1, mUseLineDrawingUsesG0 = true; + } + + @Override + public String toString() { + return "TerminalEmulator[size=" + mScreen.mColumns + "x" + mScreen.mScreenRows + ", margins={" + mTopMargin + "," + mRightMargin + "," + mBottomMargin + + "," + mLeftMargin + "}]"; + } + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java new file mode 100644 index 0000000..305082a --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalOutput.java @@ -0,0 +1,32 @@ +package com.termux.terminal; + +import java.nio.charset.StandardCharsets; + +/** A client which receives callbacks from events triggered by feeding input to a {@link TerminalEmulator}. */ +public abstract class TerminalOutput { + + /** Write a string using the UTF-8 encoding to the terminal client. */ + public final void write(String data) { + if (data == null) return; + byte[] bytes = data.getBytes(StandardCharsets.UTF_8); + write(bytes, 0, bytes.length); + } + + /** Write bytes to the terminal client. */ + public abstract void write(byte[] data, int offset, int count); + + /** Notify the terminal client that the terminal title has changed. */ + public abstract void titleChanged(String oldTitle, String newTitle); + + /** Notify the terminal client that text should be copied to clipboard. */ + public abstract void onCopyTextToClipboard(String text); + + /** Notify the terminal client that text should be pasted from clipboard. */ + public abstract void onPasteTextFromClipboard(); + + /** Notify the terminal client that a bell character (ASCII 7, bell, BEL, \a, ^G)) has been received. */ + public abstract void onBell(); + + public abstract void onColorsChanged(); + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java new file mode 100644 index 0000000..d68dc32 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalRow.java @@ -0,0 +1,283 @@ +package com.termux.terminal; + +import java.util.Arrays; + +/** + * A row in a terminal, composed of a fixed number of cells. + *

+ * The text in the row is stored in a char[] array, {@link #mText}, for quick access during rendering. + */ +public final class TerminalRow { + + private static final float SPARE_CAPACITY_FACTOR = 1.5f; + + /** + * Max combining characters that can exist in a column, that are separate from the base character + * itself. Any additional combining characters will be ignored and not added to the column. + * + * There does not seem to be limit in unicode standard for max number of combination characters + * that can be combined but such characters are primarily under 10. + * + * "Section 3.6 Combination" of unicode standard contains combining characters info. + * - https://www.unicode.org/versions/Unicode15.0.0/ch03.pdf + * - https://en.wikipedia.org/wiki/Combining_character#Unicode_ranges + * - https://stackoverflow.com/questions/71237212/what-is-the-maximum-number-of-unicode-combined-characters-that-may-be-needed-to + * + * UAX15-D3 Stream-Safe Text Format limits to max 30 combining characters. + * > The value of 30 is chosen to be significantly beyond what is required for any linguistic or technical usage. + * > While it would have been feasible to chose a smaller number, this value provides a very wide margin, + * > yet is well within the buffer size limits of practical implementations. + * - https://unicode.org/reports/tr15/#Stream_Safe_Text_Format + * - https://stackoverflow.com/a/11983435/14686958 + * + * We choose the value 15 because it should be enough for terminal based applications and keep + * the memory usage low for a terminal row, won't affect performance or cause terminal to + * lag or hang, and will keep malicious applications from causing harm. The value can be + * increased if ever needed for legitimate applications. + */ + private static final int MAX_COMBINING_CHARACTERS_PER_COLUMN = 15; + + /** The number of columns in this terminal row. */ + private final int mColumns; + /** The text filling this terminal row. */ + public char[] mText; + /** The number of java chars used in {@link #mText}. */ + private short mSpaceUsed; + /** If this row has been line wrapped due to text output at the end of line. */ + boolean mLineWrap; + /** The style bits of each cell in the row. See {@link TextStyle}. */ + final long[] mStyle; + /** If this row might contain chars with width != 1, used for deactivating fast path */ + boolean mHasNonOneWidthOrSurrogateChars; + + /** Construct a blank row (containing only whitespace, ' ') with a specified style. */ + public TerminalRow(int columns, long style) { + mColumns = columns; + mText = new char[(int) (SPARE_CAPACITY_FACTOR * columns)]; + mStyle = new long[columns]; + clear(style); + } + + /** NOTE: The sourceX2 is exclusive. */ + public void copyInterval(TerminalRow line, int sourceX1, int sourceX2, int destinationX) { + mHasNonOneWidthOrSurrogateChars |= line.mHasNonOneWidthOrSurrogateChars; + final int x1 = line.findStartOfColumn(sourceX1); + final int x2 = line.findStartOfColumn(sourceX2); + boolean startingFromSecondHalfOfWideChar = (sourceX1 > 0 && line.wideDisplayCharacterStartingAt(sourceX1 - 1)); + final char[] sourceChars = (this == line) ? Arrays.copyOf(line.mText, line.mText.length) : line.mText; + int latestNonCombiningWidth = 0; + for (int i = x1; i < x2; i++) { + char sourceChar = sourceChars[i]; + int codePoint = Character.isHighSurrogate(sourceChar) ? Character.toCodePoint(sourceChar, sourceChars[++i]) : sourceChar; + if (startingFromSecondHalfOfWideChar) { + // Just treat copying second half of wide char as copying whitespace. + codePoint = ' '; + startingFromSecondHalfOfWideChar = false; + } + int w = WcWidth.width(codePoint); + if (w > 0) { + destinationX += latestNonCombiningWidth; + sourceX1 += latestNonCombiningWidth; + latestNonCombiningWidth = w; + } + setChar(destinationX, codePoint, line.getStyle(sourceX1)); + } + } + + public int getSpaceUsed() { + return mSpaceUsed; + } + + /** Note that the column may end of second half of wide character. */ + public int findStartOfColumn(int column) { + if (column == mColumns) return getSpaceUsed(); + + int currentColumn = 0; + int currentCharIndex = 0; + while (true) { // 0<2 1 < 2 + int newCharIndex = currentCharIndex; + char c = mText[newCharIndex++]; // cci=1, cci=2 + boolean isHigh = Character.isHighSurrogate(c); + int codePoint = isHigh ? Character.toCodePoint(c, mText[newCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); // 1, 2 + if (wcwidth > 0) { + currentColumn += wcwidth; + if (currentColumn == column) { + while (newCharIndex < mSpaceUsed) { + // Skip combining chars. + if (Character.isHighSurrogate(mText[newCharIndex])) { + if (WcWidth.width(Character.toCodePoint(mText[newCharIndex], mText[newCharIndex + 1])) <= 0) { + newCharIndex += 2; + } else { + break; + } + } else if (WcWidth.width(mText[newCharIndex]) <= 0) { + newCharIndex++; + } else { + break; + } + } + return newCharIndex; + } else if (currentColumn > column) { + // Wide column going past end. + return currentCharIndex; + } + } + currentCharIndex = newCharIndex; + } + } + + private boolean wideDisplayCharacterStartingAt(int column) { + for (int currentCharIndex = 0, currentColumn = 0; currentCharIndex < mSpaceUsed; ) { + char c = mText[currentCharIndex++]; + int codePoint = Character.isHighSurrogate(c) ? Character.toCodePoint(c, mText[currentCharIndex++]) : c; + int wcwidth = WcWidth.width(codePoint); + if (wcwidth > 0) { + if (currentColumn == column && wcwidth == 2) return true; + currentColumn += wcwidth; + if (currentColumn > column) return false; + } + } + return false; + } + + public void clear(long style) { + Arrays.fill(mText, ' '); + Arrays.fill(mStyle, style); + mSpaceUsed = (short) mColumns; + mHasNonOneWidthOrSurrogateChars = false; + } + + // https://github.com/steven676/Android-Terminal-Emulator/commit/9a47042620bec87617f0b4f5d50568535668fe26 + public void setChar(int columnToSet, int codePoint, long style) { + if (columnToSet < 0 || columnToSet >= mStyle.length) + throw new IllegalArgumentException("TerminalRow.setChar(): columnToSet=" + columnToSet + ", codePoint=" + codePoint + ", style=" + style); + + mStyle[columnToSet] = style; + + final int newCodePointDisplayWidth = WcWidth.width(codePoint); + + // Fast path when we don't have any chars with width != 1 + if (!mHasNonOneWidthOrSurrogateChars) { + if (codePoint >= Character.MIN_SUPPLEMENTARY_CODE_POINT || newCodePointDisplayWidth != 1) { + mHasNonOneWidthOrSurrogateChars = true; + } else { + mText[columnToSet] = (char) codePoint; + return; + } + } + + final boolean newIsCombining = newCodePointDisplayWidth <= 0; + + boolean wasExtraColForWideChar = (columnToSet > 0) && wideDisplayCharacterStartingAt(columnToSet - 1); + + if (newIsCombining) { + // When standing at second half of wide character and inserting combining: + if (wasExtraColForWideChar) columnToSet--; + } else { + // Check if we are overwriting the second half of a wide character starting at the previous column: + if (wasExtraColForWideChar) setChar(columnToSet - 1, ' ', style); + // Check if we are overwriting the first half of a wide character starting at the next column: + boolean overwritingWideCharInNextColumn = newCodePointDisplayWidth == 2 && wideDisplayCharacterStartingAt(columnToSet + 1); + if (overwritingWideCharInNextColumn) setChar(columnToSet + 1, ' ', style); + } + + char[] text = mText; + final int oldStartOfColumnIndex = findStartOfColumn(columnToSet); + final int oldCodePointDisplayWidth = WcWidth.width(text, oldStartOfColumnIndex); + + // Get the number of elements in the mText array this column uses now + int oldCharactersUsedForColumn; + if (columnToSet + oldCodePointDisplayWidth < mColumns) { + int oldEndOfColumnIndex = findStartOfColumn(columnToSet + oldCodePointDisplayWidth); + oldCharactersUsedForColumn = oldEndOfColumnIndex - oldStartOfColumnIndex; + } else { + // Last character. + oldCharactersUsedForColumn = mSpaceUsed - oldStartOfColumnIndex; + } + + // If MAX_COMBINING_CHARACTERS_PER_COLUMN already exist in column, then ignore adding additional combining characters. + if (newIsCombining) { + int combiningCharsCount = WcWidth.zeroWidthCharsCount(mText, oldStartOfColumnIndex, oldStartOfColumnIndex + oldCharactersUsedForColumn); + if (combiningCharsCount >= MAX_COMBINING_CHARACTERS_PER_COLUMN) + return; + } + + // Find how many chars this column will need + int newCharactersUsedForColumn = Character.charCount(codePoint); + if (newIsCombining) { + // Combining characters are added to the contents of the column instead of overwriting them, so that they + // modify the existing contents. + // FIXME: Unassigned characters also get width=0. + newCharactersUsedForColumn += oldCharactersUsedForColumn; + } + + int oldNextColumnIndex = oldStartOfColumnIndex + oldCharactersUsedForColumn; + int newNextColumnIndex = oldStartOfColumnIndex + newCharactersUsedForColumn; + + final int javaCharDifference = newCharactersUsedForColumn - oldCharactersUsedForColumn; + if (javaCharDifference > 0) { + // Shift the rest of the line right. + int oldCharactersAfterColumn = mSpaceUsed - oldNextColumnIndex; + if (mSpaceUsed + javaCharDifference > text.length) { + // We need to grow the array + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, oldNextColumnIndex); + System.arraycopy(text, oldNextColumnIndex, newText, newNextColumnIndex, oldCharactersAfterColumn); + mText = text = newText; + } else { + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, oldCharactersAfterColumn); + } + } else if (javaCharDifference < 0) { + // Shift the rest of the line left. + System.arraycopy(text, oldNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - oldNextColumnIndex); + } + mSpaceUsed += javaCharDifference; + + // Store char. A combining character is stored at the end of the existing contents so that it modifies them: + //noinspection ResultOfMethodCallIgnored - since we already now how many java chars is used. + Character.toChars(codePoint, text, oldStartOfColumnIndex + (newIsCombining ? oldCharactersUsedForColumn : 0)); + + if (oldCodePointDisplayWidth == 2 && newCodePointDisplayWidth == 1) { + // Replace second half of wide char with a space. Which mean that we actually add a ' ' java character. + if (mSpaceUsed + 1 > text.length) { + char[] newText = new char[text.length + mColumns]; + System.arraycopy(text, 0, newText, 0, newNextColumnIndex); + System.arraycopy(text, newNextColumnIndex, newText, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + mText = text = newText; + } else { + System.arraycopy(text, newNextColumnIndex, text, newNextColumnIndex + 1, mSpaceUsed - newNextColumnIndex); + } + text[newNextColumnIndex] = ' '; + + ++mSpaceUsed; + } else if (oldCodePointDisplayWidth == 1 && newCodePointDisplayWidth == 2) { + if (columnToSet == mColumns - 1) { + throw new IllegalArgumentException("Cannot put wide character in last column"); + } else if (columnToSet == mColumns - 2) { + // Truncate the line to the second part of this wide char: + mSpaceUsed = (short) newNextColumnIndex; + } else { + // Overwrite the contents of the next column, which mean we actually remove java characters. Due to the + // check at the beginning of this method we know that we are not overwriting a wide char. + int newNextNextColumnIndex = newNextColumnIndex + (Character.isHighSurrogate(mText[newNextColumnIndex]) ? 2 : 1); + int nextLen = newNextNextColumnIndex - newNextColumnIndex; + + // Shift the array leftwards. + System.arraycopy(text, newNextNextColumnIndex, text, newNextColumnIndex, mSpaceUsed - newNextNextColumnIndex); + mSpaceUsed -= nextLen; + } + } + } + + boolean isBlank() { + for (int charIndex = 0, charLen = getSpaceUsed(); charIndex < charLen; charIndex++) + if (mText[charIndex] != ' ') return false; + return true; + } + + public final long getStyle(int column) { + return mStyle[column]; + } + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java new file mode 100644 index 0000000..4f1d72b --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalSession.java @@ -0,0 +1,194 @@ +package com.termux.terminal; + +import android.annotation.SuppressLint; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * A terminal session backed by a remote transport (no local PTY/JNI). + * + *

Originally Termux's {@code TerminalSession} drove a local pseudoterminal via JNI. Muxy's + * Android client never spawns a local shell — bytes flow over a WebSocket to the Mac. This vendored + * fork strips the PTY/JNI dependency and exposes: + * + *

    + *
  • {@link #feedRemoteOutput(byte[], int)} — main-thread entry point for incoming bytes. + *
  • {@link #write(byte[], int, int)} — overridden by {@code MuxyTerminalSession} to forward + * outgoing user input to {@code MuxyClient.terminalInput}. + *
+ * + *

The class is no longer {@code final}; subclasses provide the transport binding. + */ +public class TerminalSession extends TerminalOutput { + + private static final int MSG_NEW_INPUT = 1; + + public final String mHandle = UUID.randomUUID().toString(); + + TerminalEmulator mEmulator; + + private final ByteQueue mIncomingQueue = new ByteQueue(64 * 1024); + private final byte[] mUtf8InputBuffer = new byte[5]; + + TerminalSessionClient mClient; + + public String mSessionName; + + final Handler mMainThreadHandler; + + private final Integer mTranscriptRows; + + public TerminalSession(Integer transcriptRows, TerminalSessionClient client) { + this.mTranscriptRows = transcriptRows; + this.mClient = client; + this.mMainThreadHandler = new MainThreadHandler(Looper.getMainLooper()); + } + + public void updateTerminalSessionClient(TerminalSessionClient client) { + mClient = client; + + if (mEmulator != null) + mEmulator.updateTerminalSessionClient(client); + } + + public void updateSize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + if (mEmulator == null) { + initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels); + } else { + mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels); + } + } + + public String getTitle() { + return (mEmulator == null) ? null : mEmulator.getTitle(); + } + + public void initializeEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + mEmulator = new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, mTranscriptRows, mClient); + } + + /** + * Append output bytes received from the remote transport. Safe to call from any thread; the + * emulator is fed on the main thread. + */ + public void feedRemoteOutput(byte[] data, int length) { + if (length <= 0) return; + if (!mIncomingQueue.write(data, 0, length)) return; + mMainThreadHandler.sendEmptyMessage(MSG_NEW_INPUT); + } + + @Override + public void write(byte[] data, int offset, int count) { + // Default no-op. MuxyTerminalSession overrides to forward user input over the wire. + } + + public void writeCodePoint(boolean prependEscape, int codePoint) { + if (codePoint > 1114111 || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + throw new IllegalArgumentException("Invalid code point: " + codePoint); + } + + int bufferPosition = 0; + if (prependEscape) mUtf8InputBuffer[bufferPosition++] = 27; + + if (codePoint <= /* 7 bits */0b1111111) { + mUtf8InputBuffer[bufferPosition++] = (byte) codePoint; + } else if (codePoint <= /* 11 bits */0b11111111111) { + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11000000 | (codePoint >> 6)); + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else if (codePoint <= /* 16 bits */0b1111111111111111) { + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11100000 | (codePoint >> 12)); + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } else { + mUtf8InputBuffer[bufferPosition++] = (byte) (0b11110000 | (codePoint >> 18)); + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 12) & 0b111111)); + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | ((codePoint >> 6) & 0b111111)); + mUtf8InputBuffer[bufferPosition++] = (byte) (0b10000000 | (codePoint & 0b111111)); + } + write(mUtf8InputBuffer, 0, bufferPosition); + } + + public TerminalEmulator getEmulator() { + return mEmulator; + } + + protected void notifyScreenUpdate() { + if (mClient != null) mClient.onTextChanged(this); + } + + public void reset() { + if (mEmulator != null) { + mEmulator.reset(); + notifyScreenUpdate(); + } + } + + public void finishIfRunning() { + mIncomingQueue.close(); + } + + @Override + public void titleChanged(String oldTitle, String newTitle) { + if (mClient != null) mClient.onTitleChanged(this); + } + + public synchronized boolean isRunning() { + return true; + } + + public synchronized int getExitStatus() { + return 0; + } + + @Override + public void onCopyTextToClipboard(String text) { + if (mClient != null) mClient.onCopyTextToClipboard(this, text); + } + + @Override + public void onPasteTextFromClipboard() { + if (mClient != null) mClient.onPasteTextFromClipboard(this); + } + + @Override + public void onBell() { + if (mClient != null) mClient.onBell(this); + } + + @Override + public void onColorsChanged() { + if (mClient != null) mClient.onColorsChanged(this); + } + + public int getPid() { + return 0; + } + + public String getCwd() { + return null; + } + + @SuppressLint("HandlerLeak") + final class MainThreadHandler extends Handler { + + final byte[] mReceiveBuffer = new byte[64 * 1024]; + + MainThreadHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + if (mEmulator == null) return; + int bytesRead = mIncomingQueue.read(mReceiveBuffer, false); + if (bytesRead > 0) { + mEmulator.append(mReceiveBuffer, bytesRead); + notifyScreenUpdate(); + } + } + } +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java new file mode 100644 index 0000000..fbd8e55 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TerminalSessionClient.java @@ -0,0 +1,51 @@ +package com.termux.terminal; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * The interface for communication between {@link TerminalSession} and its client. It is used to + * send callbacks to the client when {@link TerminalSession} changes or for sending other + * back data to the client like logs. + */ +public interface TerminalSessionClient { + + void onTextChanged(@NonNull TerminalSession changedSession); + + void onTitleChanged(@NonNull TerminalSession changedSession); + + void onSessionFinished(@NonNull TerminalSession finishedSession); + + void onCopyTextToClipboard(@NonNull TerminalSession session, String text); + + void onPasteTextFromClipboard(@Nullable TerminalSession session); + + void onBell(@NonNull TerminalSession session); + + void onColorsChanged(@NonNull TerminalSession session); + + void onTerminalCursorStateChange(boolean state); + + void setTerminalShellPid(@NonNull TerminalSession session, int pid); + + + + Integer getTerminalCursorStyle(); + + + + void logError(String tag, String message); + + void logWarn(String tag, String message); + + void logInfo(String tag, String message); + + void logDebug(String tag, String message); + + void logVerbose(String tag, String message); + + void logStackTraceWithMessage(String tag, String message, Exception e); + + void logStackTrace(String tag, Exception e); + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java new file mode 100644 index 0000000..173d6ae --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/TextStyle.java @@ -0,0 +1,90 @@ +package com.termux.terminal; + +/** + *

+ * Encodes effects, foreground and background colors into a 64 bit long, which are stored for each cell in a terminal + * row in {@link TerminalRow#mStyle}. + *

+ *

+ * The bit layout is: + *

+ * - 16 flags (11 currently used). + * - 24 for foreground color (only 9 first bits if a color index). + * - 24 for background color (only 9 first bits if a color index). + */ +public final class TextStyle { + + public final static int CHARACTER_ATTRIBUTE_BOLD = 1; + public final static int CHARACTER_ATTRIBUTE_ITALIC = 1 << 1; + public final static int CHARACTER_ATTRIBUTE_UNDERLINE = 1 << 2; + public final static int CHARACTER_ATTRIBUTE_BLINK = 1 << 3; + public final static int CHARACTER_ATTRIBUTE_INVERSE = 1 << 4; + public final static int CHARACTER_ATTRIBUTE_INVISIBLE = 1 << 5; + public final static int CHARACTER_ATTRIBUTE_STRIKETHROUGH = 1 << 6; + /** + * The selective erase control functions (DECSED and DECSEL) can only erase characters defined as erasable. + *

+ * This bit is set if DECSCA (Select Character Protection Attribute) has been used to define the characters that + * come after it as erasable from the screen. + *

+ */ + public final static int CHARACTER_ATTRIBUTE_PROTECTED = 1 << 7; + /** Dim colors. Also known as faint or half intensity. */ + public final static int CHARACTER_ATTRIBUTE_DIM = 1 << 8; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND = 1 << 9; + /** If true (24-bit) color is used for the cell for foreground. */ + private final static int CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND= 1 << 10; + + public final static int COLOR_INDEX_FOREGROUND = 256; + public final static int COLOR_INDEX_BACKGROUND = 257; + public final static int COLOR_INDEX_CURSOR = 258; + + /** The 256 standard color entries and the three special (foreground, background and cursor) ones. */ + public final static int NUM_INDEXED_COLORS = 259; + + /** Normal foreground and background colors and no effects. */ + final static long NORMAL = encode(COLOR_INDEX_FOREGROUND, COLOR_INDEX_BACKGROUND, 0); + + static long encode(int foreColor, int backColor, int effect) { + long result = effect & 0b111111111; + if ((0xff000000 & foreColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND | ((foreColor & 0x00ffffffL) << 40L); + } else { + // Indexed color. + result |= (foreColor & 0b111111111L) << 40; + } + if ((0xff000000 & backColor) == 0xff000000) { + // 24-bit color. + result |= CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND | ((backColor & 0x00ffffffL) << 16L); + } else { + // Indexed color. + result |= (backColor & 0b111111111L) << 16L; + } + + return result; + } + + public static int decodeForeColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_FOREGROUND) == 0) { + return (int) ((style >>> 40) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 40) & 0x00ffffffL); + } + + } + + public static int decodeBackColor(long style) { + if ((style & CHARACTER_ATTRIBUTE_TRUECOLOR_BACKGROUND) == 0) { + return (int) ((style >>> 16) & 0b111111111L); + } else { + return 0xff000000 | (int) ((style >>> 16) & 0x00ffffffL); + } + } + + public static int decodeEffect(long style) { + return (int) (style & 0b11111111111); + } + +} diff --git a/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java new file mode 100644 index 0000000..d71cc27 --- /dev/null +++ b/android/terminal/vendor/terminal-emulator/src/main/java/com/termux/terminal/WcWidth.java @@ -0,0 +1,566 @@ +package com.termux.terminal; + +/** + * Implementation of wcwidth(3) for Unicode 15. + * + * Implementation from https://github.com/jquast/wcwidth but we return 0 for unprintable characters. + * + * IMPORTANT: + * Must be kept in sync with the following: + * https://github.com/termux/wcwidth + * https://github.com/termux/libandroid-support + * https://github.com/termux/termux-packages/tree/master/packages/libandroid-support + */ +public final class WcWidth { + + // From https://github.com/jquast/wcwidth/blob/master/wcwidth/table_zero.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] ZERO_WIDTH = { + {0x00300, 0x0036f}, // Combining Grave Accent ..Combining Latin Small Le + {0x00483, 0x00489}, // Combining Cyrillic Titlo..Combining Cyrillic Milli + {0x00591, 0x005bd}, // Hebrew Accent Etnahta ..Hebrew Point Meteg + {0x005bf, 0x005bf}, // Hebrew Point Rafe ..Hebrew Point Rafe + {0x005c1, 0x005c2}, // Hebrew Point Shin Dot ..Hebrew Point Sin Dot + {0x005c4, 0x005c5}, // Hebrew Mark Upper Dot ..Hebrew Mark Lower Dot + {0x005c7, 0x005c7}, // Hebrew Point Qamats Qata..Hebrew Point Qamats Qata + {0x00610, 0x0061a}, // Arabic Sign Sallallahou ..Arabic Small Kasra + {0x0064b, 0x0065f}, // Arabic Fathatan ..Arabic Wavy Hamza Below + {0x00670, 0x00670}, // Arabic Letter Superscrip..Arabic Letter Superscrip + {0x006d6, 0x006dc}, // Arabic Small High Ligatu..Arabic Small High Seen + {0x006df, 0x006e4}, // Arabic Small High Rounde..Arabic Small High Madda + {0x006e7, 0x006e8}, // Arabic Small High Yeh ..Arabic Small High Noon + {0x006ea, 0x006ed}, // Arabic Empty Centre Low ..Arabic Small Low Meem + {0x00711, 0x00711}, // Syriac Letter Superscrip..Syriac Letter Superscrip + {0x00730, 0x0074a}, // Syriac Pthaha Above ..Syriac Barrekh + {0x007a6, 0x007b0}, // Thaana Abafili ..Thaana Sukun + {0x007eb, 0x007f3}, // Nko Combining Short High..Nko Combining Double Dot + {0x007fd, 0x007fd}, // Nko Dantayalan ..Nko Dantayalan + {0x00816, 0x00819}, // Samaritan Mark In ..Samaritan Mark Dagesh + {0x0081b, 0x00823}, // Samaritan Mark Epentheti..Samaritan Vowel Sign A + {0x00825, 0x00827}, // Samaritan Vowel Sign Sho..Samaritan Vowel Sign U + {0x00829, 0x0082d}, // Samaritan Vowel Sign Lon..Samaritan Mark Nequdaa + {0x00859, 0x0085b}, // Mandaic Affrication Mark..Mandaic Gemination Mark + {0x00898, 0x0089f}, // Arabic Small High Word A..Arabic Half Madda Over M + {0x008ca, 0x008e1}, // Arabic Small High Farsi ..Arabic Small High Sign S + {0x008e3, 0x00902}, // Arabic Turned Damma Belo..Devanagari Sign Anusvara + {0x0093a, 0x0093a}, // Devanagari Vowel Sign Oe..Devanagari Vowel Sign Oe + {0x0093c, 0x0093c}, // Devanagari Sign Nukta ..Devanagari Sign Nukta + {0x00941, 0x00948}, // Devanagari Vowel Sign U ..Devanagari Vowel Sign Ai + {0x0094d, 0x0094d}, // Devanagari Sign Virama ..Devanagari Sign Virama + {0x00951, 0x00957}, // Devanagari Stress Sign U..Devanagari Vowel Sign Uu + {0x00962, 0x00963}, // Devanagari Vowel Sign Vo..Devanagari Vowel Sign Vo + {0x00981, 0x00981}, // Bengali Sign Candrabindu..Bengali Sign Candrabindu + {0x009bc, 0x009bc}, // Bengali Sign Nukta ..Bengali Sign Nukta + {0x009c1, 0x009c4}, // Bengali Vowel Sign U ..Bengali Vowel Sign Vocal + {0x009cd, 0x009cd}, // Bengali Sign Virama ..Bengali Sign Virama + {0x009e2, 0x009e3}, // Bengali Vowel Sign Vocal..Bengali Vowel Sign Vocal + {0x009fe, 0x009fe}, // Bengali Sandhi Mark ..Bengali Sandhi Mark + {0x00a01, 0x00a02}, // Gurmukhi Sign Adak Bindi..Gurmukhi Sign Bindi + {0x00a3c, 0x00a3c}, // Gurmukhi Sign Nukta ..Gurmukhi Sign Nukta + {0x00a41, 0x00a42}, // Gurmukhi Vowel Sign U ..Gurmukhi Vowel Sign Uu + {0x00a47, 0x00a48}, // Gurmukhi Vowel Sign Ee ..Gurmukhi Vowel Sign Ai + {0x00a4b, 0x00a4d}, // Gurmukhi Vowel Sign Oo ..Gurmukhi Sign Virama + {0x00a51, 0x00a51}, // Gurmukhi Sign Udaat ..Gurmukhi Sign Udaat + {0x00a70, 0x00a71}, // Gurmukhi Tippi ..Gurmukhi Addak + {0x00a75, 0x00a75}, // Gurmukhi Sign Yakash ..Gurmukhi Sign Yakash + {0x00a81, 0x00a82}, // Gujarati Sign Candrabind..Gujarati Sign Anusvara + {0x00abc, 0x00abc}, // Gujarati Sign Nukta ..Gujarati Sign Nukta + {0x00ac1, 0x00ac5}, // Gujarati Vowel Sign U ..Gujarati Vowel Sign Cand + {0x00ac7, 0x00ac8}, // Gujarati Vowel Sign E ..Gujarati Vowel Sign Ai + {0x00acd, 0x00acd}, // Gujarati Sign Virama ..Gujarati Sign Virama + {0x00ae2, 0x00ae3}, // Gujarati Vowel Sign Voca..Gujarati Vowel Sign Voca + {0x00afa, 0x00aff}, // Gujarati Sign Sukun ..Gujarati Sign Two-circle + {0x00b01, 0x00b01}, // Oriya Sign Candrabindu ..Oriya Sign Candrabindu + {0x00b3c, 0x00b3c}, // Oriya Sign Nukta ..Oriya Sign Nukta + {0x00b3f, 0x00b3f}, // Oriya Vowel Sign I ..Oriya Vowel Sign I + {0x00b41, 0x00b44}, // Oriya Vowel Sign U ..Oriya Vowel Sign Vocalic + {0x00b4d, 0x00b4d}, // Oriya Sign Virama ..Oriya Sign Virama + {0x00b55, 0x00b56}, // Oriya Sign Overline ..Oriya Ai Length Mark + {0x00b62, 0x00b63}, // Oriya Vowel Sign Vocalic..Oriya Vowel Sign Vocalic + {0x00b82, 0x00b82}, // Tamil Sign Anusvara ..Tamil Sign Anusvara + {0x00bc0, 0x00bc0}, // Tamil Vowel Sign Ii ..Tamil Vowel Sign Ii + {0x00bcd, 0x00bcd}, // Tamil Sign Virama ..Tamil Sign Virama + {0x00c00, 0x00c00}, // Telugu Sign Combining Ca..Telugu Sign Combining Ca + {0x00c04, 0x00c04}, // Telugu Sign Combining An..Telugu Sign Combining An + {0x00c3c, 0x00c3c}, // Telugu Sign Nukta ..Telugu Sign Nukta + {0x00c3e, 0x00c40}, // Telugu Vowel Sign Aa ..Telugu Vowel Sign Ii + {0x00c46, 0x00c48}, // Telugu Vowel Sign E ..Telugu Vowel Sign Ai + {0x00c4a, 0x00c4d}, // Telugu Vowel Sign O ..Telugu Sign Virama + {0x00c55, 0x00c56}, // Telugu Length Mark ..Telugu Ai Length Mark + {0x00c62, 0x00c63}, // Telugu Vowel Sign Vocali..Telugu Vowel Sign Vocali + {0x00c81, 0x00c81}, // Kannada Sign Candrabindu..Kannada Sign Candrabindu + {0x00cbc, 0x00cbc}, // Kannada Sign Nukta ..Kannada Sign Nukta + {0x00cbf, 0x00cbf}, // Kannada Vowel Sign I ..Kannada Vowel Sign I + {0x00cc6, 0x00cc6}, // Kannada Vowel Sign E ..Kannada Vowel Sign E + {0x00ccc, 0x00ccd}, // Kannada Vowel Sign Au ..Kannada Sign Virama + {0x00ce2, 0x00ce3}, // Kannada Vowel Sign Vocal..Kannada Vowel Sign Vocal + {0x00d00, 0x00d01}, // Malayalam Sign Combining..Malayalam Sign Candrabin + {0x00d3b, 0x00d3c}, // Malayalam Sign Vertical ..Malayalam Sign Circular + {0x00d41, 0x00d44}, // Malayalam Vowel Sign U ..Malayalam Vowel Sign Voc + {0x00d4d, 0x00d4d}, // Malayalam Sign Virama ..Malayalam Sign Virama + {0x00d62, 0x00d63}, // Malayalam Vowel Sign Voc..Malayalam Vowel Sign Voc + {0x00d81, 0x00d81}, // Sinhala Sign Candrabindu..Sinhala Sign Candrabindu + {0x00dca, 0x00dca}, // Sinhala Sign Al-lakuna ..Sinhala Sign Al-lakuna + {0x00dd2, 0x00dd4}, // Sinhala Vowel Sign Ketti..Sinhala Vowel Sign Ketti + {0x00dd6, 0x00dd6}, // Sinhala Vowel Sign Diga ..Sinhala Vowel Sign Diga + {0x00e31, 0x00e31}, // Thai Character Mai Han-a..Thai Character Mai Han-a + {0x00e34, 0x00e3a}, // Thai Character Sara I ..Thai Character Phinthu + {0x00e47, 0x00e4e}, // Thai Character Maitaikhu..Thai Character Yamakkan + {0x00eb1, 0x00eb1}, // Lao Vowel Sign Mai Kan ..Lao Vowel Sign Mai Kan + {0x00eb4, 0x00ebc}, // Lao Vowel Sign I ..Lao Semivowel Sign Lo + {0x00ec8, 0x00ece}, // Lao Tone Mai Ek ..(nil) + {0x00f18, 0x00f19}, // Tibetan Astrological Sig..Tibetan Astrological Sig + {0x00f35, 0x00f35}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f37, 0x00f37}, // Tibetan Mark Ngas Bzung ..Tibetan Mark Ngas Bzung + {0x00f39, 0x00f39}, // Tibetan Mark Tsa -phru ..Tibetan Mark Tsa -phru + {0x00f71, 0x00f7e}, // Tibetan Vowel Sign Aa ..Tibetan Sign Rjes Su Nga + {0x00f80, 0x00f84}, // Tibetan Vowel Sign Rever..Tibetan Mark Halanta + {0x00f86, 0x00f87}, // Tibetan Sign Lci Rtags ..Tibetan Sign Yang Rtags + {0x00f8d, 0x00f97}, // Tibetan Subjoined Sign L..Tibetan Subjoined Letter + {0x00f99, 0x00fbc}, // Tibetan Subjoined Letter..Tibetan Subjoined Letter + {0x00fc6, 0x00fc6}, // Tibetan Symbol Padma Gda..Tibetan Symbol Padma Gda + {0x0102d, 0x01030}, // Myanmar Vowel Sign I ..Myanmar Vowel Sign Uu + {0x01032, 0x01037}, // Myanmar Vowel Sign Ai ..Myanmar Sign Dot Below + {0x01039, 0x0103a}, // Myanmar Sign Virama ..Myanmar Sign Asat + {0x0103d, 0x0103e}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01058, 0x01059}, // Myanmar Vowel Sign Vocal..Myanmar Vowel Sign Vocal + {0x0105e, 0x01060}, // Myanmar Consonant Sign M..Myanmar Consonant Sign M + {0x01071, 0x01074}, // Myanmar Vowel Sign Geba ..Myanmar Vowel Sign Kayah + {0x01082, 0x01082}, // Myanmar Consonant Sign S..Myanmar Consonant Sign S + {0x01085, 0x01086}, // Myanmar Vowel Sign Shan ..Myanmar Vowel Sign Shan + {0x0108d, 0x0108d}, // Myanmar Sign Shan Counci..Myanmar Sign Shan Counci + {0x0109d, 0x0109d}, // Myanmar Vowel Sign Aiton..Myanmar Vowel Sign Aiton + {0x0135d, 0x0135f}, // Ethiopic Combining Gemin..Ethiopic Combining Gemin + {0x01712, 0x01714}, // Tagalog Vowel Sign I ..Tagalog Sign Virama + {0x01732, 0x01733}, // Hanunoo Vowel Sign I ..Hanunoo Vowel Sign U + {0x01752, 0x01753}, // Buhid Vowel Sign I ..Buhid Vowel Sign U + {0x01772, 0x01773}, // Tagbanwa Vowel Sign I ..Tagbanwa Vowel Sign U + {0x017b4, 0x017b5}, // Khmer Vowel Inherent Aq ..Khmer Vowel Inherent Aa + {0x017b7, 0x017bd}, // Khmer Vowel Sign I ..Khmer Vowel Sign Ua + {0x017c6, 0x017c6}, // Khmer Sign Nikahit ..Khmer Sign Nikahit + {0x017c9, 0x017d3}, // Khmer Sign Muusikatoan ..Khmer Sign Bathamasat + {0x017dd, 0x017dd}, // Khmer Sign Atthacan ..Khmer Sign Atthacan + {0x0180b, 0x0180d}, // Mongolian Free Variation..Mongolian Free Variation + {0x0180f, 0x0180f}, // Mongolian Free Variation..Mongolian Free Variation + {0x01885, 0x01886}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x018a9, 0x018a9}, // Mongolian Letter Ali Gal..Mongolian Letter Ali Gal + {0x01920, 0x01922}, // Limbu Vowel Sign A ..Limbu Vowel Sign U + {0x01927, 0x01928}, // Limbu Vowel Sign E ..Limbu Vowel Sign O + {0x01932, 0x01932}, // Limbu Small Letter Anusv..Limbu Small Letter Anusv + {0x01939, 0x0193b}, // Limbu Sign Mukphreng ..Limbu Sign Sa-i + {0x01a17, 0x01a18}, // Buginese Vowel Sign I ..Buginese Vowel Sign U + {0x01a1b, 0x01a1b}, // Buginese Vowel Sign Ae ..Buginese Vowel Sign Ae + {0x01a56, 0x01a56}, // Tai Tham Consonant Sign ..Tai Tham Consonant Sign + {0x01a58, 0x01a5e}, // Tai Tham Sign Mai Kang L..Tai Tham Consonant Sign + {0x01a60, 0x01a60}, // Tai Tham Sign Sakot ..Tai Tham Sign Sakot + {0x01a62, 0x01a62}, // Tai Tham Vowel Sign Mai ..Tai Tham Vowel Sign Mai + {0x01a65, 0x01a6c}, // Tai Tham Vowel Sign I ..Tai Tham Vowel Sign Oa B + {0x01a73, 0x01a7c}, // Tai Tham Vowel Sign Oa A..Tai Tham Sign Khuen-lue + {0x01a7f, 0x01a7f}, // Tai Tham Combining Crypt..Tai Tham Combining Crypt + {0x01ab0, 0x01ace}, // Combining Doubled Circum..Combining Latin Small Le + {0x01b00, 0x01b03}, // Balinese Sign Ulu Ricem ..Balinese Sign Surang + {0x01b34, 0x01b34}, // Balinese Sign Rerekan ..Balinese Sign Rerekan + {0x01b36, 0x01b3a}, // Balinese Vowel Sign Ulu ..Balinese Vowel Sign Ra R + {0x01b3c, 0x01b3c}, // Balinese Vowel Sign La L..Balinese Vowel Sign La L + {0x01b42, 0x01b42}, // Balinese Vowel Sign Pepe..Balinese Vowel Sign Pepe + {0x01b6b, 0x01b73}, // Balinese Musical Symbol ..Balinese Musical Symbol + {0x01b80, 0x01b81}, // Sundanese Sign Panyecek ..Sundanese Sign Panglayar + {0x01ba2, 0x01ba5}, // Sundanese Consonant Sign..Sundanese Vowel Sign Pan + {0x01ba8, 0x01ba9}, // Sundanese Vowel Sign Pam..Sundanese Vowel Sign Pan + {0x01bab, 0x01bad}, // Sundanese Sign Virama ..Sundanese Consonant Sign + {0x01be6, 0x01be6}, // Batak Sign Tompi ..Batak Sign Tompi + {0x01be8, 0x01be9}, // Batak Vowel Sign Pakpak ..Batak Vowel Sign Ee + {0x01bed, 0x01bed}, // Batak Vowel Sign Karo O ..Batak Vowel Sign Karo O + {0x01bef, 0x01bf1}, // Batak Vowel Sign U For S..Batak Consonant Sign H + {0x01c2c, 0x01c33}, // Lepcha Vowel Sign E ..Lepcha Consonant Sign T + {0x01c36, 0x01c37}, // Lepcha Sign Ran ..Lepcha Sign Nukta + {0x01cd0, 0x01cd2}, // Vedic Tone Karshana ..Vedic Tone Prenkha + {0x01cd4, 0x01ce0}, // Vedic Sign Yajurvedic Mi..Vedic Tone Rigvedic Kash + {0x01ce2, 0x01ce8}, // Vedic Sign Visarga Svari..Vedic Sign Visarga Anuda + {0x01ced, 0x01ced}, // Vedic Sign Tiryak ..Vedic Sign Tiryak + {0x01cf4, 0x01cf4}, // Vedic Tone Candra Above ..Vedic Tone Candra Above + {0x01cf8, 0x01cf9}, // Vedic Tone Ring Above ..Vedic Tone Double Ring A + {0x01dc0, 0x01dff}, // Combining Dotted Grave A..Combining Right Arrowhea + {0x020d0, 0x020f0}, // Combining Left Harpoon A..Combining Asterisk Above + {0x02cef, 0x02cf1}, // Coptic Combining Ni Abov..Coptic Combining Spiritu + {0x02d7f, 0x02d7f}, // Tifinagh Consonant Joine..Tifinagh Consonant Joine + {0x02de0, 0x02dff}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0302a, 0x0302d}, // Ideographic Level Tone M..Ideographic Entering Ton + {0x03099, 0x0309a}, // Combining Katakana-hirag..Combining Katakana-hirag + {0x0a66f, 0x0a672}, // Combining Cyrillic Vzmet..Combining Cyrillic Thous + {0x0a674, 0x0a67d}, // Combining Cyrillic Lette..Combining Cyrillic Payer + {0x0a69e, 0x0a69f}, // Combining Cyrillic Lette..Combining Cyrillic Lette + {0x0a6f0, 0x0a6f1}, // Bamum Combining Mark Koq..Bamum Combining Mark Tuk + {0x0a802, 0x0a802}, // Syloti Nagri Sign Dvisva..Syloti Nagri Sign Dvisva + {0x0a806, 0x0a806}, // Syloti Nagri Sign Hasant..Syloti Nagri Sign Hasant + {0x0a80b, 0x0a80b}, // Syloti Nagri Sign Anusva..Syloti Nagri Sign Anusva + {0x0a825, 0x0a826}, // Syloti Nagri Vowel Sign ..Syloti Nagri Vowel Sign + {0x0a82c, 0x0a82c}, // Syloti Nagri Sign Altern..Syloti Nagri Sign Altern + {0x0a8c4, 0x0a8c5}, // Saurashtra Sign Virama ..Saurashtra Sign Candrabi + {0x0a8e0, 0x0a8f1}, // Combining Devanagari Dig..Combining Devanagari Sig + {0x0a8ff, 0x0a8ff}, // Devanagari Vowel Sign Ay..Devanagari Vowel Sign Ay + {0x0a926, 0x0a92d}, // Kayah Li Vowel Ue ..Kayah Li Tone Calya Plop + {0x0a947, 0x0a951}, // Rejang Vowel Sign I ..Rejang Consonant Sign R + {0x0a980, 0x0a982}, // Javanese Sign Panyangga ..Javanese Sign Layar + {0x0a9b3, 0x0a9b3}, // Javanese Sign Cecak Telu..Javanese Sign Cecak Telu + {0x0a9b6, 0x0a9b9}, // Javanese Vowel Sign Wulu..Javanese Vowel Sign Suku + {0x0a9bc, 0x0a9bd}, // Javanese Vowel Sign Pepe..Javanese Consonant Sign + {0x0a9e5, 0x0a9e5}, // Myanmar Sign Shan Saw ..Myanmar Sign Shan Saw + {0x0aa29, 0x0aa2e}, // Cham Vowel Sign Aa ..Cham Vowel Sign Oe + {0x0aa31, 0x0aa32}, // Cham Vowel Sign Au ..Cham Vowel Sign Ue + {0x0aa35, 0x0aa36}, // Cham Consonant Sign La ..Cham Consonant Sign Wa + {0x0aa43, 0x0aa43}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa4c, 0x0aa4c}, // Cham Consonant Sign Fina..Cham Consonant Sign Fina + {0x0aa7c, 0x0aa7c}, // Myanmar Sign Tai Laing T..Myanmar Sign Tai Laing T + {0x0aab0, 0x0aab0}, // Tai Viet Mai Kang ..Tai Viet Mai Kang + {0x0aab2, 0x0aab4}, // Tai Viet Vowel I ..Tai Viet Vowel U + {0x0aab7, 0x0aab8}, // Tai Viet Mai Khit ..Tai Viet Vowel Ia + {0x0aabe, 0x0aabf}, // Tai Viet Vowel Am ..Tai Viet Tone Mai Ek + {0x0aac1, 0x0aac1}, // Tai Viet Tone Mai Tho ..Tai Viet Tone Mai Tho + {0x0aaec, 0x0aaed}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0aaf6, 0x0aaf6}, // Meetei Mayek Virama ..Meetei Mayek Virama + {0x0abe5, 0x0abe5}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abe8, 0x0abe8}, // Meetei Mayek Vowel Sign ..Meetei Mayek Vowel Sign + {0x0abed, 0x0abed}, // Meetei Mayek Apun Iyek ..Meetei Mayek Apun Iyek + {0x0fb1e, 0x0fb1e}, // Hebrew Point Judeo-spani..Hebrew Point Judeo-spani + {0x0fe00, 0x0fe0f}, // Variation Selector-1 ..Variation Selector-16 + {0x0fe20, 0x0fe2f}, // Combining Ligature Left ..Combining Cyrillic Titlo + {0x101fd, 0x101fd}, // Phaistos Disc Sign Combi..Phaistos Disc Sign Combi + {0x102e0, 0x102e0}, // Coptic Epact Thousands M..Coptic Epact Thousands M + {0x10376, 0x1037a}, // Combining Old Permic Let..Combining Old Permic Let + {0x10a01, 0x10a03}, // Kharoshthi Vowel Sign I ..Kharoshthi Vowel Sign Vo + {0x10a05, 0x10a06}, // Kharoshthi Vowel Sign E ..Kharoshthi Vowel Sign O + {0x10a0c, 0x10a0f}, // Kharoshthi Vowel Length ..Kharoshthi Sign Visarga + {0x10a38, 0x10a3a}, // Kharoshthi Sign Bar Abov..Kharoshthi Sign Dot Belo + {0x10a3f, 0x10a3f}, // Kharoshthi Virama ..Kharoshthi Virama + {0x10ae5, 0x10ae6}, // Manichaean Abbreviation ..Manichaean Abbreviation + {0x10d24, 0x10d27}, // Hanifi Rohingya Sign Har..Hanifi Rohingya Sign Tas + {0x10eab, 0x10eac}, // Yezidi Combining Hamza M..Yezidi Combining Madda M + {0x10efd, 0x10eff}, // (nil) ..(nil) + {0x10f46, 0x10f50}, // Sogdian Combining Dot Be..Sogdian Combining Stroke + {0x10f82, 0x10f85}, // Old Uyghur Combining Dot..Old Uyghur Combining Two + {0x11001, 0x11001}, // Brahmi Sign Anusvara ..Brahmi Sign Anusvara + {0x11038, 0x11046}, // Brahmi Vowel Sign Aa ..Brahmi Virama + {0x11070, 0x11070}, // Brahmi Sign Old Tamil Vi..Brahmi Sign Old Tamil Vi + {0x11073, 0x11074}, // Brahmi Vowel Sign Old Ta..Brahmi Vowel Sign Old Ta + {0x1107f, 0x11081}, // Brahmi Number Joiner ..Kaithi Sign Anusvara + {0x110b3, 0x110b6}, // Kaithi Vowel Sign U ..Kaithi Vowel Sign Ai + {0x110b9, 0x110ba}, // Kaithi Sign Virama ..Kaithi Sign Nukta + {0x110c2, 0x110c2}, // Kaithi Vowel Sign Vocali..Kaithi Vowel Sign Vocali + {0x11100, 0x11102}, // Chakma Sign Candrabindu ..Chakma Sign Visarga + {0x11127, 0x1112b}, // Chakma Vowel Sign A ..Chakma Vowel Sign Uu + {0x1112d, 0x11134}, // Chakma Vowel Sign Ai ..Chakma Maayyaa + {0x11173, 0x11173}, // Mahajani Sign Nukta ..Mahajani Sign Nukta + {0x11180, 0x11181}, // Sharada Sign Candrabindu..Sharada Sign Anusvara + {0x111b6, 0x111be}, // Sharada Vowel Sign U ..Sharada Vowel Sign O + {0x111c9, 0x111cc}, // Sharada Sandhi Mark ..Sharada Extra Short Vowe + {0x111cf, 0x111cf}, // Sharada Sign Inverted Ca..Sharada Sign Inverted Ca + {0x1122f, 0x11231}, // Khojki Vowel Sign U ..Khojki Vowel Sign Ai + {0x11234, 0x11234}, // Khojki Sign Anusvara ..Khojki Sign Anusvara + {0x11236, 0x11237}, // Khojki Sign Nukta ..Khojki Sign Shadda + {0x1123e, 0x1123e}, // Khojki Sign Sukun ..Khojki Sign Sukun + {0x11241, 0x11241}, // (nil) ..(nil) + {0x112df, 0x112df}, // Khudawadi Sign Anusvara ..Khudawadi Sign Anusvara + {0x112e3, 0x112ea}, // Khudawadi Vowel Sign U ..Khudawadi Sign Virama + {0x11300, 0x11301}, // Grantha Sign Combining A..Grantha Sign Candrabindu + {0x1133b, 0x1133c}, // Combining Bindu Below ..Grantha Sign Nukta + {0x11340, 0x11340}, // Grantha Vowel Sign Ii ..Grantha Vowel Sign Ii + {0x11366, 0x1136c}, // Combining Grantha Digit ..Combining Grantha Digit + {0x11370, 0x11374}, // Combining Grantha Letter..Combining Grantha Letter + {0x11438, 0x1143f}, // Newa Vowel Sign U ..Newa Vowel Sign Ai + {0x11442, 0x11444}, // Newa Sign Virama ..Newa Sign Anusvara + {0x11446, 0x11446}, // Newa Sign Nukta ..Newa Sign Nukta + {0x1145e, 0x1145e}, // Newa Sandhi Mark ..Newa Sandhi Mark + {0x114b3, 0x114b8}, // Tirhuta Vowel Sign U ..Tirhuta Vowel Sign Vocal + {0x114ba, 0x114ba}, // Tirhuta Vowel Sign Short..Tirhuta Vowel Sign Short + {0x114bf, 0x114c0}, // Tirhuta Sign Candrabindu..Tirhuta Sign Anusvara + {0x114c2, 0x114c3}, // Tirhuta Sign Virama ..Tirhuta Sign Nukta + {0x115b2, 0x115b5}, // Siddham Vowel Sign U ..Siddham Vowel Sign Vocal + {0x115bc, 0x115bd}, // Siddham Sign Candrabindu..Siddham Sign Anusvara + {0x115bf, 0x115c0}, // Siddham Sign Virama ..Siddham Sign Nukta + {0x115dc, 0x115dd}, // Siddham Vowel Sign Alter..Siddham Vowel Sign Alter + {0x11633, 0x1163a}, // Modi Vowel Sign U ..Modi Vowel Sign Ai + {0x1163d, 0x1163d}, // Modi Sign Anusvara ..Modi Sign Anusvara + {0x1163f, 0x11640}, // Modi Sign Virama ..Modi Sign Ardhacandra + {0x116ab, 0x116ab}, // Takri Sign Anusvara ..Takri Sign Anusvara + {0x116ad, 0x116ad}, // Takri Vowel Sign Aa ..Takri Vowel Sign Aa + {0x116b0, 0x116b5}, // Takri Vowel Sign U ..Takri Vowel Sign Au + {0x116b7, 0x116b7}, // Takri Sign Nukta ..Takri Sign Nukta + {0x1171d, 0x1171f}, // Ahom Consonant Sign Medi..Ahom Consonant Sign Medi + {0x11722, 0x11725}, // Ahom Vowel Sign I ..Ahom Vowel Sign Uu + {0x11727, 0x1172b}, // Ahom Vowel Sign Aw ..Ahom Sign Killer + {0x1182f, 0x11837}, // Dogra Vowel Sign U ..Dogra Sign Anusvara + {0x11839, 0x1183a}, // Dogra Sign Virama ..Dogra Sign Nukta + {0x1193b, 0x1193c}, // Dives Akuru Sign Anusvar..Dives Akuru Sign Candrab + {0x1193e, 0x1193e}, // Dives Akuru Virama ..Dives Akuru Virama + {0x11943, 0x11943}, // Dives Akuru Sign Nukta ..Dives Akuru Sign Nukta + {0x119d4, 0x119d7}, // Nandinagari Vowel Sign U..Nandinagari Vowel Sign V + {0x119da, 0x119db}, // Nandinagari Vowel Sign E..Nandinagari Vowel Sign A + {0x119e0, 0x119e0}, // Nandinagari Sign Virama ..Nandinagari Sign Virama + {0x11a01, 0x11a0a}, // Zanabazar Square Vowel S..Zanabazar Square Vowel L + {0x11a33, 0x11a38}, // Zanabazar Square Final C..Zanabazar Square Sign An + {0x11a3b, 0x11a3e}, // Zanabazar Square Cluster..Zanabazar Square Cluster + {0x11a47, 0x11a47}, // Zanabazar Square Subjoin..Zanabazar Square Subjoin + {0x11a51, 0x11a56}, // Soyombo Vowel Sign I ..Soyombo Vowel Sign Oe + {0x11a59, 0x11a5b}, // Soyombo Vowel Sign Vocal..Soyombo Vowel Length Mar + {0x11a8a, 0x11a96}, // Soyombo Final Consonant ..Soyombo Sign Anusvara + {0x11a98, 0x11a99}, // Soyombo Gemination Mark ..Soyombo Subjoiner + {0x11c30, 0x11c36}, // Bhaiksuki Vowel Sign I ..Bhaiksuki Vowel Sign Voc + {0x11c38, 0x11c3d}, // Bhaiksuki Vowel Sign E ..Bhaiksuki Sign Anusvara + {0x11c3f, 0x11c3f}, // Bhaiksuki Sign Virama ..Bhaiksuki Sign Virama + {0x11c92, 0x11ca7}, // Marchen Subjoined Letter..Marchen Subjoined Letter + {0x11caa, 0x11cb0}, // Marchen Subjoined Letter..Marchen Vowel Sign Aa + {0x11cb2, 0x11cb3}, // Marchen Vowel Sign U ..Marchen Vowel Sign E + {0x11cb5, 0x11cb6}, // Marchen Sign Anusvara ..Marchen Sign Candrabindu + {0x11d31, 0x11d36}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3a, 0x11d3a}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3c, 0x11d3d}, // Masaram Gondi Vowel Sign..Masaram Gondi Vowel Sign + {0x11d3f, 0x11d45}, // Masaram Gondi Vowel Sign..Masaram Gondi Virama + {0x11d47, 0x11d47}, // Masaram Gondi Ra-kara ..Masaram Gondi Ra-kara + {0x11d90, 0x11d91}, // Gunjala Gondi Vowel Sign..Gunjala Gondi Vowel Sign + {0x11d95, 0x11d95}, // Gunjala Gondi Sign Anusv..Gunjala Gondi Sign Anusv + {0x11d97, 0x11d97}, // Gunjala Gondi Virama ..Gunjala Gondi Virama + {0x11ef3, 0x11ef4}, // Makasar Vowel Sign I ..Makasar Vowel Sign U + {0x11f00, 0x11f01}, // (nil) ..(nil) + {0x11f36, 0x11f3a}, // (nil) ..(nil) + {0x11f40, 0x11f40}, // (nil) ..(nil) + {0x11f42, 0x11f42}, // (nil) ..(nil) + {0x13440, 0x13440}, // (nil) ..(nil) + {0x13447, 0x13455}, // (nil) ..(nil) + {0x16af0, 0x16af4}, // Bassa Vah Combining High..Bassa Vah Combining High + {0x16b30, 0x16b36}, // Pahawh Hmong Mark Cim Tu..Pahawh Hmong Mark Cim Ta + {0x16f4f, 0x16f4f}, // Miao Sign Consonant Modi..Miao Sign Consonant Modi + {0x16f8f, 0x16f92}, // Miao Tone Right ..Miao Tone Below + {0x16fe4, 0x16fe4}, // Khitan Small Script Fill..Khitan Small Script Fill + {0x1bc9d, 0x1bc9e}, // Duployan Thick Letter Se..Duployan Double Mark + {0x1cf00, 0x1cf2d}, // Znamenny Combining Mark ..Znamenny Combining Mark + {0x1cf30, 0x1cf46}, // Znamenny Combining Tonal..Znamenny Priznak Modifie + {0x1d167, 0x1d169}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d17b, 0x1d182}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d185, 0x1d18b}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d1aa, 0x1d1ad}, // Musical Symbol Combining..Musical Symbol Combining + {0x1d242, 0x1d244}, // Combining Greek Musical ..Combining Greek Musical + {0x1da00, 0x1da36}, // Signwriting Head Rim ..Signwriting Air Sucking + {0x1da3b, 0x1da6c}, // Signwriting Mouth Closed..Signwriting Excitement + {0x1da75, 0x1da75}, // Signwriting Upper Body T..Signwriting Upper Body T + {0x1da84, 0x1da84}, // Signwriting Location Hea..Signwriting Location Hea + {0x1da9b, 0x1da9f}, // Signwriting Fill Modifie..Signwriting Fill Modifie + {0x1daa1, 0x1daaf}, // Signwriting Rotation Mod..Signwriting Rotation Mod + {0x1e000, 0x1e006}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e008, 0x1e018}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e01b, 0x1e021}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e023, 0x1e024}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e026, 0x1e02a}, // Combining Glagolitic Let..Combining Glagolitic Let + {0x1e08f, 0x1e08f}, // (nil) ..(nil) + {0x1e130, 0x1e136}, // Nyiakeng Puachue Hmong T..Nyiakeng Puachue Hmong T + {0x1e2ae, 0x1e2ae}, // Toto Sign Rising Tone ..Toto Sign Rising Tone + {0x1e2ec, 0x1e2ef}, // Wancho Tone Tup ..Wancho Tone Koini + {0x1e4ec, 0x1e4ef}, // (nil) ..(nil) + {0x1e8d0, 0x1e8d6}, // Mende Kikakui Combining ..Mende Kikakui Combining + {0x1e944, 0x1e94a}, // Adlam Alif Lengthener ..Adlam Nukta + {0xe0100, 0xe01ef}, // Variation Selector-17 ..Variation Selector-256 + }; + + // https://github.com/jquast/wcwidth/blob/master/wcwidth/table_wide.py + // from https://github.com/jquast/wcwidth/pull/64 + // at commit 1b9b6585b0080ea5cb88dc9815796505724793fe (2022-12-16): + private static final int[][] WIDE_EASTASIAN = { + {0x01100, 0x0115f}, // Hangul Choseong Kiyeok ..Hangul Choseong Filler + {0x0231a, 0x0231b}, // Watch ..Hourglass + {0x02329, 0x0232a}, // Left-pointing Angle Brac..Right-pointing Angle Bra + {0x023e9, 0x023ec}, // Black Right-pointing Dou..Black Down-pointing Doub + {0x023f0, 0x023f0}, // Alarm Clock ..Alarm Clock + {0x023f3, 0x023f3}, // Hourglass With Flowing S..Hourglass With Flowing S + {0x025fd, 0x025fe}, // White Medium Small Squar..Black Medium Small Squar + {0x02614, 0x02615}, // Umbrella With Rain Drops..Hot Beverage + {0x02648, 0x02653}, // Aries ..Pisces + {0x0267f, 0x0267f}, // Wheelchair Symbol ..Wheelchair Symbol + {0x02693, 0x02693}, // Anchor ..Anchor + {0x026a1, 0x026a1}, // High Voltage Sign ..High Voltage Sign + {0x026aa, 0x026ab}, // Medium White Circle ..Medium Black Circle + {0x026bd, 0x026be}, // Soccer Ball ..Baseball + {0x026c4, 0x026c5}, // Snowman Without Snow ..Sun Behind Cloud + {0x026ce, 0x026ce}, // Ophiuchus ..Ophiuchus + {0x026d4, 0x026d4}, // No Entry ..No Entry + {0x026ea, 0x026ea}, // Church ..Church + {0x026f2, 0x026f3}, // Fountain ..Flag In Hole + {0x026f5, 0x026f5}, // Sailboat ..Sailboat + {0x026fa, 0x026fa}, // Tent ..Tent + {0x026fd, 0x026fd}, // Fuel Pump ..Fuel Pump + {0x02705, 0x02705}, // White Heavy Check Mark ..White Heavy Check Mark + {0x0270a, 0x0270b}, // Raised Fist ..Raised Hand + {0x02728, 0x02728}, // Sparkles ..Sparkles + {0x0274c, 0x0274c}, // Cross Mark ..Cross Mark + {0x0274e, 0x0274e}, // Negative Squared Cross M..Negative Squared Cross M + {0x02753, 0x02755}, // Black Question Mark Orna..White Exclamation Mark O + {0x02757, 0x02757}, // Heavy Exclamation Mark S..Heavy Exclamation Mark S + {0x02795, 0x02797}, // Heavy Plus Sign ..Heavy Division Sign + {0x027b0, 0x027b0}, // Curly Loop ..Curly Loop + {0x027bf, 0x027bf}, // Double Curly Loop ..Double Curly Loop + {0x02b1b, 0x02b1c}, // Black Large Square ..White Large Square + {0x02b50, 0x02b50}, // White Medium Star ..White Medium Star + {0x02b55, 0x02b55}, // Heavy Large Circle ..Heavy Large Circle + {0x02e80, 0x02e99}, // Cjk Radical Repeat ..Cjk Radical Rap + {0x02e9b, 0x02ef3}, // Cjk Radical Choke ..Cjk Radical C-simplified + {0x02f00, 0x02fd5}, // Kangxi Radical One ..Kangxi Radical Flute + {0x02ff0, 0x02ffb}, // Ideographic Description ..Ideographic Description + {0x03000, 0x0303e}, // Ideographic Space ..Ideographic Variation In + {0x03041, 0x03096}, // Hiragana Letter Small A ..Hiragana Letter Small Ke + {0x03099, 0x030ff}, // Combining Katakana-hirag..Katakana Digraph Koto + {0x03105, 0x0312f}, // Bopomofo Letter B ..Bopomofo Letter Nn + {0x03131, 0x0318e}, // Hangul Letter Kiyeok ..Hangul Letter Araeae + {0x03190, 0x031e3}, // Ideographic Annotation L..Cjk Stroke Q + {0x031f0, 0x0321e}, // Katakana Letter Small Ku..Parenthesized Korean Cha + {0x03220, 0x03247}, // Parenthesized Ideograph ..Circled Ideograph Koto + {0x03250, 0x04dbf}, // Partnership Sign ..Cjk Unified Ideograph-4d + {0x04e00, 0x0a48c}, // Cjk Unified Ideograph-4e..Yi Syllable Yyr + {0x0a490, 0x0a4c6}, // Yi Radical Qot ..Yi Radical Ke + {0x0a960, 0x0a97c}, // Hangul Choseong Tikeut-m..Hangul Choseong Ssangyeo + {0x0ac00, 0x0d7a3}, // Hangul Syllable Ga ..Hangul Syllable Hih + {0x0f900, 0x0faff}, // Cjk Compatibility Ideogr..(nil) + {0x0fe10, 0x0fe19}, // Presentation Form For Ve..Presentation Form For Ve + {0x0fe30, 0x0fe52}, // Presentation Form For Ve..Small Full Stop + {0x0fe54, 0x0fe66}, // Small Semicolon ..Small Equals Sign + {0x0fe68, 0x0fe6b}, // Small Reverse Solidus ..Small Commercial At + {0x0ff01, 0x0ff60}, // Fullwidth Exclamation Ma..Fullwidth Right White Pa + {0x0ffe0, 0x0ffe6}, // Fullwidth Cent Sign ..Fullwidth Won Sign + {0x16fe0, 0x16fe4}, // Tangut Iteration Mark ..Khitan Small Script Fill + {0x16ff0, 0x16ff1}, // Vietnamese Alternate Rea..Vietnamese Alternate Rea + {0x17000, 0x187f7}, // (nil) ..(nil) + {0x18800, 0x18cd5}, // Tangut Component-001 ..Khitan Small Script Char + {0x18d00, 0x18d08}, // (nil) ..(nil) + {0x1aff0, 0x1aff3}, // Katakana Letter Minnan T..Katakana Letter Minnan T + {0x1aff5, 0x1affb}, // Katakana Letter Minnan T..Katakana Letter Minnan N + {0x1affd, 0x1affe}, // Katakana Letter Minnan N..Katakana Letter Minnan N + {0x1b000, 0x1b122}, // Katakana Letter Archaic ..Katakana Letter Archaic + {0x1b132, 0x1b132}, // (nil) ..(nil) + {0x1b150, 0x1b152}, // Hiragana Letter Small Wi..Hiragana Letter Small Wo + {0x1b155, 0x1b155}, // (nil) ..(nil) + {0x1b164, 0x1b167}, // Katakana Letter Small Wi..Katakana Letter Small N + {0x1b170, 0x1b2fb}, // Nushu Character-1b170 ..Nushu Character-1b2fb + {0x1f004, 0x1f004}, // Mahjong Tile Red Dragon ..Mahjong Tile Red Dragon + {0x1f0cf, 0x1f0cf}, // Playing Card Black Joker..Playing Card Black Joker + {0x1f18e, 0x1f18e}, // Negative Squared Ab ..Negative Squared Ab + {0x1f191, 0x1f19a}, // Squared Cl ..Squared Vs + {0x1f200, 0x1f202}, // Square Hiragana Hoka ..Squared Katakana Sa + {0x1f210, 0x1f23b}, // Squared Cjk Unified Ideo..Squared Cjk Unified Ideo + {0x1f240, 0x1f248}, // Tortoise Shell Bracketed..Tortoise Shell Bracketed + {0x1f250, 0x1f251}, // Circled Ideograph Advant..Circled Ideograph Accept + {0x1f260, 0x1f265}, // Rounded Symbol For Fu ..Rounded Symbol For Cai + {0x1f300, 0x1f320}, // Cyclone ..Shooting Star + {0x1f32d, 0x1f335}, // Hot Dog ..Cactus + {0x1f337, 0x1f37c}, // Tulip ..Baby Bottle + {0x1f37e, 0x1f393}, // Bottle With Popping Cork..Graduation Cap + {0x1f3a0, 0x1f3ca}, // Carousel Horse ..Swimmer + {0x1f3cf, 0x1f3d3}, // Cricket Bat And Ball ..Table Tennis Paddle And + {0x1f3e0, 0x1f3f0}, // House Building ..European Castle + {0x1f3f4, 0x1f3f4}, // Waving Black Flag ..Waving Black Flag + {0x1f3f8, 0x1f43e}, // Badminton Racquet And Sh..Paw Prints + {0x1f440, 0x1f440}, // Eyes ..Eyes + {0x1f442, 0x1f4fc}, // Ear ..Videocassette + {0x1f4ff, 0x1f53d}, // Prayer Beads ..Down-pointing Small Red + {0x1f54b, 0x1f54e}, // Kaaba ..Menorah With Nine Branch + {0x1f550, 0x1f567}, // Clock Face One Oclock ..Clock Face Twelve-thirty + {0x1f57a, 0x1f57a}, // Man Dancing ..Man Dancing + {0x1f595, 0x1f596}, // Reversed Hand With Middl..Raised Hand With Part Be + {0x1f5a4, 0x1f5a4}, // Black Heart ..Black Heart + {0x1f5fb, 0x1f64f}, // Mount Fuji ..Person With Folded Hands + {0x1f680, 0x1f6c5}, // Rocket ..Left Luggage + {0x1f6cc, 0x1f6cc}, // Sleeping Accommodation ..Sleeping Accommodation + {0x1f6d0, 0x1f6d2}, // Place Of Worship ..Shopping Trolley + {0x1f6d5, 0x1f6d7}, // Hindu Temple ..Elevator + {0x1f6dc, 0x1f6df}, // (nil) ..Ring Buoy + {0x1f6eb, 0x1f6ec}, // Airplane Departure ..Airplane Arriving + {0x1f6f4, 0x1f6fc}, // Scooter ..Roller Skate + {0x1f7e0, 0x1f7eb}, // Large Orange Circle ..Large Brown Square + {0x1f7f0, 0x1f7f0}, // Heavy Equals Sign ..Heavy Equals Sign + {0x1f90c, 0x1f93a}, // Pinched Fingers ..Fencer + {0x1f93c, 0x1f945}, // Wrestlers ..Goal Net + {0x1f947, 0x1f9ff}, // First Place Medal ..Nazar Amulet + {0x1fa70, 0x1fa7c}, // Ballet Shoes ..Crutch + {0x1fa80, 0x1fa88}, // Yo-yo ..(nil) + {0x1fa90, 0x1fabd}, // Ringed Planet ..(nil) + {0x1fabf, 0x1fac5}, // (nil) ..Person With Crown + {0x1face, 0x1fadb}, // (nil) ..(nil) + {0x1fae0, 0x1fae8}, // Melting Face ..(nil) + {0x1faf0, 0x1faf8}, // Hand With Index Finger A..(nil) + {0x20000, 0x2fffd}, // Cjk Unified Ideograph-20..(nil) + {0x30000, 0x3fffd}, // Cjk Unified Ideograph-30..(nil) + }; + + + private static boolean intable(int[][] table, int c) { + // First quick check f|| Latin1 etc. characters. + if (c < table[0][0]) return false; + + // Binary search in table. + int bot = 0; + int top = table.length - 1; // (int)(size / sizeof(struct interval) - 1); + while (top >= bot) { + int mid = (bot + top) / 2; + if (table[mid][1] < c) { + bot = mid + 1; + } else if (table[mid][0] > c) { + top = mid - 1; + } else { + return true; + } + } + return false; + } + + /** Return the terminal display width of a code point: 0, 1 || 2. */ + public static int width(int ucs) { + if (ucs == 0 || + ucs == 0x034F || + (0x200B <= ucs && ucs <= 0x200F) || + ucs == 0x2028 || + ucs == 0x2029 || + (0x202A <= ucs && ucs <= 0x202E) || + (0x2060 <= ucs && ucs <= 0x2063)) { + return 0; + } + + // C0/C1 control characters + // Termux change: Return 0 instead of -1. + if (ucs < 32 || (0x07F <= ucs && ucs < 0x0A0)) return 0; + + // combining characters with zero width + if (intable(ZERO_WIDTH, ucs)) return 0; + + return intable(WIDE_EASTASIAN, ucs) ? 2 : 1; + } + + /** The width at an index position in a java char array. */ + public static int width(char[] chars, int index) { + char c = chars[index]; + return Character.isHighSurrogate(c) ? width(Character.toCodePoint(c, chars[index + 1])) : width(c); + } + + /** + * The zero width characters count like combining characters in the `chars` array from start + * index to end index (exclusive). + */ + public static int zeroWidthCharsCount(char[] chars, int start, int end) { + if (start < 0 || start >= chars.length) + return 0; + + int count = 0; + for (int i = start; i < end && i < chars.length;) { + if (Character.isHighSurrogate(chars[i])) { + if (width(Character.toCodePoint(chars[i], chars[i + 1])) <= 0) { + count++; + } + i += 2; + } else { + if (width(chars[i]) <= 0) { + count++; + } + i++; + } + } + return count; + } + +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java new file mode 100644 index 0000000..f7fc9d2 --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/GestureAndScaleRecognizer.java @@ -0,0 +1,112 @@ +package com.termux.view; + +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; + +/** A combination of {@link GestureDetector} and {@link ScaleGestureDetector}. */ +final class GestureAndScaleRecognizer { + + public interface Listener { + boolean onSingleTapUp(MotionEvent e); + + boolean onDoubleTap(MotionEvent e); + + boolean onScroll(MotionEvent e2, float dx, float dy); + + boolean onFling(MotionEvent e, float velocityX, float velocityY); + + boolean onScale(float focusX, float focusY, float scale); + + boolean onDown(float x, float y); + + boolean onUp(MotionEvent e); + + void onLongPress(MotionEvent e); + } + + private final GestureDetector mGestureDetector; + private final ScaleGestureDetector mScaleDetector; + final Listener mListener; + boolean isAfterLongPress; + + public GestureAndScaleRecognizer(Context context, Listener listener) { + mListener = listener; + + mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) { + return mListener.onScroll(e2, dx, dy); + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return mListener.onFling(e2, velocityX, velocityY); + } + + @Override + public boolean onDown(MotionEvent e) { + return mListener.onDown(e.getX(), e.getY()); + } + + @Override + public void onLongPress(MotionEvent e) { + mListener.onLongPress(e); + isAfterLongPress = true; + } + }, null, true /* ignoreMultitouch */); + + mGestureDetector.setOnDoubleTapListener(new GestureDetector.OnDoubleTapListener() { + @Override + public boolean onSingleTapConfirmed(MotionEvent e) { + return mListener.onSingleTapUp(e); + } + + @Override + public boolean onDoubleTap(MotionEvent e) { + return mListener.onDoubleTap(e); + } + + @Override + public boolean onDoubleTapEvent(MotionEvent e) { + return true; + } + }); + + mScaleDetector = new ScaleGestureDetector(context, new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + return mListener.onScale(detector.getFocusX(), detector.getFocusY(), detector.getScaleFactor()); + } + }); + mScaleDetector.setQuickScaleEnabled(false); + } + + public void onTouchEvent(MotionEvent event) { + mGestureDetector.onTouchEvent(event); + mScaleDetector.onTouchEvent(event); + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + isAfterLongPress = false; + break; + case MotionEvent.ACTION_UP: + if (!isAfterLongPress) { + // This behaviour is desired when in e.g. vim with mouse events, where we do not + // want to move the cursor when lifting finger after a long press. + mListener.onUp(event); + } + break; + } + } + + public boolean isInProgress() { + return mScaleDetector.isInProgress(); + } + +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java new file mode 100644 index 0000000..a4bef7d --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalRenderer.java @@ -0,0 +1,249 @@ +package com.termux.view; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.Typeface; + +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalRow; +import com.termux.terminal.TextStyle; +import com.termux.terminal.WcWidth; + +/** + * Renderer of a {@link TerminalEmulator} into a {@link Canvas}. + *

+ * Saves font metrics, so needs to be recreated each time the typeface or font size changes. + */ +public final class TerminalRenderer { + + final int mTextSize; + final Typeface mTypeface; + private final Paint mTextPaint = new Paint(); + + /** The width of a single mono spaced character obtained by {@link Paint#measureText(String)} on a single 'X'. */ + final float mFontWidth; + /** The {@link Paint#getFontSpacing()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + final int mFontLineSpacing; + /** The {@link Paint#ascent()}. See http://www.fampennings.nl/maarten/android/08numgrid/font.png */ + private final int mFontAscent; + /** The {@link #mFontLineSpacing} + {@link #mFontAscent}. */ + final int mFontLineSpacingAndAscent; + + private final float[] asciiMeasures = new float[127]; + + public TerminalRenderer(int textSize, Typeface typeface) { + mTextSize = textSize; + mTypeface = typeface; + + mTextPaint.setTypeface(typeface); + mTextPaint.setAntiAlias(true); + mTextPaint.setTextSize(textSize); + + mFontLineSpacing = (int) Math.ceil(mTextPaint.getFontSpacing()); + mFontAscent = (int) Math.ceil(mTextPaint.ascent()); + mFontLineSpacingAndAscent = mFontLineSpacing + mFontAscent; + mFontWidth = mTextPaint.measureText("X"); + + StringBuilder sb = new StringBuilder(" "); + for (int i = 0; i < asciiMeasures.length; i++) { + sb.setCharAt(0, (char) i); + asciiMeasures[i] = mTextPaint.measureText(sb, 0, 1); + } + } + + /** Render the terminal to a canvas with at a specified row scroll, and an optional rectangular selection. */ + public final void render(TerminalEmulator mEmulator, Canvas canvas, int topRow, + int selectionY1, int selectionY2, int selectionX1, int selectionX2) { + final boolean reverseVideo = mEmulator.isReverseVideo(); + final int endRow = topRow + mEmulator.mRows; + final int columns = mEmulator.mColumns; + final int cursorCol = mEmulator.getCursorCol(); + final int cursorRow = mEmulator.getCursorRow(); + final boolean cursorVisible = mEmulator.shouldCursorBeVisible(); + final TerminalBuffer screen = mEmulator.getScreen(); + final int[] palette = mEmulator.mColors.mCurrentColors; + final int cursorShape = mEmulator.getCursorStyle(); + + if (reverseVideo) + canvas.drawColor(palette[TextStyle.COLOR_INDEX_FOREGROUND], PorterDuff.Mode.SRC); + + float heightOffset = mFontLineSpacingAndAscent; + for (int row = topRow; row < endRow; row++) { + heightOffset += mFontLineSpacing; + + final int cursorX = (row == cursorRow && cursorVisible) ? cursorCol : -1; + int selx1 = -1, selx2 = -1; + if (row >= selectionY1 && row <= selectionY2) { + if (row == selectionY1) selx1 = selectionX1; + selx2 = (row == selectionY2) ? selectionX2 : mEmulator.mColumns; + } + + TerminalRow lineObject = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row)); + final char[] line = lineObject.mText; + final int charsUsedInLine = lineObject.getSpaceUsed(); + + long lastRunStyle = 0; + boolean lastRunInsideCursor = false; + boolean lastRunInsideSelection = false; + int lastRunStartColumn = -1; + int lastRunStartIndex = 0; + boolean lastRunFontWidthMismatch = false; + int currentCharIndex = 0; + float measuredWidthForRun = 0.f; + + for (int column = 0; column < columns; ) { + final char charAtIndex = line[currentCharIndex]; + final boolean charIsHighsurrogate = Character.isHighSurrogate(charAtIndex); + final int charsForCodePoint = charIsHighsurrogate ? 2 : 1; + final int codePoint = charIsHighsurrogate ? Character.toCodePoint(charAtIndex, line[currentCharIndex + 1]) : charAtIndex; + final int codePointWcWidth = WcWidth.width(codePoint); + final boolean insideCursor = (cursorX == column || (codePointWcWidth == 2 && cursorX == column + 1)); + final boolean insideSelection = column >= selx1 && column <= selx2; + final long style = lineObject.getStyle(column); + + // Check if the measured text width for this code point is not the same as that expected by wcwidth(). + // This could happen for some fonts which are not truly monospace, or for more exotic characters such as + // smileys which android font renders as wide. + // If this is detected, we draw this code point scaled to match what wcwidth() expects. + final float measuredCodePointWidth = (codePoint < asciiMeasures.length) ? asciiMeasures[codePoint] : mTextPaint.measureText(line, + currentCharIndex, charsForCodePoint); + final boolean fontWidthMismatch = Math.abs(measuredCodePointWidth / mFontWidth - codePointWcWidth) > 0.01; + + if (style != lastRunStyle || insideCursor != lastRunInsideCursor || insideSelection != lastRunInsideSelection || fontWidthMismatch || lastRunFontWidthMismatch) { + if (column == 0) { + // Skip first column as there is nothing to draw, just record the current style. + } else { + final int columnWidthSinceLastRun = column - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, + lastRunStartIndex, charsSinceLastRun, measuredWidthForRun, + cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + measuredWidthForRun = 0.f; + lastRunStyle = style; + lastRunInsideCursor = insideCursor; + lastRunInsideSelection = insideSelection; + lastRunStartColumn = column; + lastRunStartIndex = currentCharIndex; + lastRunFontWidthMismatch = fontWidthMismatch; + } + measuredWidthForRun += measuredCodePointWidth; + column += codePointWcWidth; + currentCharIndex += charsForCodePoint; + while (currentCharIndex < charsUsedInLine && WcWidth.width(line, currentCharIndex) <= 0) { + // Eat combining chars so that they are treated as part of the last non-combining code point, + // instead of e.g. being considered inside the cursor in the next run. + currentCharIndex += Character.isHighSurrogate(line[currentCharIndex]) ? 2 : 1; + } + } + + final int columnWidthSinceLastRun = columns - lastRunStartColumn; + final int charsSinceLastRun = currentCharIndex - lastRunStartIndex; + int cursorColor = lastRunInsideCursor ? mEmulator.mColors.mCurrentColors[TextStyle.COLOR_INDEX_CURSOR] : 0; + boolean invertCursorTextColor = false; + if (lastRunInsideCursor && cursorShape == TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK) { + invertCursorTextColor = true; + } + drawTextRun(canvas, line, palette, heightOffset, lastRunStartColumn, columnWidthSinceLastRun, lastRunStartIndex, charsSinceLastRun, + measuredWidthForRun, cursorColor, cursorShape, lastRunStyle, reverseVideo || invertCursorTextColor || lastRunInsideSelection); + } + } + + private void drawTextRun(Canvas canvas, char[] text, int[] palette, float y, int startColumn, int runWidthColumns, + int startCharIndex, int runWidthChars, float mes, int cursor, int cursorStyle, + long textStyle, boolean reverseVideo) { + int foreColor = TextStyle.decodeForeColor(textStyle); + final int effect = TextStyle.decodeEffect(textStyle); + int backColor = TextStyle.decodeBackColor(textStyle); + final boolean bold = (effect & (TextStyle.CHARACTER_ATTRIBUTE_BOLD | TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0; + final boolean underline = (effect & TextStyle.CHARACTER_ATTRIBUTE_UNDERLINE) != 0; + final boolean italic = (effect & TextStyle.CHARACTER_ATTRIBUTE_ITALIC) != 0; + final boolean strikeThrough = (effect & TextStyle.CHARACTER_ATTRIBUTE_STRIKETHROUGH) != 0; + final boolean dim = (effect & TextStyle.CHARACTER_ATTRIBUTE_DIM) != 0; + + if ((foreColor & 0xff000000) != 0xff000000) { + // Let bold have bright colors if applicable (one of the first 8): + if (bold && foreColor >= 0 && foreColor < 8) foreColor += 8; + foreColor = palette[foreColor]; + } + + if ((backColor & 0xff000000) != 0xff000000) { + backColor = palette[backColor]; + } + + // Reverse video here if _one and only one_ of the reverse flags are set: + final boolean reverseVideoHere = reverseVideo ^ (effect & (TextStyle.CHARACTER_ATTRIBUTE_INVERSE)) != 0; + if (reverseVideoHere) { + int tmp = foreColor; + foreColor = backColor; + backColor = tmp; + } + + float left = startColumn * mFontWidth; + float right = left + runWidthColumns * mFontWidth; + + mes = mes / mFontWidth; + boolean savedMatrix = false; + if (Math.abs(mes - runWidthColumns) > 0.01) { + canvas.save(); + canvas.scale(runWidthColumns / mes, 1.f); + left *= mes / runWidthColumns; + right *= mes / runWidthColumns; + savedMatrix = true; + } + + if (backColor != palette[TextStyle.COLOR_INDEX_BACKGROUND]) { + // Only draw non-default background. + mTextPaint.setColor(backColor); + canvas.drawRect(left, y - mFontLineSpacingAndAscent + mFontAscent, right, y, mTextPaint); + } + + if (cursor != 0) { + mTextPaint.setColor(cursor); + float cursorHeight = mFontLineSpacingAndAscent - mFontAscent; + if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_UNDERLINE) cursorHeight /= 4.; + else if (cursorStyle == TerminalEmulator.TERMINAL_CURSOR_STYLE_BAR) right -= ((right - left) * 3) / 4.; + canvas.drawRect(left, y - cursorHeight, right, y, mTextPaint); + } + + if ((effect & TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) == 0) { + if (dim) { + int red = (0xFF & (foreColor >> 16)); + int green = (0xFF & (foreColor >> 8)); + int blue = (0xFF & foreColor); + // Dim color handling used by libvte which in turn took it from xterm + // (https://bug735245.bugzilla-attachments.gnome.org/attachment.cgi?id=284267): + red = red * 2 / 3; + green = green * 2 / 3; + blue = blue * 2 / 3; + foreColor = 0xFF000000 + (red << 16) + (green << 8) + blue; + } + + mTextPaint.setFakeBoldText(bold); + mTextPaint.setUnderlineText(underline); + mTextPaint.setTextSkewX(italic ? -0.35f : 0.f); + mTextPaint.setStrikeThruText(strikeThrough); + mTextPaint.setColor(foreColor); + + // The text alignment is the default Paint.Align.LEFT. + canvas.drawTextRun(text, startCharIndex, runWidthChars, startCharIndex, runWidthChars, left, y - mFontLineSpacingAndAscent, false, mTextPaint); + } + + if (savedMatrix) canvas.restore(); + } + + public float getFontWidth() { + return mFontWidth; + } + + public int getFontLineSpacing() { + return mFontLineSpacing; + } +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalView.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalView.java new file mode 100644 index 0000000..0b3f515 --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalView.java @@ -0,0 +1,1500 @@ +package com.termux.view; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ActionMode; +import android.view.HapticFeedbackConstants; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewTreeObserver; +import android.view.accessibility.AccessibilityManager; +import android.view.autofill.AutofillManager; +import android.view.autofill.AutofillValue; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Scroller; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import com.termux.terminal.KeyHandler; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; +import com.termux.view.textselection.TextSelectionCursorController; + +/** View displaying and interacting with a {@link TerminalSession}. */ +public final class TerminalView extends View { + + /** Log terminal view key and IME events. */ + private static boolean TERMINAL_VIEW_KEY_LOGGING_ENABLED = false; + + /** The currently displayed terminal session, whose emulator is {@link #mEmulator}. */ + public TerminalSession mTermSession; + /** Our terminal emulator whose session is {@link #mTermSession}. */ + public TerminalEmulator mEmulator; + + public TerminalRenderer mRenderer; + + public TerminalViewClient mClient; + + private TextSelectionCursorController mTextSelectionCursorController; + + private Handler mTerminalCursorBlinkerHandler; + private TerminalCursorBlinkerRunnable mTerminalCursorBlinkerRunnable; + private int mTerminalCursorBlinkerRate; + private boolean mCursorInvisibleIgnoreOnce; + public static final int TERMINAL_CURSOR_BLINK_RATE_MIN = 100; + public static final int TERMINAL_CURSOR_BLINK_RATE_MAX = 2000; + + /** The top row of text to display. Ranges from -activeTranscriptRows to 0. */ + int mTopRow; + int[] mDefaultSelectors = new int[]{-1,-1,-1,-1}; + + float mScaleFactor = 1.f; + final GestureAndScaleRecognizer mGestureRecognizer; + + /** Keep track of where mouse touch event started which we report as mouse scroll. */ + private int mMouseScrollStartX = -1, mMouseScrollStartY = -1; + /** Keep track of the time when a touch event leading to sending mouse scroll events started. */ + private long mMouseStartDownTime = -1; + + final Scroller mScroller; + + /** What was left in from scrolling movement. */ + float mScrollRemainder; + + /** If non-zero, this is the last unicode code point received if that was a combining character. */ + int mCombiningAccent; + + /** + * The current AutoFill type returned for {@link View#getAutofillType()} by {@link #getAutofillType()}. + * + * The default is {@link #AUTOFILL_TYPE_NONE} so that AutoFill UI, like toolbar above keyboard + * is not shown automatically, like on Activity starts/View create. This value should be updated + * to required value, like {@link #AUTOFILL_TYPE_TEXT} before calling + * {@link AutofillManager#requestAutofill(View)} so that AutoFill UI shows. The updated value + * set will automatically be restored to {@link #AUTOFILL_TYPE_NONE} in + * {@link #autofill(AutofillValue)} so that AutoFill UI isn't shown anymore by calling + * {@link #resetAutoFill()}. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private int mAutoFillType = AUTOFILL_TYPE_NONE; + + /** + * The current AutoFill type returned for {@link View#getImportantForAutofill()} by + * {@link #getImportantForAutofill()}. + * + * The default is {@link #IMPORTANT_FOR_AUTOFILL_NO} so that view is not considered important + * for AutoFill. This value should be updated to required value, like + * {@link #IMPORTANT_FOR_AUTOFILL_YES} before calling {@link AutofillManager#requestAutofill(View)} + * so that Android and apps consider the view as important for AutoFill to process the request. + * The updated value set will automatically be restored to {@link #IMPORTANT_FOR_AUTOFILL_NO} in + * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private int mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; + + /** + * The current AutoFill hints returned for {@link View#getAutofillHints()} ()} by {@link #getAutofillHints()} ()}. + * + * The default is an empty `string[]`. This value should be updated to required value. The + * updated value set will automatically be restored an empty `string[]` in + * {@link #autofill(AutofillValue)} by calling {@link #resetAutoFill()}. + */ + private String[] mAutoFillHints = new String[0]; + + private final boolean mAccessibilityEnabled; + + /** The {@link KeyEvent} is generated from a virtual keyboard, like manually with the {@link KeyEvent#KeyEvent(int, int)} constructor. */ + public final static int KEY_EVENT_SOURCE_VIRTUAL_KEYBOARD = KeyCharacterMap.VIRTUAL_KEYBOARD; // -1 + + /** The {@link KeyEvent} is generated from a non-physical device, like if 0 value is returned by {@link KeyEvent#getDeviceId()}. */ + public final static int KEY_EVENT_SOURCE_SOFT_KEYBOARD = 0; + + private static final String LOG_TAG = "TerminalView"; + + public TerminalView(Context context, AttributeSet attributes) { // NO_UCD (unused code) + super(context, attributes); + mGestureRecognizer = new GestureAndScaleRecognizer(context, new GestureAndScaleRecognizer.Listener() { + + boolean scrolledWithFinger; + + @Override + public boolean onUp(MotionEvent event) { + mScrollRemainder = 0.0f; + if (mEmulator != null && mEmulator.isMouseTrackingActive() && !event.isFromSource(InputDevice.SOURCE_MOUSE) && !isSelectingText() && !scrolledWithFinger) { + // Quick event processing when mouse tracking is active - do not wait for check of double tapping + // for zooming. + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, true); + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, false); + return true; + } + scrolledWithFinger = false; + return false; + } + + @Override + public boolean onSingleTapUp(MotionEvent event) { + if (mEmulator == null) return true; + + if (isSelectingText()) { + stopTextSelectionMode(); + return true; + } + requestFocus(); + mClient.onSingleTapUp(event); + return true; + } + + @Override + public boolean onScroll(MotionEvent e, float distanceX, float distanceY) { + if (mEmulator == null) return true; + if (mEmulator.isMouseTrackingActive() && e.isFromSource(InputDevice.SOURCE_MOUSE)) { + // If moving with mouse pointer while pressing button, report that instead of scroll. + // This means that we never report moving with button press-events for touch input, + // since we cannot just start sending these events without a starting press event, + // which we do not do for touch input, only mouse in onTouchEvent(). + sendMouseEventCode(e, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + } else { + scrolledWithFinger = true; + distanceY += mScrollRemainder; + int deltaRows = (int) (distanceY / mRenderer.mFontLineSpacing); + mScrollRemainder = distanceY - deltaRows * mRenderer.mFontLineSpacing; + doScroll(e, deltaRows); + } + return true; + } + + @Override + public boolean onScale(float focusX, float focusY, float scale) { + if (mEmulator == null || isSelectingText()) return true; + mScaleFactor *= scale; + mScaleFactor = mClient.onScale(mScaleFactor); + return true; + } + + @Override + public boolean onFling(final MotionEvent e2, float velocityX, float velocityY) { + if (mEmulator == null) return true; + // Do not start scrolling until last fling has been taken care of: + if (!mScroller.isFinished()) return true; + + final boolean mouseTrackingAtStartOfFling = mEmulator.isMouseTrackingActive(); + float SCALE = 0.25f; + if (mouseTrackingAtStartOfFling) { + mScroller.fling(0, 0, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.mRows / 2, mEmulator.mRows / 2); + } else { + mScroller.fling(0, mTopRow, 0, -(int) (velocityY * SCALE), 0, 0, -mEmulator.getScreen().getActiveTranscriptRows(), 0); + } + + post(new Runnable() { + private int mLastY = 0; + + @Override + public void run() { + if (mouseTrackingAtStartOfFling != mEmulator.isMouseTrackingActive()) { + mScroller.abortAnimation(); + return; + } + if (mScroller.isFinished()) return; + boolean more = mScroller.computeScrollOffset(); + int newY = mScroller.getCurrY(); + int diff = mouseTrackingAtStartOfFling ? (newY - mLastY) : (newY - mTopRow); + doScroll(e2, diff); + mLastY = newY; + if (more) post(this); + } + }); + + return true; + } + + @Override + public boolean onDown(float x, float y) { + // Why is true not returned here? + // https://developer.android.com/training/gestures/detector.html#detect-a-subset-of-supported-gestures + // Although setting this to true still does not solve the following errors when long pressing in terminal view text area + // ViewDragHelper: Ignoring pointerId=0 because ACTION_DOWN was not received for this pointer before ACTION_MOVE + // Commenting out the call to mGestureDetector.onTouchEvent(event) in GestureAndScaleRecognizer#onTouchEvent() removes + // the error logging, so issue is related to GestureDetector + return false; + } + + @Override + public boolean onDoubleTap(MotionEvent event) { + // Do not treat is as a single confirmed tap - it may be followed by zoom. + return false; + } + + @Override + public void onLongPress(MotionEvent event) { + if (mGestureRecognizer.isInProgress()) return; + if (mClient.onLongPress(event)) return; + if (!isSelectingText()) { + performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + startTextSelectionMode(event); + } + } + }); + mScroller = new Scroller(context); + AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + mAccessibilityEnabled = am.isEnabled(); + } + + + + /** + * @param client The {@link TerminalViewClient} interface implementation to allow + * for communication between {@link TerminalView} and its client. + */ + public void setTerminalViewClient(TerminalViewClient client) { + this.mClient = client; + } + + /** + * Sets whether terminal view key logging is enabled or not. + * + * @param value The boolean value that defines the state. + */ + public void setIsTerminalViewKeyLoggingEnabled(boolean value) { + TERMINAL_VIEW_KEY_LOGGING_ENABLED = value; + } + + + + /** + * Attach a {@link TerminalSession} to this view. + * + * @param session The {@link TerminalSession} this view will be displaying. + */ + public boolean attachSession(TerminalSession session) { + if (session == mTermSession) return false; + mTopRow = 0; + + mTermSession = session; + mEmulator = null; + mCombiningAccent = 0; + + updateSize(); + + // Wait with enabling the scrollbar until we have a terminal to get scroll position from. + setVerticalScrollBarEnabled(true); + + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + // Ensure that inputType is only set if TerminalView is selected view with the keyboard and + // an alternate view is not selected, like an EditText. This is necessary if an activity is + // initially started with the alternate view or if activity is returned to from another app + // and the alternate view was the one selected the last time. + if (mClient.isTerminalViewSelected()) { + if (mClient.shouldEnforceCharBasedInput()) { + // Some keyboards seems do not reset the internal state on TYPE_NULL. + // Affects mostly Samsung stock keyboards. + // https://github.com/termux/termux-app/issues/686 + // However, this is not a valid value as per AOSP since `InputType.TYPE_CLASS_*` is + // not set and it logs a warning: + // W/InputAttributes: Unexpected input class: inputType=0x00080090 imeOptions=0x02000000 + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/inputmethods/LatinIME/java/src/com/android/inputmethod/latin/InputAttributes.java;l=79 + outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + } else { + // Using InputType.NULL is the most correct input type and avoids issues with other hacks. + // + // Previous keyboard issues: + // https://github.com/termux/termux-packages/issues/25 + // https://github.com/termux/termux-app/issues/87. + // https://github.com/termux/termux-app/issues/126. + // https://github.com/termux/termux-app/issues/137 (japanese chars and TYPE_NULL). + outAttrs.inputType = InputType.TYPE_NULL; + } + } else { + // Corresponds to android:inputType="text" + outAttrs.inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_NORMAL; + } + + // Note that IME_ACTION_NONE cannot be used as that makes it impossible to input newlines using the on-screen + // keyboard on Android TV (see https://github.com/termux/termux-app/issues/221). + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN; + + return new BaseInputConnection(this, true) { + + @Override + public boolean finishComposingText() { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "IME: finishComposingText()"); + super.finishComposingText(); + + sendTextToTerminal(getEditable()); + getEditable().clear(); + return true; + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "IME: commitText(\"" + text + "\", " + newCursorPosition + ")"); + } + super.commitText(text, newCursorPosition); + + if (mEmulator == null) return true; + + Editable content = getEditable(); + sendTextToTerminal(content); + content.clear(); + return true; + } + + @Override + public boolean deleteSurroundingText(int leftLength, int rightLength) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "IME: deleteSurroundingText(" + leftLength + ", " + rightLength + ")"); + } + // The stock Samsung keyboard with 'Auto check spelling' enabled sends leftLength > 1. + KeyEvent deleteKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL); + for (int i = 0; i < leftLength; i++) sendKeyEvent(deleteKey); + return super.deleteSurroundingText(leftLength, rightLength); + } + + void sendTextToTerminal(CharSequence text) { + stopTextSelectionMode(); + final int textLengthInChars = text.length(); + for (int i = 0; i < textLengthInChars; i++) { + char firstChar = text.charAt(i); + int codePoint; + if (Character.isHighSurrogate(firstChar)) { + if (++i < textLengthInChars) { + codePoint = Character.toCodePoint(firstChar, text.charAt(i)); + } else { + // At end of string, with no low surrogate following the high: + codePoint = TerminalEmulator.UNICODE_REPLACEMENT_CHAR; + } + } else { + codePoint = firstChar; + } + + // Check onKeyDown() for details. + if (mClient.readShiftKey()) + codePoint = Character.toUpperCase(codePoint); + + boolean ctrlHeld = false; + if (codePoint <= 31 && codePoint != 27) { + if (codePoint == '\n') { + // The AOSP keyboard and descendants seems to send \n as text when the enter key is pressed, + // instead of a key event like most other keyboard apps. A terminal expects \r for the enter + // key (although when icrnl is enabled this doesn't make a difference - run 'stty -icrnl' to + // check the behaviour). + codePoint = '\r'; + } + + // E.g. penti keyboard for ctrl input. + ctrlHeld = true; + switch (codePoint) { + case 31: + codePoint = '_'; + break; + case 30: + codePoint = '^'; + break; + case 29: + codePoint = ']'; + break; + case 28: + codePoint = '\\'; + break; + default: + codePoint += 96; + break; + } + } + + inputCodePoint(KEY_EVENT_SOURCE_SOFT_KEYBOARD, codePoint, ctrlHeld, false); + } + } + + }; + } + + @Override + protected int computeVerticalScrollRange() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows(); + } + + @Override + protected int computeVerticalScrollExtent() { + return mEmulator == null ? 1 : mEmulator.mRows; + } + + @Override + protected int computeVerticalScrollOffset() { + return mEmulator == null ? 1 : mEmulator.getScreen().getActiveRows() + mTopRow - mEmulator.mRows; + } + + public void onScreenUpdated() { + onScreenUpdated(false); + } + + public void onScreenUpdated(boolean skipScrolling) { + if (mEmulator == null) return; + + int rowsInHistory = mEmulator.getScreen().getActiveTranscriptRows(); + if (mTopRow < -rowsInHistory) mTopRow = -rowsInHistory; + + if (isSelectingText() || mEmulator.isAutoScrollDisabled()) { + + // Do not scroll when selecting text. + int rowShift = mEmulator.getScrollCounter(); + if (-mTopRow + rowShift > rowsInHistory) { + // .. unless we're hitting the end of history transcript, in which + // case we abort text selection and scroll to end. + if (isSelectingText()) + stopTextSelectionMode(); + + if (mEmulator.isAutoScrollDisabled()) { + mTopRow = -rowsInHistory; + skipScrolling = true; + } + } else { + skipScrolling = true; + mTopRow -= rowShift; + decrementYTextSelectionCursors(rowShift); + } + } + + if (!skipScrolling && mTopRow != 0) { + // Scroll down if not already there. + if (mTopRow < -3) { + // Awaken scroll bars only if scrolling a noticeable amount + // - we do not want visible scroll bars during normal typing + // of one row at a time. + awakenScrollBars(); + } + mTopRow = 0; + } + + mEmulator.clearScrollCounter(); + + invalidate(); + if (mAccessibilityEnabled) setContentDescription(getText()); + } + + /** This must be called by the hosting activity in {@link Activity#onContextMenuClosed(Menu)} + * when context menu for the {@link TerminalView} is started by + * {@link TextSelectionCursorController#ACTION_MORE} is closed. */ + public void onContextMenuClosed(Menu menu) { + // Unset the stored text since it shouldn't be used anymore and should be cleared from memory + unsetStoredSelectedText(); + } + + /** + * Sets the text size, which in turn sets the number of rows and columns. + * + * @param textSize the new font size, in density-independent pixels. + */ + public void setTextSize(int textSize) { + mRenderer = new TerminalRenderer(textSize, mRenderer == null ? Typeface.MONOSPACE : mRenderer.mTypeface); + updateSize(); + } + + public void setTypeface(Typeface newTypeface) { + mRenderer = new TerminalRenderer(mRenderer.mTextSize, newTypeface); + updateSize(); + invalidate(); + } + + @Override + public boolean onCheckIsTextEditor() { + return true; + } + + @Override + public boolean isOpaque() { + return true; + } + + /** + * Get the zero indexed column and row of the terminal view for the + * position of the event. + * + * @param event The event with the position to get the column and row for. + * @param relativeToScroll If true the column number will take the scroll + * position into account. E.g. if scrolled 3 lines up and the event + * position is in the top left, column will be -3 if relativeToScroll is + * true and 0 if relativeToScroll is false. + * @return Array with the column and row. + */ + public int[] getColumnAndRow(MotionEvent event, boolean relativeToScroll) { + int column = (int) (event.getX() / mRenderer.mFontWidth); + int row = (int) ((event.getY() - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + if (relativeToScroll) { + row += mTopRow; + } + return new int[] { column, row }; + } + + /** Send a single mouse event code to the terminal. */ + void sendMouseEventCode(MotionEvent e, int button, boolean pressed) { + int[] columnAndRow = getColumnAndRow(e, false); + int x = columnAndRow[0] + 1; + int y = columnAndRow[1] + 1; + if (pressed && (button == TerminalEmulator.MOUSE_WHEELDOWN_BUTTON || button == TerminalEmulator.MOUSE_WHEELUP_BUTTON)) { + if (mMouseStartDownTime == e.getDownTime()) { + x = mMouseScrollStartX; + y = mMouseScrollStartY; + } else { + mMouseStartDownTime = e.getDownTime(); + mMouseScrollStartX = x; + mMouseScrollStartY = y; + } + } + mEmulator.sendMouseEvent(button, x, y, pressed); + } + + /** Perform a scroll, either from dragging the screen or by scrolling a mouse wheel. */ + void doScroll(MotionEvent event, int rowsDown) { + boolean up = rowsDown < 0; + int amount = Math.abs(rowsDown); + for (int i = 0; i < amount; i++) { + if (mEmulator.isMouseTrackingActive()) { + sendMouseEventCode(event, up ? TerminalEmulator.MOUSE_WHEELUP_BUTTON : TerminalEmulator.MOUSE_WHEELDOWN_BUTTON, true); + } else if (mEmulator.isAlternateBufferActive()) { + // Send up and down key events for scrolling, which is what some terminals do to make scroll work in + // e.g. less, which shifts to the alt screen without mouse handling. + handleKeyCode(up ? KeyEvent.KEYCODE_DPAD_UP : KeyEvent.KEYCODE_DPAD_DOWN, 0); + } else { + mTopRow = Math.min(0, Math.max(-(mEmulator.getScreen().getActiveTranscriptRows()), mTopRow + (up ? -1 : 1))); + if (!awakenScrollBars()) invalidate(); + } + } + } + + /** Overriding {@link View#onGenericMotionEvent(MotionEvent)}. */ + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + if (mEmulator != null && event.isFromSource(InputDevice.SOURCE_MOUSE) && event.getAction() == MotionEvent.ACTION_SCROLL) { + // Handle mouse wheel scrolling. + boolean up = event.getAxisValue(MotionEvent.AXIS_VSCROLL) > 0.0f; + doScroll(event, up ? -3 : 3); + return true; + } + return false; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + @TargetApi(23) + public boolean onTouchEvent(MotionEvent event) { + if (mEmulator == null) return true; + final int action = event.getAction(); + + if (isSelectingText()) { + updateFloatingToolbarVisibility(event); + mGestureRecognizer.onTouchEvent(event); + return true; + } else if (event.isFromSource(InputDevice.SOURCE_MOUSE)) { + if (event.isButtonPressed(MotionEvent.BUTTON_SECONDARY)) { + if (action == MotionEvent.ACTION_DOWN) showContextMenu(); + return true; + } else if (event.isButtonPressed(MotionEvent.BUTTON_TERTIARY)) { + ClipboardManager clipboardManager = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clipData = clipboardManager.getPrimaryClip(); + if (clipData != null) { + ClipData.Item clipItem = clipData.getItemAt(0); + if (clipItem != null) { + CharSequence text = clipItem.coerceToText(getContext()); + if (!TextUtils.isEmpty(text)) mEmulator.paste(text.toString()); + } + } + } else if (mEmulator.isMouseTrackingActive()) { // BUTTON_PRIMARY. + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_UP: + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON, event.getAction() == MotionEvent.ACTION_DOWN); + break; + case MotionEvent.ACTION_MOVE: + sendMouseEventCode(event, TerminalEmulator.MOUSE_LEFT_BUTTON_MOVED, true); + break; + } + } + } + + mGestureRecognizer.onTouchEvent(event); + return true; + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyPreIme(keyCode=" + keyCode + ", event=" + event + ")"); + if (keyCode == KeyEvent.KEYCODE_BACK) { + cancelRequestAutoFill(); + if (isSelectingText()) { + stopTextSelectionMode(); + return true; + } else if (mClient.shouldBackButtonBeMappedToEscape()) { + // Intercept back button to treat it as escape: + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + return onKeyDown(keyCode, event); + case KeyEvent.ACTION_UP: + return onKeyUp(keyCode, event); + } + } + } else if (mClient.shouldUseCtrlSpaceWorkaround() && + keyCode == KeyEvent.KEYCODE_SPACE && event.isCtrlPressed()) { + /* ctrl+space does not work on some ROMs without this workaround. + However, this breaks it on devices where it works out of the box. */ + return onKeyDown(keyCode, event); + } + return super.onKeyPreIme(keyCode, event); + } + + /** + * Key presses in software keyboards will generally NOT trigger this listener, although some + * may elect to do so in some situations. Do not rely on this to catch software key presses. + * Gboard calls this when shouldEnforceCharBasedInput() is disabled (InputType.TYPE_NULL) instead + * of calling commitText(), with deviceId=-1. However, Hacker's Keyboard, OpenBoard, LG Keyboard + * call commitText(). + * + * This function may also be called directly without android calling it, like by + * `TerminalExtraKeys` which generates a KeyEvent manually which uses {@link KeyCharacterMap#VIRTUAL_KEYBOARD} + * as the device (deviceId=-1), as does Gboard. That would normally use mappings defined in + * `/system/usr/keychars/Virtual.kcm`. You can run `dumpsys input` to find the `KeyCharacterMapFile` + * used by virtual keyboard or hardware keyboard. Note that virtual keyboard device is not the + * same as software keyboard, like Gboard, etc. Its a fake device used for generating events and + * for testing. + * + * We handle shift key in `commitText()` to convert codepoint to uppercase case there with a + * call to {@link Character#toUpperCase(int)}, but here we instead rely on getUnicodeChar() for + * conversion of keyCode, for both hardware keyboard shift key (via effectiveMetaState) and + * `mClient.readShiftKey()`, based on value in kcm files. + * This may result in different behaviour depending on keyboard and android kcm files set for the + * InputDevice for the event passed to this function. This will likely be an issue for non-english + * languages since `Virtual.kcm` in english only by default or at least in AOSP. For both hardware + * shift key (via effectiveMetaState) and `mClient.readShiftKey()`, `getUnicodeChar()` is used + * for shift specific behaviour which usually is to uppercase. + * + * For fn key on hardware keyboard, android checks kcm files for hardware keyboards, which is + * `Generic.kcm` by default, unless a vendor specific one is defined. The event passed will have + * {@link KeyEvent#META_FUNCTION_ON} set. If the kcm file only defines a single character or unicode + * code point `\\uxxxx`, then only one event is passed with that value. However, if kcm defines + * a `fallback` key for fn or others, like `key DPAD_UP { ... fn: fallback PAGE_UP }`, then + * android will first pass an event with original key `DPAD_UP` and {@link KeyEvent#META_FUNCTION_ON} + * set. But this function will not consume it and android will pass another event with `PAGE_UP` + * and {@link KeyEvent#META_FUNCTION_ON} not set, which will be consumed. + * + * Now there are some other issues as well, firstly ctrl and alt flags are not passed to + * `getUnicodeChar()`, so modified key values in kcm are not used. Secondly, if the kcm file + * for other modifiers like shift or fn define a non-alphabet, like { fn: '\u0015' } to act as + * DPAD_LEFT, the `getUnicodeChar()` will correctly return `21` as the code point but action will + * not happen because the `handleKeyCode()` function that transforms DPAD_LEFT to `\033[D` + * escape sequence for the terminal to perform the left action would not be called since its + * called before `getUnicodeChar()` and terminal will instead get `21 0x15 Negative Acknowledgement`. + * The solution to such issues is calling `getUnicodeChar()` before the call to `handleKeyCode()` + * if user has defined a custom kcm file, like done in POC mentioned in #2237. Note that + * Hacker's Keyboard calls `commitText()` so don't test fn/shift with it for this function. + * https://github.com/termux/termux-app/pull/2237 + * https://github.com/agnostic-apollo/termux-app/blob/terminal-code-point-custom-mapping/terminal-view/src/main/java/com/termux/view/TerminalView.java + * + * Key Character Map (kcm) and Key Layout (kl) files info: + * https://source.android.com/devices/input/key-character-map-files + * https://source.android.com/devices/input/key-layout-files + * https://source.android.com/devices/input/keyboard-devices + * AOSP kcm and kl files: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/data/keyboards + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/packages/InputDevices/res/raw + * + * KeyCodes: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java + * https://cs.android.com/android/platform/superproject/+/master:frameworks/native/include/android/keycodes.h + * + * `dumpsys input`: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1917 + * + * Loading of keymap: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/services/inputflinger/reader/EventHub.cpp;l=1644 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/Keyboard.cpp;l=41 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/InputDevice.cpp + * OVERLAY keymaps for hardware keyboards may be combined as well: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=165 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=831 + * + * Parse kcm file: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=727 + * Parse key value: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=981 + * + * `KeyEvent.getUnicodeChar()` + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/view/KeyEvent.java;l=2716 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/KeyCharacterMap.java;l=368 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/jni/android_view_KeyCharacterMap.cpp;l=117 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/native/libs/input/KeyCharacterMap.cpp;l=231 + * + * Keyboard layouts advertised by applications, like for hardware keyboards via #ACTION_QUERY_KEYBOARD_LAYOUTS + * Config is stored in `/data/system/input-manager-state.xml` + * https://github.com/ris58h/custom-keyboard-layout + * Loading from apps: + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1221 + * Set: + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=89 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/hardware/input/InputManager.java;l=543 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:packages/apps/Settings/src/com/android/settings/inputmethod/KeyboardLayoutDialogFragment.java;l=167 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=1385 + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/PersistentDataStore.java + * Get overlay keyboard layout + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/services/core/java/com/android/server/input/InputManagerService.java;l=2158 + * https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/services/core/jni/com_android_server_input_InputManagerService.cpp;l=616 + */ + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyDown(keyCode=" + keyCode + ", isSystem()=" + event.isSystem() + ", event=" + event + ")"); + if (mEmulator == null) return true; + if (isSelectingText()) { + stopTextSelectionMode(); + } + + if (mClient.onKeyDown(keyCode, event, mTermSession)) { + invalidate(); + return true; + } else if (event.isSystem() && (!mClient.shouldBackButtonBeMappedToEscape() || keyCode != KeyEvent.KEYCODE_BACK)) { + return super.onKeyDown(keyCode, event); + } else if (event.getAction() == KeyEvent.ACTION_MULTIPLE && keyCode == KeyEvent.KEYCODE_UNKNOWN) { + mTermSession.write(event.getCharacters()); + return true; + } else if (keyCode == KeyEvent.KEYCODE_LANGUAGE_SWITCH) { + return super.onKeyDown(keyCode, event); + } + + final int metaState = event.getMetaState(); + final boolean controlDown = event.isCtrlPressed() || mClient.readControlKey(); + final boolean leftAltDown = (metaState & KeyEvent.META_ALT_LEFT_ON) != 0 || mClient.readAltKey(); + final boolean shiftDown = event.isShiftPressed() || mClient.readShiftKey(); + final boolean rightAltDownFromEvent = (metaState & KeyEvent.META_ALT_RIGHT_ON) != 0; + + int keyMod = 0; + if (controlDown) keyMod |= KeyHandler.KEYMOD_CTRL; + if (event.isAltPressed() || leftAltDown) keyMod |= KeyHandler.KEYMOD_ALT; + if (shiftDown) keyMod |= KeyHandler.KEYMOD_SHIFT; + if (event.isNumLockOn()) keyMod |= KeyHandler.KEYMOD_NUM_LOCK; + // https://github.com/termux/termux-app/issues/731 + if (!event.isFunctionPressed() && handleKeyCode(keyCode, keyMod)) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) mClient.logInfo(LOG_TAG, "handleKeyCode() took key event"); + return true; + } + + // Clear Ctrl since we handle that ourselves: + int bitsToClear = KeyEvent.META_CTRL_MASK; + if (rightAltDownFromEvent) { + // Let right Alt/Alt Gr be used to compose characters. + } else { + // Use left alt to send to terminal (e.g. Left Alt+B to jump back a word), so remove: + bitsToClear |= KeyEvent.META_ALT_ON | KeyEvent.META_ALT_LEFT_ON; + } + int effectiveMetaState = event.getMetaState() & ~bitsToClear; + + if (shiftDown) effectiveMetaState |= KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON; + if (mClient.readFnKey()) effectiveMetaState |= KeyEvent.META_FUNCTION_ON; + + int result = event.getUnicodeChar(effectiveMetaState); + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "KeyEvent#getUnicodeChar(" + effectiveMetaState + ") returned: " + result); + if (result == 0) { + return false; + } + + int oldCombiningAccent = mCombiningAccent; + if ((result & KeyCharacterMap.COMBINING_ACCENT) != 0) { + // If entered combining accent previously, write it out: + if (mCombiningAccent != 0) + inputCodePoint(event.getDeviceId(), mCombiningAccent, controlDown, leftAltDown); + mCombiningAccent = result & KeyCharacterMap.COMBINING_ACCENT_MASK; + } else { + if (mCombiningAccent != 0) { + int combinedChar = KeyCharacterMap.getDeadChar(mCombiningAccent, result); + if (combinedChar > 0) result = combinedChar; + mCombiningAccent = 0; + } + inputCodePoint(event.getDeviceId(), result, controlDown, leftAltDown); + } + + if (mCombiningAccent != oldCombiningAccent) invalidate(); + + return true; + } + + public void inputCodePoint(int eventSource, int codePoint, boolean controlDownFromEvent, boolean leftAltDownFromEvent) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) { + mClient.logInfo(LOG_TAG, "inputCodePoint(eventSource=" + eventSource + ", codePoint=" + codePoint + ", controlDownFromEvent=" + controlDownFromEvent + ", leftAltDownFromEvent=" + + leftAltDownFromEvent + ")"); + } + + if (mTermSession == null) return; + + // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys + if (mEmulator != null) + mEmulator.setCursorBlinkState(true); + + final boolean controlDown = controlDownFromEvent || mClient.readControlKey(); + final boolean altDown = leftAltDownFromEvent || mClient.readAltKey(); + + if (mClient.onCodePoint(codePoint, controlDown, mTermSession)) return; + + if (controlDown) { + if (codePoint >= 'a' && codePoint <= 'z') { + codePoint = codePoint - 'a' + 1; + } else if (codePoint >= 'A' && codePoint <= 'Z') { + codePoint = codePoint - 'A' + 1; + } else if (codePoint == ' ' || codePoint == '2') { + codePoint = 0; + } else if (codePoint == '[' || codePoint == '3') { + codePoint = 27; // ^[ (Esc) + } else if (codePoint == '\\' || codePoint == '4') { + codePoint = 28; + } else if (codePoint == ']' || codePoint == '5') { + codePoint = 29; + } else if (codePoint == '^' || codePoint == '6') { + codePoint = 30; // control-^ + } else if (codePoint == '_' || codePoint == '7' || codePoint == '/') { + // "Ctrl-/ sends 0x1f which is equivalent of Ctrl-_ since the days of VT102" + // - http://apple.stackexchange.com/questions/24261/how-do-i-send-c-that-is-control-slash-to-the-terminal + codePoint = 31; + } else if (codePoint == '8') { + codePoint = 127; // DEL + } + } + + if (codePoint > -1) { + // If not virtual or soft keyboard. + if (eventSource > KEY_EVENT_SOURCE_SOFT_KEYBOARD) { + // Work around bluetooth keyboards sending funny unicode characters instead + // of the more normal ones from ASCII that terminal programs expect - the + // desire to input the original characters should be low. + switch (codePoint) { + case 0x02DC: // SMALL TILDE. + codePoint = 0x007E; // TILDE (~). + break; + case 0x02CB: // MODIFIER LETTER GRAVE ACCENT. + codePoint = 0x0060; // GRAVE ACCENT (`). + break; + case 0x02C6: // MODIFIER LETTER CIRCUMFLEX ACCENT. + codePoint = 0x005E; // CIRCUMFLEX ACCENT (^). + break; + } + } + + // If left alt, send escape before the code point to make e.g. Alt+B and Alt+F work in readline: + mTermSession.writeCodePoint(altDown, codePoint); + } + } + + /** Input the specified keyCode if applicable and return if the input was consumed. */ + public boolean handleKeyCode(int keyCode, int keyMod) { + // Ensure cursor is shown when a key is pressed down like long hold on (arrow) keys + if (mEmulator != null) + mEmulator.setCursorBlinkState(true); + + if (handleKeyCodeAction(keyCode, keyMod)) + return true; + + TerminalEmulator term = mTermSession.getEmulator(); + String code = KeyHandler.getCode(keyCode, keyMod, term.isCursorKeysApplicationMode(), term.isKeypadApplicationMode()); + if (code == null) return false; + mTermSession.write(code); + return true; + } + + public boolean handleKeyCodeAction(int keyCode, int keyMod) { + boolean shiftDown = (keyMod & KeyHandler.KEYMOD_SHIFT) != 0; + + switch (keyCode) { + case KeyEvent.KEYCODE_PAGE_UP: + case KeyEvent.KEYCODE_PAGE_DOWN: + // shift+page_up and shift+page_down should scroll scrollback history instead of + // scrolling command history or changing pages + if (shiftDown) { + long time = SystemClock.uptimeMillis(); + MotionEvent motionEvent = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 0, 0, 0); + doScroll(motionEvent, keyCode == KeyEvent.KEYCODE_PAGE_UP ? -mEmulator.mRows : mEmulator.mRows); + motionEvent.recycle(); + return true; + } + } + + return false; + } + + /** + * Called when a key is released in the view. + * + * @param keyCode The keycode of the key which was released. + * @param event A {@link KeyEvent} describing the event. + * @return Whether the event was handled. + */ + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logInfo(LOG_TAG, "onKeyUp(keyCode=" + keyCode + ", event=" + event + ")"); + + // Do not return for KEYCODE_BACK and send it to the client since user may be trying + // to exit the activity. + if (mEmulator == null && keyCode != KeyEvent.KEYCODE_BACK) return true; + + if (mClient.onKeyUp(keyCode, event)) { + invalidate(); + return true; + } else if (event.isSystem()) { + // Let system key events through. + return super.onKeyUp(keyCode, event); + } + + return true; + } + + /** + * This is called during layout when the size of this view has changed. If you were just added to the view + * hierarchy, you're called with the old values of 0. + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + updateSize(); + } + + /** Check if the terminal size in rows and columns should be updated. */ + public void updateSize() { + int viewWidth = getWidth(); + int viewHeight = getHeight(); + if (viewWidth == 0 || viewHeight == 0 || mTermSession == null) return; + + // Set to 80 and 24 if you want to enable vttest. + int newColumns = Math.max(4, (int) (viewWidth / mRenderer.mFontWidth)); + int newRows = Math.max(4, (viewHeight - mRenderer.mFontLineSpacingAndAscent) / mRenderer.mFontLineSpacing); + + if (mEmulator == null || (newColumns != mEmulator.mColumns || newRows != mEmulator.mRows)) { + mTermSession.updateSize(newColumns, newRows, (int) mRenderer.getFontWidth(), mRenderer.getFontLineSpacing()); + mEmulator = mTermSession.getEmulator(); + mClient.onEmulatorSet(); + + // Update mTerminalCursorBlinkerRunnable inner class mEmulator on session change + if (mTerminalCursorBlinkerRunnable != null) + mTerminalCursorBlinkerRunnable.setEmulator(mEmulator); + + mTopRow = 0; + scrollTo(0, 0); + invalidate(); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (mEmulator == null) { + canvas.drawColor(0XFF000000); + } else { + // render the terminal view and highlight any selected text + int[] sel = mDefaultSelectors; + if (mTextSelectionCursorController != null) { + mTextSelectionCursorController.getSelectors(sel); + } + + mRenderer.render(mEmulator, canvas, mTopRow, sel[0], sel[1], sel[2], sel[3]); + + // render the text selection handles + renderTextSelection(); + } + } + + public TerminalSession getCurrentSession() { + return mTermSession; + } + + private CharSequence getText() { + return mEmulator.getScreen().getSelectedText(0, mTopRow, mEmulator.mColumns, mTopRow + mEmulator.mRows); + } + + public int getCursorX(float x) { + return (int) (x / mRenderer.mFontWidth); + } + + public int getCursorY(float y) { + return (int) (((y - 40) / mRenderer.mFontLineSpacing) + mTopRow); + } + + public int getPointX(int cx) { + if (cx > mEmulator.mColumns) { + cx = mEmulator.mColumns; + } + return Math.round(cx * mRenderer.mFontWidth); + } + + public int getPointY(int cy) { + return Math.round((cy - mTopRow) * mRenderer.mFontLineSpacing); + } + + public int getTopRow() { + return mTopRow; + } + + public void setTopRow(int mTopRow) { + this.mTopRow = mTopRow; + } + + + + /** + * Define functions required for AutoFill API + */ + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public void autofill(AutofillValue value) { + if (value.isText()) { + mTermSession.write(value.getTextValue().toString()); + } + + resetAutoFill(); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public int getAutofillType() { + return mAutoFillType; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public String[] getAutofillHints() { + return mAutoFillHints; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public AutofillValue getAutofillValue() { + return AutofillValue.forText(""); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public int getImportantForAutofill() { + return mAutoFillImportance; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private synchronized void resetAutoFill() { + // Restore none type so that AutoFill UI isn't shown anymore. + mAutoFillType = AUTOFILL_TYPE_NONE; + mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_NO; + mAutoFillHints = new String[0]; + } + + public AutofillManager getAutoFillManagerService() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null; + + try { + Context context = getContext(); + if (context == null) return null; + return context.getSystemService(AutofillManager.class); + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to get AutofillManager service", e); + return null; + } + } + + public boolean isAutoFillEnabled() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + return autofillManager != null && autofillManager.isEnabled(); + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to check if Autofill is enabled", e); + return false; + } + } + + public synchronized void requestAutoFillUsername() { + requestAutoFill( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_USERNAME} : + null); + } + + public synchronized void requestAutoFillPassword() { + requestAutoFill( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new String[]{View.AUTOFILL_HINT_PASSWORD} : + null); + } + + public synchronized void requestAutoFill(String[] autoFillHints) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (autoFillHints == null || autoFillHints.length < 1) return; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + if (autofillManager != null && autofillManager.isEnabled()) { + // Update type that will be returned by `getAutofillType()` so that AutoFill UI is shown. + mAutoFillType = AUTOFILL_TYPE_TEXT; + // Update importance that will be returned by `getImportantForAutofill()` so that + // AutoFill considers the view as important. + mAutoFillImportance = IMPORTANT_FOR_AUTOFILL_YES; + // Update hints that will be returned by `getAutofillHints()` for which to show AutoFill UI. + mAutoFillHints = autoFillHints; + autofillManager.requestAutofill(this); + } + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to request Autofill", e); + } + } + + public synchronized void cancelRequestAutoFill() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (mAutoFillType == AUTOFILL_TYPE_NONE) return; + + try { + AutofillManager autofillManager = getAutoFillManagerService(); + if (autofillManager != null && autofillManager.isEnabled()) { + resetAutoFill(); + autofillManager.cancel(); + } + } catch (Exception e) { + mClient.logStackTraceWithMessage(LOG_TAG, "Failed to cancel Autofill request", e); + } + } + + + + + + /** + * Set terminal cursor blinker rate. It must be between {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} + * and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}, otherwise it will be disabled. + * + * The {@link #setTerminalCursorBlinkerState(boolean, boolean)} must be called after this + * for changes to take effect if not disabling. + * + * @param blinkRate The value to set. + * @return Returns {@code true} if setting blinker rate was successfully set, otherwise [@code false}. + */ + public synchronized boolean setTerminalCursorBlinkerRate(int blinkRate) { + boolean result; + + // If cursor blinking rate is not valid + if (blinkRate != 0 && (blinkRate < TERMINAL_CURSOR_BLINK_RATE_MIN || blinkRate > TERMINAL_CURSOR_BLINK_RATE_MAX)) { + mClient.logError(LOG_TAG, "The cursor blink rate must be in between " + TERMINAL_CURSOR_BLINK_RATE_MIN + "-" + TERMINAL_CURSOR_BLINK_RATE_MAX + ": " + blinkRate); + mTerminalCursorBlinkerRate = 0; + result = false; + } else { + mClient.logVerbose(LOG_TAG, "Setting cursor blinker rate to " + blinkRate); + mTerminalCursorBlinkerRate = blinkRate; + result = true; + } + + if (mTerminalCursorBlinkerRate == 0) { + mClient.logVerbose(LOG_TAG, "Cursor blinker disabled"); + stopTerminalCursorBlinker(); + } + + return result; + } + + /** + * Sets whether cursor blinker should be started or stopped. Cursor blinker will only be + * started if {@link #mTerminalCursorBlinkerRate} does not equal 0 and is between + * {@link #TERMINAL_CURSOR_BLINK_RATE_MIN} and {@link #TERMINAL_CURSOR_BLINK_RATE_MAX}. + * + * This should be called when the view holding this activity is resumed or stopped so that + * cursor blinker does not run when activity is not visible. If you call this on onResume() + * to start cursor blinking, then ensure that {@link #mEmulator} is set, otherwise wait for the + * {@link TerminalViewClient#onEmulatorSet()} event after calling {@link #attachSession(TerminalSession)} + * for the first session added in the activity since blinking will not start if {@link #mEmulator} + * is not set, like if activity is started again after exiting it with double back press. Do not + * call this directly after {@link #attachSession(TerminalSession)} since {@link #updateSize()} + * may return without setting {@link #mEmulator} since width/height may be 0. Its called again in + * {@link #onSizeChanged(int, int, int, int)}. Calling on onResume() if emulator is already set + * is necessary, since onEmulatorSet() may not be called after activity is started after device + * display timeout with double tap and not power button. + * + * It should also be called on the + * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)} + * callback when cursor is enabled or disabled so that blinker is disabled if cursor is not + * to be shown. It should also be checked if activity is visible if blinker is to be started + * before calling this. + * + * It should also be called after terminal is reset with {@link TerminalSession#reset()} in case + * cursor blinker was disabled before reset due to call to + * {@link com.termux.terminal.TerminalSessionClient#onTerminalCursorStateChange(boolean)}. + * + * How cursor blinker starting works is by registering a {@link Runnable} with the looper of + * the main thread of the app which when run, toggles the cursor blinking state and re-registers + * itself to be called with the delay set by {@link #mTerminalCursorBlinkerRate}. When cursor + * blinking needs to be disabled, we just cancel any callbacks registered. We don't run our own + * "thread" and let the thread for the main looper do the work for us, whose usage is also + * required to update the UI, since it also handles other calls to update the UI as well based + * on a queue. + * + * Note that when moving cursor in text editors like nano, the cursor state is quickly + * toggled `-> off -> on`, which would call this very quickly sequentially. So that if cursor + * is moved 2 or more times quickly, like long hold on arrow keys, it would trigger + * `-> off -> on -> off -> on -> ...`, and the "on" callback at index 2 is automatically + * cancelled by next "off" callback at index 3 before getting a chance to be run. For this case + * we log only if {@link #TERMINAL_VIEW_KEY_LOGGING_ENABLED} is enabled, otherwise would clutter + * the log. We don't start the blinking with a delay to immediately show cursor in case it was + * previously not visible. + * + * @param start If cursor blinker should be started or stopped. + * @param startOnlyIfCursorEnabled If set to {@code true}, then it will also be checked if the + * cursor is even enabled by {@link TerminalEmulator} before + * starting the cursor blinker. + */ + public synchronized void setTerminalCursorBlinkerState(boolean start, boolean startOnlyIfCursorEnabled) { + // Stop any existing cursor blinker callbacks + stopTerminalCursorBlinker(); + + if (mEmulator == null) return; + + mEmulator.setCursorBlinkingEnabled(false); + + if (start) { + // If cursor blinker is not enabled or is not valid + if (mTerminalCursorBlinkerRate < TERMINAL_CURSOR_BLINK_RATE_MIN || mTerminalCursorBlinkerRate > TERMINAL_CURSOR_BLINK_RATE_MAX) + return; + // If cursor blinder is to be started only if cursor is enabled + else if (startOnlyIfCursorEnabled && ! mEmulator.isCursorEnabled()) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Ignoring call to start cursor blinker since cursor is not enabled"); + return; + } + + // Start cursor blinker runnable + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Starting cursor blinker with the blink rate " + mTerminalCursorBlinkerRate); + if (mTerminalCursorBlinkerHandler == null) + mTerminalCursorBlinkerHandler = new Handler(Looper.getMainLooper()); + mTerminalCursorBlinkerRunnable = new TerminalCursorBlinkerRunnable(mEmulator, mTerminalCursorBlinkerRate); + mEmulator.setCursorBlinkingEnabled(true); + mTerminalCursorBlinkerRunnable.run(); + } + } + + /** + * Cancel the terminal cursor blinker callbacks + */ + private void stopTerminalCursorBlinker() { + if (mTerminalCursorBlinkerHandler != null && mTerminalCursorBlinkerRunnable != null) { + if (TERMINAL_VIEW_KEY_LOGGING_ENABLED) + mClient.logVerbose(LOG_TAG, "Stopping cursor blinker"); + mTerminalCursorBlinkerHandler.removeCallbacks(mTerminalCursorBlinkerRunnable); + } + } + + private class TerminalCursorBlinkerRunnable implements Runnable { + + private TerminalEmulator mEmulator; + private final int mBlinkRate; + + // Initialize with false so that initial blink state is visible after toggling + boolean mCursorVisible = false; + + public TerminalCursorBlinkerRunnable(TerminalEmulator emulator, int blinkRate) { + mEmulator = emulator; + mBlinkRate = blinkRate; + } + + public void setEmulator(TerminalEmulator emulator) { + mEmulator = emulator; + } + + public void run() { + try { + if (mEmulator != null) { + // Toggle the blink state and then invalidate() the view so + // that onDraw() is called, which then calls TerminalRenderer.render() + // which checks with TerminalEmulator.shouldCursorBeVisible() to decide whether + // to draw the cursor or not + mCursorVisible = !mCursorVisible; + //mClient.logVerbose(LOG_TAG, "Toggling cursor blink state to " + mCursorVisible); + mEmulator.setCursorBlinkState(mCursorVisible); + invalidate(); + } + } finally { + // Recall the Runnable after mBlinkRate milliseconds to toggle the blink state + mTerminalCursorBlinkerHandler.postDelayed(this, mBlinkRate); + } + } + } + + + + /** + * Define functions required for text selection and its handles. + */ + TextSelectionCursorController getTextSelectionCursorController() { + if (mTextSelectionCursorController == null) { + mTextSelectionCursorController = new TextSelectionCursorController(this); + + final ViewTreeObserver observer = getViewTreeObserver(); + if (observer != null) { + observer.addOnTouchModeChangeListener(mTextSelectionCursorController); + } + } + + return mTextSelectionCursorController; + } + + private void showTextSelectionCursors(MotionEvent event) { + getTextSelectionCursorController().show(event); + } + + private boolean hideTextSelectionCursors() { + return getTextSelectionCursorController().hide(); + } + + private void renderTextSelection() { + if (mTextSelectionCursorController != null) + mTextSelectionCursorController.render(); + } + + public boolean isSelectingText() { + if (mTextSelectionCursorController != null) { + return mTextSelectionCursorController.isActive(); + } else { + return false; + } + } + + /** Get the currently selected text if selecting. */ + public String getSelectedText() { + if (isSelectingText() && mTextSelectionCursorController != null) + return mTextSelectionCursorController.getSelectedText(); + else + return null; + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mTextSelectionCursorController != null ? mTextSelectionCursorController.getStoredSelectedText() : null; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + if (mTextSelectionCursorController != null) mTextSelectionCursorController.unsetStoredSelectedText(); + } + + private ActionMode getTextSelectionActionMode() { + if (mTextSelectionCursorController != null) { + return mTextSelectionCursorController.getActionMode(); + } else { + return null; + } + } + + public void startTextSelectionMode(MotionEvent event) { + if (!requestFocus()) { + return; + } + + showTextSelectionCursors(event); + mClient.copyModeChanged(isSelectingText()); + + invalidate(); + } + + public void stopTextSelectionMode() { + if (hideTextSelectionCursors()) { + mClient.copyModeChanged(isSelectingText()); + invalidate(); + } + } + + private void decrementYTextSelectionCursors(int decrement) { + if (mTextSelectionCursorController != null) { + mTextSelectionCursorController.decrementYTextSelectionCursors(decrement); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (mTextSelectionCursorController != null) { + getViewTreeObserver().addOnTouchModeChangeListener(mTextSelectionCursorController); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (mTextSelectionCursorController != null) { + // Might solve the following exception + // android.view.WindowLeaked: Activity com.termux.app.TermuxActivity has leaked window android.widget.PopupWindow + stopTextSelectionMode(); + + getViewTreeObserver().removeOnTouchModeChangeListener(mTextSelectionCursorController); + mTextSelectionCursorController.onDetached(); + } + } + + + + /** + * Define functions required for long hold toolbar. + */ + private final Runnable mShowFloatingToolbar = new Runnable() { + @RequiresApi(api = Build.VERSION_CODES.M) + @Override + public void run() { + if (getTextSelectionActionMode() != null) { + getTextSelectionActionMode().hide(0); // hide off. + } + } + }; + + @RequiresApi(api = Build.VERSION_CODES.M) + private void showFloatingToolbar() { + if (getTextSelectionActionMode() != null) { + int delay = ViewConfiguration.getDoubleTapTimeout(); + postDelayed(mShowFloatingToolbar, delay); + } + } + + @RequiresApi(api = Build.VERSION_CODES.M) + void hideFloatingToolbar() { + if (getTextSelectionActionMode() != null) { + removeCallbacks(mShowFloatingToolbar); + getTextSelectionActionMode().hide(-1); + } + } + + public void updateFloatingToolbarVisibility(MotionEvent event) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && getTextSelectionActionMode() != null) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_MOVE: + hideFloatingToolbar(); + break; + case MotionEvent.ACTION_UP: // fall through + case MotionEvent.ACTION_CANCEL: + showFloatingToolbar(); + } + } + } + +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java new file mode 100644 index 0000000..d6b49b8 --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/TerminalViewClient.java @@ -0,0 +1,83 @@ +package com.termux.view; + +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; + +import com.termux.terminal.TerminalSession; + +/** + * The interface for communication between {@link TerminalView} and its client. It allows for getting + * various configuration options from the client and for sending back data to the client like logs, + * key events, both hardware and IME (which makes it different from that available with + * {@link View#setOnKeyListener(View.OnKeyListener)}, etc. It must be set for the + * {@link TerminalView} through {@link TerminalView#setTerminalViewClient(TerminalViewClient)}. + */ +public interface TerminalViewClient { + + /** + * Callback function on scale events according to {@link ScaleGestureDetector#getScaleFactor()}. + */ + float onScale(float scale); + + + + /** + * On a single tap on the terminal if terminal mouse reporting not enabled. + */ + void onSingleTapUp(MotionEvent e); + + boolean shouldBackButtonBeMappedToEscape(); + + boolean shouldEnforceCharBasedInput(); + + boolean shouldUseCtrlSpaceWorkaround(); + + boolean isTerminalViewSelected(); + + + + void copyModeChanged(boolean copyMode); + + + + boolean onKeyDown(int keyCode, KeyEvent e, TerminalSession session); + + boolean onKeyUp(int keyCode, KeyEvent e); + + boolean onLongPress(MotionEvent event); + + + + boolean readControlKey(); + + boolean readAltKey(); + + boolean readShiftKey(); + + boolean readFnKey(); + + + + boolean onCodePoint(int codePoint, boolean ctrlDown, TerminalSession session); + + + void onEmulatorSet(); + + + void logError(String tag, String message); + + void logWarn(String tag, String message); + + void logInfo(String tag, String message); + + void logDebug(String tag, String message); + + void logVerbose(String tag, String message); + + void logStackTraceWithMessage(String tag, String message, Exception e); + + void logStackTrace(String tag, Exception e); + +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java new file mode 100644 index 0000000..24a1797 --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/support/PopupWindowCompatGingerbread.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2015 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 com.termux.view.support; + +import android.util.Log; +import android.widget.PopupWindow; + +import java.lang.reflect.Method; + +/** + * Implementation of PopupWindow compatibility that can call Gingerbread APIs. + * https://chromium.googlesource.com/android_tools/+/HEAD/sdk/extras/android/support/v4/src/gingerbread/android/support/v4/widget/PopupWindowCompatGingerbread.java + */ +public class PopupWindowCompatGingerbread { + + private static Method sSetWindowLayoutTypeMethod; + private static boolean sSetWindowLayoutTypeMethodAttempted; + private static Method sGetWindowLayoutTypeMethod; + private static boolean sGetWindowLayoutTypeMethodAttempted; + + public static void setWindowLayoutType(PopupWindow popupWindow, int layoutType) { + if (!sSetWindowLayoutTypeMethodAttempted) { + try { + sSetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod( + "setWindowLayoutType", int.class); + sSetWindowLayoutTypeMethod.setAccessible(true); + } catch (Exception e) { + // Reflection method fetch failed. Oh well. + } + sSetWindowLayoutTypeMethodAttempted = true; + } + if (sSetWindowLayoutTypeMethod != null) { + try { + sSetWindowLayoutTypeMethod.invoke(popupWindow, layoutType); + } catch (Exception e) { + // Reflection call failed. Oh well. + } + } + } + + public static int getWindowLayoutType(PopupWindow popupWindow) { + if (!sGetWindowLayoutTypeMethodAttempted) { + try { + sGetWindowLayoutTypeMethod = PopupWindow.class.getDeclaredMethod( + "getWindowLayoutType"); + sGetWindowLayoutTypeMethod.setAccessible(true); + } catch (Exception e) { + // Reflection method fetch failed. Oh well. + } + sGetWindowLayoutTypeMethodAttempted = true; + } + if (sGetWindowLayoutTypeMethod != null) { + try { + return (Integer) sGetWindowLayoutTypeMethod.invoke(popupWindow); + } catch (Exception e) { + // Reflection call failed. Oh well. + } + } + return 0; + } + +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java new file mode 100644 index 0000000..f0e1cc5 --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/CursorController.java @@ -0,0 +1,55 @@ +package com.termux.view.textselection; + +import android.view.MotionEvent; +import android.view.ViewTreeObserver; + +import com.termux.view.TerminalView; + +/** + * A CursorController instance can be used to control cursors in the text. + * It is not used outside of {@link TerminalView}. + */ +public interface CursorController extends ViewTreeObserver.OnTouchModeChangeListener { + /** + * Show the cursors on screen. Will be drawn by {@link #render()} by a call during onDraw. + * See also {@link #hide()}. + */ + void show(MotionEvent event); + + /** + * Hide the cursors from screen. + * See also {@link #show(MotionEvent event)}. + */ + boolean hide(); + + /** + * Render the cursors. + */ + void render(); + + /** + * Update the cursor positions. + */ + void updatePosition(TextSelectionHandleView handle, int x, int y); + + /** + * This method is called by {@link #onTouchEvent(MotionEvent)} and gives the cursors + * a chance to become active and/or visible. + * + * @param event The touch event + */ + boolean onTouchEvent(MotionEvent event); + + /** + * Called when the view is detached from window. Perform house keeping task, such as + * stopping Runnable thread that would otherwise keep a reference on the context, thus + * preventing the activity to be recycled. + */ + void onDetached(); + + /** + * @return true if the cursors are currently active. + */ + boolean isActive(); + +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java new file mode 100644 index 0000000..0e8d82e --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionCursorController.java @@ -0,0 +1,407 @@ +package com.termux.view.textselection; + +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.termux.terminal.TerminalBuffer; +import com.termux.terminal.WcWidth; +import com.muxy.terminal.R; +import com.termux.view.TerminalView; + +public class TextSelectionCursorController implements CursorController { + + private final TerminalView terminalView; + private final TextSelectionHandleView mStartHandle, mEndHandle; + private String mStoredSelectedText; + private boolean mIsSelectingText = false; + private long mShowStartTime = System.currentTimeMillis(); + + private final int mHandleHeight; + private int mSelX1 = -1, mSelX2 = -1, mSelY1 = -1, mSelY2 = -1; + + private ActionMode mActionMode; + public final int ACTION_COPY = 1; + public final int ACTION_PASTE = 2; + public final int ACTION_MORE = 3; + + public TextSelectionCursorController(TerminalView terminalView) { + this.terminalView = terminalView; + mStartHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.LEFT); + mEndHandle = new TextSelectionHandleView(terminalView, this, TextSelectionHandleView.RIGHT); + + mHandleHeight = Math.max(mStartHandle.getHandleHeight(), mEndHandle.getHandleHeight()); + } + + @Override + public void show(MotionEvent event) { + setInitialTextSelectionPosition(event); + mStartHandle.positionAtCursor(mSelX1, mSelY1, true); + mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, true); + + setActionModeCallBacks(); + mShowStartTime = System.currentTimeMillis(); + mIsSelectingText = true; + } + + @Override + public boolean hide() { + if (!isActive()) return false; + + // prevent hide calls right after a show call, like long pressing the down key + // 300ms seems long enough that it wouldn't cause hide problems if action button + // is quickly clicked after the show, otherwise decrease it + if (System.currentTimeMillis() - mShowStartTime < 300) { + return false; + } + + mStartHandle.hide(); + mEndHandle.hide(); + + if (mActionMode != null) { + // This will hide the TextSelectionCursorController + mActionMode.finish(); + } + + mSelX1 = mSelY1 = mSelX2 = mSelY2 = -1; + mIsSelectingText = false; + + return true; + } + + @Override + public void render() { + if (!isActive()) return; + + mStartHandle.positionAtCursor(mSelX1, mSelY1, false); + mEndHandle.positionAtCursor(mSelX2 + 1, mSelY2, false); + + if (mActionMode != null) { + mActionMode.invalidate(); + } + } + + public void setInitialTextSelectionPosition(MotionEvent event) { + int[] columnAndRow = terminalView.getColumnAndRow(event, true); + mSelX1 = mSelX2 = columnAndRow[0]; + mSelY1 = mSelY2 = columnAndRow[1]; + + TerminalBuffer screen = terminalView.mEmulator.getScreen(); + if (!" ".equals(screen.getSelectedText(mSelX1, mSelY1, mSelX1, mSelY1))) { + // Selecting something other than whitespace. Expand to word. + while (mSelX1 > 0 && !"".equals(screen.getSelectedText(mSelX1 - 1, mSelY1, mSelX1 - 1, mSelY1))) { + mSelX1--; + } + while (mSelX2 < terminalView.mEmulator.mColumns - 1 && !"".equals(screen.getSelectedText(mSelX2 + 1, mSelY1, mSelX2 + 1, mSelY1))) { + mSelX2++; + } + } + } + + public void setActionModeCallBacks() { + final ActionMode.Callback callback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + int show = MenuItem.SHOW_AS_ACTION_IF_ROOM | MenuItem.SHOW_AS_ACTION_WITH_TEXT; + + ClipboardManager clipboard = (ClipboardManager) terminalView.getContext().getSystemService(Context.CLIPBOARD_SERVICE); + menu.add(Menu.NONE, ACTION_COPY, Menu.NONE, R.string.copy_text).setShowAsAction(show); + menu.add(Menu.NONE, ACTION_PASTE, Menu.NONE, R.string.paste_text).setEnabled(clipboard != null && clipboard.hasPrimaryClip()).setShowAsAction(show); + menu.add(Menu.NONE, ACTION_MORE, Menu.NONE, R.string.text_selection_more); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (!isActive()) { + // Fix issue where the dialog is pressed while being dismissed. + return true; + } + + switch (item.getItemId()) { + case ACTION_COPY: + String selectedText = getSelectedText(); + terminalView.mTermSession.onCopyTextToClipboard(selectedText); + terminalView.stopTextSelectionMode(); + break; + case ACTION_PASTE: + terminalView.stopTextSelectionMode(); + terminalView.mTermSession.onPasteTextFromClipboard(); + break; + case ACTION_MORE: + // We first store the selected text in case TerminalViewClient needs the + // selected text before MORE button was pressed since we are going to + // stop selection mode + mStoredSelectedText = getSelectedText(); + // The text selection needs to be stopped before showing context menu, + // otherwise handles will show above popup + terminalView.stopTextSelectionMode(); + terminalView.showContextMenu(); + break; + } + + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + + }; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + mActionMode = terminalView.startActionMode(callback); + return; + } + + //noinspection NewApi + mActionMode = terminalView.startActionMode(new ActionMode.Callback2() { + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return callback.onCreateActionMode(mode, menu); + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + return callback.onActionItemClicked(mode, item); + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + // Ignore. + } + + @Override + public void onGetContentRect(ActionMode mode, View view, Rect outRect) { + int x1 = Math.round(mSelX1 * terminalView.mRenderer.getFontWidth()); + int x2 = Math.round(mSelX2 * terminalView.mRenderer.getFontWidth()); + int y1 = Math.round((mSelY1 - 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); + int y2 = Math.round((mSelY2 + 1 - terminalView.getTopRow()) * terminalView.mRenderer.getFontLineSpacing()); + + if (x1 > x2) { + int tmp = x1; + x1 = x2; + x2 = tmp; + } + + int terminalBottom = terminalView.getBottom(); + int top = y1 + mHandleHeight; + int bottom = y2 + mHandleHeight; + if (top > terminalBottom) top = terminalBottom; + if (bottom > terminalBottom) bottom = terminalBottom; + + outRect.set(x1, top, x2, bottom); + } + }, ActionMode.TYPE_FLOATING); + } + + @Override + public void updatePosition(TextSelectionHandleView handle, int x, int y) { + TerminalBuffer screen = terminalView.mEmulator.getScreen(); + final int scrollRows = screen.getActiveRows() - terminalView.mEmulator.mRows; + if (handle == mStartHandle) { + mSelX1 = terminalView.getCursorX(x); + mSelY1 = terminalView.getCursorY(y); + if (mSelX1 < 0) { + mSelX1 = 0; + } + + if (mSelY1 < -scrollRows) { + mSelY1 = -scrollRows; + + } else if (mSelY1 > terminalView.mEmulator.mRows - 1) { + mSelY1 = terminalView.mEmulator.mRows - 1; + + } + + if (mSelY1 > mSelY2) { + mSelY1 = mSelY2; + } + if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { + mSelX1 = mSelX2; + } + + if (!terminalView.mEmulator.isAlternateBufferActive()) { + int topRow = terminalView.getTopRow(); + + if (mSelY1 <= topRow) { + topRow--; + if (topRow < -scrollRows) { + topRow = -scrollRows; + } + } else if (mSelY1 >= topRow + terminalView.mEmulator.mRows) { + topRow++; + if (topRow > 0) { + topRow = 0; + } + } + + terminalView.setTopRow(topRow); + } + + mSelX1 = getValidCurX(screen, mSelY1, mSelX1); + + } else { + mSelX2 = terminalView.getCursorX(x); + mSelY2 = terminalView.getCursorY(y); + if (mSelX2 < 0) { + mSelX2 = 0; + } + + if (mSelY2 < -scrollRows) { + mSelY2 = -scrollRows; + } else if (mSelY2 > terminalView.mEmulator.mRows - 1) { + mSelY2 = terminalView.mEmulator.mRows - 1; + } + + if (mSelY1 > mSelY2) { + mSelY2 = mSelY1; + } + if (mSelY1 == mSelY2 && mSelX1 > mSelX2) { + mSelX2 = mSelX1; + } + + if (!terminalView.mEmulator.isAlternateBufferActive()) { + int topRow = terminalView.getTopRow(); + + if (mSelY2 <= topRow) { + topRow--; + if (topRow < -scrollRows) { + topRow = -scrollRows; + } + } else if (mSelY2 >= topRow + terminalView.mEmulator.mRows) { + topRow++; + if (topRow > 0) { + topRow = 0; + } + } + + terminalView.setTopRow(topRow); + } + + mSelX2 = getValidCurX(screen, mSelY2, mSelX2); + } + + terminalView.invalidate(); + } + + private int getValidCurX(TerminalBuffer screen, int cy, int cx) { + String line = screen.getSelectedText(0, cy, cx, cy); + if (!TextUtils.isEmpty(line)) { + int col = 0; + for (int i = 0, len = line.length(); i < len; i++) { + char ch1 = line.charAt(i); + if (ch1 == 0) { + break; + } + + int wc; + if (Character.isHighSurrogate(ch1) && i + 1 < len) { + char ch2 = line.charAt(++i); + wc = WcWidth.width(Character.toCodePoint(ch1, ch2)); + } else { + wc = WcWidth.width(ch1); + } + + final int cend = col + wc; + if (cx > col && cx < cend) { + return cend; + } + if (cend == col) { + return col; + } + col = cend; + } + } + return cx; + } + + public void decrementYTextSelectionCursors(int decrement) { + mSelY1 -= decrement; + mSelY2 -= decrement; + } + + public boolean onTouchEvent(MotionEvent event) { + return false; + } + + public void onTouchModeChanged(boolean isInTouchMode) { + if (!isInTouchMode) { + terminalView.stopTextSelectionMode(); + } + } + + @Override + public void onDetached() { + } + + @Override + public boolean isActive() { + return mIsSelectingText; + } + + public void getSelectors(int[] sel) { + if (sel == null || sel.length != 4) { + return; + } + + sel[0] = mSelY1; + sel[1] = mSelY2; + sel[2] = mSelX1; + sel[3] = mSelX2; + } + + /** Get the currently selected text. */ + public String getSelectedText() { + return terminalView.mEmulator.getSelectedText(mSelX1, mSelY1, mSelX2, mSelY2); + } + + /** Get the selected text stored before "MORE" button was pressed on the context menu. */ + @Nullable + public String getStoredSelectedText() { + return mStoredSelectedText; + } + + /** Unset the selected text stored before "MORE" button was pressed on the context menu. */ + public void unsetStoredSelectedText() { + mStoredSelectedText = null; + } + + public ActionMode getActionMode() { + return mActionMode; + } + + /** + * @return true if this controller is currently used to move the start selection. + */ + public boolean isSelectionStartDragged() { + return mStartHandle.isDragging(); + } + + /** + * @return true if this controller is currently used to move the end selection. + */ + public boolean isSelectionEndDragged() { + return mEndHandle.isDragging(); + } + +} diff --git a/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java new file mode 100644 index 0000000..ec34027 --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/java/com/termux/view/textselection/TextSelectionHandleView.java @@ -0,0 +1,352 @@ +package com.termux.view.textselection; + +import android.annotation.SuppressLint; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.SystemClock; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.WindowManager; +import android.widget.PopupWindow; + +import com.muxy.terminal.R; +import com.termux.view.TerminalView; +import com.termux.view.support.PopupWindowCompatGingerbread; + +@SuppressLint("ViewConstructor") +public class TextSelectionHandleView extends View { + private final TerminalView terminalView; + private PopupWindow mHandle; + private final CursorController mCursorController; + + private final Drawable mHandleLeftDrawable; + private final Drawable mHandleRightDrawable; + private Drawable mHandleDrawable; + + private boolean mIsDragging; + + final int[] mTempCoords = new int[2]; + Rect mTempRect; + + private int mPointX; + private int mPointY; + private float mTouchToWindowOffsetX; + private float mTouchToWindowOffsetY; + private float mHotspotX; + private float mHotspotY; + private float mTouchOffsetY; + private int mLastParentX; + private int mLastParentY; + + private int mHandleHeight; + private int mHandleWidth; + + private final int mInitialOrientation; + private int mOrientation; + + public static final int LEFT = 0; + public static final int RIGHT = 2; + + private long mLastTime; + + public TextSelectionHandleView(TerminalView terminalView, CursorController cursorController, int initialOrientation) { + super(terminalView.getContext()); + this.terminalView = terminalView; + mCursorController = cursorController; + mInitialOrientation = initialOrientation; + + mHandleLeftDrawable = getContext().getDrawable(R.drawable.text_select_handle_left_material); + mHandleRightDrawable = getContext().getDrawable(R.drawable.text_select_handle_right_material); + + setOrientation(mInitialOrientation); + } + + private void initHandle() { + mHandle = new PopupWindow(terminalView.getContext(), null, + android.R.attr.textSelectHandleWindowStyle); + mHandle.setSplitTouchEnabled(true); + mHandle.setClippingEnabled(false); + mHandle.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); + mHandle.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + mHandle.setBackgroundDrawable(null); + mHandle.setAnimationStyle(0); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mHandle.setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL); + mHandle.setEnterTransition(null); + mHandle.setExitTransition(null); + } else { + PopupWindowCompatGingerbread.setWindowLayoutType(mHandle, WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL); + } + mHandle.setContentView(this); + } + + public void setOrientation(int orientation) { + mOrientation = orientation; + int handleWidth = 0; + switch (orientation) { + case LEFT: { + mHandleDrawable = mHandleLeftDrawable; + handleWidth = mHandleDrawable.getIntrinsicWidth(); + mHotspotX = (handleWidth * 3) / (float) 4; + break; + } + + case RIGHT: { + mHandleDrawable = mHandleRightDrawable; + handleWidth = mHandleDrawable.getIntrinsicWidth(); + mHotspotX = handleWidth / (float) 4; + break; + } + } + + mHandleHeight = mHandleDrawable.getIntrinsicHeight(); + + mHandleWidth = handleWidth; + mTouchOffsetY = -mHandleHeight * 0.3f; + mHotspotY = 0; + invalidate(); + } + + public void show() { + if (!isPositionVisible()) { + hide(); + return; + } + + // We remove handle from its parent first otherwise the following exception may be thrown + // java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first. + removeFromParent(); + + initHandle(); // init the handle + invalidate(); // invalidate to make sure onDraw is called + + final int[] coords = mTempCoords; + terminalView.getLocationInWindow(coords); + coords[0] += mPointX; + coords[1] += mPointY; + + if (mHandle != null) + mHandle.showAtLocation(terminalView, 0, coords[0], coords[1]); + } + + public void hide() { + mIsDragging = false; + + if (mHandle != null) { + mHandle.dismiss(); + + // We remove handle from its parent, otherwise it may still be shown in some cases even after the dismiss call + removeFromParent(); + mHandle = null; // garbage collect the handle + } + invalidate(); + } + + public void removeFromParent() { + if (!isParentNull()) { + ((ViewGroup)this.getParent()).removeView(this); + } + } + + public void positionAtCursor(final int cx, final int cy, boolean forceOrientationCheck) { + int x = terminalView.getPointX(cx); + int y = terminalView.getPointY(cy + 1); + moveTo(x, y, forceOrientationCheck); + } + + private void moveTo(int x, int y, boolean forceOrientationCheck) { + float oldHotspotX = mHotspotX; + checkChangedOrientation(x, forceOrientationCheck); + mPointX = (int) (x - (isShowing() ? oldHotspotX : mHotspotX)); + mPointY = y; + + if (isPositionVisible()) { + int[] coords = null; + + if (isShowing()) { + coords = mTempCoords; + terminalView.getLocationInWindow(coords); + int x1 = coords[0] + mPointX; + int y1 = coords[1] + mPointY; + if (mHandle != null) + mHandle.update(x1, y1, getWidth(), getHeight()); + } else { + show(); + } + + if (mIsDragging) { + if (coords == null) { + coords = mTempCoords; + terminalView.getLocationInWindow(coords); + } + if (coords[0] != mLastParentX || coords[1] != mLastParentY) { + mTouchToWindowOffsetX += coords[0] - mLastParentX; + mTouchToWindowOffsetY += coords[1] - mLastParentY; + mLastParentX = coords[0]; + mLastParentY = coords[1]; + } + } + } else { + hide(); + } + } + + public void changeOrientation(int orientation) { + if (mOrientation != orientation) { + setOrientation(orientation); + } + } + + private void checkChangedOrientation(int posX, boolean force) { + if (!mIsDragging && !force) { + return; + } + long millis = SystemClock.currentThreadTimeMillis(); + if (millis - mLastTime < 50 && !force) { + return; + } + mLastTime = millis; + + final TerminalView hostView = terminalView; + final int left = hostView.getLeft(); + final int right = hostView.getWidth(); + final int top = hostView.getTop(); + final int bottom = hostView.getHeight(); + + if (mTempRect == null) { + mTempRect = new Rect(); + } + final Rect clip = mTempRect; + clip.left = left + terminalView.getPaddingLeft(); + clip.top = top + terminalView.getPaddingTop(); + clip.right = right - terminalView.getPaddingRight(); + clip.bottom = bottom - terminalView.getPaddingBottom(); + + final ViewParent parent = hostView.getParent(); + if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) { + return; + } + + if (posX - mHandleWidth < clip.left) { + changeOrientation(RIGHT); + } else if (posX + mHandleWidth > clip.right) { + changeOrientation(LEFT); + } else { + changeOrientation(mInitialOrientation); + } + } + + private boolean isPositionVisible() { + // Always show a dragging handle. + if (mIsDragging) { + return true; + } + + final TerminalView hostView = terminalView; + final int left = 0; + final int right = hostView.getWidth(); + final int top = 0; + final int bottom = hostView.getHeight(); + + if (mTempRect == null) { + mTempRect = new Rect(); + } + final Rect clip = mTempRect; + clip.left = left + terminalView.getPaddingLeft(); + clip.top = top + terminalView.getPaddingTop(); + clip.right = right - terminalView.getPaddingRight(); + clip.bottom = bottom - terminalView.getPaddingBottom(); + + final ViewParent parent = hostView.getParent(); + if (parent == null || !parent.getChildVisibleRect(hostView, clip, null)) { + return false; + } + + final int[] coords = mTempCoords; + hostView.getLocationInWindow(coords); + final int posX = coords[0] + mPointX + (int) mHotspotX; + final int posY = coords[1] + mPointY + (int) mHotspotY; + + return posX >= clip.left && posX <= clip.right && + posY >= clip.top && posY <= clip.bottom; + } + + @Override + public void onDraw(Canvas c) { + final int width = mHandleDrawable.getIntrinsicWidth(); + int height = mHandleDrawable.getIntrinsicHeight(); + mHandleDrawable.setBounds(0, 0, width, height); + mHandleDrawable.draw(c); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent event) { + terminalView.updateFloatingToolbarVisibility(event); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + final float rawX = event.getRawX(); + final float rawY = event.getRawY(); + mTouchToWindowOffsetX = rawX - mPointX; + mTouchToWindowOffsetY = rawY - mPointY; + final int[] coords = mTempCoords; + terminalView.getLocationInWindow(coords); + mLastParentX = coords[0]; + mLastParentY = coords[1]; + mIsDragging = true; + break; + } + + case MotionEvent.ACTION_MOVE: { + final float rawX = event.getRawX(); + final float rawY = event.getRawY(); + + final float newPosX = rawX - mTouchToWindowOffsetX + mHotspotX; + final float newPosY = rawY - mTouchToWindowOffsetY + mHotspotY + mTouchOffsetY; + + mCursorController.updatePosition(this, Math.round(newPosX), Math.round(newPosY)); + break; + } + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsDragging = false; + } + return true; + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + setMeasuredDimension(mHandleDrawable.getIntrinsicWidth(), + mHandleDrawable.getIntrinsicHeight()); + } + + public int getHandleHeight() { + return mHandleHeight; + } + + public int getHandleWidth() { + return mHandleWidth; + } + + public boolean isShowing() { + if (mHandle != null) + return mHandle.isShowing(); + else + return false; + } + + public boolean isParentNull() { + return this.getParent() == null; + } + + public boolean isDragging() { + return mIsDragging; + } + +} diff --git a/android/terminal/vendor/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml b/android/terminal/vendor/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml new file mode 100644 index 0000000..576ff4a --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/res/drawable/text_select_handle_left_material.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/terminal/vendor/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml b/android/terminal/vendor/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml new file mode 100644 index 0000000..d049d3a --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/res/drawable/text_select_handle_right_material.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/terminal/vendor/terminal-view/src/main/res/values/strings.xml b/android/terminal/vendor/terminal-view/src/main/res/values/strings.xml new file mode 100644 index 0000000..cd03c61 --- /dev/null +++ b/android/terminal/vendor/terminal-view/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Paste + Copy + More… + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..4e89a0a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,777 @@ +# Architecture + +Muxy is a macOS terminal multiplexer built with SwiftUI that uses libghostty for terminal emulation. +It is structured as a monorepo with a companion iOS app (MuxyMobile) that connects to the +desktop app over the local network. + +## Monorepo Structure + +``` +MuxyShared/ Shared types (macOS + iOS): protocol DTOs, messages, codec + ProjectDTO.swift Project data transfer object + WorktreeDTO.swift Worktree data transfer object + WorkspaceDTO.swift Workspace layout DTOs (SplitNodeDTO, TabAreaDTO, TabDTO) + NotificationDTO.swift Notification data transfer object + VCSStatusDTO.swift Git status/file DTOs + MuxyProtocol.swift Protocol enums: methods, results, events + ProtocolParams.swift Request parameter types for each method + MuxyMessage.swift Message envelope (request/response/event) + JSON codec + +MuxyServer/ WebSocket server library (macOS only, embedded in Muxy.app) + MuxyRemoteServer.swift NWListener-based WebSocket server + delegate protocol + request routing + ClientConnection.swift Per-client NWConnection wrapper, WebSocket framing + +MuxyMobile/ iOS companion app + MuxyMobileApp.swift App entry point + ContentView.swift Root view (connection state router) + ConnectView.swift Host/port connection form + RemoteWorkspaceView.swift Project list + workspace detail + ConnectionManager.swift WebSocket client, state sync, request/response handling + DeviceCredentialsStore.swift Persistent deviceID + token stored in iOS Keychain + +android/ Android companion app (Gradle Kotlin DSL, multi-module) + protocol/ DTOs, envelope, JSON codec mirroring MuxyShared + net/ OkHttp WebSocket client (MuxyClient), credentials store, saved devices + terminal/ Compose terminal UI; vendors Termux's terminal-emulator + terminal-view + vendor/ Pinned-commit copy of Termux libraries (GPL-3.0; libtermux JNI removed) + app/ Compose app: Connect, Project list, Settings, Workspace nav +``` + +## Desktop App Directory Map + +``` +Muxy/ + MuxyApp.swift App entry point, delegate, window setup + Commands/ + MuxyCommands.swift macOS menu bar commands + Extensions/ + BundleExtension.swift Bundle helper + Notification+Names.swift Custom notification names + View+KeyboardShortcut.swift .shortcut(for:store:) View extension + Models/ + MuxyNotification.swift Notification data model (pane, project, worktree IDs, source, content) + AppState.swift @Observable root state, dispatches workspace actions + WorkspaceReducer.swift Pure reducer: all workspace state transitions + WorkspaceSnapshot.swift Save/restore workspace layout to disk + NavigationHistory.swift Stacked back/forward history over project+worktree+area+tab tuples + SplitNode.swift Recursive binary tree for pane splits + TabArea.swift Container for tabs within a single pane + TerminalTab.swift Terminal, VCS, editor, or diff-viewer tab model + TabDragCoordinator.swift Cross-pane tab drag-and-drop, TabMoveRequest, SplitPlacement + CommandShortcut.swift User-defined terminal command shortcut model for layered command chords + KeyBinding.swift ShortcutAction enum + KeyBinding defaults + KeyCombo.swift Key combo encoding, display, matching + VCSTabState.swift Git diff viewer state + loading orchestration + EditorTabState.swift Code editor tab state (backing store, cursor, search, save) + DiffViewerTabState.swift Standalone diff-viewer tab state (single-file diff, unified/split toggle, session-only — not persisted) + FileTreeState.swift Lightweight file tree state per worktree (lazy expansion, git statuses) + EditorSettings.swift @Observable editor preferences (default editor, font) + TextBackingStore.swift Line-array backing store for editor documents + ViewportState.swift Viewport window computation and line mapping for editor documents + TerminalSettings.swift Terminal preference keys and quick-select label layout helpers + ProjectLifecyclePreferences.swift Project lifecycle preferences (keep-open-when-no-tabs) + Project.swift Project folder metadata + Worktree.swift Per-project worktree slot (primary or git worktree) + WorktreeKey.swift Hashable (projectID, worktreeID) key for workspace maps + WorktreeConfig.swift Decoder for .muxy/worktree.json setup commands + TerminalPaneState.swift Per-pane terminal state, including startup commands for terminal editors + TerminalSearchState.swift Terminal find-in-page state + TerminalQuickSelectState.swift Keyboard quick-select match state and label generation + Services/ + GhosttyService.swift Singleton managing ghostty_app_t lifecycle + GhosttyRuntimeEventAdapter.swift C callback bridge from libghostty (OSC + command finished → notifications) + NotificationStore.swift @Observable notification store singleton (persisted to notifications.json) + NotificationNavigator.swift Pane context resolution + click-to-navigate dispatch + NotificationSocketServer.swift Unix domain socket IPC for external tool notifications + IDEIntegrationService.swift Discovers installed IDE-like apps, remembers the selected target, and launches the active project or file in external editors + AIProviderIntegration.swift Protocol + AIProviderRegistry (notification-hook integrations, usage provider registry) + AIUsageService.swift @Observable @MainActor snapshot store, parallel fetch orchestration, refresh coalescing, row composition (Catalog, SnapshotComposer, RowPolicy) + AIUsagePreferences.swift UserDefaults-backed stores: provider tracking/enabled toggles, display mode, auto-refresh interval, global enabled flag, auto-tracking + AIUsageProvider.swift AIUsageProvider protocol (fetchUsageSnapshot) with default snapshot helper + AIUsageModels.swift AIProviderUsageSnapshot, AIUsageMetricRow, state enum + AIUsageSession.swift Shared request/response pipeline for token-auth HTTP providers + AIUsageOAuth.swift OAuth access-token refresh + persistence for providers that use the flow + AIUsageTokenReader.swift Reads tokens from env vars, JSON credential files, or macOS Keychain (/usr/bin/security) + AIUsageParserSupport.swift Shared JSON navigation helpers (number/date/string extractors, formatters) + AIUsagePaceCalculator.swift Projects end-of-period usage from current percent, reset date, and period duration + {Amp,Claude,Codex,Copilot,Factory,Kimi,MiniMax,Zai}UsageParser.swift Per-provider JSON → metric row parsers + Providers/ + ClaudeCodeProvider.swift Claude hook install + usage fetch (OAuth token from env/credentials/keychain) + OpenCodeProvider.swift OpenCode notification-hook integration + CodexProvider.swift Codex CLI notification-hook integration (~/.codex/hooks.json) + CursorProvider.swift Cursor CLI notification-hook integration (~/.cursor/hooks.json) + {Amp,Codex,Copilot,Factory,Kimi,MiniMax,Zai}UsageProvider.swift Per-provider usage fetchers (AIUsageProvider) + Git/ + GitRepositoryService.swift Git command execution (Sendable struct; dispatches via GitProcessRunner) + GitProcessRunner.swift Concurrent Process dispatcher for git/gh, unblocks main thread + GitSignpost.swift os_signpost helpers for instrumenting git/gh calls + GitWorktreeService.swift git worktree list/add/remove (actor) + GitDiffParser.swift Diff patch parsing, context collapsing + GitStatusParser.swift Porcelain + numstat output parsing + GitModels.swift GitStatusFile, DiffDisplayRow, NumstatEntry + GitDirectoryWatcher.swift FSEvents watcher for .git changes + FileSearchService.swift Quick open file search via /usr/bin/find subprocess + FileTreeService.swift Lazy directory listing that respects .gitignore via git check-ignore + FileSystemOperations.swift Off-main create / rename / move / copy / trash primitives + FileClipboard.swift NSPasteboard wrapper for file cut/copy/paste with cut-marker type + ThemeService.swift Theme discovery + application + MuxyConfig.swift Ghostty config file read/write + KeyBindingStore.swift @Observable store for keyboard shortcuts + KeyBindingPersistence.swift JSON persistence for shortcuts + CommandShortcutStore.swift @Observable store + JSON persistence for custom command shortcuts and command-layer prefix state + ProjectStore.swift @Observable store for projects list + ProjectPersistence.swift JSON persistence for projects + ApprovedDevicesStore.swift Approved mobile devices (deviceID, SHA-256 token hash), revocation + PairingRequestCoordinator.swift Queues pending pairing requests for UI approval prompts + MobileServerService.swift Lifecycle wrapper around MuxyRemoteServer + WorktreeStore.swift @Observable store for per-project worktrees + WorktreePersistence.swift JSON persistence for worktrees (one file per project) + ProjectOpenService.swift Shared open-project flow used by commands and sidebar + WorktreeSetupRunner.swift Dispatches .muxy/worktree.json setup commands to a new tab + WorkspacePersistence.swift JSON persistence for workspaces + JSONFilePersistence.swift Shared App Support directory helper + ModifierKeyMonitor.swift Global modifier key state tracking + UpdateService.swift Sparkle update checker + ShortcutContext.swift Window focus context for shortcuts + AppEnvironment.swift Dependency injection container + AppStateDependencies.swift Protocol definitions for DI + Syntax/ + SyntaxScope.swift Scope enum (keyword/string/comment/…) + SyntaxTheme mapping scopes to Ghostty palette colors + SyntaxGrammar.swift SyntaxGrammar model (keywords, comments, strings, numbers) + LineEndState enum + SyntaxTokenizer.swift Stateful per-line tokenizer emitting TokenSpans and end state + SyntaxHighlighter.swift Per-file line-end-state cache + viewport highlight API + SyntaxLanguageRegistry.swift File-extension → grammar lookup + MarkdownInlineHighlighter.swift Pure decorator: scans a markdown line and emits MarkdownInlineDecoration values (heading, bold/italic/strike, code-span, blockquote, list marker). Consumed by MarkdownInlineExtension to apply font and attribute changes in the editor. + Grammars/ + CFamilyGrammars.swift Swift, JS, TS, Objective-C, C, C++, C#, Java, Kotlin, Scala, Go, Rust, Dart, PHP + ScriptGrammars.swift Python, Ruby, Lua, Shell, Perl, Elixir, Haskell + MarkupGrammars.swift HTML, XML, CSS, Markdown + DataGrammars.swift JSON, YAML, TOML, INI, SQL, Dockerfile, Makefile + Theme/ + MuxyTheme.swift Color system derived from Ghostty palette + Views/ + NotificationPanel.swift Notification list popover (bell icon in sidebar footer) + MainWindow.swift Main window layout (sidebar + workspace) + Sidebar.swift Narrow icon-strip sidebar (44px), add-project button, project icons + Sidebar/ + ProjectRow.swift Project icon (first letter or emoji logo), tooltip, context menu with logo + color pickers + ProjectIconColorPicker.swift Preset color palette popover for tinting the default letter icon + WorktreePopover.swift Worktree picker popover triggered from the active project row + CreateWorktreeSheet.swift Sheet for creating a new git worktree + AIUsagePanel.swift AI usage popover: preview button, panel header/list, provider and metric rows, used/remaining conversion + ProviderIconView.swift Renders SVG provider icons from Muxy/Resources/ProviderIcons with monochrome tinting + ThemePicker.swift Theme selection popover (hosted in topbar right) + WelcomeView.swift Empty state view + Components/ + IconButton.swift Reusable icon button + FileDiffIcon.swift Git diff file icon (SVG shape) + FileTreeIcon.swift File tree toggle button (SF symbol) + WindowDragView.swift NSView for window title bar dragging + MiddleClickView.swift NSView for middle-click tab close + UUIDFramePreferenceKey.swift Generic PreferenceKey for frame tracking + NotificationBadge.swift Unread count badge for sidebar project icons + QuickOpenOverlay.swift Cmd+P file search overlay (name substring match via find) + AppBundleIconView.swift Renders and caches installed app bundle icons for menus and launcher controls + OpenInIDEControl.swift Split button for opening the active project or editor file in the remembered or selected IDE + Terminal/ + GhosttyTerminalNSView.swift AppKit view wrapping ghostty_surface_t + NSTextInputClient + TerminalPane.swift SwiftUI wrapper for terminal, search, and quick-select overlays + TerminalSearchBar.swift Find-in-terminal UI + TerminalViewRegistry.swift Terminal view lifecycle management + Editor/ + CodeEditorRepresentable.swift NSViewRepresentable bridge for code editor (viewport rendering path); coordinator dispatches render and incremental events to a list of EditorExtensions + EditorPane.swift SwiftUI wrapper for editor tab (breadcrumb + editor) + Extensions/ + EditorExtension.swift Protocol with lifecycle hooks (didMount, willUnmount, renderViewport, applyIncremental, textDidChange) and default no-op implementations + EditorRenderContext.swift Bundle of render-time dependencies (textView, storage, layoutManager, viewport, backingStore, line offsets, settings, state) handed to extensions + SyntaxHighlightExtension.swift Owns .foregroundColor temporary attributes from SyntaxHighlighter spans; schedules cascade reapply via SyntaxHighlightCoordinator + MarkdownInlineExtension.swift Markdown-only: heading sizes, bold/italic/strike font traits, code-span/blockquote/list muted markers; gates on EditorTabState.isMarkdownFile + Markdown/ + MarkdownScrollSyncController.swift Drives editor↔preview scroll sync for markdown split mode; owns the isApplyingScroll guard and pending-request version tracking + Search/ + SearchController.swift Owns find/replace state and viewport-anchored search highlights; communicates with the coordinator through SearchControllerHost + History/ + ViewportEditHistory.swift Owns viewport-mode undo/redo stacks, edit coalescing, and apply logic; ViewportCursor / ViewportEdit / ViewportEditGroup / PendingViewportEdit lifted to file scope; communicates with the coordinator through ViewportEditHistoryHost + FileTree/ + FileTreeView.swift Side panel rendering of the lightweight file tree + FileTreeCommands.swift Orchestrates create/rename/delete/cut/copy/paste/drop + VCS/ + VCSTabView.swift Source control tab (commit, stage, diff, branch) + PRPill + PRPopover + PullRequestsListView.swift Pull Requests section: list, search, state filter, manual + auto sync + BranchPicker.swift Branch selection dropdown with filter and right-click delete + UnifiedDiffView.swift Unified diff rendering + SplitDiffView.swift Side-by-side diff rendering + DiffViewerPane.swift Standalone diff-viewer tab (top bar + unified/split switch) + DiffComponents.swift Shared diff UI: line rows, highlighting, cache + CreatePRSheet.swift Sheet for opening a pull request on the current branch + CommitHistoryView.swift Commit history list with context menu actions + Workspace/ + Workspace.swift Workspace container (split tree root) + PaneNode.swift Recursive split pane rendering + SplitContainer.swift Split pane with resize handle + TabAreaView.swift Tab area wrapper (tabs + content) + TabStrip.swift Tab bar with drag reordering + DropZoneOverlay.swift Tab split-mode drop targets + Settings/ + SettingsView.swift Settings window layout + SettingsComponents.swift Shared section/row primitives used across all tabs + AppearanceSettingsView.swift Theme settings tab + EditorSettingsView.swift Editor preferences tab (default editor, font) + TerminalSettingsView.swift Terminal preferences tab, including quick-select label layout + KeyboardShortcutsSettingsView.swift Shortcut config tab, including layered custom terminal command shortcuts + NotificationSettingsView.swift Notification preferences tab + AIUsageSettingsView.swift AI usage tab (global enable, display mode, auto-refresh, secondary limits, per-provider toggles) + MobileSettingsView.swift Mobile server and approved devices tab + ShortcutRecorderView.swift Shortcut capture field + ShortcutBadge.swift Shortcut label display +``` + +## Hierarchy + +``` +Project → Worktree → SplitNode (splits/tab areas) → TerminalTab → Pane +``` + +Each project has at least one **primary** worktree pointing at `Project.path`. Git +projects may add more worktrees via `git worktree add`, each with their own split +tree, tabs, focus state, and working directory. Secondary worktrees can be either +Muxy-managed checkouts created from the sidebar or externally created Git worktrees +that are imported into the sidebar with a manual refresh. Workspace state is keyed by +`WorktreeKey(projectID, worktreeID)` in `AppState` so every per-project map is +actually per-worktree. `AppState.activeWorktreeID[projectID]` tracks which +worktree is currently visible for each project. + +## Data Flow + +``` +User action → AppState.dispatch() → WorkspaceReducer.reduce() + ↓ + WorkspaceState (immutable update) + WorkspaceSideEffects (pane create/destroy) + ↓ + AppState applies effects + TerminalViewRegistry creates/destroys surfaces +``` + +## Key Integration Points + +- **Editor Pipeline**: File opening routes through `AppState.openFile`. `EditorSettings.defaultEditor` + chooses either the built-in editor or a configured terminal command. Built-in editor tabs load files into + `TextBackingStore` and render through `CodeEditorRepresentable`; terminal editor tabs create a normal + terminal pane with the configured Ghostty startup command. The size thresholds in + `EditorTabState` apply only to the built-in editor path. +- **IDE Launching**: `MainWindow` and `MuxyCommands` surface project-level IDE launch actions through + `OpenInIDEControl` and the app menu. `IDEIntegrationService` scans installed applications, classifies + editor-like apps by bundle metadata, remembers the last launched bundle identifier in user defaults, + and prefers CLI-based launch commands for VS Code-like and Zed-like apps so the current file, line, + and column can be highlighted when available. The same launcher surface also provides a native Finder + reveal action for the active project path. +- **Syntax Highlighting**: `EditorTabState` owns a `SyntaxHighlighter` created from the file + extension via `SyntaxLanguageRegistry`. The highlighter keeps a per-line `LineEndState` cache + so multiline constructs (block comments, multiline strings) are preserved across scroll without + rescanning from the file start. During `CodeEditorRepresentable.refreshViewport`, the + highlighter tokenizes the visible lines and returns `AppliedSpan`s that are applied as + `.foregroundColor` attributes on the viewport's `NSTextStorage`. Colors come from + `SyntaxTheme` which maps scopes (`.keyword`, `.string`, `.comment`, …) to the active + Ghostty palette — themes Just Work. Edits invalidate the cache from the earliest affected + line; search highlights (temporary attributes) layer on top without losing syntax colors. +- **GhosttyKit**: C module wrapping `ghostty.h`. Precompiled xcframework from `muxy-app/ghostty` fork. Surfaces created/destroyed via `TerminalViewRegistry`. +- **Terminal Working Directory Preservation**: When a user navigates within a terminal (e.g., `cd src/`), libghostty emits `GHOSTTY_ACTION_PWD` events. `GhosttyRuntimeEventAdapter` receives these events and routes them via the `onWorkingDirectoryChange` callback to `TerminalPane`, which updates `TerminalPaneState.currentWorkingDirectory`. This directory is persisted to disk through `TerminalTabSnapshot` in `workspaces.json`. On restore, `TerminalTab` initializes each terminal pane with its saved working directory (or the project root if none was saved), allowing terminals to reopen at their last-used directory instead of always starting at the project root. +- **Persistence**: All files in `~/Library/Application Support/Muxy/`. Shared directory helper: `MuxyFileStorage`. Shortcuts are stored in `keybindings.json`; custom command shortcuts are stored in `command-shortcuts.json`. Worktrees are persisted per-project at `worktrees/{projectID}.json`, including whether a secondary worktree is Muxy-managed or externally discovered. Git projects can manually refresh this list from `git worktree list --porcelain` to import existing worktrees without deleting absent entries; paths are matched after symlink resolution so a repo opened via a symlinked path still collapses onto a single primary entry. Externally discovered worktrees are never touched by Muxy's `cleanupOnDisk` paths (project removal, post-merge cleanup, manual removal) — they can only be unregistered by the user in the underlying repo. Worktree setup commands live in-repo at `{Project.path}/.muxy/worktree.json`. +- **Ghostty Config**: Managed by `MuxyConfig`, stored at `~/Library/Application Support/Muxy/ghostty.conf`. Seeded from `~/.config/ghostty/config` on first run. +- **Updates**: Sparkle framework via `UpdateService`. +- **Window Title**: `NSWindow.title` is hidden visually (`titleVisibility = .hidden`) but set + reactively by `WindowTitleUpdater` in `MainWindow` to `{project name} — {active tab title}` + (or just the project name if no tab title is known). This makes Muxy sessions identifiable + to accessibility readers and activity trackers (e.g., ActivityWatch) that read `AXTitle`. + Tab titles come from the active tab's `TerminalTab.title`, which follows OSC 0/2 updates + via `GhosttyRuntimeEventAdapter` → `TerminalPaneState.setTitle`. Users can override the + auto-title via `TerminalTab.customTitle` ("Rename Tab" context menu / `⌃⌘R`) and assign a + color accent via `TerminalTab.colorID` ("Set Tab Color…" context menu). Both fields persist + to `workspaces.json` through `TerminalTabSnapshot`. Colors resolve through + `ProjectIconColor.palette` (shared with project icon colors). + +## File Tree + +The file tree is a lightweight side panel mounted at the trailing edge of the +main window, in the same slot used by the attached VCS panel. Only one of the +two panels can be visible at a time — opening one closes the other. Both are +toggled from buttons in the topbar (file tree button appears only when the VCS +display mode is `attached`, since the file tree panel reuses the attached slot). + +`FileTreeState` is created per `WorktreeKey` and held by `MainWindow`. It lazily +loads directory contents through `FileTreeService.loadChildren`, which calls +`git check-ignore --stdin` for the candidate names in each directory so the +visible tree matches `.gitignore`. Non-git folders fall back to a hardcoded +prune list (same one used by `FileSearchService`). + +Per-file git statuses come from `git status --porcelain=v1 -z` and are mapped +to colors (modified → diff hunk color, added/untracked → diff add color, +deleted/conflict → diff remove color). Parent directories of changed files are +highlighted with the modified color. The tree subscribes to +`.vcsRepoDidChange` and uses `GitDirectoryWatcher` so external changes refresh +the panel without user action — there is no manual refresh button. Clicking a +file routes through `AppState.openFile`, the same path used by the quick open +overlay. + +The header has a filter button that toggles `showOnlyChanges`, hiding any +entry whose absolute path is not in the status set (and any directory whose +subtree has no changes). The panel also tracks the active editor file via +`AppState.activeTab(for:)?.content.editorState?.filePath`: changes to that path +auto-expand its parent directories and highlight the row using +`MuxyTheme.accentSoft`. Deleted paths that no longer exist on disk are +materialized as synthetic tree rows so removals still appear in both the full +tree and the changed-only filter. + +The panel width is persisted in `UserDefaults` under `muxy.fileTreeWidth`. +Expansion state is in-memory only. + +### File Operations + +The tree supports direct manipulation through a right-click context menu, +keyboard shortcuts, and drag-and-drop. `FileTreeCommands` (held as view +state inside `FileTreeView`) orchestrates the flow: it mutates transient +`FileTreeState` fields (`pendingNewEntry`, `pendingRenamePath`, +`pendingDeletePaths`, `cutPaths`, `dropHighlightPath`, `selectedPaths`, +`selectionAnchorPath`) and dispatches work to `FileSystemOperations`, a +stateless service that runs create / rename / move / copy / trash off the +main thread via `GitProcessRunner.offMainThrowing`. Trash goes through +`NSWorkspace.shared.recycle` so the OS handles Undo. + +Selection is multi-item: plain click selects one, `⌘`-click toggles, and +`⇧`-click extends the range using the currently visible row order. +Rename and new-entry both use `FileTreeRenameField`, an inline text field +that commits on Return / blur and cancels on Escape. Errors from any +operation surface through `ToastState.shared` and are also logged. + +Cut / copy / paste is backed by `FileClipboard`, which writes file URLs to +`NSPasteboard.general` and tags cuts with a private pasteboard type +(`app.muxy.fileCut`). This lets Muxy round-trip cut state while remaining +interoperable with Finder (which only sees the file URLs). Paste into a +file selects that file's parent directory as the destination. + +Drag-and-drop accepts `.fileURL` providers on every directory row and on +the empty space below the tree. Holding Option turns a move into a copy; +drops that would move a path into itself are filtered out. The dragged +row and all drop targets are driven by the same `FileTreeDropDelegate`. + +When a path changes on disk (rename, move, paste) the tree calls +`AppState.handleFileMoved(from:to:)`, which walks every open editor tab +and rewrites `EditorTabState.filePath` — both exact matches and paths +under a moved directory — keeping editors pointed at the same content. +"Open in Terminal" dispatches `.createTabInDirectory`, a reducer case +that opens a new terminal tab rooted at the selected directory rather +than the project root. + +## VCS Tab Layout + +The VCS tab is organized top-to-bottom as: + +1. **Header** — worktree trigger, branch picker, `PRPill`, settings, refresh. +2. **Commit area** — commit message field + three first-class buttons: `Commit`, `Pull` (with `↓N` badge when behind), `Push` (with `↑N` badge when ahead). Commit hotkey is `⌘↵`. +3. **Sections** — Staged / Changes / History / Pull Requests resizable split. + +Pull request management lives entirely in the header via `PRPill`, not in the commit area. `PRPill` renders one of the states from `VCSTabState.PRLaunchState`: + +- `hidden` — nothing to PR (clean tree on default branch, or loading). Pill is not rendered. +- `ghMissing` — disabled pill prompting to install `gh`. +- `canCreate` — "Create PR" button that opens `CreatePRSheet`. +- `hasPR(info)` — pill opens `PRPopover` showing state, base branch, mergeability, and actions (Open on GitHub, Merge, Close, Refresh). + +`canCreate` is gated by `VCSTabState.canCreatePR`: shown when `gh` is installed, no PR exists for this branch, and either the working tree has changes OR the current branch differs from the default branch. + +`CreatePRSheet` drives the end-to-end flow via a `PRCreateRequest` passed to `VCSTabState.openPullRequest`: + +1. **Target branch** — picked from `GitRepositoryService.listRemoteBranches` (remote-only), pre-selecting the repo's default branch. +2. **Title + description** — entered by the user; both fields start blank. +3. **Branch strategy** — radio between "use current branch" (hidden when on the default branch or when current == target) and "create new branch" (starts blank, then auto-slugs from the title until the user edits the name manually). +4. **Include** — radio between "all changes" (default) and "only staged"; hidden when there are no changes or only one kind. +5. **Draft** — checkbox that adds `--draft` to `gh pr create`. + +On submit, `performPRFlow` runs: optional branch create+switch → optional stage (all if include=all, staged-only otherwise) → commit with title if anything is staged → `git push -u origin ` → `gh pr create`. No rollback on partial failure — errors surface to the sheet with a clear message so the user can retry manually from wherever the flow stopped. Ahead/behind counts are populated by `GitRepositoryService.aheadBehind` during refresh and drive the push/pull badges in the commit area. + +### Pull Requests Section + +The Pull Requests section is independent from the rest of VCS data and never auto-fetches with the file/branch refresh. It exposes search, a state filter (Open / Closed / Merged / All), a manual sync button, and an auto-sync interval menu (Off / 5m / 15m / 30m / 1h) persisted per-repo in `UserDefaults` under `vcs.prAutoSyncMinutes.`. `VCSTabState.loadPullRequests` calls `GitRepositoryService.listPullRequests` which shells out to `gh pr list --json …`. Selecting a PR row triggers `gh pr checkout ` via `checkoutPullRequest`; if the working tree is dirty, `VCSTabView` first presents an NSAlert confirmation. After checkout, the tab refreshes branches, files, and PR info. + +## Navigation History + +`AppState` owns a `NavigationHistory` that captures a stacked history of +user navigation across projects, worktrees, split areas, and tabs. Each +entry is a `(projectID, worktreeID, areaID, tabID)` tuple. After every +successful `dispatch`, the current tuple is recorded (deduping against the +top of the stack). Selecting a different project, switching worktrees, +focusing another split pane, or selecting a different tab all count as +navigation events. + +Back/forward navigation is exposed via `AppState.goBack()` / +`AppState.goForward()`. Both validate the target entry still references +live state (the worktree root is still in `workspaceRoots`, the area and +tab still exist) and transparently skip stale entries. The single state +transition is driven through the reducer via a dedicated +`Action.navigate(projectID:worktreeID:areaID:tabID:)` case so all +workspace mutations stay in the reducer. Re-recording during a +back/forward step is gated by +`NavigationHistory.performWithRecordingSuppressed`. After every dispatch +the history is swept: entries whose project, worktree, area, or tab no +longer exist are removed eagerly, and the cursor snaps to the post-reducer +active tuple when it is still present — so closing a tab simply takes +that entry out of the stack rather than leaving a stale hop. + +The topbar hosts two chevron buttons (to the right of the sidebar border) +wired to these calls. Keyboard (default `⌃⌘←` / `⌃⌘→`), mouse side +buttons (buttons 3/4), and horizontal swipe gestures (Magic Mouse +1-finger, 3-finger trackpad) all trigger the same actions. The main +window's shortcut interceptor installs a local `addLocalMonitorForEvents` +handler for `[.otherMouseDown, .swipe]`, gated on the monitored window +being key and identified as a Muxy main window. + +## CLI / URL Scheme Entry Points + +External callers can open a project in Muxy through three coordinated paths, +all funneled into a single `AppDelegate.handleOpenProjectPath(_:)` choke point +so persistence, dedupe, and activation behave consistently. + +- **`muxy` shell wrapper** (`Muxy/Resources/scripts/muxy-cli`, installed to + `/usr/local/bin/muxy` via `CLIAccessor.installCLI`) — resolves the argument to + an absolute directory and tries, in order via `||` chaining: open the + `muxy://open?path=` URL, fall back to `open -b com.muxy.app` + Apple Events, and finally pipe `open-project|` to the Unix socket. A + small `python3`/`python` percent-encoder shells out without taking a + dependency on `jq`. +- **`muxy://` URL scheme** — handled by `AppDelegate.application(_:open:)`. + `AppDelegate.resolveProjectPath(from:)` parses with `URLComponents`, + prefers a `path` query item, falls back to `host + path`, percent-decodes, + and standardizes via `URL(fileURLWithPath:).standardizedFileURL.path`. File + URLs are accepted directly. Foreign schemes are rejected. +- **Launch arguments** — `applicationDidFinishLaunching` reads + `CommandLine.arguments[1]` only when the candidate begins with `/` or `~` and + resolves to an existing directory, so Xcode/test runner flags do not get + treated as project paths. +- **Notification socket** — `NotificationSocketServer` accepts an + `open-project|` line in addition to its notification format. It + validates the path is an existing directory and dispatches via an injected + `openProjectHandler` closure (wired in `MainWindow.onAppear`). No global + app-state references are read from inside the socket handler. + +`AppDelegate` holds an `openProjectFromPath` closure plus a `pendingOpenPaths` +queue. URL events that arrive before `MainWindow.onAppear` wires the closure +are buffered and replayed via `flushPendingOpens()` once the app state is +ready. `CLIAccessor.openProjectFromPath` standardizes the path once and uses +the same value for both the dedupe lookup and the persisted `Project.path`, +so reopening the same folder always selects the existing project rather than +creating a duplicate. + +The privileged install flow in `CLIAccessor.installCLI` runs off the main +thread (`Task.detached` + AppleScript), and the bundle path is escaped using +`ShellEscaper` before it is interpolated into `do shell script "…" with +administrator privileges`, defending against backslash / `$` / backtick +injection from the bundle path. + +## Notification System + +Notifications alert users when terminal events occur (command completion, AI agent +messages, OSC escape sequences). Each notification carries full navigation context +(projectID, worktreeID, areaID, tabID) to enable click-to-focus on the originating pane. + +### Sources + +- **OSC 9/777** — Desktop notification escape sequences handled via + `GHOSTTY_ACTION_DESKTOP_NOTIFICATION` in `GhosttyRuntimeEventAdapter`. +- **Claude Code hooks** — Rich notifications from Claude Code sessions via a wrapper + script that injects `--hooks` to route lifecycle events through the Unix socket. +- **Unix socket** — External tool integration via `~/Library/Application Support/Muxy/muxy.sock`. Accepts + pipe-delimited messages with paneID for routing. + +### Data Flow + +``` +Terminal event → GhosttyRuntimeEventAdapter / NotificationSocketServer + → TerminalViewRegistry.paneID(for:) (reverse lookup) + → NotificationNavigator.resolveContext() (pane → project/worktree/area/tab) + → NotificationStore.add() (suppressed if pane is focused and app active) + → Toast + sound delivery + → Persist to notifications.json (debounced) + → UI update (badge on sidebar, notification panel) +``` + +### Environment Variables + +Each terminal surface receives `MUXY_PANE_ID`, `MUXY_PROJECT_ID`, +`MUXY_WORKTREE_ID`, and `MUXY_SOCKET_PATH` via `ghostty_surface_config_s.env_vars`. +These are used by the Claude wrapper script and socket API to identify the +originating pane. + +### Click-to-Navigate + +`NotificationNavigator.navigate(to:)` dispatches three `AppState` actions in +sequence: `selectProject` → `focusArea` → `selectTab`. System notifications encode +the navigation context in `userInfo` and bring the app to front on click. + +## AI Usage Tracking + +Muxy displays live usage quota for the user's AI coding tools in a sidebar +popover. Unlike the notification hooks, usage tracking is read-only: it reads +credentials the user has already configured for each tool and queries the +vendor's usage endpoint directly. Nothing is written to the tools' settings. + +### Component Map + +``` +AIUsageService (@Observable, @MainActor singleton) + │ + ├── AIUsageSettingsStore / ProviderTrackingStore / ProviderEnabledStore + │ (UserDefaults-backed, in AIUsagePreferences.swift) + │ + ├── AIUsageProviderCatalog + │ (built from AIProviderRegistry.usageProviders on first access) + │ + ├── fetchSnapshots(for:) ── TaskGroup ──► provider.fetchUsageSnapshot() × N + │ │ + │ ▼ + │ AIUsageTokenReader → env / JSON file / Keychain + │ AIUsageOAuth → refresh access token + │ AIUsageSession → HTTP request + common errors + │ {Provider}UsageParser → JSON → [AIUsageMetricRow] + │ + ├── AIUsageAutoTracking + │ (first time a provider returns data, mark it as tracked) + │ + └── AIUsageSnapshotComposer + AIUsageRowPolicy + (filter to tracked providers, hide secondary rows unless opted in) +``` + +The service is observed by `SidebarFooter` (preview icon + popover) and +`AIUsageSettingsView` (settings tab). Both hold the singleton as `let` and rely +on the `@Observable` framework to invalidate on read. + +### Providers + +`AIUsageProvider` is the read-only counterpart to `AIProviderIntegration`. A +single concrete type can adopt both (e.g. `ClaudeCodeProvider` installs hooks +AND fetches usage). The registry (`AIProviderRegistry.usageProviders`) lists +all usage providers; today: + +- Claude Code, Codex, Copilot, Amp, Z.ai, MiniMax, Kimi, Factory + +Each provider has a matching `{Name}UsageParser` that takes raw JSON and +returns `[AIUsageMetricRow]`. Parsers are unit-tested against fixture payloads +in `Tests/MuxyTests/Services/*UsageParserTests.swift`; HTTP paths are tested +with `URLProtocol` stubs in `*UsageAPIClientTests.swift` where present. + +### Credentials + +`AIUsageTokenReader` is the single entry point for reading tokens and supports +three sources, tried in provider-defined order: + +1. Environment variables (e.g. `CLAUDE_CODE_OAUTH_TOKEN`, `ZAI_API_KEY`). +2. JSON credential files written by the vendor CLI under `~/.claude`, + `~/.codex`, etc. Some providers honor env-var overrides (`CLAUDE_CONFIG_DIR`, + `CODEX_HOME`) that match upstream CLI behavior. +3. macOS Keychain via `/usr/bin/security find-generic-password`. The account + name is passed through `Process.arguments` (array form, not a shell string) + to avoid argument injection. + +OAuth providers that rotate access tokens (Factory, Kimi) use +`AIUsageOAuth.refreshAccessToken` to exchange a refresh token and persist the +updated credential file back to disk with the same shape the vendor CLI wrote. + +### Refresh Lifecycle + +`AIUsageService.refresh(force:)` and `refreshIfNeeded()` are coalesced: if a +task is in-flight, subsequent callers await the existing task's result rather +than starting a parallel fetch. The `@MainActor` isolation plus an internal +`refreshTask` field gate concurrent entry. Auto-refresh cadence is driven by +`AIUsageAutoRefreshInterval` (5m / 15m / 30m / 1h) persisted in UserDefaults; a +60-second view-level timer in `SidebarFooter` calls `refreshIfNeeded` and the +service decides whether enough time has elapsed. + +### Settings & Defaults + +Per-provider "tracked" and "enabled" flags live in UserDefaults keyed by the +canonical provider ID (`muxy.usage.provider..{tracked,enabled}`). Global +settings: `muxy.usage.enabled`, `muxy.usage.displayMode` (used/remaining), +`muxy.usage.autoRefreshIntervalSeconds`, `muxy.usage.showSecondaryLimits`. On +first launch `AIUsageSettingsStore.isUsageEnabled()` runs a one-shot migration: +if any provider already has a tracked preference, the global flag is turned on +so users who enabled tracking before the global toggle existed keep seeing the +panel. + +### Row Policy + +`AIUsageRowPolicy` splits metric rows into primary (session / 5h / hourly / +premium) and secondary (weekly / monthly / daily / billing) buckets by label +prefix. By default the UI only shows primary rows; the "Show Secondary Limits" +settings toggle opts in to the full list. Dollar-denominated detail strings +are filtered out so the sidebar stays focused on usage quotas. + +## Remote Server (MuxyServer) + +The desktop app embeds a WebSocket server (`MuxyRemoteServer`) that exposes +workspace state and terminal operations to the iOS companion app over the local +network (LAN, Tailscale, etc.). + +### Architecture + +``` +MuxyMobile (iOS) ◄── WebSocket (JSON) ──► MuxyRemoteServer (inside Muxy.app) + │ + ▼ + MuxyRemoteServerDelegate + (AppState, ProjectStore, etc.) +``` + +The server listens on a user-configurable port (default 4865) when enabled in +Mobile settings. The port is stored in `UserDefaults` and applied on start. +`MobileServerService` reports bind failures back to the UI: if the listener +fails to start (e.g. port in use), the enable toggle is rolled off and the +settings view displays the error. It uses Apple's Network framework +(`NWListener` + `NWConnection`) with the WebSocket protocol. All messages use +the `MuxyMessage` JSON envelope from `MuxyShared`. + +### Protocol + +Request-response with server-pushed events: + +- **Request/Response** — Client sends `MuxyRequest` (method + params), server + replies with `MuxyResponse` (result or error). Each request has a unique ID + for correlation. +- **Events** — Server pushes `MuxyEvent` to all connected clients when state + changes (workspace updates, new notifications, project list changes). + +### Shared Types (MuxyShared) + +Platform-agnostic DTOs used by both apps. All types are `Codable` and `Sendable`. +The `MuxyCodec` handles JSON encoding/decoding with ISO 8601 dates. + +### Terminal I/O Streaming + +Terminal traffic between Mac and remote clients flows as raw PTY bytes, not +rendered cell grids. This relies on two additive exports on the `muxy-app/ghostty` +fork (see [building-ghostty.md](building-ghostty.md)): + +- `ghostty_surface_set_data_callback(surface, cb, userdata)` — registers a + per-surface callback invoked on the termio thread every time Ghostty receives + a chunk of bytes from the PTY, before its emulator parses them. +- `ghostty_surface_send_input_raw(surface, ptr, len)` — writes bytes directly + to the PTY, bypassing Ghostty's paste pipeline (no bracketed-paste wrapping, + no newline filtering, no keyboard-protocol interpretation). + +`RemoteTerminalStreamer` on the Mac registers the data callback on every +terminal surface at creation (`GhosttyTerminalNSView.createSurface`), +unregisters on teardown, and forwards bytes as `terminalOutput` events targeted +at the owning client via `MuxyRemoteServer.send(_:to:)`. The event payload is +a `TerminalOutputEventDTO` containing the paneID and a `Data` of raw bytes +(base64-encoded on the JSON wire). + +Input from mobile flows as raw bytes (`TerminalInputParams.bytes: Data`, +base64-encoded on the JSON wire) through `terminalInput → sendRemoteBytes → +ghostty_surface_send_input_raw`, so every byte — including escape sequences, +mouse reports, arrow keys, and control codes — is delivered to the child +process verbatim. + +### iOS App (MuxyMobile) + +`ConnectionManager` manages the WebSocket lifecycle and maintains a local mirror +of the remote state (projects, workspace layout, notifications). It also keeps a +rolling connection trace so mobile failures can surface a user-shareable +technical report from the phone's error sheet. + +`TerminalView` hosts a full VT emulator on-device via [SwiftTerm] +(https://github.com/migueldeicaza/SwiftTerm), wrapped in a `UIViewRepresentable` +as `SwiftTermRepresentable` / `MuxySwiftTermView`. The Mac streams raw PTY +bytes to the owning client via `terminalOutput` events; `ConnectionManager` +routes them via `subscribeTerminalBytes(paneID:handler:)` into +`SwiftTerm.TerminalView.feed(byteArray:)`. User input from the on-screen +keyboard flows out through `TerminalViewDelegate.send(source:data:)` back to +`sendTerminalInput`. Text selection (double-tap for word, triple-tap for line), +scrollback, tap-to-position, hardware-keyboard chords, accessibility, and +predictive text all come from SwiftTerm's native implementation. + +Mobile scroll gestures are translated to escape sequences based on the remote +TUI's state. When the remote program has enabled mouse reporting +(`terminal.mouseMode != .off`), a custom `UIPanGestureRecognizer` on +`MuxySwiftTermView` emits SGR mouse-wheel events +(`ESC[<64;x;yM` / `ESC[<65;x;yM`) via `terminal.encodeButton` + `sendEvent`. +When mouse reporting is off, SwiftTerm's built-in gesture converts panning into +cursor-key sequences (`ESC[A/B`), which scrolls pagers and arrow-key-driven +TUIs. The custom Muxy accessory bar (`TerminalAccessoryBar` + +`TerminalAccessoryView`) is wired as `inputAccessoryView` and provides the +D-pad, esc / tab / `~` / `|` / `/` / `-` keys, a long-press modifier arm-next +key for Ctrl/Shift/Alt/Cmd chords, and Copy / Paste actions. + +### Device Pairing + +Connections are gated by a trust-on-first-use pairing handshake. Each mobile +device generates a persistent `deviceID` (UUID) and a random `token` on first +launch; both are stored in the iOS Keychain (`DeviceCredentialsStore`). + +On every connect, the mobile app sends `authenticateDevice` first. The Mac +(`ApprovedDevicesStore`) compares the device's SHA-256 token hash against the +stored hash for that `deviceID`: + +- **Known device with matching token** → immediately authorized. +- **Unknown device** → server returns `401 Unauthorized`. Mobile falls back to + `pairDevice`, and `PairingRequestCoordinator` on the Mac queues the request + and surfaces an approval sheet on `MainWindow`. Approval stores the token + hash in `~/Library/Application Support/Muxy/approved-devices.json`; denial + returns `403`. +- **Token mismatch** → treated the same as unknown; server returns `401` so a + stolen but outdated credential can't resume authentication. + +Until the handshake succeeds the server rejects every other RPC with +`401 Unauthorized`. After success, the client is added to an +`authenticatedClients` set on `MuxyRemoteServer`; broadcasts only go to clients +in that set. The `Mobile` tab in Settings lists approved devices with a Revoke +action, which removes the device from storage and terminates any active +connection for that `deviceID` via `MuxyRemoteServer.disconnect(deviceID:)`. + +### Android Companion App + +`android/` is a Kotlin/Compose client mirroring MuxyMobile's scope (remote +view + input). Layout: + +- **`:protocol`** — Kotlin port of `MuxyShared`. DTOs, envelope, kotlinx.serialization + codec configured to round-trip the Mac's JSON byte-for-byte (custom serializers + for `SplitNodeDTO`, `PaneOwnerDTO`, ISO-8601 dates, base64 `ByteArray`, UUIDs). +- **`:net`** — `MuxyClient`, OkHttp WebSocket. State machine + (`Idle → Connecting → Authenticating → AwaitingApproval → Connected → Reconnecting → Failed`), + `terminalInput` fire-and-forget, `terminalOutput`+`terminalSnapshot` demuxing per + paneID, ping-then-reconnect on foreground, 120-entry diagnostic ring buffer, + `DeviceCredentialsStore` (Android Keystore AES-GCM encrypted DataStore), + `SavedDevicesStore`, `DeviceTheme` tracked from `PairingResultDTO` + + `themeChanged` events. +- **`:terminal`** — Compose terminal UI. Vendors Termux's `terminal-emulator` + (pure-Java VT core) and `terminal-view` (Android `View` with native selection, + IME hardening, mouse reporting) under `vendor/` at a pinned commit. The + vendored `TerminalSession` is patched to drop libtermux JNI and the local-PTY + threads; instead `MuxyTerminalSession.write` forwards user input to + `MuxyClient.terminalInput`, and `feedRemoteOutput` pumps incoming + `terminalOutput` / `terminalSnapshot` bytes into `TerminalEmulator.append` on + the main thread. The Compose wrapper `MuxyTerminalView` tracks pane ownership, + auto-takes-over once per `paneID`, sends `terminalResize` on layout changes, + releases the pane on disposal, and renders `TakeOverOverlay` when another + client owns the pane. `TerminalAccessoryBar` provides the IME-pinned key row + (Esc, Tab, Paste, Copy, `~|/-`, modifier picker, kbd toggle, analog D-pad with + 300 ms initial / 60 ms repeat). `ModifierTransform` matches iOS's chord table + exactly (Ctrl+a..z → control byte, Ctrl+space → NUL, Shift → uppercase, Alt → + ESC prefix, Cmd → passthrough). +- **`:app`** — Compose entry point. `MuxyNavHost` routes the `MuxyClient.state` + flow into Connect / Connecting / Pairing / Connected / Failed. Connected flow + shows `ProjectListScreen` and workspace tabs hosting `MuxyTerminalView`. The + app also wires `MuxyLifecycleBinder` to `ProcessLifecycleOwner` (clean + socket close on background, silent reconnect on foreground / network change), + persists last-used `(host, port, deviceName)` and active project ID via + `LastSessionStore` so cold starts auto-resume into the previous workspace, + and ships a Settings screen (font size 8…24, Use Nerd Font toggle backed by a + bundled JetBrains Mono Nerd Font in `:terminal` assets, About, Forget Device + → wipes credentials + saved devices + last session). Connection failures + open a `ConnectionIssueDetailsSheet` that exports the recent diagnostic ring + buffer plus device / state / target info via clipboard or share intent. + +The Android binary is GPL-3.0 because it links the vendored Termux libraries +(per Termux's repository LICENSE.md these specific libraries are Apache 2.0 +derivatives of jackpal/Android-Terminal-Emulator, but the Termux app itself is +GPL-3.0; we treat the whole APK conservatively as GPL-3.0). Swift targets +remain under the existing repository license. `android/terminal/vendor/UPSTREAM` +records the pinned commit and our local modifications. diff --git a/docs/plans/android-companion.md b/docs/plans/android-companion.md new file mode 100644 index 0000000..21a3aea --- /dev/null +++ b/docs/plans/android-companion.md @@ -0,0 +1,1137 @@ +# Android Companion — Implementation Plan + +## Goal + +Build an Android remote-control client for Muxy. Same scope as `MuxyMobile/` +(iOS): pair with the desktop app, browse projects/worktrees/tabs, view +terminal output, send keyboard input. Transport is the existing +`MuxyRemoteServer` WebSocket protocol on port 4865, accessed over Tailscale +(or any network the user controls). + +## Decisions (confirmed) + +- **Scope:** Remote control (view + input). +- **Transport:** Tailscale / VPN. Manual host entry. mDNS optional later. +- **Auth:** Pairing code (trust-on-first-use, same flow as iOS). +- **Repo:** Same Muxy repo, in a new top-level `android/` directory. +- **Q1 — Terminal emulator: Termux's `terminal-emulator` + `terminal-view` + libraries**, vendored under `android/terminal/vendor/`. **Licensing + decision (confirmed): GPL-3.0 is accepted for the Android app.** This + means the resulting Android APK is distributed under GPL-3.0 — anyone + receiving the APK gets re-distribution rights and corresponding source + must be made available. Action items this triggers: + - Add a top-level `LICENSE` file at `android/LICENSE` (GPL-3.0 full + text) plus an `UPSTREAM` file noting the pinned Termux commit + - In-app About screen must include a "Source code" link satisfying + GPL §6 (link to the Muxy repo and to the Termux upstream) + - Root README / license docs must say the repo is mixed-license: + existing Swift targets stay under the current license, while the + Android binary and `android/` source are GPL-3.0 + - GitHub releases must link the exact source tag used for the APK, + not just the default branch + + Native Android `View` with `Canvas` glyph rendering — best mobile UX + on the platform: native text-selection handles + system selection + toolbar, hardened IME handling that disables predictive text and fixes + Samsung/Gboard quirks, native overscroll/fling scroll physics, fast + rendering on heavy output bursts. Battle-tested in the most-used + Android terminal. Wiring shape mirrors iOS's SwiftTerm: + `TerminalEmulator.append(bytes, len)` for output, key handler → + `TerminalSession` for input. +- **Q2 — Min Android SDK: API 31 (Android 12).** ~85% device coverage, + Material 3, cleaner IME APIs, modern window insets. Support predictive + back on Android versions where the platform exposes it. Target SDK = + latest stable. +- **Q3 — UI toolkit: Hybrid (Compose + Views).** Compose for all app + screens (connect, project list, workspace, settings, notifications); + drop to Android `View` for the terminal via `AndroidView { … }` since + Termux ships a `View`. Same approach iOS uses (SwiftUI + `UIView` + representable). No pure-XML layouts. +- **Q4 — Connection lifecycle: reconnect-on-foreground.** Android v1 + cleanly closes the socket when the app backgrounds, then reconnects on + foreground + network change. This is intentionally Android-specific: + iOS keeps its socket and pings on foreground, but Android v1 has no + persistent foreground service. **Reconnect UI is silent — match iOS's + user-facing behavior.** Keep state visually connected while the new + socket is being recreated, and only show a "Reconnecting…" banner if + reconnect actually fails into the error state. +- **Q5 — Push notifications: deferred.** v1 ships an in-app notification + list backed by `listNotifications` snapshots only. No FCM, no Google + Play Services dependency, no live `notificationReceived` stream until + Mac-side event emission exists. Revisit after v1 ships. +- **Q6 — Distribution: GitHub Releases.** Sign release APK in CI, attach + to GitHub releases. Users sideload. F-Droid / Play Store only if there + is real demand later. + +## Phase status legend + +`[ ]` not started · `[~]` in progress · `[x]` done + +--- + +## Phase 0 — Pre-work and decisions + +**Goal:** Lock open decisions, capture protocol fixtures from a live +Mac+iOS session for use as test vectors during the protocol port. + +- [ ] Capture JSON fixtures from a real iOS↔Mac session. **Only four + events actually fire on the wire today** — verified by grepping + every `MuxyEvent(event: …)` and `broadcast/send` call-site in + `Muxy/`: + 1. `paneOwnershipChanged` (broadcast — `RemoteServerDelegate.swift:40`) + 2. `themeChanged` (broadcast — `RemoteServerDelegate.swift:45`) + 3. `terminalSnapshot` (**unicast to the taker** — + `RemoteServerDelegate.swift:214-215`) + 4. `terminalOutput` (**unicast to the pane's current owner** — + `RemoteTerminalStreamer.swift:42-49`) + + The DTOs/event-kinds `workspaceChanged`, `projectsChanged`, + `notificationReceived`, `tabChanged` are defined in `MuxyShared` + and have iOS handler cases at `ConnectionManager.swift:805-825`, + but **no Mac code path emits them**. Don't try to capture + fixtures for those — they will be empty. Capture instead: pair, + auth (success + 401 → pair flow), `listProjects`, `getWorkspace` + (response, not event), `takeOverPane` → `terminalSnapshot` + unicast, live `terminalOutput` unicast chunks, `terminalInput` + send, `terminalResize` send, `paneOwnershipChanged` broadcast, + `themeChanged` broadcast, `getProjectLogo`, `listNotifications` + response. Save under `docs/plans/android-fixtures/*.json`. + Fixtures contain UUIDs and dates — round-trip tests must use + schema/shape equality or pre-normalize those fields, not raw + byte equality +- [ ] Fix stale remote docs before using them as implementation + references. `docs/remote-server.md` currently overstates + `workspaceChanged` as authoritative and describes + `terminalSnapshot` as `terminalCells`; current code sends + `terminalSnapshot` as `TerminalOutputEventDTO` bytes and emits no + workspace/project/tab/notification events. Update that doc and + the protocol section in `docs/architecture.md` before Phase 2 +- [ ] **Spike: Termux library buildability without native deps.** The + single highest-risk item in the whole plan. Verify before Phase 6 + that Termux's `terminal-emulator/` module builds and runs as a + pure-Java VT core (no `libtermux.so`), and that `TerminalSession` + can be subclassed (or its PTY-write path replaced) so outgoing + bytes route to `MuxyClient.terminalInput` instead of a local FD. + If JNI / native lib coupling makes this hard, the whole "vendor + Termux" approach needs reconsideration before committing to + Phase 1's module layout +- [ ] Confirm Mac-side protocol exposes everything Android needs (audit + `MuxyShared/MuxyProtocol.swift` and compare against MuxyMobile's + `ConnectionManager.swift` request surface). Now that we know only + four events fire, this audit is short — focus on RPC coverage + (project/workspace/tab/worktree/VCS/terminal), not events +- [ ] Identify any Mac-side gaps — confirmed: + - No `platform` field on `ApprovedDevice` / `pairDevice` params + (so the approval sheet can't say "Pixel 8 Pro (Android)"). + `deviceName` is already passed and shown today + (`PairingRequestCoordinator.swift:83`) + - mDNS `_muxy._tcp.local` is not advertised — file as a small + follow-up if we want LAN discovery + - **`PairingRequestCoordinator` has no server-side timeout.** + `withCheckedContinuation` waits indefinitely + (`PairingRequestCoordinator.swift:34-42`). The 120s "pairing + timeout" is purely a client-side give-up timer. If the client + reconnects before the Mac user responds, it can enqueue another + pairing request while the first alert is still pending (queue at + line 23 is unbounded). If the user approves after the Android + timeout but before another pair attempt, the device is stored and + the next authenticate succeeds. File server-side timeout as a + Mac-side follow-up + - **`408 pairingTimeout` error code is unreachable.** Defined in + `MuxyError` but never returned by any code path. Drop it from + the Android error mapping + - No protocol version/handshake field anywhere in `MuxyMessage` + / `MuxyRequest` / `MuxyResponse`. Future protocol changes are + silent breakage. Document as a known limitation + +--- + +## Phase 1 — Android project scaffolding + +**Goal:** Empty Android app builds, runs, and lives at `android/`. + +- [x] Create `android/` directory with Gradle Kotlin DSL multi-module setup +- [x] Modules: `:app` (UI), `:protocol` (DTOs + envelope + codec), + `:net` (WebSocket client + connection manager), `:terminal` + (Termux libs vendor + Compose wrapper) +- [x] Base deps: Compose BOM, kotlinx.serialization-json, + kotlinx.coroutines, OkHttp (WebSocket), AndroidX lifecycle, + AndroidX datastore-preferences. Do **not** use AndroidX + security-crypto for new credential code; `EncryptedSharedPreferences` + is deprecated. Use Android Keystore directly in Phase 4 +- [x] Min SDK 31, target SDK = latest stable, compile SDK = latest stable +- [x] App scaffolding: `MainActivity`, theme, navigation host (Navigation + Compose) +- [x] Manifest permissions: + - `android.permission.INTERNET` for the WebSocket itself + - `android.permission.ACCESS_NETWORK_STATE` because Phase 11 uses + `ConnectivityManager.NetworkCallback` + - `android.permission.ACCESS_LOCAL_NETWORK` on API 37+ if v1 allows + direct LAN hosts or mDNS discovery. Tailscale/VPN paths are not + local network paths, but manual LAN IPs are local network paths +- [x] **Cleartext traffic config (REQUIRED).** Android 9+ blocks + plaintext sockets by default. Since transport is `ws://` (no + TLS — see Phase 4), the app will fail to connect unless cleartext + is explicitly allowed. Because users type arbitrary hosts/IPs, + `network-security-config.xml` cannot dynamically scope cleartext to + those user-supplied hosts. Pick one of two honest choices: + (a) app-wide cleartext via `network-security-config.xml` + `base-config cleartextTrafficPermitted="true"` plus a strong + trusted-network warning, or (b) ship only a fixed allowlist of known + domains and reject arbitrary manual hosts. For current Tailscale / + manual-host scope, choose (a) +- [x] **`android:allowBackup="false"` (REQUIRED for credential safety).** + Default Android auto-backup can upload the stored credential + ciphertext. The Android Keystore key will not restore with it, so + restore can either leak stale blobs or create unrecoverable + credentials. Either set `allowBackup="false"` on ``, + or add `data_extraction_rules.xml` + `full_backup_content.xml` that + exclude the credential store. Document the choice in the README's + security section +- [x] **R8 / ProGuard rules for kotlinx.serialization.** Add or verify the + kotlinx-serialization-recommended rules in modules that define + `@Serializable` types, then prove release mode with Phase 13's + installed release APK test. Do not rely on debug builds for codec + confidence +- [x] **OkHttp config.** `OkHttpClient.Builder().pingInterval(20, SECONDS)` + so OkHttp self-detects dead sockets — required for the + reconnect-on-foreground path in Phase 11 to work. Default is 0 + (disabled). Also: explicit `readTimeout(0, MILLISECONDS)` for the + WebSocket (no read timeout on a long-lived stream) +- [x] `.gitignore` for Android artifacts (`build/`, `.gradle/`, + `local.properties`, IDE files) +- [x] Sample `local.properties.example` +- [x] README in `android/` with build instructions (Android Studio version, + JDK version, target/min SDK), the cleartext-traffic note, and the + "trusted network only" caveat from Phase 4 + +--- + +## Phase 2 — Protocol port + +**Goal:** Kotlin types that round-trip the same JSON as `MuxyShared/`. + +- [x] Port envelope: `MuxyMessage`, `MuxyRequest`, `MuxyResponse`, + `MuxyEvent` → `:protocol`. **There are three JSON shapes — get all + three or workspace decoding will silently fail:** + 1. `MuxyMessage` (outer) → `{type: "request"|"response"|"event", + payload: ...}` (see `MuxyShared/MuxyMessage.swift:8-11`) + 2. `MuxyParams` / `MuxyResult` / `MuxyEventData` (inner enums) → + `{type, value}` (see `MuxyShared/MuxyProtocol.swift`) + 3. `SplitNodeDTO` (workspace tree, recursive) → the inner key is + **named after the type, not `value`**: + `{type: "tabArea", tabArea: TabAreaDTO}` or + `{type: "split", split: SplitBranchDTO}` (see + `MuxyShared/WorkspaceDTO.swift:27-64`) +- [x] Port enums: `MuxyMethod`, `MuxyEventKind`, error codes. Error + codes actually returned by the Mac: `400 invalidParams`, + `401 unauthorized` (semantically: "device unknown — try + `pairDevice` next"), `403 pairingDenied`, `404 notFound`, + `500 internalError`. **`408 pairingTimeout` is defined in + `MuxyError` but unreachable** — `PairingRequestCoordinator` + never times out. Don't surface it as a distinct case on Android. + Skip `registerDevice` from the wire — it is a `MuxyMethod` value + but iOS never sends it; the Mac calls its own delegate's + `registerDevice` internally inside `finalizeAuth` to populate + `PaneOwnershipStore` with the client name (which becomes the + `displayName` on `PaneOwnerDTO.remote(deviceID, name)` in the + take-over overlay) +- [x] **Event-kind ↔ data-case naming mismatches** — JSON has both an + outer event kind and an inner data type, and they are NOT the same + string. Decoder must handle: `workspaceChanged` → data + `workspace`; `projectsChanged` → data `projects`; + `notificationReceived` → data `notification`. Other pairs match + (`tabChanged`/`tab`, `terminalOutput`/`terminalOutput`, + `terminalSnapshot`/`terminalSnapshot`, + `paneOwnershipChanged`/`paneOwnership`, `themeChanged`/`deviceTheme`). + Note: only the four owner-ship/theme/terminal pairs ever appear + on the live wire — see Phase 0. Decoders must still handle the + others for the unit tests / future use, but don't expect to see + them at runtime +- [x] **Custom kotlinx.serialization serializers required** for the + Swift enums-with-associated-values that don't match kotlinx's + default polymorphic shape: + 1. `SplitNodeDTO` — wire is `{type, tabArea}` / `{type, split}`, + not the kotlinx default `{type, value}`. Write a custom + `KSerializer` that reads the discriminator and dispatches + 2. `PaneOwnerDTO` — Swift enum `.mac(name)` / `.remote(deviceID, + name)`. Verify wire shape against fixtures, write custom + serializer if it doesn't match the default + 3. `NotificationDTO.SourceDTO` — Swift enum with `.aiProvider(String)` + associated value. Same: verify wire shape and write custom + serializer if needed + The default `@Serializable sealed class` with `JsonClassDiscriminator` + will NOT produce the right JSON for these +- [x] Port DTOs: `ProjectDTO`, `WorktreeDTO`, `WorkspaceDTO` (with + `SplitNodeDTO`, `SplitBranchDTO`, `TabAreaDTO`, `TabDTO`, + `TabKindDTO`, `SplitDirectionDTO`, `SplitPositionDTO`), + `NotificationDTO`, `VCSStatusDTO`, `VCSBranchesDTO`, + `VCSCreatePRResultDTO`, `TerminalOutputEventDTO`, + `TerminalContentDTO`, `TerminalCellsDTO` (+ `TerminalCellDTO`, + `TerminalCellFlag`), `PaneOwnerDTO`, `PaneOwnershipEventDTO`, + `DeviceThemeEventDTO`, `PairingResultDTO`, `DeviceInfoDTO`, + `ProjectLogoDTO`, `TabChangeEventDTO`, `ProjectIconColor`. The + live wire uses `TerminalCellsDTO` for `getTerminalContent` + responses; `TerminalContentDTO` is unused on the wire today but + ships in the Swift module — port both for parity +- [x] Port `ProtocolParams.*` request/result types +- [x] Configure kotlinx.serialization to match `MuxyCodec` exactly + (verify with fixtures): + - ISO 8601 date serializer + - UUID string serializer for every Swift `UUID` + - `explicitNulls = false`, because Swift `JSONEncoder` omits nil + optionals while kotlinx.serialization includes nulls by default + - keep `encodeDefaults = false` unless a fixture proves Swift emits + a default-valued field +- [x] JSON round-trip tests against captured fixtures (decode, re-encode, + assert shape/value equality after normalizing UUIDs and dates — + raw byte equality is unreliable because key order isn't guaranteed + and our fixtures contain randomized UUIDs/timestamps) +- [x] Base64 serializer for `Data` ↔ ByteArray. `Data` Codable on Apple + defaults to base64 strings on the wire, while kotlinx.serialization + `ByteArraySerializer` encodes a JSON list of numbers by default. + Do not use Kotlin's default `ByteArray` JSON shape. The actual + `Data`-typed wire fields are only `TerminalInputParams.bytes` and + `TerminalOutputEventDTO.bytes`. `ProjectLogoDTO.pngData` is a + `String` that already contains base64 (`MuxyShared/ProtocolParams.swift:506`). + The device `token` is also a `String` field (already base64-encoded + client-side via `Data(bytes).base64EncodedString()`) — not a + `Data` field. **`TerminalContentDTO.content` is a `String`, NOT + a `Data` / base64 field** (`MuxyShared/ProtocolParams.swift:253`). + Earlier drafts of this plan said it was base64; that was wrong + +--- + +## Phase 3 — WebSocket client + connection manager + +**Goal:** Kotlin equivalent of iOS's `ConnectionManager.swift`. + +- [x] `MuxyClient` in `:net`: OkHttp `WebSocket` over `ws://host:port` + (no TLS — see Phase 4 security note). Lifecycle (connect, close, + error), exponential-backoff reconnect with jitter. Send/receive + uses **text frames** (Mac uses `NWProtocolWebSocket` opcode + `.text`, iOS uses `URLSessionWebSocketTask.send(.string)`) — JSON + goes over text frames, not binary +- [x] Request/response correlation via string ID (iOS uses + `UUID().uuidString`, not numeric). Exposed as + `suspend fun send(method, params, timeout): MuxyResponse?`. + Per-request timeouts from iOS, mirror them exactly: + - default RPC: 10 s + - `pairDevice`: 120 s (client-side give-up — Mac has no timeout) + - `vcsCommit`: 60 s + - `vcsPush`, `vcsPull`, `vcsCreatePR`: 120 s + - `vcsSwitchBranch`, `vcsCreateBranch`: 30 s + - `vcsAddWorktree`, `vcsRemoveWorktree`: 60 s +- [x] **Fire-and-forget path for `terminalInput`** — the Mac's + `voidMethods` set drops the response for `terminalInput` + (`MuxyServer/MuxyRemoteServer.swift:262`); confirmed + `voidMethods` contains only `terminalInput`. Awaiting it leaks + pending-request entries on every keystroke. Mirror iOS's + `sendFireAndForget` (`ConnectionManager.swift:620`) +- [x] Event bus: `MutableSharedFlow` per event kind, plus a + typed `terminalOutput(paneID)` `Flow` accessor that + demuxes both `terminalOutput` AND `terminalSnapshot` events to the + same per-pane handler (iOS pattern in + `ConnectionManager.swift:820-823`). **Important caveat:** both + events are unicast — `terminalOutput` is sent only to the pane's + current owner (`RemoteTerminalStreamer.swift:42-49`), + `terminalSnapshot` is sent only to the client that just took + over (`RemoteServerDelegate.swift:214-215`). A non-owner gets + no live output at all +- [x] **No `subscribe`/`unsubscribe` requests are sent** — the Mac's + handler is a no-op (`MuxyRemoteServer.swift:616-618`). The Mac + decides for itself which client receives what (broadcast for + ownership/theme; unicast-to-owner for terminal bytes). Filtering + on the client is per-pane handler dispatch, not subscription + management. Do NOT add per-pane subscribe RPCs +- [x] **Suppress error surfacing while backgrounded.** When a send + fails AND `isBackgrounded`, swallow the error and don't + transition to `.error` state — iOS does this at + `ConnectionManager.swift:719-727`. Without this, every + foreground/background cycle that catches an in-flight request + flashes a spurious error banner +- [x] Connection state machine: `Idle → Connecting → Authenticating → + AwaitingApproval → Connected → Reconnecting → Failed`. Modeled as + a sealed class exposed via `StateFlow`. `AwaitingApproval` is the + window between sending `pairDevice` and the Mac user tapping + Approve/Deny in the modal NSAlert (can take up to the 120s + pairing timeout) +- [x] Connection trace ring buffer — **120 entries** (matches iOS + `diagnosticLog`), timestamped with ISO 8601 (fractional seconds) + via a single shared formatter. Exposed to UI for the error-report + sheet (mirror iOS `recordDiagnostic`) +- [x] Reconnect strategy: ping-then-reconnect on foreground. iOS calls + `URLSessionWebSocketTask.sendPing` first; if it errors, it + tears the socket down and runs `reconnectSilently()` + (`ConnectionManager.swift:374-394`). On Android, OkHttp's + `pingInterval(20s)` (Phase 1) makes the listener fire on dead + sockets automatically; the foreground hook only needs to verify + `webSocket != null` and trigger silent reconnect if not. + Reconnect must: + 1. Be guarded by an `isReconnecting` flag so concurrent triggers + only fire once (iOS `ConnectionManager.swift:404-405`) + 2. Clear `paneOwners` (iOS `:407`) — server side cleared them + on disconnect via `RemoteServerDelegate.clientDisconnected` + → `releaseAll(clientID)` + 3. Re-authenticate, then explicitly call + `selectProject(activeProjectID)` followed by `getWorkspace` + (iOS does this through `selectProject` → `refreshWorkspace` at + `ConnectionManager.swift:424-426` and `:489-534`). Raw + `selectProject` returns only `ok`; it does not carry workspace. + **There is no `workspaceChanged` event from the server** — + without `getWorkspace`, the workspace is stale (deferred to + the workspace UI layer in Phase 8 — `:net` exposes the + primitives `selectProject` + `getWorkspace`) + 4. Any active terminal view will need to re-issue `takeOverPane` + after reconnect (server released ownership on disconnect) +- [x] **Pane ownership reset points** — clear `paneOwners` on: + project select (iOS `:493`), reconnect (iOS `:407`), disconnect + (iOS `:166`). Plan only mentions disconnect originally; the + other two matter because stale owner data renders a wrong + "Controlled by X" overlay on the next pane mount +- [x] **Saved devices CRUD.** iOS persists `[SavedDevice]` + (`name + host + port`, Codable list) and exposes add / remove on + the Connect screen — not just "last-used host/port". Port the + full list, with `add(SavedDevice)`, `remove(SavedDevice)`, and + ordered iteration. Persist as JSON in DataStore +- [x] Tests with OkHttp `MockWebServer`: handshake, authenticate flow + including 401-then-pair, fire-and-forget `terminalInput`, RPC + round-trip, event delivery (with the kind/data naming mismatch), + reconnect + +--- + +## Phase 4 — Pairing + +**Goal:** Trust-on-first-use, matching the iOS handshake exactly. + +- [x] `DeviceCredentialsStore` (Kotlin): generate `deviceID` (UUID) + + `token` (32 random bytes from `SecureRandom`, **base64-encoded + string** — match iOS `DeviceCredentialsStore.generateToken` which + returns `Data(bytes).base64EncodedString()`) on first launch. + Persist with Android Keystore directly: create an AES-GCM key via + `KeyGenerator` in `AndroidKeyStore`, encrypt `deviceID` + `token`, + and store ciphertext + IV in a private prefs/DataStore file that is + excluded from backup. Do not use `EncryptedSharedPreferences`; it is + deprecated +- [x] **No client-side hashing.** The client sends the **raw** token in + both `authenticateDevice` and `pairDevice`. The Mac hashes + (SHA-256) for comparison inside `ApprovedDevicesStore` + (`Muxy/Services/ApprovedDevicesStore.swift:85`). Do not pre-hash + on Android +- [x] Connect flow: send `authenticateDevice(deviceID, deviceName, + token)` → on `401 unauthorized` send `pairDevice(deviceID, + deviceName, token)` and wait up to 120 s for the Mac user to + approve in the modal NSAlert. **The 120 s is a client-side + give-up timer only — the Mac has no timeout and its NSAlert + blocks until tapped** (`PairingRequestCoordinator.swift:34-42`). + If the client times out and the user later approves, the device + is stored on the Mac but the client has already moved on — the + next reconnect will succeed via `authenticateDevice` (no second + pair prompt). Success result for both RPCs is + `MuxyResult.pairing(PairingResultDTO)` carrying `clientID`, + `deviceName`, and optional `themeFg/themeBg/themePalette`. On + `403 pairingDenied` surface "Approval denied on Mac" +- [x] **Security note (plaintext over `ws://`)** — there is no TLS; + device token + all traffic travel in the clear. Safe on Tailscale + / a trusted VPN, dangerous on open Wi-Fi. Surface this in connect + UX (e.g. a one-time "Use only on a trusted network" notice) and + document in the README +- [x] Pair pending state in UI: "Awaiting approval on Mac" with cancel + button. **The cancel button is local-only** — it stops the + Android client from waiting on the response, but the Mac alert + stays up until the user taps it. Document this in the UX so + users know to dismiss the Mac alert manually if they cancel on + Android. Pairing also requires the Mac user to be physically at + their Mac to dismiss the `NSAlert` + (`Muxy/Services/PairingRequestCoordinator.swift:79-100`) — there + is no asynchronous push approval today +- [x] **Backup safety check.** Verify Phase 1's `allowBackup` / + `data_extraction_rules.xml` decision actually excludes the + credential ciphertext store holding `deviceID` + `token`. + Manually run `adb shell bmgr backupnow` after pairing and + inspect the resulting backup to confirm credentials are not + included +- [x] Forget-device action (settings) clears local credentials and the + Android Keystore key. There is no remote revoke RPC today, so the + Mac's approved-device entry remains until the user removes it in + Mac settings + +--- + +## Phase 5 — Connect + project list UI + +**Goal:** User can connect to their Mac and see the project list. + +- [x] `ConnectScreen` (Compose): saved-devices list at top (rendered + from the `SavedDevice` store from Phase 3), each row tap-to- + connect, swipe-to-remove. Below the list: an "Add device" form + with name + host text field + port field (default 4865; the Mac + dev build listens on 4866 — + `MobileServerService.defaultPort = MuxyRemoteServer.defaultPort + 1` + when `AppEnvironment.isDevelopment`, so document this for anyone + pointing Android at a debug Mac). On successful connect, persist + the device into the saved list (matches iOS behavior) +- [ ] (Optional, behind feature flag) mDNS discovery via `NsdManager` for + `_muxy._tcp.local` — only useful on LAN, not Tailscale. Show as + suggestions, never required +- [x] `ProjectListScreen`: render `[ProjectDTO]` from `listProjects`, + show project color, and only call `getProjectLogo` for projects + whose `logo` field is non-nil (iOS pattern in + `ConnectionManager.fetchLogo` — `ProjectLogoDTO.pngData` is + base64 PNG, decode and cache by `projectID`). Tap to navigate +- [x] ViewModel layer: `ConnectViewModel`, `ProjectListViewModel`, + observing `MuxyClient` flows +- [x] Settings entry stub (server address management) + +--- + +## Phase 6 — Terminal rendering + pane ownership (Termux libs) + +**Goal:** Open a tab, see live output, type into it. The big rock. + +**Protocol shape (correct mental model — verified against the Mac +implementation, not the original draft of this plan):** + +Ownership is **single-writer AND single-reader**: at any moment a +pane is owned by either the Mac or exactly one remote client, and +**only the owner sees live output**. There is no "spectator" or +"read-only viewer" mode in v1. Specifically: + +- `terminalOutput` is **unicast to the current owner only** + (`RemoteTerminalStreamer.swift:42-49`: + `server?.send(event, to: clientID)`). A non-owner gets nothing. +- `terminalSnapshot` is **unicast to the client that just took over** + (`RemoteServerDelegate.swift:214-215`). +- `paneOwnershipChanged` is broadcast to all authenticated clients, + so non-owners can render the "Controlled by X" overlay correctly. +- The Mac silently drops `terminalInput`, `terminalResize`, and + `terminalScroll` from any client that does not own the pane + (`RemoteServerDelegate.swift:128-159`). + +To interact: + +1. Client sends `takeOverPane(paneID, cols, rows)`. +2. Mac assigns ownership and unicasts a one-shot `terminalSnapshot` + event (a `TerminalOutputEventDTO` carrying VT bytes synthesized + from current cells via `RemoteTerminalSnapshotBuilder`) to that + client only — this is the initial visible grid snapshot. It is not + historical scrollback. +3. Mac broadcasts a `paneOwnershipChanged` event so all clients + update their owner map. +4. Mac attaches `RemoteTerminalStreamer` to the pane's surface and + begins unicasting live `terminalOutput` events to the new owner. +5. Client sends `releasePane(paneID)` when navigating away. On + disconnect, the Mac auto-releases all panes the client owned + (`RemoteServerDelegate.clientDisconnected` → + `releaseAll(clientID)`). + +**Implication for UX:** A user opening the workspace sees the tab +list but no live output until they tap "Take Over" on a pane. +Auto-take-over on view attach (already in the plan, kept below) is +what makes this feel seamless. Don't promise live preview without +take-over. + +`getTerminalContent` returns parsed cells (`TerminalCellsDTO`) and is +NOT used by iOS for the live terminal path — skip it for v1 unless we +later want a non-takeover read-only view. Do **not** send `subscribe` / +`unsubscribe` requests; the Mac's handler is a no-op. + +- [x] Vendor Termux libraries under `android/terminal/vendor/`: + pinned at commit `30ebb2dee381d292ade0f2868cfde0f9f20b89fe`. JNI + module dropped (`JNI.java` excluded; jni/ subtree not vendored). + `LICENSE.md` + `UPSTREAM` files written +- [x] Strip Termux-specific bits we don't need: JNI/PTY plumbing + removed from `TerminalSession`; extra-keys row not vendored + (we ship our own accessory bar). Termux session manager is + not used — we own lifecycle via `MuxyClient` +- [x] Implement a `MuxyTerminalSession` that adapts Termux's + `TerminalSession` to our transport: vendored `TerminalSession` + lost its `final` modifier and its local-PTY constructor was + replaced with a remote-mode constructor; our subclass overrides + `write(...)` to call `MuxyClient.sendTerminalInput`, and + `feedRemoteOutput(byte[], int)` pumps `terminalOutput` AND + `terminalSnapshot` bytes through `TerminalEmulator.append` on + the main thread via the session's `MainThreadHandler` +- [x] `MuxyTerminalView` Compose wrapper around Termux's + `TerminalView` (AndroidView interop). Inputs: `paneID`, font + size sp, theme palette via `MuxyClient.deviceTheme`, derived + `isOwnedBySelf` flag from owners map +- [x] **Pane ownership state in `MuxyClient`**: `paneOwners`, + `myClientID`, `paneIsOwnedBySelf` were already wired in Phase 3 +- [x] **Take-over UX**: `TakeOverOverlay` Composable rendered with + `View.alpha = 0` and focus disabled on the underlying + `TerminalView` when not owned. Owner name comes from + `PaneOwnerDTO.displayName` +- [x] **Lifecycle**: + - Auto-take-over once per paneID via a `LaunchedEffect` keyed on + `paneID/cols/rows` with an `autoTakenPaneID` guard set BEFORE + the take-over launches; emulator screen reset before take-over + so the snapshot lands on a clean grid + - `terminalSnapshot` and `terminalOutput` flow through the + single `client.terminalBytes(paneID)` flow demuxed in `:net` + - `DisposableEffect.onDispose` calls `client.releasePane(paneID)` + and `session.finishIfRunning()` + - On reconnect, owners are cleared in `:net`; the next + composition cycle sees `isOwnedBySelf = false` and triggers + the auto-take-over guard freshly when the size is re-reported +- [x] Resize: `View.OnLayoutChangeListener` reads + `emulator.mColumns/mRows` after layout and fires + `client.resizeTerminal` only when (cols, rows) actually changes. + Take-over guard not reset on resize, matching iOS's behavior +- [x] **Scroll forwarding — skipped, mirrors iOS exactly.** Termux + `TerminalView` already encodes touch as SGR mouse events when + mouse reporting is on. No `terminalScroll` calls +- [x] **Theme comes from the Mac**. `MuxyClient.deviceTheme` is now + populated from `PairingResultDTO` and updated on `themeChanged` + events; `MuxyTerminalView.applyTheme` writes + fg/bg/cursor/palette into `emulator.mColors.mCurrentColors`. + Falls back to white on black when no theme is sent +- [ ] **Honor terminal mode flags from `TerminalCellsDTO`** — + deferred. We don't call `getTerminalContent`; the emulator + tracks mode flags from VT bytes naturally +- [x] Native selection / clipboard: Termux's `TerminalView` selection + handles + system toolbar are kept; Paste from the accessory bar + reads Android `ClipboardManager` and writes to the session +- [x] Mouse mode: Termux encodes SGR mouse events on touch when the + remote enables reporting — preserved by the vendored view +- [x] IME: Termux's `onCreateInputConnection` (prediction-disabling + `InputType.TYPE_NULL`) is preserved by our `TerminalViewClient` + adapter (`shouldEnforceCharBasedInput = true`) +- [ ] Manual QA: deferred — requires a connected Mac + Android device + to run `top`, `vim`, `htop`, `tmux` end-to-end + +--- + +## Phase 7 — Mobile keyboard accessory bar + +**Goal:** Custom toolbar above the soft keyboard for terminal-friendly +keys, matching iOS. (Termux's stock extra-keys row is stripped during +vendoring; we ship Muxy's own.) + +Reference implementation: `MuxyMobile/TerminalView.swift:685-1014` +(`TerminalAccessoryView`, `ModifierKeyButton`, `ModifierPickerView`, +`DPadControl`). + +- [x] `TerminalAccessoryBar` Compose row sitting above IME via + `Modifier.imePadding()` on the parent column (the bar is + pinned at the bottom of the terminal column; the column also + hosts the `TerminalView`, so IME insets push everything up + together) +- [x] **Key set (full iOS parity)**: + - `Esc`, `Tab` + - **Paste** button — reads Android `ClipboardManager` and writes + bytes via `MuxyTerminalSession.write` + - **Copy** button — wires through `AccessoryActions.copySelectionToClipboard`; + Termux's selection toolbar handles the actual copy + - `~`, `|`, `/`, `-` + - **Modifier key** with tap = arm/disarm and long-press = + `ModifierPicker` Compose popover that changes which modifier + the next tap will arm (Ctrl ↔ Shift ↔ Alt ↔ Cmd) + - **Keyboard hide/show toggle** — Material `KeyboardHide` / + `Keyboard` icons; calls `InputMethodManager.showSoftInput` + / `hideSoftInputFromWindow` + - **D-pad** — analog Compose `Box` with `detectDragGestures`, + 5px deadzone, **300 ms initial delay then 60 ms cadence** + coroutine-driven repeat +- [x] Modifier transform table — `ModifierTransform.kt` matches iOS + exactly. Verified by `ModifierTransformTest`: + - Ctrl + `a..z`/`A..Z` → control byte + - Ctrl + space → `` + - Shift + text → uppercase + - Alt + text → `` prefix + - Cmd + text → passthrough +- [x] Map armed-modifier + key to byte sequences — UTF-8 encoding of + transformed strings matches iOS's `Data(transformed.utf8)` path; + unit tests assert specific control byte values for all 26 + letters and the NUL/ESC special cases +- [ ] Hardware keyboard: deferred to manual QA. Termux's + `TerminalView` key handling is preserved through our + `MuxyTerminalViewClient` (no overrides on key events) + +--- + +## Phase 8 — Workspace tree + tab UI + +**Goal:** Render the worktree → split → tab area structure from +`WorkspaceDTO` and let the user switch tabs. + +- [x] Tab picker (Compose `DropdownMenu` from a toolbar icon) — mirror + iOS `RemoteWorkspaceView.tabPicker` at + `MuxyMobile/RemoteWorkspaceView.swift:95-127`. Lists every tab + across every area, plus a "New Terminal" item that calls + `createTab`. **Not** a horizontal strip — iOS uses a Menu and we + should match +- [x] Active tab content router: `MuxyTerminalView` for `.terminal` + tabs (with a `paneID`); for `.vcs` / `.editor` / `.diffViewer` + kinds, render an "Open on desktop" placeholder. VCS gets a + first-class sheet (Phase 9) — the tab placeholder is just for + when the user lands on a VCS-kind tab in the workspace +- [x] Split rendering: v1 shows only the focused area's active tab — + iOS does the same (`RemoteWorkspaceView.swift:20-29`). Show + "Use desktop to manage splits" hint. Defer recursive split pane + rendering +- [x] **VCS toolbar button** in the workspace top bar — opens the VCS + sheet (Phase 9). Disabled when no active project. Mirrors iOS + `vcsButton` at `MuxyMobile/RemoteWorkspaceView.swift:60-68` +- [x] **Workspace sync — there is no live `workspaceChanged` event.** + Confirmed: no Mac code path emits one. The workspace updates + only via: + 1. An explicit `getWorkspace(activeProjectID)` after this client + triggers a workspace-changing RPC. Raw `selectProject`, + `selectWorktree`, `selectTab`, and `focusArea` responses are + `ok`, not workspace payloads; `createTab` returns only the new + tab. The Android wrapper layer must refresh workspace after + `selectProject` / `selectWorktree` / `createTab` / `closeTab` / + `selectTab` / `focusArea` / `splitArea` / `closeArea` + 2. Explicit user-triggered refresh via pull-to-refresh + 3. Reconnect, which re-issues `selectProject` and then + `getWorkspace` (Phase 3) + Tab/worktree changes made by the Mac itself or by *another* + remote device are **invisible** to this client until a refresh. + Document this in the UI ("Pull to refresh — workspace updates + from other devices won't appear automatically") rather than + promising live sync we can't deliver +- [x] Pull-to-refresh on workspace screen — implemented as an explicit + Refresh button in the top bar (Compose's `LazyColumn`/SwipeRefresh + pattern doesn't fit the focused-area-only layout cleanly). Calls + `client.refreshWorkspace(activeProjectID)` and replaces the local + `WorkspaceDTO`. Recoverable error handling on missing project / + worktree deferred + +--- + +## Phase 9 — Source Control (VCS) + +**Goal:** Full Git workflow against the active project — match iOS's +VCS sheet, not just placeholders. iOS exposes Source Control as a +**top-level toolbar sheet from the workspace** (not a tab), reachable +from any project context. We mirror that. + +Reference iOS files: + +- `MuxyMobile/VCSView.swift` — status sheet (stage/unstage/discard, + commit, push, pull, PR display) +- `MuxyMobile/BranchesSheet.swift` — list, switch, create branch +- `MuxyMobile/WorktreesSheet.swift` — list, add, remove, switch worktree +- `MuxyMobile/CreatePRSheet.swift` — full PR creation flow +- `MuxyMobile/ConnectionManager+VCS.swift` — RPC wrappers + +Underlying RPCs and DTOs are already ported in Phase 2 +(`getVCSStatus`, `vcsStageFiles`, `vcsUnstageFiles`, `vcsDiscardFiles`, +`vcsCommit`, `vcsPush`, `vcsPull`, `vcsListBranches`, `vcsSwitchBranch`, +`vcsCreateBranch`, `vcsCreatePR`, `vcsAddWorktree`, `vcsRemoveWorktree`, +plus `selectWorktree`). + +### 9.1 — Connection manager VCS extension + +- [x] Kotlin extension on `MuxyClient` matching the iOS surface in + `ConnectionManager+VCS.swift`: + - `suspend fun fetchVCSStatus(projectID): VCSStatusDTO?` + - `suspend fun stageFiles / unstageFiles / discardFiles` + (`discardFiles` takes both `paths` and `untrackedPaths` — + iOS-side splits at `VCSView.swift:340-353`; preserve that + split because the Mac-side Git ops differ for tracked vs + untracked) + - `suspend fun vcsCommit(projectID, message, stageAll)` — + timeout 60 s + - `suspend fun vcsPush(projectID)` — timeout 120 s; surface + `noUpstreamBranch` failure path (Mac auto-fallbacks to + `pushSetUpstream` in `RemoteServerDelegate.vcsPush:336-345`, + no client-side handling needed) + - `suspend fun vcsPull(projectID)` — timeout 120 s + - `suspend fun listBranches` — default 10 s + - `suspend fun switchBranch(projectID, branch)` — timeout 30 s + - `suspend fun createBranch(projectID, name)` — timeout 30 s + (Mac creates and switches in one op — + `RemoteServerDelegate.vcsCreateBranch:389-393`) + - `suspend fun createPullRequest(projectID, title, body, + baseBranch, draft): VCSCreatePRResultDTO` — timeout 120 s + - `suspend fun addWorktree(...)` — timeout 60 s; calls + `refreshWorktrees` after success + - `suspend fun removeWorktree(...)` — timeout 60 s; calls + `refreshWorktrees` after success + - `suspend fun selectWorktree(...)` — calls `refreshWorkspace` + after success (iOS `ConnectionManager+VCS.swift:122-126`) +- [x] Throwing wrapper helper to convert response errors into a sealed + `VCSClientError` (timeout, server(message), unexpectedResponse) — + mirror iOS at `ConnectionManager+VCS.swift:128-154` + +### 9.2 — Source Control sheet (the equivalent of iOS `VCSView`) + +- [x] Compose modal sheet, opened from the workspace top-bar VCS button +- [x] Sections, in order (matches iOS): + 1. **Summary** — current branch, ahead/behind counts (icons: + `arrow.up`/`arrow.down`), pull-request link if `pullRequest` + non-null, Pull / Push action buttons (Push disabled when + `aheadCount == 0 && hasUpstream`) + 2. **Staged files** — count header with "Unstage All" action; + row swipe = Unstage + 3. **Changes** — count header with "Stage All" action; row + swipe = Stage / Discard (Discard splits paths vs + untrackedPaths based on `GitFileDTO.isUntracked`) + 4. **Clean** — green checkmark "Working tree clean" when both + lists empty + 5. **Commit** — `TextField` (multiline 2…5 lines), Commit + button disabled on empty/whitespace-only message; calls + `vcsCommit(stageAll: false)` + 6. **Error row** — when an in-flight op fails +- [x] `StatusBadge` per file row — A / M / D / R / C / U / `!`, color + coded (added/untracked = green, modified/renamed/copied = orange, + deleted = red, unmerged = purple). Mirrors iOS + `VCSView.swift:404-439` +- [x] In-flight tracking: per-action `Set` so multiple + operations can be in flight without crashing the UI; mirror iOS + `inFlight` at `VCSView.swift:13` +- [ ] Pull-to-refresh on the status list (deferred — Refresh action lives + on the workspace top bar; a `LazyColumn` `pullRefresh` modifier can + be added on the status list later if users ask) +- [x] Top-bar overflow menu: Branches, Worktrees, Create Pull Request + (the PR option is hidden when `status.pullRequest` is non-null) +- [x] Theming: respect `deviceTheme` exactly like iOS — every row uses + `themeFg.opacity(0.06)` for backgrounds, accent = `themeFg`. + Mirrors iOS at `VCSView.swift:391-401` + +### 9.3 — Branches sheet + +- [x] Modal sheet opened from VCS sheet overflow menu +- [x] List local branches with checkmark on current; tap = switch (no-op + if same as current) +- [x] `+` button in toolbar opens an alert/dialog with a single text + field and Create button → calls `createBranch` (creates and + switches, per Mac at + `RemoteServerDelegate.vcsCreateBranch:389-393`) +- [x] Per-row spinner while the switch RPC is in flight; close sheet on + success and call `onChange()` so the parent VCS view refreshes + +### 9.4 — Worktrees sheet + +- [x] Modal sheet opened from VCS sheet overflow menu +- [x] List worktrees from `connection.projectWorktrees[projectID]`, + green checkmark on the active worktree (compare against + `workspace.worktreeID`) +- [x] Tap non-active row = `selectWorktree` → workspace refresh → + dismiss +- [x] Swipe-to-remove only when `worktree.canBeRemoved && !isActive` + (implemented as a contextual delete affordance — Compose lacks a + first-class swipe-to-action like SwiftUI's `swipeActions`; the + delete icon shows a confirmation menu) +- [x] `+` button opens an Add Worktree form: + - Name field + - Branch source toggle: "New Branch" vs "Existing" + - For existing: dropdown of `listBranches.locals` + - For new: text field + - Submit calls `addWorktree(createBranch: !useExistingBranch)` + - Disable submit until name + branch are non-empty + +### 9.5 — Create PR sheet + +- [x] Modal sheet opened from VCS sheet overflow menu, only when + `status.pullRequest == nil` +- [x] Fields: Base branch (default = `status.defaultBranch`, editable), + Title, Body (multi-line 4…10), Draft toggle +- [x] Disable Create until title is non-empty +- [x] On success: open the returned PR URL in the system browser + (Compose `LocalUriHandler` / `Intent.ACTION_VIEW`), then call + `onCreated()` and dismiss. iOS does this at + `CreatePRSheet.swift:96-100` +- [x] On failure: show the error string in the sheet, do not dismiss + +### 9.6 — VCS QA checklist + +- [ ] Stage / unstage / discard one file; staged-all / unstaged-all +- [ ] Commit with empty message disabled; commit success clears field +- [ ] Push when upstream missing succeeds (Mac auto-`pushSetUpstream`) +- [ ] Pull merge / fast-forward / conflict surface a sensible error + string +- [ ] Switch branch with dirty tree (Mac error must surface to user) +- [ ] Create branch with invalid name (Mac error string surfaces) +- [ ] Add worktree, switch to it, remove non-primary worktree, attempt + to remove primary (must be blocked) +- [ ] Create PR for a branch without a pushed upstream (Mac auto-pushes + first per `RemoteServerDelegate.vcsCreatePR:395-427`) +- [ ] Create PR with default base = `defaultBranch` +- [ ] PR link in summary opens in browser + +--- + +## Phase 10 — Notifications + +**Goal:** In-app notification list with click-to-navigate. + +**Scope note — this is NEW functionality, not parity, AND there are +no live notification events on the wire.** Two facts: + +1. iOS does NOT have a notifications UI today: the `notifications` + array on `ConnectionManager` exists + (`MuxyMobile/ConnectionManager.swift:811-813`) but is never + displayed anywhere. iOS also does not call `listNotifications` — + only the DemoBackend stub does. +2. **The `notificationReceived` event never fires from the real + Mac server.** The handler case is wired up on iOS but there is + no Mac code path that emits `MuxyEvent(event: .notificationReceived, ...)`. + Confirmed by exhaustive grep: the only events the Mac actually + sends are `paneOwnershipChanged`, `themeChanged`, `terminalSnapshot`, + `terminalOutput`. + +So Android's notification screen can ONLY be backed by `listNotifications` +RPC. There is no live push to merge in. Decide between two strategies: + +- **Snapshot-only (simplest):** Fetch on screen open + pull-to-refresh. + No live updates while the screen is open. +- **Periodic poll:** Fetch on screen open, then re-fetch every 30 s + while the screen is in foreground. + +Pick snapshot-only for v1; revisit if users complain. + +Android shipping a notifications screen is a deliberate +platform-ahead-of-iOS choice; flag in the planning notes that real +live-push needs the Mac to start emitting `notificationReceived` +events (separate Mac-side follow-up). + +- [x] Notification list screen: calls `listNotifications` on screen open + and on the top-bar Refresh action. List is sorted by timestamp desc + inside `MuxyClient.refreshNotifications`. **No "merge live events + on top" path** — that event never fires +- [x] Unread badge on the project list top bar and the workspace top bar + — driven by `client.notifications` snapshot (count where + `!isRead`), refreshed when the project list opens. Staleness is + acceptable for v1 +- [x] Tap notification → `selectProject` (only when not already active) + → `selectWorktree` (only when changed) → `focusArea` → + `selectTab`. Workspace refresh runs implicitly inside each of + those wrappers. If the referenced project/worktree/area/tab no + longer exists, surface "This notification points to a closed tab" + via a snackbar and stay on the notification list. The notification + is still marked read in the closed-tab path so it stops counting + against the unread badge +- [x] Mark-read on tap by calling `markNotificationRead` after the + navigation handoff. No swipe-to-dismiss or "mark all read" in v1 + (the Mac exposes neither RPC today) +- [ ] FCM push deferred to a future phase (out of scope for v1). + Live in-app push deferred too, since it requires Mac-side work + to actually emit `notificationReceived` events first + +--- + +## Phase 11 — Connection lifecycle and resilience + +**Goal:** App survives the messy realities of mobile networking. + +- [ ] App-foreground / app-background hooks via `ProcessLifecycleOwner`: + clean disconnect on background, auto-reconnect on foreground. This + is an Android-specific battery/lifecycle choice, not exact iOS + parity. iOS keeps the socket around and pings on foreground; Android + v1 closes because there is no foreground service +- [ ] Network-change handling via `ConnectivityManager.NetworkCallback`: + reconnect when network returns. Requires + `ACCESS_NETWORK_STATE`; unregister callbacks when the connection + manager is disposed +- [ ] Reconnect respects exponential backoff with jitter. **Silent by + default — keep state `Connected` while the new socket is being + established (mirrors iOS `reconnectSilently`).** Only flip into a + visible "Reconnecting…" banner if reconnect transitions to the + error state, not for every fast foreground reconnect. After a + successful reconnect, re-issue `takeOverPane` for the active + pane (server cleared ownership on disconnect) +- [ ] **Process death recovery.** Android can kill and later restore + the app process. After process death, in-memory state + (`paneOwners`, `myClientID`, terminal byte handlers, the + WebSocket itself, the `WorkspaceDTO`) is gone. On cold start + after restoration: + 1. Read and decrypt `deviceID` + `token` from the Android + Keystore-backed credential store (durable unless the app data or + Keystore key is cleared) + 2. Read last-active host/port + project/worktree from DataStore + if we want to deep-link back into the workspace + 3. Re-run the full connect → authenticate → selectProject flow + Document which navigation state survives via `SavedStateHandle` + (route + scroll positions) vs. which gets re-derived from the + RPC layer (`WorkspaceDTO`, projects list) +- [ ] **Aggressive-OEM expectation note.** Samsung / Xiaomi / OnePlus + / Huawei battery optimizations kill backgrounded apps faster + than stock Android, and may delay the `NetworkCallback` / + foreground-reconnect path by tens of seconds. This is a known + limitation of "no foreground service" — surface it in the README + ("If reconnect is slow on your phone, whitelist Muxy in + Settings → Battery → App optimization"), don't try to fix it + with code in v1 +- [ ] No foreground service in v1. If a future user need emerges, revisit + as an opt-in toggle with battery warning + +--- + +## Phase 12 — Polish + shipping prep + +**Goal:** v1 is presentable and supportable. + +- [ ] Settings screen — match iOS parity (Android is iOS minus + Demo Mode): Use Nerd Font toggle, font size stepper (8…24), + About (version + build), Forget Device action. **iOS ships a + Demo Mode toggle (`MuxyMobile/SettingsSheet.swift:39-45`) — + Android is intentionally NOT porting it** (personal project, no + app-store screenshots to seed). Saved devices list lives on + the Connect screen, not Settings, so "multiple Macs" is already + covered there. No theme override and no accessory-bar layout + customization — iOS doesn't have these and shipping config + Android-only diverges from iOS for no reason +- [ ] Error-report sheet that exports the connection trace + device + info to share/save — mirror iOS `ConnectionIssueDetailsView` + (`MuxyMobile/ContentView.swift:94-137`). Concrete pieces: a + monospaced scrollable text area, a Copy button (uses + `ClipboardManager`), an Android `Intent.ACTION_SEND` share + button (Android equivalent of iOS `ShareLink`), and the body + content built from: ISO 8601 timestamps with fractional seconds, + last 25 diagnostic lines from the 120-entry ring buffer (Phase 3), + version + build, connection state, target host:port, last + request method/ID, response error code +- [ ] **Connect-state intermediate screens.** iOS routes `ContentView` + based on `connection.state` and renders distinct screens for + `.disconnected` (ConnectView), `.connecting` (ConnectingView), + `.awaitingApproval` (AwaitingApprovalView), `.connected` + (ProjectPickerView), `.error` (ErrorView with Retry / Debug Info + / Disconnect buttons). Android's navigation host should likewise + have explicit destinations / screens for each — not just a + single screen with a banner. Especially `awaitingApproval` needs + its own screen with the cancel button and the local-only-cancel + caveat from Phase 4 +- [ ] App icon, splash, store-listing copy stubs +- [ ] Phone + tablet layouts (responsive Compose) +- [ ] Accessibility pass: TalkBack labels on all controls, focus order, + large-text scaling +- [ ] Manual end-to-end QA matrix: pair, unpair, multi-Mac, network + drop, large scrollback, busy TUI, hardware keyboard + +--- + +## Phase 13 — CI + release + +**Goal:** APK builds in CI, distributable to chosen channel(s). + +- [ ] GitHub Actions workflow for Android: assemble, lint, unit tests, + instrumented tests on a single emulator +- [ ] Detekt (lint) + ktlint (format) checks; integrate into a + `scripts/checks-android.sh` separate from the existing Swift + `scripts/checks.sh` +- [ ] Debug signing config in repo; release signing key + password held + in GitHub Actions secrets, applied only on tagged builds. Use + APK Signature Scheme v2 + v3 (for key rotation later) +- [ ] **Verify R8 release build produces a working APK.** Phase 1 + added the kotlinx.serialization keep rules; this task is the + end-to-end check: assemble release, install, pair, open a tab, + take over, type into terminal, submit a VCS action. Catches + missed `-keep` rules early (the failure mode is + `SerializationException` at runtime with an obfuscated class + name, which is hard to triage post-release) +- [ ] **Upload R8 mapping file to GitHub Release** alongside the APK + so we can de-obfuscate stack traces from any user-supplied error + report +- [ ] Tag-driven release workflow: on `vX.Y.Z-android` tag, build signed + release APK, generate release notes, attach APK + mapping.txt to + a GitHub Release. No Play Store / F-Droid for v1 +- [ ] Update root `README` with Android sideload instructions (download + APK from Releases, enable "Install unknown apps" for the browser + or file manager) plus the trusted-network requirement and the + auto-backup-disabled note from Phase 1. Also update the license + section to call out the mixed-license repo boundary: root Swift app + remains under the current license, Android APK/source is GPL-3.0 + because of vendored Termux terminal code +- [ ] Update `docs/architecture.md` to add an "Android App (MuxyAndroid)" + section mirroring the existing "iOS App (MuxyMobile)" subsection + and correct the protocol event section if Phase 0 has not already + done so +- [ ] Update `docs/remote-server.md` if Phase 0 has not already done so: + `workspaceChanged` is not emitted, `terminalSnapshot` carries + `TerminalOutputEventDTO` bytes, and notification live events do not + exist today + +--- + +## Mac-side companion tasks (small, can interleave) + +These may surface during Phase 0 audit or later phases: + +- [ ] (Maybe) Add Bonjour `_muxy._tcp.local` advertisement to + `MuxyRemoteServer` so LAN discovery works on both iOS and Android +- [ ] (Maybe) Extend `PairDeviceParams` / `AuthenticateDeviceParams` + with an optional `platform` field (`deviceName` is already + passed and already shown in the approval alert today — + `PairingRequestCoordinator.swift:83`). Add a matching `platform` + field to `ApprovedDevice` so `MobileSettingsView` can render + "Pixel 8 Pro (Android)" vs "iPhone 16 Pro (iOS)" +- [ ] **Pairing timeout on the Mac side.** Today + `PairingRequestCoordinator` waits forever via + `withCheckedContinuation` (`PairingRequestCoordinator.swift:34-42`). + A client that gives up after 120 s leaves a queued NSAlert that + will pop on the next reconnect, possibly stacking. Add a + server-side timeout (e.g. 120 s matching the client) that + auto-denies and emits the unused `408 pairingTimeout` error code. + Without this, Android clients on flaky networks can produce a + stack of pending Mac alerts +- [ ] **Live workspace events.** Today no `workspaceChanged`, + `projectsChanged`, `notificationReceived`, or `tabChanged` + events fire from the Mac, so iOS and Android cannot see changes + made by other devices or by the Mac itself without a manual + refresh. Adding event broadcasts on the relevant `appState` + mutations would unlock real multi-device sync. iOS already has + handler cases for these (`ConnectionManager.swift:805-825`); the + missing piece is server-side emission. Out of scope for v1, but + file as the highest-leverage Mac-side follow-up +- [ ] **Remote notification mutation RPCs.** Android v1 only supports + `listNotifications` and `markNotificationRead` because those are + the only notification RPCs exposed today. If users need + swipe-to-dismiss or "mark all read", add `dismissNotification` and + `markAllNotificationsRead` to `MuxyShared`, route them through + `MuxyRemoteServer`, and cover them in remote routing tests before + wiring Android UI actions +- [ ] **Protocol versioning.** No version field in `MuxyMessage` / + `MuxyRequest` / `MuxyResponse`. Adding one (and a min-version + check on connect) would let us evolve the protocol without + silently breaking older clients. Out of scope for v1 + +--- + +## Cross-cutting non-goals for v1 + +- Editor / diff viewer / file tree on Android (server returns these + tabs, app shows "Open on desktop") +- Creating or destroying splits from Android +- AI usage panel +- FCM system push +- Offline mode +- Demo mode (iOS has it; intentionally not ported — this is a personal + project, no app-store screenshots to seed) +- `terminalScroll` RPC (defined in protocol, unused on iOS today — + see Phase 6 note) +- Spectator / read-only viewer for panes the user doesn't own. The + protocol unicasts `terminalOutput` to the owner only — supporting a + spectator mode requires either repeated `getTerminalContent` polls + or new Mac-side broadcast logic. Take-over remains the only way to + see a pane's output in v1 +- Live multi-device workspace sync. Without server-side emission of + `workspaceChanged` / `projectsChanged` / `tabChanged` / + `notificationReceived`, changes from another device require manual + refresh on Android. Listed as a Mac-side follow-up above +- Live notification push within the app. Same root cause — + `notificationReceived` is never emitted by the Mac today + +## Estimated effort + +Rough order-of-magnitude, single engineer, full-time: + +- Phase 0: ~3–4 days (now includes the Termux buildability spike, + which is non-trivial — if it fails we re-plan Phase 6) +- Phases 1–2: ~4–5 days (custom kotlinx serializers for SplitNodeDTO + / PaneOwnerDTO / NotificationDTO.SourceDTO are real work) +- Phases 3–4: ~1 week +- Phases 5–6: ~2 weeks (Phase 6 is still the big unknown even after + the Phase 0 spike — the spike answers "can we build it" but not + "does it integrate cleanly with our event loop") +- Phases 7–8: ~1.5 weeks +- Phase 9 (VCS): ~1.5 weeks (five sheets + RPC layer) +- Phases 10–11: ~1 week (Phase 11 now includes process-death + recovery and aggressive-OEM testing) +- Phases 12–13: ~1 week + +Total: ~9.5–10.5 weeks for v1, plus contingency on Phase 6's +integration unknowns. diff --git a/docs/remote-server.md b/docs/remote-server.md new file mode 100644 index 0000000..6ecff5a --- /dev/null +++ b/docs/remote-server.md @@ -0,0 +1,516 @@ +# Remote Server API + +Muxy exposes a WebSocket API that lets external clients connect to the desktop app over the local network. + +This API is intended for mobile apps, dashboards, companion tools, and custom integrations. + +## Overview + +- Protocol: WebSocket +- Endpoint: `ws://:` +- Default port: `4865` +- Message format: JSON +- Character encoding: UTF-8 +- Date format: ISO 8601 +- Identifier format: UUID strings + +The server is disabled by default and must be enabled in Muxy's Mobile settings on macOS. + +## Security Model + +The current API is designed for trusted local networks. + +- Transport is `ws://`, not TLS +- Clients must authenticate before using the API +- New devices must be approved from the Mac before they become trusted + +For production integrations, treat the connection as local-network only unless you provide your own secure tunnel such as Tailscale or a VPN. + +## Authentication and Pairing + +Each client should generate and persist these values: + +- `deviceID`: a stable UUID for that client install +- `deviceName`: a user-friendly device label +- `token`: a random secret persisted securely on the client + +Connection flow: + +1. Connect to the WebSocket endpoint. +2. Send `authenticateDevice`. +3. If the server returns `401`, send `pairDevice`. +4. The user approves the device in Muxy on macOS. +5. On success, the server returns a `clientID` for the active session. + +Until authentication succeeds, all other API methods return `401 Authentication required`. + +## Message Model + +Every WebSocket frame is a JSON object with a top-level `type` field. + +Supported message types: + +- `request` +- `response` +- `event` + +### Request Envelope + +```json +{ + "type": "request", + "payload": { + "id": "request-id", + "method": "listProjects", + "params": null + } +} +``` + +### Response Envelope + +Success: + +```json +{ + "type": "response", + "payload": { + "id": "request-id", + "result": { + "type": "ok" + } + } +} +``` + +Failure: + +```json +{ + "type": "response", + "payload": { + "id": "request-id", + "error": { + "code": 401, + "message": "Authentication required" + } + } +} +``` + +Only one of `result` or `error` is present on a given response; the unused field is omitted from the JSON. + +### Event Envelope + +```json +{ + "type": "event", + "payload": { + "event": "workspaceChanged", + "data": { + "type": "workspace", + "value": { + "projectID": "9b84c9a0-1d55-4c64-bbf6-ef59ee02fa09", + "worktreeID": "ef8d7324-5b0d-4fe7-8d87-4f9d6f8106e2", + "focusedAreaID": "d62a57b7-eb66-42d8-9d18-54d8c603ca7d", + "root": { + "type": "tabArea", + "tabArea": { + "id": "d62a57b7-eb66-42d8-9d18-54d8c603ca7d", + "projectPath": "/Users/example/project", + "tabs": [], + "activeTabID": null + } + } + } + } + } +} +``` + +## Request Format + +Requests use this shape: + +```json +{ + "id": "request-id", + "method": "getWorkspace", + "params": { + "type": "getWorkspace", + "value": { + "projectID": "9b84c9a0-1d55-4c64-bbf6-ef59ee02fa09" + } + } +} +``` + +Rules: + +- `id` must be unique per in-flight request +- `method` identifies the API operation +- `params.type` must match `method` when params are present +- methods without parameters may send `params: null` + +## Error Codes + +| Code | Meaning | +| --- | --- | +| `400` | Invalid parameters | +| `401` | Authentication required | +| `403` | Pairing denied | +| `404` | Resource not found | +| `408` | Pairing request timed out | +| `500` | Internal error or operation failure | + +## Authentication Methods + +### `authenticateDevice` + +Authenticates a previously approved device. + +Request: + +```json +{ + "type": "authenticateDevice", + "value": { + "deviceID": "2f8d1f9f-e065-4f62-af30-8c4b3d0bfc53", + "deviceName": "Pixel 9", + "token": "random-secret-token" + } +} +``` + +Success result: + +```json +{ + "type": "pairing", + "value": { + "clientID": "62ea9d06-a1f4-4a11-9f39-33ee322f6573", + "deviceName": "Pixel 9", + "themeFg": 16777215, + "themeBg": 197379, + "themePalette": [0, 16711680, 65280] + } +} +``` + +`themeFg`, `themeBg`, and `themePalette` are optional and may be omitted. + +### `pairDevice` + +Requests approval for a new device. + +Request shape is the same as `authenticateDevice`. + +Success returns the same `pairing` result. + +### `registerDevice` + +Registers a transient session for a device that has not persisted credentials. The server returns a `deviceInfo` result with the same fields as `pairing` (`clientID`, `deviceName`, optional `themeFg`, `themeBg`, `themePalette`). + +Request: + +```json +{ + "type": "registerDevice", + "value": { + "deviceName": "Pixel 9" + } +} +``` + +## Recommended Client Startup Flow + +1. Connect. +2. Authenticate or pair. +3. Call `listProjects`. +4. Choose a project. +5. Call `listWorktrees`. +6. Call `selectProject` and optionally `selectWorktree`. +7. Call `getWorkspace`. +8. Optionally load notifications, logos, and VCS state. + +## API Methods + +### Projects and Workspace + +| Method | Parameters | Result | +| --- | --- | --- | +| `listProjects` | none | `projects` | +| `selectProject` | `projectID` | `ok` | +| `listWorktrees` | `projectID` | `worktrees` | +| `selectWorktree` | `projectID`, `worktreeID` | `ok` | +| `getWorkspace` | `projectID` | `workspace` | +| `createTab` | `projectID`, `areaID` optional, `kind` | `tab` | +| `closeTab` | `projectID`, `areaID`, `tabID` | `ok` | +| `selectTab` | `projectID`, `areaID`, `tabID` | `ok` | +| `splitArea` | `projectID`, `areaID`, `direction`, `position` | `ok` | +| `closeArea` | `projectID`, `areaID` | `ok` | +| `focusArea` | `projectID`, `areaID` | `ok` | + +Valid enum values: + +- `kind`: `terminal`, `vcs`, `editor`, `diffViewer` +- `direction`: `horizontal`, `vertical` +- `position`: `first`, `second` + +### Terminal Control + +| Method | Parameters | Result | +| --- | --- | --- | +| `takeOverPane` | `paneID`, `cols`, `rows` | `ok` | +| `releasePane` | `paneID` | `ok` | +| `terminalInput` | `paneID`, `bytes` | `ok` | +| `terminalResize` | `paneID`, `cols`, `rows` | `ok` | +| `terminalScroll` | `paneID`, `deltaX`, `deltaY`, `precise` | `ok` | +| `getTerminalContent` | `paneID` | `terminalCells` | + +Notes: + +- Terminal control is ownership-based. +- A client should call `takeOverPane` before sending input or resize events. +- `releasePane` returns control to the Mac. +- If the pane is owned by another client, control requests may be ignored. +- `terminalInput` carries raw bytes (base64-encoded on the JSON wire) that are + delivered verbatim to the PTY, so the client is responsible for encoding + escape sequences, control codes, and mouse reports directly. +- `getTerminalContent` is a legacy pull API that snapshots the rendered grid. + New clients should render the pane with their own VT emulator and subscribe + to the `terminalOutput` event stream instead. + +### Notifications and Visual Data + +| Method | Parameters | Result | +| --- | --- | --- | +| `getProjectLogo` | `projectID` | `projectLogo` | +| `listNotifications` | none | `notifications` | +| `markNotificationRead` | `notificationID` | `ok` | +| `subscribe` | `events` | `ok` | +| `unsubscribe` | `events` | `ok` | + +`subscribe` and `unsubscribe` are accepted for compatibility, but clients should still be prepared to receive all broadcast event types. + +### Git and Worktrees + +| Method | Parameters | Result | +| --- | --- | --- | +| `getVCSStatus` | `projectID` | `vcsStatus` | +| `vcsCommit` | `projectID`, `message`, `stageAll` | `ok` | +| `vcsPush` | `projectID` | `ok` | +| `vcsPull` | `projectID` | `ok` | +| `vcsStageFiles` | `projectID`, `paths` | `ok` | +| `vcsUnstageFiles` | `projectID`, `paths` | `ok` | +| `vcsDiscardFiles` | `projectID`, `paths`, `untrackedPaths` | `ok` | +| `vcsListBranches` | `projectID` | `vcsBranches` | +| `vcsSwitchBranch` | `projectID`, `branch` | `ok` | +| `vcsCreateBranch` | `projectID`, `name` | `ok` | +| `vcsCreatePR` | `projectID`, `title`, `body`, `baseBranch`, `draft` | `vcsPRCreated` | +| `vcsAddWorktree` | `projectID`, `name`, `branch`, `createBranch` | `worktrees` | +| `vcsRemoveWorktree` | `projectID`, `worktreeID` | `ok` | + +## Events + +The server can push these event names. **Only the four events marked +"emitted" below actually fire from the live Mac server today** — the rest +of the event kinds are defined in `MuxyShared` and decoded by the iOS +and Android clients but no Mac code path emits them. Treat them as +reserved future events for now and refresh state via explicit RPCs +(`getWorkspace`, `listProjects`, `listNotifications`) plus reconnect. + +| Event | Data type | Status | Description | +| --- | --- | --- | --- | +| `paneOwnershipChanged` | `paneOwnership` | emitted (broadcast) | Pane control changed between Mac and remote clients | +| `themeChanged` | `deviceTheme` | emitted (broadcast) | Updated terminal foreground/background colors | +| `terminalOutput` | `terminalOutput` | emitted (unicast to owner) | Raw PTY bytes for a pane the client owns. Pushed as the shell/TUI writes. | +| `terminalSnapshot` | `terminalSnapshot` | emitted (unicast to taker) | One-shot VT byte snapshot of the visible grid, sent on `takeOverPane`. Decoded as a `TerminalOutputEventDTO` (raw bytes), **not** a parsed `terminalCells` payload — the cells representation is only used by the `getTerminalContent` response. | +| `workspaceChanged` | `workspace` | not emitted | Defined in shared types; Mac never fires it. | +| `tabChanged` | `tab` | not emitted | Defined in shared types; Mac never fires it. | +| `projectsChanged` | `projects` | not emitted | Defined in shared types; Mac never fires it. | +| `notificationReceived` | `notification` | not emitted | Defined in shared types; Mac never fires it. | + +Because `workspaceChanged` does not fire today, clients must call +`getWorkspace(projectID)` after any workspace-changing RPC and on every +reconnect to keep their local mirror current. Layout changes made by the +Mac itself or by another remote device are invisible to a given client +until the next refresh. + +### `terminalOutput` Event + +Pushed only to the client that currently owns the pane. Payload: + +```json +{ + "type": "terminalOutput", + "value": { + "paneID": "uuid", + "bytes": "" + } +} +``` + +The bytes are the exact sequence Ghostty read from the PTY on the Mac, before +any terminal emulation. A client should feed them into its own VT emulator +(e.g. SwiftTerm's `feed(byteArray:)`) to render the pane. There is no guarantee +that a chunk ends on a UTF-8 boundary or an escape-sequence boundary; the +emulator is expected to buffer partial sequences across chunks. + +## Data Objects + +### Project + +```json +{ + "id": "uuid", + "name": "muxy", + "path": "/Users/example/project", + "sortOrder": 0, + "createdAt": "2026-04-19T10:00:00Z", + "icon": "hammer", + "logo": "custom", + "iconColor": "#7C3AED" +} +``` + +### Worktree + +```json +{ + "id": "uuid", + "name": "main", + "path": "/Users/example/project", + "branch": "main", + "isPrimary": true, + "canBeRemoved": false, + "createdAt": "2026-04-19T10:00:00Z" +} +``` + +### Workspace + +A workspace contains: + +- `projectID` +- `worktreeID` +- `focusedAreaID` +- `root` + +`root` is a recursive tree with two node types: + +- `tabArea` +- `split` + +A `tabArea` contains: + +- `id` +- `projectPath` +- `tabs` +- `activeTabID` + +A tab contains: + +- `id` +- `kind` +- `title` +- `isPinned` +- `paneID` + +`paneID` is required for terminal-related methods. + +### Terminal Snapshot + +`getTerminalContent` returns a full terminal grid: + +```json +{ + "paneID": "uuid", + "cols": 120, + "rows": 40, + "cursorX": 10, + "cursorY": 5, + "cursorVisible": true, + "defaultFg": 16777215, + "defaultBg": 0, + "cells": [ + { + "codepoint": 65, + "fg": 16777215, + "bg": 0, + "flags": 0 + } + ] +} +``` + +Notes: + +- colors are integer RGB values in `0xRRGGBB` form +- `cells` is a flat array representing the full terminal grid +- `flags` is a bitmask for text styling and wide-character metadata + +### Notification + +A notification includes: + +- `id` +- `paneID` +- `projectID` +- `worktreeID` +- `areaID` +- `tabID` +- `source` +- `title` +- `body` +- `timestamp` +- `isRead` + +This allows clients to link a notification back to the exact pane and tab that produced it. + +### Project Logo + +Project logos are returned as Base64-encoded PNG data. + +```json +{ + "projectID": "uuid", + "pngData": "iVBORw0KGgoAAAANS..." +} +``` + +## Example Authentication Request + +```json +{ + "type": "request", + "payload": { + "id": "1", + "method": "authenticateDevice", + "params": { + "type": "authenticateDevice", + "value": { + "deviceID": "2f8d1f9f-e065-4f62-af30-8c4b3d0bfc53", + "deviceName": "Android Client", + "token": "random-secret-token" + } + } + } +} +``` + +## Integration Recommendations + +- Persist `deviceID` and `token` securely +- Re-authenticate after reconnecting +- Refresh workspace state by calling `getWorkspace(projectID)` after any + workspace-changing RPC and on every reconnect — `workspaceChanged` is + defined but never emitted by the Mac today +- Refresh notifications via `listNotifications` rather than waiting for + `notificationReceived`, which is never emitted today +- Cache project logos after decoding the Base64 payload +- Call `takeOverPane` before interactive terminal control. `terminalOutput` + is unicast to the current owner only; non-owners receive nothing until + they take over and the Mac unicasts a one-shot `terminalSnapshot`. +- Handle `401` by retrying with pairing only when appropriate +- Do not assume event filtering is enforced server-side diff --git a/scripts/checks-android.sh b/scripts/checks-android.sh new file mode 100755 index 0000000..cb9b311 --- /dev/null +++ b/scripts/checks-android.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +BOLD="\033[1m" +DIM="\033[2m" +RED="\033[31m" +GREEN="\033[32m" +YELLOW="\033[33m" +RESET="\033[0m" + +FIX=0 +for arg in "$@"; do + case "$arg" in + --fix) FIX=1 ;; + esac +done + +PASS="✓" +FAIL="✗" + +steps=() +statuses=() +errors=() +durations=() +total_start=$SECONDS + +format_duration() { + local secs=$1 + if [ "$secs" -ge 60 ]; then + printf "%dm %ds" $((secs / 60)) $((secs % 60)) + else + printf "%ds" "$secs" + fi +} + +run_step() { + local name="$1" + shift + steps+=("$name") + local step_start=$SECONDS + + local tmpfile + tmpfile=$(mktemp) + + if "$@" > "$tmpfile" 2>&1; then + local elapsed=$(( SECONDS - step_start )) + local dur + dur=$(format_duration $elapsed) + durations+=("$dur") + statuses+=("pass") + errors+=("") + printf " ${GREEN}${PASS}${RESET} %s ${DIM}%s${RESET}\n" "$name" "$dur" + rm -f "$tmpfile" + return 0 + else + local exit_code=$? + local elapsed=$(( SECONDS - step_start )) + local dur + dur=$(format_duration $elapsed) + durations+=("$dur") + statuses+=("fail") + errors+=("$(cat "$tmpfile")") + printf " ${RED}${FAIL}${RESET} %s ${DIM}%s${RESET}\n" "$name" "$dur" + rm -f "$tmpfile" + return "$exit_code" + fi +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ANDROID_DIR="$SCRIPT_DIR/../android" + +if [ ! -d "$ANDROID_DIR" ]; then + printf "${RED}${FAIL}${RESET} android/ directory not found at %s\n" "$ANDROID_DIR" + exit 1 +fi + +cd "$ANDROID_DIR" + +if [ ! -x "./gradlew" ]; then + chmod +x ./gradlew +fi + +if ! command -v java &>/dev/null; then + printf "${RED}${FAIL}${RESET} Java not found. Install JDK 17.\n" + exit 1 +fi + +JAVA_VERSION=$(java -version 2>&1 | awk -F[\".] '/version/ {print $2}') +if [ -z "$JAVA_VERSION" ] || [ "$JAVA_VERSION" -lt 17 ]; then + printf "${YELLOW}!${RESET} Java 17+ recommended. Found: $(java -version 2>&1 | head -1)\n" +fi + +printf "\n" +failed=0 + +if [ "$FIX" -eq 1 ]; then + run_step "ktlintFormat (fix)" ./gradlew ktlintFormat --no-daemon || failed=1 +fi + +if [ "$failed" -eq 0 ]; then + run_step "Detekt" ./gradlew detekt --no-daemon || failed=1 +fi + +if [ "$failed" -eq 0 ]; then + run_step "ktlintCheck" ./gradlew ktlintCheck --no-daemon || failed=1 +fi + +if [ "$failed" -eq 0 ]; then + run_step "Android lint" ./gradlew lint --no-daemon || failed=1 +fi + +if [ "$failed" -eq 0 ]; then + run_step "Unit tests" ./gradlew test --no-daemon || failed=1 +fi + +if [ "$failed" -eq 0 ]; then + run_step "Assemble debug" ./gradlew :app:assembleDebug --no-daemon || failed=1 +fi + +printf "\n" + +total_dur=$(format_duration $(( SECONDS - total_start ))) + +if [ "$failed" -ne 0 ]; then + printf "${RED}${BOLD} Failed${RESET} ${DIM}in %s${RESET}\n\n" "$total_dur" + for i in "${!steps[@]}"; do + if [ "${statuses[$i]}" = "fail" ] && [ -n "${errors[$i]}" ]; then + printf "${DIM}─── %s ───${RESET}\n" "${steps[$i]}" + echo "${errors[$i]}" | tail -50 + printf "\n" + fi + done + exit 1 +else + printf "${GREEN}${BOLD} All Android checks passed${RESET} ${DIM}in %s${RESET}\n\n" "$total_dur" +fi