diff --git a/README.md b/README.md index ce14550d..a6fe866e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`compose-preview/`](compose-preview/) | Renders Jetpack Compose `@Preview` functions on-device — no full app build or run. | | [`ai-literacy-course/`](ai-literacy-course/) | Bundles Learn AI Anywhere's offline "Introduction to AI" course (26 videos + interactive activities) and plays it full-screen, fully offline. | | [`layout-editor/`](layout-editor/) | Visual drag-and-drop editor for Android XML layouts. | +| [`pair/`](pair/) | Real-time pair programming across two devices on the same WiFi — host or join a session and sync edits and cursor presence live over the local network. | ## Building a plugin diff --git a/ndk-installer-plugin/src/main/AndroidManifest.xml b/ndk-installer-plugin/src/main/AndroidManifest.xml index d19ae657..14622295 100644 --- a/ndk-installer-plugin/src/main/AndroidManifest.xml +++ b/ndk-installer-plugin/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:value="NDK Installer (64-bit only)" /> **Note:** Pair requires an extended `plugin-api` beyond the current `stage` baseline — `IdeProjectService.openProject(File)` (open a project after a pull-model sync) and `IdeEditorService.showPeerCursor` / `hidePeerCursor` / `clearPeerCursors` (inline remote-cursor decoration). These land on the `feat/ADFA-4419-remote-peer-editor-decoration` branch. If `assemblePlugin` fails with unresolved references to those symbols, the shared `libs/` jars are older than the API Pair needs; refresh them from a CodeOnTheGo build that includes the extensions (`../scripts/update-libs.sh --local --ref feat/ADFA-4419-remote-peer-editor-decoration`). + +## License + +Pair is an open-source example plugin for Code on the Go. Its source is licensed per the surrounding `plugin-examples` repository (see `LICENSE` at the repo root). It makes no cloud calls — all traffic stays on the local network between the paired devices. diff --git a/pair/build.gradle.kts b/pair/build.gradle.kts new file mode 100644 index 00000000..bf9da177 --- /dev/null +++ b/pair/build.gradle.kts @@ -0,0 +1,105 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "pair" +} + +android { + namespace = "com.appdevforall.pair.plugin" + compileSdk = 34 + + defaultConfig { + applicationId = "com.appdevforall.pair.plugin" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + } + + buildTypes { + release { + isMinifyEnabled = false + isShrinkResources = false + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/INDEX.LIST", + "META-INF/io.netty.versions.properties" + ) + } + } +} + +dependencies { + compileOnly(files("../libs/plugin-api.jar")) + compileOnly(files("../libs/eventbus-events.jar")) + compileOnly(files("../libs/shared.jar")) + + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + implementation("org.java-websocket:Java-WebSocket:1.5.6") + implementation("org.slf4j:slf4j-nop:1.7.36") + + compileOnly("org.greenrobot:eventbus:3.3.1") + + val composeBom = platform("androidx.compose:compose-bom:2024.02.00") + compileOnly(composeBom) + compileOnly("androidx.compose.ui:ui") + compileOnly("androidx.compose.ui:ui-graphics") + compileOnly("androidx.compose.ui:ui-tooling-preview") + compileOnly("androidx.compose.foundation:foundation") + compileOnly("androidx.compose.material3:material3") + compileOnly("androidx.compose.runtime:runtime") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + + debugImplementation(platform("androidx.compose:compose-bom:2024.02.00")) + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui") + debugImplementation("androidx.compose.ui:ui-graphics") + debugImplementation("androidx.compose.foundation:foundation") + debugImplementation("androidx.compose.material3:material3") + debugImplementation("androidx.compose.runtime:runtime") +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} diff --git a/pair/gradle.properties b/pair/gradle.properties new file mode 100644 index 00000000..04894fe8 --- /dev/null +++ b/pair/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/pair/gradle/wrapper/gradle-wrapper.jar b/pair/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 00000000..8bdaf60c Binary files /dev/null and b/pair/gradle/wrapper/gradle-wrapper.jar differ diff --git a/pair/gradle/wrapper/gradle-wrapper.properties b/pair/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df97d72b --- /dev/null +++ b/pair/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/pair/gradlew b/pair/gradlew new file mode 100755 index 00000000..ef07e016 --- /dev/null +++ b/pair/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# 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. +# You may obtain a copy of the License at +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# 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 +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# 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\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# 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" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +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='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/pair/gradlew.bat b/pair/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/pair/gradlew.bat @@ -0,0 +1,94 @@ +@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="-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= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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 diff --git a/pair/pair-documentation.html b/pair/pair-documentation.html new file mode 100644 index 00000000..ca001b9f --- /dev/null +++ b/pair/pair-documentation.html @@ -0,0 +1,457 @@ + + + + + +Pair Plugin Documentation + + + + +
+

Pair

+

Real-time pair programming across two devices on the same WiFi — shared editing, live cursors, no server

+
Version 1.0.0 · Author: App Dev For All · Package: com.appdevforall.pair.plugin
+
+ + + + +
+

1. Executive Overview

+

+ Pair is a Code on the Go plugin that turns the IDE into a real-time + collaborative editor. Two devices on the same local network share one editing + session: one device hosts, others join, and + from then on edits, cursor positions, and file opens flow between devices as + they happen. There is no server, no cloud account, and no signup — the + two phones talk directly over WiFi. +

+

+ It surfaces as a Pair tab in the editor. The host taps + Host session and an invite card shows the ip:port and a QR + code; a guest either scans that code or types the address, and the handshake + completes. When the host types in a file, the guest sees the same file open and + the text arrive keystroke by keystroke, with each peer's live position shown in + the peer list. +

+

+ The plugin implements three extension interfaces (IPlugin, + EditorTabExtension, UIExtension) and consumes host + services for the editor, project model, files, and environment. It links only + against the plugin-api contract — never host-internal + modules — and observes edits through the IDE's existing EventBus. +

+
+ + +
+

2. Core Functionality

+ +

Hosting and joining

+

+ The host starts a session on the device's LAN address; the invite card shows a + tappable ip:port and a QR code. A guest joins by scanning the QR + with the camera or typing the address. A handshake message (hi) + exchanges each peer's id, display name, and color; a protocol-version mismatch + rejects the peer rather than connecting a stale build. +

+ +

Shared editing

+

+ Every local edit becomes an edit message and every remote edit is + applied to the local buffer, so both devices converge on the same text. Edits + carry an operation (insert / delete / replace) and a selection range, keyed by a + project-relative file path. +

+ +

Presence and cursors

+ + + + + + + + + +
SignalWhat peers see
Peer listEach connected device with its name, role (host / guest), color dot, and current file:line:column.
Cursor moveA cur message updates a peer's position as they navigate.
Inline caretWith the extended API (see §4), a remote peer's caret is drawn inside the editor in that peer's color.
+ +

File lifecycle

+

+ Opening, closing, and switching files propagate as fo / + fc / ff messages, so a guest's editor follows the file + the host is working in. A file-open message carries the full current contents so + the joining side starts from an identical snapshot. +

+ +

Session history

+

+ Past sessions persist across IDE restarts as a JSON file in the plugin's private + data directory. The Home screen lists recent sessions (newest first) to rename, + delete, or tap to reconnect; it renders nothing when empty, so first-run Home + stays clean. +

+
+ + +
+

3. Technical Architecture

+ +

File layout

+
    +
  • pair/ +
      +
    • build.gradle.kts, settings.gradle.kts, proguard-rules.pro
    • +
    • src/main/ +
        +
      • AndroidManifest.xml (plugin id, main class, icons, permissions)
      • +
      • assets/ (icon_day.png, icon_night.png — the 190×190 interlocking-rings mark)
      • +
      • kotlin/com/appdevforall/pair/plugin/ +
          +
        • PairPlugin.kt: entry point; tab + sidebar registration
        • +
        • data/: wire protocol + codec, PairWebSocketServer / PairWebSocketClient, session models + history store
        • +
        • domain/: EditBroker (orchestrator), EditObserver / EditApplier, PathMapper, PeerRegistry, RemoteMarkerController
        • +
        • ui/: Compose theme, components, and screens (Home / Host / Guest / Scan)
        • +
        • util/: LAN IP discovery, QR decode
        • +
        +
      • +
      • res/values/, res/values-night/
      • +
      +
    • +
    +
  • +
+ +

Class overview

+ + + + + + + + + + + + +
ClassRoleKey interfaces
PairPluginEntry point. Registers the Pair tab and sidebar entry, wires the service locator.IPlugin, EditorTabExtension, UIExtension
EditBrokerOrchestrator. Connects the sockets, the EventBus observer, and the editor service; owns session state.None
EditObserver / EditApplierOutbound: EventBus edits → wire messages. Inbound: wire messages → IdeEditorService writes.EventBus @Subscribe
PathMapperConverts between absolute paths and project-root-relative wire paths.None
RemoteMarkerControllerMaps peer cursors to inline editor markers via the editor decoration API.None
SessionHistoryStoreJSON-file-backed StateFlow of recent sessions (capped, newest first).None
+ +

Data flow for one local edit

+
+ user types in the IDE editor + | + v + IDE posts DocumentChangeEvent on the EventBus + | + v + EditObserver.@Subscribe (skipped while a remote edit is being applied) + | PathMapper.toWire(absolutePath) -> project-root-relative + v + EditBroker.broadcast(Edit) + | + v + WebSocket server/client sends compact JSON over ws:// + | + v + remote EditApplier.applyEdit() + | PathMapper.toLocal(wirePath) -> re-anchored to this device + | loopback guard: enter / apply / exit + v + IdeEditorService.replaceRange(file, range, text)
+
+ Paths are project-root-relative. A file's wire identity is its + path relative to IdeProjectService.getCurrentProject().rootDir, so a + session works across two devices whose projects live at different absolute + locations. If a remote edit names a file that does not exist locally it is + logged and flags the session out of sync — never silently dropped. +
+ +

Transport topology

+

+ A star, not a mesh. The host runs a WebSocketServer; + each guest opens one WebSocketClient to the host, and the host echoes + inbound messages to the other guests. When a socket drops, the host removes the + peer and broadcasts a synthetic bye, so stale peer rows clear within + seconds. +

+ +

WebSocket protocol

+

Compact JSON over ws://; f is a project-relative path.

+ + + + + + + + + + + + +
TypeMeaning
hiHandshake (both directions); carries peer id, name, color, protocol version.
editAn insert / delete / replace with its selection range and sequence numbers.
curA cursor-position update.
fo / fc / ffFile opened (with contents) / closed / focused.
syncHost-authoritative full-file snapshot that clears an out-of-sync flag.
byePeer left (also synthesized by the host on a dropped socket).
+ +

Conflict resolution

+

+ Each peer keeps a per-file sequence number; every edit carries the sequence it + was authored against plus the new one. On receive, a mismatch on a guest flags + the session out of sync (the edit is still applied — + last-write-wins). Only the host can force a resync, which broadcasts a + sync snapshot of the full file that every receiver adopts. Two edits + at the exact same offset are non-deterministic; the design targets two people + with one mostly driving. +

+ +

Build configuration

+ + + + + + + + + +
SettingValue
Gradle plugincom.itsaky.androidide.plugins.build
Compile / Target SDK34
Min SDK26
Java / Kotlin target17
ComposeEnabled; linked compileOnly (host-provided), not bundled
Plugin APIcompileOnly via ../libs/plugin-api.jar
Output format.cgp package
+
+ + +
+

4. Integration Points

+

+ Pair implements three extension interfaces and consumes five host services, all + through plugin-api. +

+ +

4.1 Plugin lifecycle (IPlugin)

+

+ initialize() stores the PluginContext and builds the + service locator; activate() / deactivate() / + dispose() round out the lifecycle. Sockets are started when a + session begins and torn down when it ends. +

+ +

4.2 Tab and sidebar (EditorTabExtension, UIExtension)

+

+ EditorTabExtension contributes the Pair editor tab and + UIExtension the sidebar entry. The tab hosts a Compose UI + (PairMainFragmentPairRoot) with Home, Host, + Guest, and QR-scan screens. +

+ +

4.3 Host services consumed

+ + + + + + + + + + + +
ServiceUsed for
IdeEditorServiceApply remote edits (replaceRange), read the current cursor and file contents, and draw inline peer cursors.
IdeProjectServiceResolve the project root for relative paths, and openProject() after a pull-model file sync.
IdeFileServiceCreate, write, and delete files during project transfer.
IdeEditorTabServiceManage the plugin's editor tab.
IdeEnvironmentServiceThe plugin's private data directory for session history.
+ +

4.4 EventBus subscription

+

+ Local activity is observed, not polled: Pair subscribes to + DocumentChangeEvent, DocumentOpenEvent, + DocumentCloseEvent, DocumentSelectedEvent, and the file + creation / deletion / rename events on the IDE's EventBus. The observer runs on + the main thread, ordered, which keeps the loopback guard around applied remote + edits correct. +

+ +
+ Extended plugin-api requirement. Two capabilities go beyond the + current stage API surface: IdeProjectService.openProject(File) + (open a project after a pull-model sync) and + IdeEditorService.showPeerCursor / hidePeerCursor / + clearPeerCursors (inline remote-cursor decoration). They live on the + feat/ADFA-4419-remote-peer-editor-decoration branch. If + assemblePlugin fails with unresolved references to those symbols, the + shared libs/ jars are older than the API Pair needs — refresh + them from a CodeOnTheGo build that includes the extensions + (../scripts/update-libs.sh --local <path> --ref feat/ADFA-4419-remote-peer-editor-decoration). +
+ +

4.5 Permissions

+
<meta-data android:name="plugin.permissions"
+           android:value="filesystem.read,filesystem.write,network.access,native.code" />
+

+ Filesystem access backs project transfer and history; network.access + runs the WebSocket server and client; native.code covers the bundled + camera / QR-decoding native libraries used by the scan screen. +

+
+ + +
+

5. Deployment & Usage

+ +

Building

+
cd pair
+./gradlew clean assemblePlugin      # or assemblePluginDebug for the debug variant
+

+ Produces pair/build/plugin/pair.cgp, the bundle you sideload into + Code on the Go. +

+
+ Always clean first. The plugin builder copies the + built APK into the .cgp and then deletes the source APK, so an + incremental build can package an empty artifact. +
+ +

Installation

+
    +
  1. Open Preferences → Plugin Manager → +.
  2. +
  3. Select the pair.cgp file.
  4. +
  5. The IDE discovers PairPlugin via manifest metadata and activates it.
  6. +
+ +

Using the plugin

+
    +
  1. Put both devices on the same WiFi network (a phone hotspot works; hotel / captive-portal WiFi that blocks device-to-device traffic does not).
  2. +
  3. On the host, open the Pair tab and tap Host session.
  4. +
  5. On the guest, open the Pair tab and either scan the host's QR or type its ip:port.
  6. +
  7. Open the same project on both devices, then edit — text and cursors sync live.
  8. +
  9. If a session shows out of sync, the host taps resync to push an authoritative snapshot.
  10. +
+ +

Runtime requirements

+ + + + + + +
RequirementValue
Min Android versionAPI 26 (Android 8)
Min IDE version1.0.0
Permissionsfilesystem.read, filesystem.write, network.access, native.code
NetworkBoth devices on the same LAN with device-to-device traffic allowed; no internet required.
+
+ + +
+

6. Key Benefits

+
    +
  • Real-time and local. Edits and presence sync directly between devices over WiFi — no server, no cloud, no signup.
  • +
  • Portable sessions. Project-root-relative paths let two devices collaborate even though their projects live at different absolute locations.
  • +
  • Observes, does not intercept. Local edits are read from the IDE's existing EventBus and applied through the public editor service — no host source changes.
  • +
  • Presence that reads. A colored peer list plus inline carets show who is where without cluttering the editor.
  • +
  • Recoverable. Divergence flags the session rather than losing edits, and the host can push an authoritative resync at any time.
  • +
  • Plugin-API only. A reference for a collaborative-editing plugin built entirely on the stable plugin-api contract.
  • +
+
+ + +
+

7. Attribution & License

+

+ Pair is an open-source example plugin for Code on the Go. Its source is licensed + per the surrounding plugin-examples repository (see + LICENSE at the repo root). +

+
    +
  • All session traffic stays on the local network between the paired devices; the plugin makes no cloud calls and transmits no data off-device.
  • +
  • ws:// traffic is plaintext on the LAN; a per-session passcode gates who may join, but the channel itself is not encrypted.
  • +
+
+ +
+ Pair Plugin Documentation · Version 1.0.0 · com.appdevforall.pair.plugin +
+ + + diff --git a/pair/proguard-rules.pro b/pair/proguard-rules.pro new file mode 100644 index 00000000..68c999b6 --- /dev/null +++ b/pair/proguard-rules.pro @@ -0,0 +1,7 @@ +-keep class com.appdevforall.pair.plugin.PairPlugin { *; } +-keep class com.itsaky.androidide.plugins.** { *; } +-keep class org.java_websocket.** { *; } +-keep class * implements com.itsaky.androidide.plugins.IPlugin { *; } +-keepclassmembers class * { + @org.greenrobot.eventbus.Subscribe ; +} diff --git a/pair/settings.gradle.kts b/pair/settings.gradle.kts new file mode 100644 index 00000000..8359ff88 --- /dev/null +++ b/pair/settings.gradle.kts @@ -0,0 +1,32 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +buildscript { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath(files("../libs/plugin-api.jar")) + classpath(files("../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:8.8.2") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0") + classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.3.0") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "pair" diff --git a/pair/src/main/AndroidManifest.xml b/pair/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c14f2f0b --- /dev/null +++ b/pair/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pair/src/main/assets/icon_day.png b/pair/src/main/assets/icon_day.png new file mode 100644 index 00000000..06c43be4 Binary files /dev/null and b/pair/src/main/assets/icon_day.png differ diff --git a/pair/src/main/assets/icon_night.png b/pair/src/main/assets/icon_night.png new file mode 100644 index 00000000..b4658f71 Binary files /dev/null and b/pair/src/main/assets/icon_night.png differ diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt new file mode 100644 index 00000000..d2c32574 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt @@ -0,0 +1,91 @@ +package com.appdevforall.pair.plugin + +import com.appdevforall.pair.plugin.ui.main.PairMainFragment +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.EditorTabExtension +import com.itsaky.androidide.plugins.extensions.EditorTabItem +import com.itsaky.androidide.plugins.extensions.NavigationItem +import com.itsaky.androidide.plugins.extensions.UIExtension +import com.itsaky.androidide.plugins.services.IdeEditorTabService + +class PairPlugin : IPlugin, UIExtension, EditorTabExtension { + + private lateinit var pluginContext: PluginContext + + override fun initialize(context: PluginContext): Boolean { + this.pluginContext = context + return runCatching { + PairServiceLocator.init(context) + context.logger.info("PairPlugin initialized") + true + }.getOrElse { + context.logger.error("PairPlugin: initialize failed", it) + false + } + } + + override fun activate(): Boolean { + pluginContext.logger.info("PairPlugin activated") + return true + } + + override fun deactivate(): Boolean { + runCatching { PairServiceLocator.get().broker.stopSession() } + return true + } + + override fun dispose() { + runCatching { PairServiceLocator.shutdown() } + } + + override fun getMainEditorTabs(): List { + return listOf( + EditorTabItem( + id = TAB_ID, + title = "Pair", + icon = R.drawable.ic_pair, + fragmentFactory = { PairMainFragment() }, + isCloseable = true, + isPersistent = false, + order = 0, + isEnabled = true, + isVisible = true, + tooltip = "Real-time pair programming over LAN", + ) + ) + } + + override fun getSideMenuItems(): List { + return listOf( + NavigationItem( + id = "pair_open", + title = "Pair", + icon = R.drawable.ic_pair, + isEnabled = true, + isVisible = true, + group = "tools", + order = 0, + action = { openPairTab() }, + ) + ) + } + + private fun openPairTab() { + val tabService = pluginContext.services.get(IdeEditorTabService::class.java) ?: run { + pluginContext.logger.error("PairPlugin: editor tab service unavailable") + return + } + if (!tabService.isTabSystemAvailable()) { + pluginContext.logger.error("PairPlugin: editor tab system not available") + return + } + runCatching { tabService.selectPluginTab(TAB_ID) } + .onFailure { pluginContext.logger.error("PairPlugin: failed to open tab", it) } + } + + companion object { + const val PLUGIN_ID: String = "com.appdevforall.pair.plugin" + const val TAB_ID: String = "pair_main" + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairServiceLocator.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairServiceLocator.kt new file mode 100644 index 00000000..b7470f7e --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairServiceLocator.kt @@ -0,0 +1,67 @@ +package com.appdevforall.pair.plugin + +import android.os.Build +import com.appdevforall.pair.plugin.data.DeviceSettingsStore +import com.appdevforall.pair.plugin.data.PairDiscoveryService +import com.appdevforall.pair.plugin.data.SessionHistoryStore +import com.appdevforall.pair.plugin.domain.EditBroker +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.services.IdeEditorService +import com.itsaky.androidide.plugins.services.IdeEnvironmentService +import com.itsaky.androidide.plugins.services.IdeFileService +import com.itsaky.androidide.plugins.services.IdeProjectService +import java.io.File + +object PairServiceLocator { + + private var instance: Container? = null + + fun init(context: PluginContext) { + if (instance != null) return + val editorService = context.services.get(IdeEditorService::class.java) + ?: error("PairPlugin: IdeEditorService not available") + val fileService = context.services.get(IdeFileService::class.java) + ?: error("PairPlugin: IdeFileService not available") + val projectService = context.services.get(IdeProjectService::class.java) + val deviceSettings = DeviceSettingsStore(dataFile(context, "device-settings.json")) + val broker = EditBroker( + editorService = editorService, + fileService = fileService, + projectService = projectService, + logger = context.logger, + displayName = deviceSettings.deviceName() ?: defaultDisplayName(), + showPeerCursors = deviceSettings.showPeerCursors(), + ) + val history = SessionHistoryStore(dataFile(context, "session-history.json")) + val discovery = PairDiscoveryService(context.androidContext, context.logger) + instance = Container(context, broker, history, discovery, deviceSettings) + } + + private fun dataFile(context: PluginContext, name: String): File { + val dir = runCatching { + context.services.get(IdeEnvironmentService::class.java)?.getPluginDataDirectory() + }.getOrNull() ?: File(context.androidContext.filesDir, "pair") + return File(dir, name) + } + + fun get(): Container = instance ?: error("PairServiceLocator not initialized") + + fun shutdown() { + instance?.discovery?.shutdown() + instance?.broker?.dispose() + instance = null + } + + private fun defaultDisplayName(): String { + val model = Build.MODEL?.takeIf { it.isNotBlank() } ?: "Android" + return model + } + + class Container internal constructor( + val pluginContext: PluginContext, + val broker: EditBroker, + val history: SessionHistoryStore, + val discovery: PairDiscoveryService, + val deviceSettings: DeviceSettingsStore, + ) +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DeviceSettingsStore.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DeviceSettingsStore.kt new file mode 100644 index 00000000..f0cf04d0 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DeviceSettingsStore.kt @@ -0,0 +1,46 @@ +package com.appdevforall.pair.plugin.data + +import org.json.JSONObject +import java.io.File + +class DeviceSettingsStore(private val file: File) { + + private val lock = Any() + + fun deviceName(): String? = synchronized(lock) { + read().optString(KEY_DEVICE_NAME).takeIf { it.isNotBlank() } + } + + fun setDeviceName(name: String) = synchronized(lock) { + write(read().put(KEY_DEVICE_NAME, name)) + } + + fun showPeerCursors(): Boolean = synchronized(lock) { + read().optBoolean(KEY_SHOW_PEER_CURSORS, true) + } + + fun setShowPeerCursors(enabled: Boolean) = synchronized(lock) { + write(read().put(KEY_SHOW_PEER_CURSORS, enabled)) + } + + private fun read(): JSONObject = runCatching { + if (file.exists()) JSONObject(file.readText()) else JSONObject() + }.getOrDefault(JSONObject()) + + private fun write(obj: JSONObject) { + runCatching { + file.parentFile?.mkdirs() + val temp = File(file.parentFile, "${file.name}.tmp") + temp.writeText(obj.toString()) + if (!temp.renameTo(file)) { + file.writeText(obj.toString()) + temp.delete() + } + } + } + + private companion object { + const val KEY_DEVICE_NAME = "deviceName" + const val KEY_SHOW_PEER_CURSORS = "showPeerCursors" + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DiscoveredHost.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DiscoveredHost.kt new file mode 100644 index 00000000..f6717e0c --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DiscoveredHost.kt @@ -0,0 +1,11 @@ +package com.appdevforall.pair.plugin.data + +data class DiscoveredHost( + val serviceName: String, + val peerId: String, + val displayName: String, + val host: String, + val port: Int, + val token: String, + val protocolVersion: Int, +) diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/FileChunkCodec.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/FileChunkCodec.kt new file mode 100644 index 00000000..8b5598f7 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/FileChunkCodec.kt @@ -0,0 +1,32 @@ +package com.appdevforall.pair.plugin.data + +import java.nio.ByteBuffer + +object FileChunkCodec { + + class Chunk(val path: String, val offset: Long, val data: ByteArray) + + fun encode(path: String, offset: Long, source: ByteArray, length: Int): ByteArray { + val pathBytes = path.toByteArray(Charsets.UTF_8) + val buffer = ByteBuffer.allocate(HEADER_BYTES + pathBytes.size + length) + buffer.putInt(pathBytes.size) + buffer.put(pathBytes) + buffer.putLong(offset) + buffer.putInt(length) + buffer.put(source, 0, length) + return buffer.array() + } + + fun decode(buffer: ByteBuffer): Chunk { + val pathLength = buffer.int + val pathBytes = ByteArray(pathLength) + buffer.get(pathBytes) + val offset = buffer.long + val dataLength = buffer.int + val data = ByteArray(dataLength) + buffer.get(data) + return Chunk(String(pathBytes, Charsets.UTF_8), offset, data) + } + + private const val HEADER_BYTES = 4 + 8 + 4 +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ManifestEntry.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ManifestEntry.kt new file mode 100644 index 00000000..df07bbeb --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ManifestEntry.kt @@ -0,0 +1,7 @@ +package com.appdevforall.pair.plugin.data + +data class ManifestEntry( + val path: String, + val size: Long, + val sha256: String, +) diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/MessageCodec.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/MessageCodec.kt new file mode 100644 index 00000000..653ab213 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/MessageCodec.kt @@ -0,0 +1,249 @@ +package com.appdevforall.pair.plugin.data + +import org.json.JSONArray +import org.json.JSONObject + +object MessageCodec { + + private const val KEY_TYPE = "t" + private const val KEY_PEER = "pid" + private const val KEY_NAME = "name" + private const val KEY_COLOR = "color" + private const val KEY_PROTO = "proto" + private const val KEY_FILE = "f" + private const val KEY_SEQ = "s" + private const val KEY_BASE_SEQ = "bs" + private const val KEY_OP = "op" + private const val KEY_START_LINE = "sl" + private const val KEY_START_COL = "sc" + private const val KEY_END_LINE = "el" + private const val KEY_END_COL = "ec" + private const val KEY_TEXT = "tx" + private const val KEY_LINE = "l" + private const val KEY_COL = "c" + private const val KEY_CONTENT = "ct" + private const val KEY_IS_DIR = "dir" + private const val KEY_FROM = "fr" + private const val KEY_TO = "to" + private const val KEY_ENTRIES = "en" + private const val KEY_PATHS = "ps" + private const val KEY_PATH = "p" + private const val KEY_SIZE = "sz" + private const val KEY_HASH = "h" + private const val KEY_PROJECT_NAME = "pn" + + private const val T_HELLO = "hi" + private const val T_GOODBYE = "bye" + private const val T_EDIT = "edit" + private const val T_CURSOR = "cur" + private const val T_FILE_OPEN = "fo" + private const val T_FILE_CLOSE = "fc" + private const val T_FILE_FOCUS = "ff" + private const val T_SYNC = "sync" + private const val T_FILE_CREATE = "fcr" + private const val T_FILE_DELETE = "fdl" + private const val T_FILE_RENAME = "frn" + private const val T_MANIFEST_REQUEST = "mreq" + private const val T_MANIFEST = "man" + private const val T_FILE_REQUEST = "freq" + private const val T_TRANSFER_DONE = "tdone" + private const val T_RESYNC_REQUEST = "rreq" + + fun encode(msg: ProtocolMessage): String { + val json = JSONObject() + json.put(KEY_PEER, msg.peerId) + when (msg) { + is ProtocolMessage.Hello -> { + json.put(KEY_TYPE, T_HELLO) + json.put(KEY_NAME, msg.displayName) + json.put(KEY_COLOR, msg.colorIndex) + json.put(KEY_PROTO, msg.protocolVersion) + } + is ProtocolMessage.Goodbye -> { + json.put(KEY_TYPE, T_GOODBYE) + } + is ProtocolMessage.Edit -> { + json.put(KEY_TYPE, T_EDIT) + json.put(KEY_FILE, msg.file) + json.put(KEY_SEQ, msg.seq) + json.put(KEY_BASE_SEQ, msg.baseSeq) + json.put(KEY_OP, msg.op.name) + json.put(KEY_START_LINE, msg.startLine) + json.put(KEY_START_COL, msg.startColumn) + json.put(KEY_END_LINE, msg.endLine) + json.put(KEY_END_COL, msg.endColumn) + json.put(KEY_TEXT, msg.text) + } + is ProtocolMessage.CursorMove -> { + json.put(KEY_TYPE, T_CURSOR) + json.put(KEY_FILE, msg.file) + json.put(KEY_LINE, msg.line) + json.put(KEY_COL, msg.column) + } + is ProtocolMessage.FileOpened -> { + json.put(KEY_TYPE, T_FILE_OPEN) + json.put(KEY_FILE, msg.file) + json.put(KEY_CONTENT, msg.content) + json.put(KEY_SEQ, msg.seq) + } + is ProtocolMessage.FileClosed -> { + json.put(KEY_TYPE, T_FILE_CLOSE) + json.put(KEY_FILE, msg.file) + } + is ProtocolMessage.FileFocused -> { + json.put(KEY_TYPE, T_FILE_FOCUS) + json.put(KEY_FILE, msg.file) + } + is ProtocolMessage.SyncSnapshot -> { + json.put(KEY_TYPE, T_SYNC) + json.put(KEY_FILE, msg.file) + json.put(KEY_CONTENT, msg.content) + json.put(KEY_SEQ, msg.seq) + } + is ProtocolMessage.FileCreated -> { + json.put(KEY_TYPE, T_FILE_CREATE) + json.put(KEY_FILE, msg.file) + json.put(KEY_IS_DIR, msg.isDirectory) + if (msg.contentBase64 != null) json.put(KEY_CONTENT, msg.contentBase64) + } + is ProtocolMessage.FileDeleted -> { + json.put(KEY_TYPE, T_FILE_DELETE) + json.put(KEY_FILE, msg.file) + } + is ProtocolMessage.FileRenamed -> { + json.put(KEY_TYPE, T_FILE_RENAME) + json.put(KEY_FROM, msg.from) + json.put(KEY_TO, msg.to) + } + is ProtocolMessage.ManifestRequest -> { + json.put(KEY_TYPE, T_MANIFEST_REQUEST) + } + is ProtocolMessage.ProjectManifest -> { + json.put(KEY_TYPE, T_MANIFEST) + json.put(KEY_PROJECT_NAME, msg.projectName) + val array = JSONArray() + for (entry in msg.entries) { + array.put( + JSONObject() + .put(KEY_PATH, entry.path) + .put(KEY_SIZE, entry.size) + .put(KEY_HASH, entry.sha256), + ) + } + json.put(KEY_ENTRIES, array) + } + is ProtocolMessage.FileRequest -> { + json.put(KEY_TYPE, T_FILE_REQUEST) + json.put(KEY_PATHS, JSONArray(msg.paths)) + } + is ProtocolMessage.FileTransferComplete -> { + json.put(KEY_TYPE, T_TRANSFER_DONE) + } + is ProtocolMessage.ResyncRequest -> { + json.put(KEY_TYPE, T_RESYNC_REQUEST) + json.put(KEY_FILE, msg.file) + } + } + return json.toString() + } + + fun decode(raw: String): ProtocolMessage { + val json = JSONObject(raw) + val type = json.getString(KEY_TYPE) + val peerId = json.getString(KEY_PEER) + return when (type) { + T_HELLO -> ProtocolMessage.Hello( + peerId = peerId, + displayName = json.getString(KEY_NAME), + colorIndex = json.getInt(KEY_COLOR), + protocolVersion = json.getInt(KEY_PROTO), + ) + T_GOODBYE -> ProtocolMessage.Goodbye(peerId) + T_EDIT -> ProtocolMessage.Edit( + peerId = peerId, + file = json.getString(KEY_FILE), + seq = json.getLong(KEY_SEQ), + baseSeq = json.getLong(KEY_BASE_SEQ), + op = EditOp.valueOf(json.getString(KEY_OP)), + startLine = json.getInt(KEY_START_LINE), + startColumn = json.getInt(KEY_START_COL), + endLine = json.getInt(KEY_END_LINE), + endColumn = json.getInt(KEY_END_COL), + text = json.getString(KEY_TEXT), + ) + T_CURSOR -> ProtocolMessage.CursorMove( + peerId = peerId, + file = json.getString(KEY_FILE), + line = json.getInt(KEY_LINE), + column = json.getInt(KEY_COL), + ) + T_FILE_OPEN -> ProtocolMessage.FileOpened( + peerId = peerId, + file = json.getString(KEY_FILE), + content = json.getString(KEY_CONTENT), + seq = json.getLong(KEY_SEQ), + ) + T_FILE_CLOSE -> ProtocolMessage.FileClosed( + peerId = peerId, + file = json.getString(KEY_FILE), + ) + T_FILE_FOCUS -> ProtocolMessage.FileFocused( + peerId = peerId, + file = json.getString(KEY_FILE), + ) + T_SYNC -> ProtocolMessage.SyncSnapshot( + peerId = peerId, + file = json.getString(KEY_FILE), + content = json.getString(KEY_CONTENT), + seq = json.getLong(KEY_SEQ), + ) + T_FILE_CREATE -> ProtocolMessage.FileCreated( + peerId = peerId, + file = json.getString(KEY_FILE), + isDirectory = json.getBoolean(KEY_IS_DIR), + contentBase64 = if (json.has(KEY_CONTENT)) json.getString(KEY_CONTENT) else null, + ) + T_FILE_DELETE -> ProtocolMessage.FileDeleted( + peerId = peerId, + file = json.getString(KEY_FILE), + ) + T_FILE_RENAME -> ProtocolMessage.FileRenamed( + peerId = peerId, + from = json.getString(KEY_FROM), + to = json.getString(KEY_TO), + ) + T_MANIFEST_REQUEST -> ProtocolMessage.ManifestRequest(peerId) + T_MANIFEST -> { + val array = json.getJSONArray(KEY_ENTRIES) + val entries = ArrayList(array.length()) + for (i in 0 until array.length()) { + val item = array.getJSONObject(i) + entries.add( + ManifestEntry( + path = item.getString(KEY_PATH), + size = item.getLong(KEY_SIZE), + sha256 = item.getString(KEY_HASH), + ), + ) + } + ProtocolMessage.ProjectManifest( + peerId = peerId, + entries = entries, + projectName = if (json.has(KEY_PROJECT_NAME)) json.getString(KEY_PROJECT_NAME) else "", + ) + } + T_FILE_REQUEST -> { + val array = json.getJSONArray(KEY_PATHS) + val paths = ArrayList(array.length()) + for (i in 0 until array.length()) paths.add(array.getString(i)) + ProtocolMessage.FileRequest(peerId, paths) + } + T_TRANSFER_DONE -> ProtocolMessage.FileTransferComplete(peerId) + T_RESYNC_REQUEST -> ProtocolMessage.ResyncRequest( + peerId = peerId, + file = json.getString(KEY_FILE), + ) + else -> throw IllegalArgumentException("Unknown message type: $type") + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairDiscoveryService.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairDiscoveryService.kt new file mode 100644 index 00000000..65a07456 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairDiscoveryService.kt @@ -0,0 +1,215 @@ +package com.appdevforall.pair.plugin.data + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import com.appdevforall.pair.plugin.util.PairLog +import com.itsaky.androidide.plugins.PluginLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.util.ArrayDeque + +class PairDiscoveryService( + context: Context, + private val logger: PluginLogger, +) { + + private val nsdManager: NsdManager = + context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager + + private val _hosts = MutableStateFlow>(emptyList()) + val hosts: StateFlow> = _hosts + + private val lock = Any() + private var localPeerId: String = "" + + private var registrationListener: NsdManager.RegistrationListener? = null + private var discoveryListener: NsdManager.DiscoveryListener? = null + + private val resolveQueue: ArrayDeque = ArrayDeque() + private var resolving: Boolean = false + + fun register(displayName: String, port: Int, token: String, peerId: String) { + synchronized(lock) { + unregisterLocked() + val info = NsdServiceInfo().apply { + serviceName = sanitizeName(displayName) + serviceType = SERVICE_TYPE + this.port = port + setAttribute(ATTR_PEER_ID, peerId) + setAttribute(ATTR_DISPLAY_NAME, displayName) + setAttribute(ATTR_TOKEN, token) + setAttribute(ATTR_PROTOCOL, ProtocolMessage.PROTOCOL_VERSION.toString()) + } + val listener = registrationListener() + registrationListener = listener + runCatching { + nsdManager.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener) + }.onFailure { + registrationListener = null + logger.warn("PairPlugin: NSD register failed (${it.message})") + } + } + } + + fun unregister() { + synchronized(lock) { unregisterLocked() } + } + + fun startDiscovery(localPeerId: String) { + synchronized(lock) { + this.localPeerId = localPeerId + if (discoveryListener != null) return + _hosts.value = emptyList() + val listener = discoveryListener() + discoveryListener = listener + runCatching { + nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) + }.onFailure { + discoveryListener = null + logger.warn("PairPlugin: NSD discovery failed to start (${it.message})") + } + } + } + + fun stopDiscovery() { + synchronized(lock) { + val listener = discoveryListener ?: return + runCatching { nsdManager.stopServiceDiscovery(listener) } + discoveryListener = null + resolveQueue.clear() + resolving = false + _hosts.value = emptyList() + } + } + + fun shutdown() { + unregister() + stopDiscovery() + } + + private fun unregisterLocked() { + val listener = registrationListener ?: return + runCatching { nsdManager.unregisterService(listener) } + registrationListener = null + } + + private fun registrationListener() = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { + logger.info("PairPlugin: NSD registered as ${serviceInfo.serviceName}") + PairLog.d("[DISCOVERY] advertising as '${serviceInfo.serviceName}' ($SERVICE_TYPE)") + } + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + logger.warn("PairPlugin: NSD registration failed (code $errorCode)") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) {} + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + logger.warn("PairPlugin: NSD unregistration failed (code $errorCode)") + } + } + + private fun discoveryListener() = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) {} + + override fun onDiscoveryStopped(serviceType: String) {} + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + logger.warn("PairPlugin: NSD start discovery failed (code $errorCode)") + synchronized(lock) { discoveryListener = null } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + logger.warn("PairPlugin: NSD stop discovery failed (code $errorCode)") + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + enqueueResolve(serviceInfo) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + val name = serviceInfo.serviceName + _hosts.update { current -> current.filterNot { it.serviceName == name } } + } + } + + private fun enqueueResolve(info: NsdServiceInfo) { + synchronized(lock) { + resolveQueue.addLast(info) + pumpResolveQueueLocked() + } + } + + private fun pumpResolveQueueLocked() { + if (resolving) return + val next = resolveQueue.pollFirst() ?: return + resolving = true + runCatching { + nsdManager.resolveService(next, resolveListener()) + }.onFailure { + logger.warn("PairPlugin: NSD resolve failed to start (${it.message})") + resolving = false + pumpResolveQueueLocked() + } + } + + private fun resolveListener() = object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + logger.warn("PairPlugin: NSD resolve failed for ${serviceInfo.serviceName} (code $errorCode)") + onResolveFinished() + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + addResolved(serviceInfo) + onResolveFinished() + } + } + + private fun onResolveFinished() { + synchronized(lock) { + resolving = false + pumpResolveQueueLocked() + } + } + + private fun addResolved(info: NsdServiceInfo) { + val attributes = info.attributes ?: return + val peerId = attributes.text(ATTR_PEER_ID) ?: return + if (peerId == localPeerId) return + val token = attributes.text(ATTR_TOKEN) ?: return + val host = info.host?.hostAddress ?: return + val protocol = attributes.text(ATTR_PROTOCOL)?.toIntOrNull() ?: 0 + val displayName = attributes.text(ATTR_DISPLAY_NAME) ?: info.serviceName + val discovered = DiscoveredHost( + serviceName = info.serviceName, + peerId = peerId, + displayName = displayName, + host = host, + port = info.port, + token = token, + protocolVersion = protocol, + ) + PairLog.d("[DISCOVERY] resolved host '$displayName' at $host:${info.port} (peerId=$peerId)") + _hosts.update { current -> + current.filterNot { it.peerId == peerId } + discovered + } + } + + private fun Map.text(key: String): String? = + this[key]?.let { String(it, Charsets.UTF_8) } + + private fun sanitizeName(name: String): String = + name.trim().take(MAX_SERVICE_NAME).ifBlank { "Pair host" } + + private companion object { + const val SERVICE_TYPE = "_pairide._tcp." + const val ATTR_PEER_ID = "pid" + const val ATTR_DISPLAY_NAME = "dn" + const val ATTR_TOKEN = "t" + const val ATTR_PROTOCOL = "v" + const val MAX_SERVICE_NAME = 60 + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketClient.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketClient.kt new file mode 100644 index 00000000..803cf639 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketClient.kt @@ -0,0 +1,58 @@ +package com.appdevforall.pair.plugin.data + +import com.appdevforall.pair.plugin.util.NetUtil +import com.appdevforall.pair.plugin.util.PairLog +import org.java_websocket.client.WebSocketClient +import org.java_websocket.handshake.ServerHandshake +import java.net.URI +import java.nio.ByteBuffer + +class PairWebSocketClient( + host: String, + port: Int, + token: String, + private val callbacks: ClientCallbacks, + private val onDecodeError: (Throwable) -> Unit = {}, +) : WebSocketClient(URI("ws://$host:$port"), mapOf(NetUtil.PAIR_TOKEN_HEADER to token)) { + + private val target: String = "$host:$port" + + interface ClientCallbacks { + fun onConnected() + fun onDisconnected(reason: String?) + fun onMessageReceived(message: ProtocolMessage) + fun onBinaryReceived(data: ByteBuffer) + fun onError(error: Throwable) + } + + override fun onOpen(handshakedata: ServerHandshake?) { + PairLog.d("[CLIENT] onOpen connected to $target (httpStatus=${handshakedata?.httpStatus})") + callbacks.onConnected() + } + + override fun onMessage(message: String) { + val parsed = runCatching { MessageCodec.decode(message) } + .onFailure { onDecodeError(it) } + .getOrNull() ?: return + callbacks.onMessageReceived(parsed) + } + + override fun onMessage(bytes: ByteBuffer) { + callbacks.onBinaryReceived(bytes) + } + + override fun onClose(code: Int, reason: String?, remote: Boolean) { + PairLog.d("[CLIENT] onClose target=$target code=$code reason=$reason remote=$remote") + callbacks.onDisconnected(reason) + } + + override fun onError(ex: Exception) { + PairLog.e("[CLIENT] onError target=$target ${ex.javaClass.simpleName}: ${ex.message}", ex) + callbacks.onError(ex) + } + + fun sendMessage(message: ProtocolMessage) { + if (!isOpen) return + runCatching { send(MessageCodec.encode(message)) } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketServer.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketServer.kt new file mode 100644 index 00000000..830848c5 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketServer.kt @@ -0,0 +1,99 @@ +package com.appdevforall.pair.plugin.data + +import com.appdevforall.pair.plugin.util.NetUtil +import com.appdevforall.pair.plugin.util.PairLog +import org.java_websocket.WebSocket +import org.java_websocket.framing.CloseFrame +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.security.MessageDigest +import java.util.Collections + +class PairWebSocketServer( + port: Int, + private val expectedToken: String, + private val callbacks: ServerCallbacks, + private val onDecodeError: (Throwable) -> Unit = {}, +) : WebSocketServer(InetSocketAddress(port)) { + + interface ServerCallbacks { + fun onClientConnected(connection: WebSocket) + fun onClientDisconnected(connection: WebSocket, reason: String?) + fun onMessageReceived(connection: WebSocket, message: ProtocolMessage) + fun onServerStarted(port: Int) + fun onError(error: Throwable) + } + + private val connections: MutableSet = Collections.synchronizedSet(mutableSetOf()) + + init { + isReuseAddr = true + } + + override fun onOpen(conn: WebSocket, handshake: ClientHandshake?) { + val provided = handshake?.getFieldValue(NetUtil.PAIR_TOKEN_HEADER).orEmpty() + val match = tokenMatches(provided) + PairLog.d("[SERVER] onOpen from ${conn.remoteSocketAddress} providedTokenLen=${provided.length} match=$match") + if (!match) { + PairLog.w("[SERVER] rejecting ${conn.remoteSocketAddress}: token mismatch") + runCatching { conn.close(CloseFrame.POLICY_VALIDATION, "unauthorized") } + return + } + connections.add(conn) + callbacks.onClientConnected(conn) + } + + private fun tokenMatches(provided: String): Boolean { + if (provided.isEmpty()) return false + return MessageDigest.isEqual( + provided.toByteArray(Charsets.UTF_8), + expectedToken.toByteArray(Charsets.UTF_8), + ) + } + + override fun onClose(conn: WebSocket, code: Int, reason: String?, remote: Boolean) { + PairLog.d("[SERVER] onClose ${conn.remoteSocketAddress} code=$code reason=$reason remote=$remote") + connections.remove(conn) + callbacks.onClientDisconnected(conn, reason) + } + + override fun onMessage(conn: WebSocket, message: String) { + val parsed = runCatching { MessageCodec.decode(message) } + .onFailure { onDecodeError(it) } + .getOrNull() ?: return + callbacks.onMessageReceived(conn, parsed) + } + + override fun onError(conn: WebSocket?, ex: Exception) { + PairLog.e("[SERVER] onError conn=${conn?.remoteSocketAddress} ${ex.javaClass.simpleName}: ${ex.message}", ex) + callbacks.onError(ex) + } + + override fun onStart() { + PairLog.d("[SERVER] onStart listening on port $port") + callbacks.onServerStarted(port) + } + + fun broadcastExcept(origin: WebSocket?, message: ProtocolMessage) { + val encoded = MessageCodec.encode(message) + val snapshot = synchronized(connections) { connections.toList() } + for (conn in snapshot) { + if (conn !== origin && conn.isOpen) { + runCatching { conn.send(encoded) } + } + } + } + + fun sendTo(target: WebSocket, message: ProtocolMessage) { + if (!target.isOpen) return + runCatching { target.send(MessageCodec.encode(message)) } + } + + fun sendBinaryTo(target: WebSocket, data: ByteArray) { + if (!target.isOpen) return + runCatching { target.send(data) } + } + + fun connectionCount(): Int = connections.size +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PeerSession.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PeerSession.kt new file mode 100644 index 00000000..22954ff0 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PeerSession.kt @@ -0,0 +1,36 @@ +package com.appdevforall.pair.plugin.data + +data class PeerSession( + val peerId: String, + val displayName: String, + val colorIndex: Int, + val isHost: Boolean, + val joinedAtMillis: Long, + val currentFile: String? = null, + val cursorLine: Int? = null, + val cursorColumn: Int? = null, +) + +enum class SessionRole { + IDLE, + HOST, + GUEST, +} + +data class SessionState( + val role: SessionRole, + val localPeerId: String, + val localDisplayName: String, + val localAddress: String? = null, + val localPort: Int? = null, + val localToken: String? = null, + val remoteAddress: String? = null, + val connecting: Boolean = false, + val showPeerCursors: Boolean = true, + val peers: List = emptyList(), + val outOfSync: Boolean = false, + val transferReceived: Int = 0, + val transferTotal: Int = 0, + val pendingProjectPath: String? = null, + val lastError: String? = null, +) diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ProtocolMessage.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ProtocolMessage.kt new file mode 100644 index 00000000..ae2de75e --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ProtocolMessage.kt @@ -0,0 +1,112 @@ +package com.appdevforall.pair.plugin.data + +sealed interface ProtocolMessage { + val peerId: String + + data class Hello( + override val peerId: String, + val displayName: String, + val colorIndex: Int, + val protocolVersion: Int = PROTOCOL_VERSION, + ) : ProtocolMessage + + data class Goodbye( + override val peerId: String, + ) : ProtocolMessage + + data class Edit( + override val peerId: String, + val file: String, + val seq: Long, + val baseSeq: Long, + val op: EditOp, + val startLine: Int, + val startColumn: Int, + val endLine: Int, + val endColumn: Int, + val text: String, + ) : ProtocolMessage + + data class CursorMove( + override val peerId: String, + val file: String, + val line: Int, + val column: Int, + ) : ProtocolMessage + + data class FileOpened( + override val peerId: String, + val file: String, + val content: String, + val seq: Long, + ) : ProtocolMessage + + data class FileClosed( + override val peerId: String, + val file: String, + ) : ProtocolMessage + + data class FileFocused( + override val peerId: String, + val file: String, + ) : ProtocolMessage + + data class SyncSnapshot( + override val peerId: String, + val file: String, + val content: String, + val seq: Long, + ) : ProtocolMessage + + data class FileCreated( + override val peerId: String, + val file: String, + val isDirectory: Boolean, + val contentBase64: String?, + ) : ProtocolMessage + + data class FileDeleted( + override val peerId: String, + val file: String, + ) : ProtocolMessage + + data class FileRenamed( + override val peerId: String, + val from: String, + val to: String, + ) : ProtocolMessage + + data class ManifestRequest( + override val peerId: String, + ) : ProtocolMessage + + data class ProjectManifest( + override val peerId: String, + val entries: List, + val projectName: String = "", + ) : ProtocolMessage + + data class FileRequest( + override val peerId: String, + val paths: List, + ) : ProtocolMessage + + data class FileTransferComplete( + override val peerId: String, + ) : ProtocolMessage + + data class ResyncRequest( + override val peerId: String, + val file: String, + ) : ProtocolMessage + + companion object { + const val PROTOCOL_VERSION: Int = 1 + } +} + +enum class EditOp { + INSERT, + DELETE, + REPLACE, +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/SessionHistoryStore.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/SessionHistoryStore.kt new file mode 100644 index 00000000..983361e5 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/SessionHistoryStore.kt @@ -0,0 +1,106 @@ +package com.appdevforall.pair.plugin.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject +import java.io.File + +class SessionHistoryStore(private val file: File) { + + private val lock = Any() + private val _sessions = MutableStateFlow(load()) + val sessions: StateFlow> = _sessions.asStateFlow() + + fun record(address: String, port: Int, role: SessionRole) = synchronized(lock) { + val id = "$address:$port" + val existing = _sessions.value.firstOrNull { it.id == id } + val updated = StoredSession( + id = id, + customName = existing?.customName, + address = address, + port = port, + role = role, + lastConnectedMillis = System.currentTimeMillis(), + ) + commit(_sessions.value.filterNot { it.id == id } + updated) + } + + fun rename(id: String, name: String) = synchronized(lock) { + val trimmed = name.trim().ifBlank { null } + commit( + _sessions.value.map { + if (it.id == id) it.copy(customName = trimmed) else it + }, + ) + } + + fun delete(id: String) = synchronized(lock) { + commit(_sessions.value.filterNot { it.id == id }) + } + + private fun commit(next: List) { + val capped = next + .sortedByDescending { it.lastConnectedMillis } + .take(MAX_SESSIONS) + _sessions.value = capped + persist(capped) + } + + private fun load(): List = runCatching { + if (!file.exists()) return emptyList() + val array = JSONArray(file.readText()) + (0 until array.length()) + .mapNotNull { index -> parse(array.optJSONObject(index)) } + .sortedByDescending { it.lastConnectedMillis } + .take(MAX_SESSIONS) + }.getOrDefault(emptyList()) + + private fun parse(obj: JSONObject?): StoredSession? { + if (obj == null) return null + return runCatching { + StoredSession( + id = obj.getString(KEY_ID), + customName = if (obj.isNull(KEY_NAME)) null else obj.optString(KEY_NAME).ifBlank { null }, + address = obj.getString(KEY_ADDRESS), + port = obj.getInt(KEY_PORT), + role = SessionRole.valueOf(obj.getString(KEY_ROLE)), + lastConnectedMillis = obj.getLong(KEY_TS), + ) + }.getOrNull() + } + + private fun persist(sessions: List) { + runCatching { + val array = JSONArray() + sessions.forEach { session -> + val obj = JSONObject() + obj.put(KEY_ID, session.id) + obj.put(KEY_NAME, session.customName ?: JSONObject.NULL) + obj.put(KEY_ADDRESS, session.address) + obj.put(KEY_PORT, session.port) + obj.put(KEY_ROLE, session.role.name) + obj.put(KEY_TS, session.lastConnectedMillis) + array.put(obj) + } + file.parentFile?.mkdirs() + val temp = File(file.parentFile, "${file.name}.tmp") + temp.writeText(array.toString()) + if (!temp.renameTo(file)) { + file.writeText(array.toString()) + temp.delete() + } + } + } + + private companion object { + const val MAX_SESSIONS = 8 + const val KEY_ID = "id" + const val KEY_NAME = "name" + const val KEY_ADDRESS = "address" + const val KEY_PORT = "port" + const val KEY_ROLE = "role" + const val KEY_TS = "ts" + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/StoredSession.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/StoredSession.kt new file mode 100644 index 00000000..eb6e5dde --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/data/StoredSession.kt @@ -0,0 +1,10 @@ +package com.appdevforall.pair.plugin.data + +data class StoredSession( + val id: String, + val customName: String?, + val address: String, + val port: Int, + val role: SessionRole, + val lastConnectedMillis: Long, +) diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditApplier.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditApplier.kt new file mode 100644 index 00000000..d617d610 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditApplier.kt @@ -0,0 +1,157 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.EditOp +import com.appdevforall.pair.plugin.data.ProtocolMessage +import com.appdevforall.pair.plugin.util.PairLog +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.services.IdeEditorService +import com.itsaky.androidide.plugins.services.SelectionRange +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +class EditApplier( + private val editorService: IdeEditorService, + private val scope: CoroutineScope, + private val suppression: LoopbackSuppression, + private val pathMapper: PathMapper, + private val logger: PluginLogger, + private val onOutOfSync: (wireFile: String?) -> Unit, +) { + + private val seqByFile: ConcurrentHashMap = ConcurrentHashMap() + + fun currentSeq(file: String): Long = seqByFile.getOrPut(file) { AtomicLong(0L) }.get() + + private fun flagOutOfSync(wireFile: String?) = onOutOfSync(wireFile) + + // After applying a remote change the editor moves the local caret to the edit site. Record it + // so the cursor poll doesn't mistake that drag for a local cursor move and echo it back. + private fun recordAutoCaret(file: File, wireFile: String) { + if (editorService.getCurrentFile() != file) return + val pos = editorService.getCurrentCursorPosition() ?: return + suppression.recordAutoCaret(wireFile, pos.line, pos.column) + } + + // replaceRange enqueues a DocumentChangeEvent (EventBus MAIN_ORDERED) that is delivered on a + // LATER main-loop tick. Exiting suppression synchronously leaves it off when that echo arrives, + // so the applied edit is re-observed and re-broadcast — an unbounded duplication loop. Posting + // the exit makes it run after the echo (enqueued earlier), so the echo stays suppressed. + private fun releaseSuppressionAfterEcho() { + scope.launch(Dispatchers.Main) { suppression.exit() } + } + + private fun resolveLocal(wirePath: String): File? { + val file = File(pathMapper.toLocal(wirePath)) + if (file.exists()) return file + logger.warn("PairPlugin: remote edit for unavailable file '$wirePath' — flagging out of sync") + flagOutOfSync(wirePath) + return null + } + + fun applyEdit(message: ProtocolMessage.Edit) { + scope.launch { + withContext(Dispatchers.Main) { + val file = resolveLocal(message.file) ?: return@withContext + PairLog.d("[APPLY] edit ${message.op} ${message.file} @${message.startLine}:${message.startColumn} seq=${message.seq} textLen=${message.text.length}") + // INSERT's wire range spans the newly-inserted text on the sender; the receiver + // doesn't have that text yet, so it must insert at the collapsed start point — + // replacing [start..end) would clobber the receiver's own content there. + val range = when (message.op) { + EditOp.INSERT -> SelectionRange( + message.startLine, message.startColumn, message.startLine, message.startColumn, + ) + else -> SelectionRange( + message.startLine, message.startColumn, message.endLine, message.endColumn, + ) + } + val text = when (message.op) { + EditOp.INSERT -> message.text + EditOp.DELETE -> "" + EditOp.REPLACE -> message.text + } + val inBounds = fits(file, message.startLine, message.startColumn) && + (message.op == EditOp.INSERT || fits(file, message.endLine, message.endColumn)) + if (!inBounds) { + PairLog.w( + "[APPLY] out-of-bounds edit dropped for ${message.file} " + + "(${message.startLine}:${message.startColumn}..${message.endLine}:${message.endColumn}) " + + "— local content diverged; flagging out of sync", + ) + logger.warn("PairPlugin: remote edit out of bounds for '${message.file}' — flagging out of sync") + flagOutOfSync(message.file) + return@withContext + } + suppression.enter() + runCatching { editorService.replaceRange(file, range, text) } + .onSuccess { + seqByFile.getOrPut(message.file) { AtomicLong(0L) }.set(message.seq) + suppression.recordApplied(message.file, editorService.getFileContent(file).orEmpty()) + recordAutoCaret(file, message.file) + } + .onFailure { + PairLog.e("[APPLY] replaceRange threw for ${message.file}: ${it.message}", it) + logger.warn("PairPlugin: replaceRange failed for '${message.file}' — flagging out of sync") + flagOutOfSync(message.file) + } + releaseSuppressionAfterEcho() + } + } + } + + private fun fits(file: File, line: Int, column: Int): Boolean { + if (line < 0 || column < 0) return false + if (line >= editorService.getLineCount(file)) return false + val length = editorService.getLineText(file, line)?.length ?: return false + return column <= length + } + + fun applySnapshot(message: ProtocolMessage.SyncSnapshot) { + scope.launch { + withContext(Dispatchers.Main) { + val file = resolveLocal(message.file) ?: return@withContext + // Compute the replace range from the ACTUAL current content, not from + // getLineCount/getLineText: those are unreliable for a file that isn't the active + // editor buffer and collapse the range toward (0,0), which makes replaceRange INSERT + // the snapshot without clearing the file — duplicating the whole document. + val current = editorService.getFileContent(file) + if (current == null) { + PairLog.w("[APPLY] snapshot skipped — ${message.file} not open/available in editor") + return@withContext + } + if (current == message.content) { + PairLog.d("[APPLY] snapshot no-op (already in sync) ${message.file} len=${current.length}") + seqByFile.getOrPut(message.file) { AtomicLong(0L) }.set(message.seq) + return@withContext + } + val lines = current.split('\n') + val endLine = (lines.size - 1).coerceAtLeast(0) + val endColumn = lines.last().length + val fullRange = SelectionRange(0, 0, endLine, endColumn) + PairLog.d("[APPLY] snapshot ${message.file}: ${current.length}->${message.content.length} chars over 0:0..$endLine:$endColumn") + suppression.recordApplied(message.file, message.content) + suppression.enter() + runCatching { editorService.replaceRange(file, fullRange, message.content) } + .onSuccess { + seqByFile.getOrPut(message.file) { AtomicLong(0L) }.set(message.seq) + recordAutoCaret(file, message.file) + } + .onFailure { + PairLog.e("[APPLY] snapshot replaceRange threw for ${message.file}: ${it.message}", it) + logger.warn("PairPlugin: snapshot apply failed for '${message.file}' — flagging out of sync") + flagOutOfSync(message.file) + } + releaseSuppressionAfterEcho() + } + } + } + + fun resetSequences() { + seqByFile.clear() + suppression.clearApplied() + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditBroker.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditBroker.kt new file mode 100644 index 00000000..5fa62e6b --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditBroker.kt @@ -0,0 +1,678 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.PairWebSocketClient +import com.appdevforall.pair.plugin.data.PairWebSocketServer +import com.appdevforall.pair.plugin.data.ProtocolMessage +import com.appdevforall.pair.plugin.data.SessionRole +import com.appdevforall.pair.plugin.data.SessionState +import com.appdevforall.pair.plugin.util.NetUtil +import com.appdevforall.pair.plugin.util.PairLog +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.services.FileChangeListener +import com.itsaky.androidide.plugins.services.IdeEditorService +import com.itsaky.androidide.plugins.services.IdeFileService +import com.itsaky.androidide.plugins.services.IdeProjectService +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.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.java_websocket.WebSocket +import java.security.SecureRandom +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +private const val PIN_LENGTH = 4 +private const val PIN_BOUND = 10_000 +private const val CURSOR_POLL_INTERVAL_MS = 150L + +class EditBroker( + private val editorService: IdeEditorService, + private val fileService: IdeFileService, + private val projectService: IdeProjectService?, + private val logger: PluginLogger, + private var displayName: String, + private var showPeerCursors: Boolean = true, +) { + + fun setDisplayName(name: String) { + displayName = name + _state.value = _state.value.copy(localDisplayName = name) + } + + fun setShowPeerCursors(enabled: Boolean) { + showPeerCursors = enabled + PairLog.d("[MARKERS] show-peer-cursors toggled = $enabled") + if (!enabled) markers.clearAll() + _state.value = _state.value.copy(showPeerCursors = enabled) + } + + private val localPeerId: String = UUID.randomUUID().toString() + private val localColorIndex: Int = (0 until 5).random() + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Default) + + private val suppression = LoopbackSuppression() + private val peerRegistry = PeerRegistry() + private val pathMapper = PathMapper(projectService) + private val applier = EditApplier( + editorService = editorService, + scope = scope, + suppression = suppression, + pathMapper = pathMapper, + logger = logger, + onOutOfSync = { wireFile -> + _state.value = _state.value.copy(outOfSync = true) + if (wireFile != null) requestResync(wireFile) + }, + ) + private val observer = EditObserver(localPeerId, editorService, suppression, pathMapper, OutboundDispatcher()) + private val fsSuppression = LoopbackSuppression() + private val fsApplier = FileSystemApplier( + fileService = fileService, + scope = scope, + suppression = fsSuppression, + pathMapper = pathMapper, + logger = logger, + onOutOfSync = { _state.value = _state.value.copy(outOfSync = true) }, + ) + private val fsObserver = FileSystemObserver(localPeerId, pathMapper, fsSuppression, OutboundDispatcher(), logger) + private val manifestBuilder = ProjectManifestBuilder(projectService, logger) + private val syncCoordinator = ProjectSyncCoordinator( + localPeerId = localPeerId, + manifestBuilder = manifestBuilder, + fileService = fileService, + pathMapper = pathMapper, + suppression = fsSuppression, + scope = scope, + logger = logger, + onProgress = { received, total -> + _state.value = _state.value.copy(transferReceived = received, transferTotal = total) + }, + onComplete = { pulledRoot -> + _state.value = _state.value.copy(outOfSync = false) + val currentRoot = runCatching { projectService?.getCurrentProject()?.rootDir?.absolutePath }.getOrNull() + if (pulledRoot != null && pulledRoot.absolutePath != currentRoot) { + pendingProjectRoot.set(pulledRoot) + _state.value = _state.value.copy(pendingProjectPath = pulledRoot.absolutePath) + PairLog.d("[PULL] complete → awaiting confirmation to open ${pulledRoot.absolutePath}") + } else { + PairLog.d("[PULL] complete → already on ${pulledRoot?.absolutePath ?: "(none)"}; files re-synced, no reopen needed") + } + }, + ) + private val markers = RemoteMarkerController(editorService, pathMapper) + + // The host reports the active editor file (null when a non-file tab — e.g. the Pair tab — is + // shown). Clear remote peer cursors whenever the local user leaves the editor so stale markers + // don't linger over the plugin UI. + private val fileChangeListener = FileChangeListener { file -> + if (file == null) { + PairLog.d("[MARKERS] active file → null (left editor); clearing peer cursors") + markers.clearAll() + } else { + PairLog.d("[MARKERS] active file → ${file.name}") + } + } + private val connToPeer: ConcurrentHashMap = ConcurrentHashMap() + + private val serverRef: AtomicReference = AtomicReference(null) + private val clientRef: AtomicReference = AtomicReference(null) + private val pendingRemote: AtomicReference = AtomicReference(null) + private val pendingLocalIp: AtomicReference = AtomicReference(null) + private val pendingProjectRoot: AtomicReference = AtomicReference(null) + private val resyncPending: MutableSet = ConcurrentHashMap.newKeySet() + private val cursorJob: AtomicReference = AtomicReference(null) + private var lastCursor: Triple? = null + + private val _state: MutableStateFlow = MutableStateFlow( + SessionState( + role = SessionRole.IDLE, + localPeerId = localPeerId, + localDisplayName = displayName, + showPeerCursors = showPeerCursors, + ) + ) + + val state: StateFlow = _state + + init { + peerRegistry.peers + .onEach { peers -> _state.value = _state.value.copy(peers = peers) } + .launchIn(scope) + _state + .distinctUntilChangedBy { it.role } + .onEach { session -> + if (session.role == SessionRole.IDLE) stopCursorTracking() else startCursorTracking() + } + .launchIn(scope) + } + + fun startHosting(port: Int = NetUtil.DEFAULT_PAIR_PORT): Result { + PairLog.d("[HOST] startHosting requested (port=$port, peerId=$localPeerId)") + if (_state.value.role != SessionRole.IDLE) { + PairLog.w("[HOST] ignored: session already active (role=${_state.value.role})") + return Result.failure(IllegalStateException("Session already active")) + } + val localIp = NetUtil.findLanIpv4() + if (localIp == null) { + PairLog.w("[HOST] aborting: no LAN address available") + _state.value = _state.value.copy(lastError = "No network connection available") + return Result.failure(IllegalStateException("No LAN address available")) + } + + observer.start() + fsObserver.start() + editorService.addFileChangeListener(fileChangeListener) + val token = generateToken() + val server = PairWebSocketServer( + port = port, + expectedToken = token, + callbacks = ServerCallbacksImpl(), + onDecodeError = { logger.warn("PairPlugin: dropped malformed message (${it.message})") }, + ) + return runCatching { + PairLog.d("[HOST] binding WebSocket server on 0.0.0.0:$port, advertising $localIp") + server.start() + serverRef.set(server) + _state.value = _state.value.copy( + role = SessionRole.HOST, + localAddress = localIp, + localPort = port, + localToken = token, + lastError = null, + ) + PairLog.d("[HOST] hosting as $localIp:$port (tokenLen=${token.length})") + }.onFailure { + observer.stop() + fsObserver.stop() + logger.error("PairPlugin: failed to start server", it) + PairLog.e("[HOST] server.start() failed", it) + _state.value = _state.value.copy(lastError = "Could not start hosting (${it.message})") + } + } + + fun joinSession(host: String, port: Int, token: String): Result { + PairLog.d("[GUEST] joinSession requested → $host:$port (tokenLen=${token.length}, peerId=$localPeerId)") + PairLog.d("[GUEST] resolving this device's own LAN address for subnet comparison:") + val localIp = NetUtil.findLanIpv4() + pendingLocalIp.set(localIp) + PairLog.d("[GUEST] this device=$localIp, target host=$host — compare the first 3 octets (same /24?)") + if (_state.value.role != SessionRole.IDLE || _state.value.connecting) { + PairLog.w("[GUEST] ignored: role=${_state.value.role} connecting=${_state.value.connecting}") + return Result.failure(IllegalStateException("Session already active")) + } + val client = PairWebSocketClient( + host = host, + port = port, + token = token, + callbacks = ClientCallbacksImpl(), + onDecodeError = { logger.warn("PairPlugin: dropped malformed message (${it.message})") }, + ) + pendingRemote.set("$host:$port") + _state.value = _state.value.copy(connecting = true, lastError = null) + return runCatching { + PairLog.d("[GUEST] opening socket to ws://$host:$port") + client.connect() + clientRef.set(client) + }.onFailure { + pendingRemote.set(null) + logger.error("PairPlugin: failed to connect to $host:$port", it) + PairLog.e("[GUEST] connect() threw synchronously for $host:$port", it) + _state.value = _state.value.copy(connecting = false, lastError = "Could not connect to $host:$port") + } + } + + private fun handleConnectFailure(reason: String?) { + if (!_state.value.connecting) return + val target = pendingRemote.getAndSet(null) + val localIp = pendingLocalIp.getAndSet(null) + clientRef.getAndSet(null)?.let { runCatching { it.close() } } + observer.stop() + fsObserver.stop() + val message = diagnoseConnectFailure(localIp, target) + logger.warn("PairPlugin: $message (reason=${reason ?: "no response"})") + PairLog.w("[GUEST] connect failed for $target (reason=${reason ?: "none"}) → $message") + _state.value = _state.value.copy(connecting = false, lastError = message) + } + + private fun diagnoseConnectFailure(localIp: String?, target: String?): String { + val targetHost = target?.substringBefore(":") + if (localIp != null && targetHost != null && subnetOf(localIp) != subnetOf(targetHost)) { + return "Different networks: this device is on ${subnetOf(localIp)}.x but the host is on " + + "${subnetOf(targetHost)}.x. Join both devices to the same WiFi or the host's hotspot." + } + return "Could not reach ${target ?: "the host"}. Check you're on the same network (hotel/public WiFi often blocks this)." + } + + private fun subnetOf(ip: String): String = ip.substringBeforeLast(".") + + fun stopSession() { + observer.stop() + fsObserver.stop() + editorService.removeFileChangeListener(fileChangeListener) + peerRegistry.clear() + markers.clearAll() + connToPeer.clear() + applier.resetSequences() + serverRef.getAndSet(null)?.let { server -> + runCatching { server.stop(500) } + } + clientRef.getAndSet(null)?.let { client -> + runCatching { client.close() } + } + _state.value = SessionState( + role = SessionRole.IDLE, + localPeerId = localPeerId, + localDisplayName = displayName, + showPeerCursors = showPeerCursors, + ) + } + + private fun startCursorTracking() { + if (cursorJob.get() != null) return + lastCursor = null + val job = scope.launch(Dispatchers.Main) { + while (isActive) { + delay(CURSOR_POLL_INTERVAL_MS) + val file = editorService.getCurrentFile() ?: continue + val position = editorService.getCurrentCursorPosition() ?: continue + val wirePath = pathMapper.toWire(file.absolutePath) + val current = Triple(wirePath, position.line, position.column) + if (current == lastCursor) continue + if (suppression.isAutoCaret(wirePath, position.line, position.column)) continue + lastCursor = current + broadcast(ProtocolMessage.CursorMove(localPeerId, wirePath, position.line, position.column)) + } + } + cursorJob.set(job) + } + + private fun stopCursorTracking() { + cursorJob.getAndSet(null)?.cancel() + lastCursor = null + } + + private fun requestResync(wireFile: String) { + if (!resyncPending.add(wireFile)) return + when (_state.value.role) { + SessionRole.HOST -> { + broadcastResyncSnapshot(wireFile) + resyncPending.remove(wireFile) + } + SessionRole.GUEST -> { + PairLog.d("[RESYNC] guest requesting fresh snapshot from host for $wireFile") + broadcast(ProtocolMessage.ResyncRequest(localPeerId, wireFile)) + } + SessionRole.IDLE -> resyncPending.remove(wireFile) + } + } + + private fun broadcastResyncSnapshot(wireFile: String) { + val localPath = pathMapper.toLocal(wireFile) + val content = editorService.getFileContent(java.io.File(localPath)) + if (content == null) { + PairLog.w("[RESYNC] cannot resync '$wireFile' — not available in editor") + return + } + val nextSeq = observer.publishedSeq(wireFile) + 1 + observer.bumpSeq(wireFile, nextSeq) + PairLog.d("[RESYNC] host broadcasting authoritative snapshot for $wireFile (${content.length} chars)") + broadcast(ProtocolMessage.SyncSnapshot(localPeerId, wireFile, content, nextSeq)) + _state.value = _state.value.copy(outOfSync = false) + } + + fun forceResyncFromMe() { + if (_state.value.role != SessionRole.HOST) return + val activeFile = editorService.getCurrentFile() ?: return + val content = editorService.getCurrentFileContent() ?: return + val wirePath = pathMapper.toWire(activeFile.absolutePath) + val nextSeq = observer.publishedSeq(wirePath) + 1 + observer.bumpSeq(wirePath, nextSeq) + broadcast( + ProtocolMessage.SyncSnapshot( + peerId = localPeerId, + file = wirePath, + content = content, + seq = nextSeq, + ) + ) + _state.value = _state.value.copy(outOfSync = false) + } + + fun requestProjectFromHost(): Result { + if (_state.value.role != SessionRole.GUEST) { + return Result.failure(IllegalStateException("Only a guest can pull the project")) + } + broadcast(ProtocolMessage.ManifestRequest(localPeerId)) + return Result.success(Unit) + } + + fun confirmOpenPulledProject() { + val root = pendingProjectRoot.getAndSet(null) ?: return + _state.value = _state.value.copy(pendingProjectPath = null) + val service = projectService + if (service == null) { + PairLog.w("[PULL] no IdeProjectService available — cannot open ${root.absolutePath}") + _state.value = _state.value.copy(lastError = "Files synced to ${root.absolutePath}. Open it manually (no project service).") + return + } + PairLog.d("[PULL] calling openProject(${root.absolutePath}) exists=${root.exists()} isDir=${root.isDirectory}") + runCatching { service.openProject(root) } + .onSuccess { opened -> + PairLog.d("[PULL] openProject returned $opened") + if (!opened) { + _state.value = _state.value.copy( + lastError = "Synced to ${root.absolutePath}. IDE returned false — its build may predate the openProject API, or there's no foreground activity. Open manually.", + ) + } + } + .onFailure { e -> + PairLog.e("[PULL] openProject threw ${e.javaClass.simpleName}: ${e.message}", e) + _state.value = _state.value.copy( + lastError = "Synced to ${root.absolutePath}. openProject error: ${e.message}. Open manually.", + ) + } + } + + fun dismissPulledProject() { + pendingProjectRoot.set(null) + _state.value = _state.value.copy(pendingProjectPath = null) + } + + fun dispose() { + stopSession() + scope.cancel() + } + + private fun generateToken(): String { + return SecureRandom().nextInt(PIN_BOUND).toString().padStart(PIN_LENGTH, '0') + } + + private fun handleIncoming(origin: WebSocket?, message: ProtocolMessage) { + if (message.peerId == localPeerId) return + when (message) { + is ProtocolMessage.Hello -> { + PairLog.d("[HANDSHAKE] Hello from ${message.displayName} (peerId=${message.peerId}, proto=${message.protocolVersion})") + if (message.protocolVersion != ProtocolMessage.PROTOCOL_VERSION) { + logger.warn("PairPlugin: rejecting peer with protocol v${message.protocolVersion} (local v${ProtocolMessage.PROTOCOL_VERSION})") + _state.value = _state.value.copy(lastError = "Incompatible Pair protocol version") + return + } + if (origin != null) connToPeer[origin] = message.peerId + peerRegistry.upsertFromHello(message, isHost = (origin != null)) + if (_state.value.role == SessionRole.HOST && origin != null) { + serverRef.get()?.sendTo( + origin, + ProtocolMessage.Hello( + peerId = localPeerId, + displayName = displayName, + colorIndex = localColorIndex, + ) + ) + } + } + is ProtocolMessage.Goodbye -> { + peerRegistry.remove(message.peerId) + markers.remove(message.peerId) + } + is ProtocolMessage.Edit -> { + val currentSeq = applier.currentSeq(message.file) + if (message.baseSeq != currentSeq && _state.value.role == SessionRole.GUEST) { + _state.value = _state.value.copy(outOfSync = true) + } + applier.applyEdit(message) + observer.bumpSeq(message.file, message.seq) + showMarker(message.peerId, message.file, message.startLine, message.startColumn) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.CursorMove -> { + peerRegistry.updateCursor(message.peerId, message.file, message.line, message.column) + showMarker(message.peerId, message.file, message.line, message.column) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.FileOpened -> { + applier.applySnapshot( + ProtocolMessage.SyncSnapshot( + peerId = message.peerId, + file = message.file, + content = message.content, + seq = message.seq, + ) + ) + peerRegistry.updateFocus(message.peerId, message.file) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.FileClosed -> { + peerRegistry.updateFocus(message.peerId, null) + markers.remove(message.peerId) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.FileFocused -> { + peerRegistry.updateFocus(message.peerId, message.file) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.SyncSnapshot -> { + applier.applySnapshot(message) + resyncPending.remove(message.file) + _state.value = _state.value.copy(outOfSync = false) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.FileCreated -> { + fsApplier.applyCreated(message) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.FileDeleted -> { + fsApplier.applyDeleted(message) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.FileRenamed -> { + fsApplier.applyRenamed(message) + if (_state.value.role == SessionRole.HOST) { + relayFromHost(origin, message) + } + } + is ProtocolMessage.ManifestRequest -> { + if (_state.value.role == SessionRole.HOST && origin != null) { + serverRef.get()?.sendTo(origin, syncCoordinator.buildManifest()) + } + } + is ProtocolMessage.ProjectManifest -> { + val request = syncCoordinator.onManifest(message) + if (request.paths.isNotEmpty()) { + broadcast(request) + } + } + is ProtocolMessage.FileRequest -> { + if (_state.value.role == SessionRole.HOST && origin != null) { + val server = serverRef.get() ?: return + syncCoordinator.serveRequest( + paths = message.paths, + sendBinary = { server.sendBinaryTo(origin, it) }, + sendComplete = { + server.sendTo(origin, ProtocolMessage.FileTransferComplete(localPeerId)) + }, + ) + } + } + is ProtocolMessage.FileTransferComplete -> { + syncCoordinator.onTransferComplete() + } + is ProtocolMessage.ResyncRequest -> { + if (_state.value.role == SessionRole.HOST) { + PairLog.d("[RESYNC] host received request for ${message.file} — sending snapshot") + broadcastResyncSnapshot(message.file) + } + } + } + } + + private fun showMarker(peerId: String, wireFile: String, line: Int, column: Int) { + if (!showPeerCursors) { + PairLog.d("[MARKERS] suppressed — 'Show others' cursors' is OFF") + return + } + val peer = peerRegistry.peer(peerId) ?: run { + PairLog.d("[MARKERS] no peer registered for $peerId — skipping marker") + return + } + PairLog.d("[MARKERS] show ${peer.displayName} at $wireFile $line:$column") + markers.show(peerId, peer.displayName, peer.colorIndex, wireFile, line, column) + } + + private fun relayFromHost(origin: WebSocket?, message: ProtocolMessage) { + serverRef.get()?.broadcastExcept(origin, message) + } + + private fun broadcast(message: ProtocolMessage) { + serverRef.get()?.broadcastExcept(null, message) + clientRef.get()?.sendMessage(message) + } + + private fun sendHelloToServer() { + clientRef.get()?.sendMessage( + ProtocolMessage.Hello( + peerId = localPeerId, + displayName = displayName, + colorIndex = localColorIndex, + ) + ) + } + + private inner class OutboundDispatcher : OutboundSink { + override fun send(message: ProtocolMessage) { + broadcast(message) + } + } + + private inner class ServerCallbacksImpl : PairWebSocketServer.ServerCallbacks { + override fun onClientConnected(connection: WebSocket) { + logger.info("PairPlugin: client connected from ${connection.remoteSocketAddress}") + PairLog.d("[SERVER] client accepted from ${connection.remoteSocketAddress} → sending Hello + snapshot") + serverRef.get()?.sendTo( + connection, + ProtocolMessage.Hello( + peerId = localPeerId, + displayName = displayName, + colorIndex = localColorIndex, + ) + ) + val activeFile = editorService.getCurrentFile() + val content = editorService.getCurrentFileContent() + if (activeFile != null && content != null) { + val wirePath = pathMapper.toWire(activeFile.absolutePath) + serverRef.get()?.sendTo( + connection, + ProtocolMessage.SyncSnapshot( + peerId = localPeerId, + file = wirePath, + content = content, + seq = observer.publishedSeq(wirePath), + ) + ) + } + } + + override fun onClientDisconnected(connection: WebSocket, reason: String?) { + logger.info("PairPlugin: client disconnected: $reason") + val peerId = connToPeer.remove(connection) ?: return + peerRegistry.remove(peerId) + markers.remove(peerId) + relayFromHost(connection, ProtocolMessage.Goodbye(peerId)) + } + + override fun onMessageReceived(connection: WebSocket, message: ProtocolMessage) { + handleIncoming(connection, message) + } + + override fun onServerStarted(port: Int) { + logger.info("PairPlugin: server listening on $port") + } + + override fun onError(error: Throwable) { + logger.error("PairPlugin: server error", error) + _state.value = _state.value.copy(lastError = error.message) + } + } + + private inner class ClientCallbacksImpl : PairWebSocketClient.ClientCallbacks { + override fun onConnected() { + logger.info("PairPlugin: connected to host") + PairLog.d("[CLIENT] socket open → sending Hello, switching to GUEST") + observer.start() + fsObserver.start() + editorService.addFileChangeListener(fileChangeListener) + _state.value = _state.value.copy( + role = SessionRole.GUEST, + connecting = false, + remoteAddress = pendingRemote.getAndSet(null), + lastError = null, + ) + sendHelloToServer() + } + + override fun onDisconnected(reason: String?) { + logger.info("PairPlugin: disconnected from host: $reason") + PairLog.d("[CLIENT] disconnected (reason=${reason ?: "none"}, connecting=${_state.value.connecting}, role=${_state.value.role})") + if (_state.value.connecting) { + handleConnectFailure(reason) + return + } + if (_state.value.role != SessionRole.GUEST) return + observer.stop() + fsObserver.stop() + peerRegistry.clear() + markers.clearAll() + clientRef.set(null) + _state.value = SessionState( + role = SessionRole.IDLE, + localPeerId = localPeerId, + localDisplayName = displayName, + showPeerCursors = showPeerCursors, + lastError = reason ?: "Disconnected from host", + ) + } + + override fun onMessageReceived(message: ProtocolMessage) { + handleIncoming(null, message) + } + + override fun onBinaryReceived(data: java.nio.ByteBuffer) { + syncCoordinator.onChunk(data) + } + + override fun onError(error: Throwable) { + logger.error("PairPlugin: client error", error) + PairLog.e("[CLIENT] onError ${error.javaClass.simpleName}: ${error.message}", error) + if (_state.value.connecting) { + handleConnectFailure(error.message) + } else { + _state.value = _state.value.copy(lastError = error.message) + } + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditObserver.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditObserver.kt new file mode 100644 index 00000000..85e40cde --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditObserver.kt @@ -0,0 +1,140 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.EditOp +import com.appdevforall.pair.plugin.data.ProtocolMessage +import com.itsaky.androidide.eventbus.events.editor.ChangeType +import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentSelectedEvent +import com.appdevforall.pair.plugin.util.PairLog +import com.itsaky.androidide.plugins.services.IdeEditorService +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +class EditObserver( + private val localPeerId: String, + private val editorService: IdeEditorService, + private val suppression: LoopbackSuppression, + private val pathMapper: PathMapper, + private val outbound: OutboundSink, +) { + + private val seqByFile: ConcurrentHashMap = ConcurrentHashMap() + private var registered: Boolean = false + + fun start() { + if (registered) return + EventBus.getDefault().register(this) + registered = true + } + + fun stop() { + if (!registered) return + EventBus.getDefault().unregister(this) + registered = false + } + + fun publishedSeq(file: String): Long = seqByFile.getOrPut(file) { AtomicLong(0L) }.get() + + fun bumpSeq(file: String, to: Long) { + seqByFile.getOrPut(file) { AtomicLong(0L) }.set(to) + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + fun onDocumentChange(event: DocumentChangeEvent) { + if (suppression.isSuppressed()) { + PairLog.d("[EDIT] suppressed echo (counter) for ${event.changedFile}") + return + } + val file = pathMapper.toWire(event.changedFile.toString()) + val currentContent = editorService.getFileContent(File(event.changedFile.toString())) + if (suppression.isEchoOfApplied(file, currentContent)) { + PairLog.d("[EDIT] suppressed echo (content matches last applied) for $file") + return + } + val counter = seqByFile.getOrPut(file) { AtomicLong(0L) } + val baseSeq = counter.get() + val nextSeq = counter.incrementAndGet() + val range = event.changeRange + val op = when (event.changeType) { + ChangeType.INSERT -> EditOp.INSERT + ChangeType.DELETE -> EditOp.DELETE + ChangeType.NEW_TEXT -> EditOp.REPLACE + } + val text = when (event.changeType) { + ChangeType.INSERT -> event.changedText + ChangeType.DELETE -> "" + ChangeType.NEW_TEXT -> event.newText ?: event.changedText + } + PairLog.d("[EDIT] outbound $op $file @${range.start.line}:${range.start.column}..${range.end.line}:${range.end.column} textLen=${text.length} seq=$nextSeq") + outbound.send( + ProtocolMessage.Edit( + peerId = localPeerId, + file = file, + seq = nextSeq, + baseSeq = baseSeq, + op = op, + startLine = range.start.line, + startColumn = range.start.column, + endLine = range.end.line, + endColumn = range.end.column, + text = text, + ) + ) + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + fun onDocumentOpen(event: DocumentOpenEvent) { + if (suppression.isSuppressed()) return + val file = pathMapper.toWire(event.openedFile.toString()) + seqByFile.getOrPut(file) { AtomicLong(0L) }.set(0L) + PairLog.d("[EDIT] outbound FileOpened $file contentLen=${event.text.length}") + outbound.send( + ProtocolMessage.FileOpened( + peerId = localPeerId, + file = file, + content = event.text, + seq = 0L, + ) + ) + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + fun onDocumentClose(event: DocumentCloseEvent) { + if (suppression.isSuppressed()) return + outbound.send( + ProtocolMessage.FileClosed( + peerId = localPeerId, + file = pathMapper.toWire(event.closedFile.toString()), + ) + ) + } + + @Subscribe(threadMode = ThreadMode.MAIN_ORDERED) + fun onDocumentSelected(event: DocumentSelectedEvent) { + if (suppression.isSuppressed()) return + val file = pathMapper.toWire(event.selectedFile.toString()) + outbound.send( + ProtocolMessage.FileFocused( + peerId = localPeerId, + file = file, + ) + ) + val pos = editorService.getCurrentCursorPosition() + if (pos != null) { + outbound.send( + ProtocolMessage.CursorMove( + peerId = localPeerId, + file = file, + line = pos.line, + column = pos.column, + ) + ) + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemApplier.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemApplier.kt new file mode 100644 index 00000000..60208e87 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemApplier.kt @@ -0,0 +1,127 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.ProtocolMessage +import com.itsaky.androidide.eventbus.events.file.FileCreationEvent +import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent +import com.itsaky.androidide.eventbus.events.file.FileRenameEvent +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.services.IdeFileService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.greenrobot.eventbus.EventBus +import java.util.Base64 + +class FileSystemApplier( + private val fileService: IdeFileService, + private val scope: CoroutineScope, + private val suppression: LoopbackSuppression, + private val pathMapper: PathMapper, + private val logger: PluginLogger, + private val onOutOfSync: () -> Unit, +) { + + fun applyCreated(message: ProtocolMessage.FileCreated) { + scope.launch { + withContext(Dispatchers.Main) { + val local = pathMapper.toLocalChecked(message.file) ?: run { + logger.warn("PairPlugin: rejected create for unsafe path '${message.file}'") + onOutOfSync() + return@withContext + } + if (message.isDirectory) { + suppression.enter() + try { + local.mkdirs() + EventBus.getDefault().post(FileCreationEvent(local)) + } finally { + suppression.exit() + } + return@withContext + } + val encoded = message.contentBase64 ?: run { + logger.warn("PairPlugin: create for '${message.file}' arrived without content — flagging out of sync") + onOutOfSync() + return@withContext + } + val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: run { + logger.warn("PairPlugin: malformed content for created file '${message.file}'") + onOutOfSync() + return@withContext + } + suppression.enter() + try { + if (fileService.writeBinary(local, bytes)) { + EventBus.getDefault().post(FileCreationEvent(local)) + } else { + logger.warn("PairPlugin: failed to write created file '${message.file}'") + onOutOfSync() + } + } finally { + suppression.exit() + } + } + } + } + + fun applyDeleted(message: ProtocolMessage.FileDeleted) { + scope.launch { + withContext(Dispatchers.Main) { + val local = pathMapper.toLocalChecked(message.file) ?: run { + logger.warn("PairPlugin: rejected delete for unsafe path '${message.file}'") + return@withContext + } + if (!local.exists()) return@withContext + suppression.enter() + try { + if (fileService.delete(local)) { + EventBus.getDefault().post(FileDeletionEvent(local)) + } else { + logger.warn("PairPlugin: failed to delete '${message.file}'") + } + } finally { + suppression.exit() + } + } + } + } + + fun applyRenamed(message: ProtocolMessage.FileRenamed) { + scope.launch { + withContext(Dispatchers.Main) { + val from = pathMapper.toLocalChecked(message.from) ?: run { + logger.warn("PairPlugin: rejected rename source '${message.from}'") + onOutOfSync() + return@withContext + } + val to = pathMapper.toLocalChecked(message.to) ?: run { + logger.warn("PairPlugin: rejected rename target '${message.to}'") + onOutOfSync() + return@withContext + } + if (!from.exists()) { + logger.warn("PairPlugin: rename source '${message.from}' missing locally — flagging out of sync") + onOutOfSync() + return@withContext + } + val bytes = runCatching { from.readBytes() }.getOrNull() ?: run { + logger.warn("PairPlugin: could not read rename source '${message.from}'") + onOutOfSync() + return@withContext + } + suppression.enter() + try { + if (fileService.writeBinary(to, bytes) && fileService.delete(from)) { + EventBus.getDefault().post(FileRenameEvent(from, to)) + } else { + logger.warn("PairPlugin: failed to apply rename '${message.from}' -> '${message.to}'") + onOutOfSync() + } + } finally { + suppression.exit() + } + } + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemObserver.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemObserver.kt new file mode 100644 index 00000000..5c2af586 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemObserver.kt @@ -0,0 +1,80 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.ProtocolMessage +import com.itsaky.androidide.eventbus.events.file.FileCreationEvent +import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent +import com.itsaky.androidide.eventbus.events.file.FileRenameEvent +import com.itsaky.androidide.plugins.PluginLogger +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import java.util.Base64 + +class FileSystemObserver( + private val localPeerId: String, + private val pathMapper: PathMapper, + private val suppression: LoopbackSuppression, + private val outbound: OutboundSink, + private val logger: PluginLogger, +) { + + private var registered: Boolean = false + + fun start() { + if (registered) return + EventBus.getDefault().register(this) + registered = true + } + + fun stop() { + if (!registered) return + EventBus.getDefault().unregister(this) + registered = false + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onFileCreated(event: FileCreationEvent) { + if (suppression.isSuppressed()) return + val source = event.file + val wire = pathMapper.toWire(source.absolutePath) + if (source.isDirectory) { + outbound.send(ProtocolMessage.FileCreated(localPeerId, wire, isDirectory = true, contentBase64 = null)) + return + } + val bytes = runCatching { source.readBytes() }.getOrNull() ?: run { + logger.warn("PairPlugin: could not read created file '${source.name}' for sync") + return + } + if (bytes.size > MAX_INLINE_FILE_BYTES) { + logger.warn("PairPlugin: created file '${source.name}' (${bytes.size} bytes) exceeds inline cap; sending without content") + outbound.send(ProtocolMessage.FileCreated(localPeerId, wire, isDirectory = false, contentBase64 = null)) + return + } + val encoded = Base64.getEncoder().encodeToString(bytes) + outbound.send(ProtocolMessage.FileCreated(localPeerId, wire, isDirectory = false, contentBase64 = encoded)) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onFileDeleted(event: FileDeletionEvent) { + if (suppression.isSuppressed()) return + outbound.send( + ProtocolMessage.FileDeleted(localPeerId, pathMapper.toWire(event.file.absolutePath)), + ) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onFileRenamed(event: FileRenameEvent) { + if (suppression.isSuppressed()) return + outbound.send( + ProtocolMessage.FileRenamed( + peerId = localPeerId, + from = pathMapper.toWire(event.file.absolutePath), + to = pathMapper.toWire(event.newFile.absolutePath), + ), + ) + } + + companion object { + const val MAX_INLINE_FILE_BYTES: Int = 1 * 1024 * 1024 + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/LoopbackSuppression.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/LoopbackSuppression.kt new file mode 100644 index 00000000..6ee3d6b1 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/LoopbackSuppression.kt @@ -0,0 +1,55 @@ +package com.appdevforall.pair.plugin.domain + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +class LoopbackSuppression { + + private val depth = AtomicInteger(0) + + // The editor updates its buffer synchronously inside replaceRange, but the resulting + // DocumentChangeEvent is delivered to observers on a LATER (and variably-delayed) main-loop + // tick. A counter that is released "after the next tick" therefore can't reliably cover the + // echo. Instead we record the exact content we just applied per file; when the echo finally + // arrives, the file's current content equals that recorded content, so the observer can + // recognize and drop it regardless of timing. A genuine local edit changes the content away + // from the recorded value and passes through. + private val appliedContentByFile: ConcurrentHashMap = ConcurrentHashMap() + + // Where the local caret was left after applying a remote edit/snapshot. The editor moves the + // caret to the edit site, but that is NOT a local cursor move — broadcasting it would make our + // marker jump to wherever the peer just typed. The cursor poll skips this position until the + // user actually moves elsewhere. + @Volatile private var autoCaret: Triple? = null + + fun enter() { + depth.incrementAndGet() + } + + fun exit() { + depth.updateAndGet { current -> (current - 1).coerceAtLeast(0) } + } + + fun isSuppressed(): Boolean = depth.get() > 0 + + fun recordApplied(wirePath: String, content: String) { + appliedContentByFile[wirePath] = content + } + + fun isEchoOfApplied(wirePath: String, currentContent: String?): Boolean { + if (currentContent == null) return false + return appliedContentByFile[wirePath] == currentContent + } + + fun clearApplied() { + appliedContentByFile.clear() + autoCaret = null + } + + fun recordAutoCaret(wireFile: String, line: Int, column: Int) { + autoCaret = Triple(wireFile, line, column) + } + + fun isAutoCaret(wireFile: String, line: Int, column: Int): Boolean = + autoCaret == Triple(wireFile, line, column) +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/OutboundSink.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/OutboundSink.kt new file mode 100644 index 00000000..f3303d3f --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/OutboundSink.kt @@ -0,0 +1,7 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.ProtocolMessage + +interface OutboundSink { + fun send(message: ProtocolMessage) +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PathMapper.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PathMapper.kt new file mode 100644 index 00000000..423fad04 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PathMapper.kt @@ -0,0 +1,33 @@ +package com.appdevforall.pair.plugin.domain + +import com.itsaky.androidide.plugins.services.IdeProjectService +import java.io.File + +class PathMapper(private val projectService: IdeProjectService?) { + + private fun rootPath(): String? = + runCatching { projectService?.getCurrentProject()?.rootDir?.absolutePath }.getOrNull() + + fun toWire(absolutePath: String): String { + val root = rootPath() ?: return absolutePath + if (!absolutePath.startsWith(root)) return absolutePath + return absolutePath.removePrefix(root).removePrefix(File.separator) + } + + fun toLocal(wirePath: String): String { + if (File(wirePath).isAbsolute) return wirePath + val root = rootPath() ?: return wirePath + return File(root, wirePath).absolutePath + } + + fun toLocalChecked(wirePath: String): File? { + if (File(wirePath).isAbsolute) return null + val root = rootPath() ?: return null + return runCatching { + val rootFile = File(root).canonicalFile + val resolved = File(rootFile, wirePath).canonicalFile + val boundary = rootFile.path + File.separator + if (resolved.path == rootFile.path || resolved.path.startsWith(boundary)) resolved else null + }.getOrNull() + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PeerRegistry.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PeerRegistry.kt new file mode 100644 index 00000000..3d4af4be --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PeerRegistry.kt @@ -0,0 +1,72 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.PeerSession +import com.appdevforall.pair.plugin.data.ProtocolMessage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.ConcurrentHashMap + +class PeerRegistry { + + private val peersById: ConcurrentHashMap = ConcurrentHashMap() + private val _peers: MutableStateFlow> = MutableStateFlow(emptyList()) + val peers: StateFlow> = _peers + + fun upsertFromHello(hello: ProtocolMessage.Hello, isHost: Boolean) { + val now = System.currentTimeMillis() + val session = peersById.compute(hello.peerId) { _, existing -> + existing?.copy( + displayName = hello.displayName, + colorIndex = hello.colorIndex, + isHost = isHost, + ) ?: PeerSession( + peerId = hello.peerId, + displayName = hello.displayName, + colorIndex = hello.colorIndex, + isHost = isHost, + joinedAtMillis = now, + ) + } + if (session != null) { + publish() + } + } + + fun remove(peerId: String) { + if (peersById.remove(peerId) != null) { + publish() + } + } + + fun updateCursor(peerId: String, file: String, line: Int, column: Int) { + val updated = peersById.compute(peerId) { _, existing -> + existing?.copy(currentFile = file, cursorLine = line, cursorColumn = column) + } + if (updated != null) { + publish() + } + } + + fun updateFocus(peerId: String, file: String?) { + val updated = peersById.compute(peerId) { _, existing -> + existing?.copy(currentFile = file) + } + if (updated != null) { + publish() + } + } + + fun snapshot(): List = peersById.values.toList() + + fun peer(peerId: String): PeerSession? = peersById[peerId] + + fun clear() { + peersById.clear() + _peers.update { emptyList() } + } + + private fun publish() { + _peers.update { peersById.values.sortedBy { it.joinedAtMillis } } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectManifestBuilder.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectManifestBuilder.kt new file mode 100644 index 00000000..b35c67c3 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectManifestBuilder.kt @@ -0,0 +1,56 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.ManifestEntry +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.services.IdeProjectService +import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest + +class ProjectManifestBuilder( + private val projectService: IdeProjectService?, + private val logger: PluginLogger, +) { + + fun rootDir(): File? = + runCatching { projectService?.getCurrentProject()?.rootDir }.getOrNull() + + fun build(): List { + val root = rootDir() ?: return emptyList() + val rootPath = root.absolutePath + val entries = ArrayList() + root.walkTopDown() + .onEnter { dir -> dir.name !in EXCLUDED_DIRS } + .filter { it.isFile } + .forEach { file -> + if (file.length() > MAX_FILE_BYTES) { + logger.warn("PairPlugin: excluding large file '${file.name}' (${file.length()} bytes) from manifest") + return@forEach + } + val hash = sha256(file) ?: return@forEach + val relative = file.absolutePath.removePrefix(rootPath).removePrefix(File.separator) + entries.add(ManifestEntry(relative, file.length(), hash)) + } + return entries + } + + fun sha256(file: File): String? = runCatching { + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { input -> + val buffer = ByteArray(BUFFER_BYTES) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + digest.digest().joinToString("") { "%02x".format(it.toInt() and 0xFF) } + }.getOrNull() + + companion object { + val EXCLUDED_DIRS: Set = + setOf("build", ".gradle", ".git", ".idea", ".cxx", "node_modules", "captures", ".kotlin") + const val MAX_FILE_BYTES: Long = 25L * 1024 * 1024 + private const val BUFFER_BYTES = 8192 + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectSyncCoordinator.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectSyncCoordinator.kt new file mode 100644 index 00000000..39bc0ba9 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectSyncCoordinator.kt @@ -0,0 +1,179 @@ +package com.appdevforall.pair.plugin.domain + +import com.appdevforall.pair.plugin.data.FileChunkCodec +import com.appdevforall.pair.plugin.data.ProtocolMessage +import com.itsaky.androidide.eventbus.events.file.FileCreationEvent +import com.itsaky.androidide.plugins.PluginLogger +import com.itsaky.androidide.plugins.services.IdeFileService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.greenrobot.eventbus.EventBus +import com.appdevforall.pair.plugin.util.PairLog +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +class ProjectSyncCoordinator( + private val localPeerId: String, + private val manifestBuilder: ProjectManifestBuilder, + private val fileService: IdeFileService, + private val pathMapper: PathMapper, + private val suppression: LoopbackSuppression, + private val scope: CoroutineScope, + private val logger: PluginLogger, + private val onProgress: (received: Int, total: Int) -> Unit, + private val onComplete: (projectRoot: File?) -> Unit, +) { + + private val expectedSizes: ConcurrentHashMap = ConcurrentHashMap() + private val buffers: ConcurrentHashMap = ConcurrentHashMap() + private val completedPaths: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) + @Volatile private var totalRequested: Int = 0 + + // When pulling, the guest writes the incoming project into a fresh directory (a sibling of + // its current project, named after the host's project) rather than nesting it inside the + // currently-open project. Set per pull from the manifest; null falls back to path-mapping. + @Volatile private var pullTargetRoot: File? = null + + fun buildManifest(): ProtocolMessage.ProjectManifest = + ProtocolMessage.ProjectManifest( + peerId = localPeerId, + entries = manifestBuilder.build(), + projectName = manifestBuilder.rootDir()?.name.orEmpty(), + ) + + fun serveRequest( + paths: List, + sendBinary: (ByteArray) -> Unit, + sendComplete: () -> Unit, + ) { + scope.launch(Dispatchers.IO) { + for (relative in paths) { + val local = pathMapper.toLocalChecked(relative) + if (local == null || !local.isFile) { + logger.warn("PairPlugin: cannot serve '$relative' (unsafe or missing)") + continue + } + runCatching { + FileInputStream(local).use { input -> + val buffer = ByteArray(CHUNK_BYTES) + var offset = 0L + while (true) { + val read = input.read(buffer) + if (read < 0) break + sendBinary(FileChunkCodec.encode(relative, offset, buffer, read)) + offset += read + } + if (offset == 0L) { + sendBinary(FileChunkCodec.encode(relative, 0L, EMPTY, 0)) + } + } + }.onFailure { logger.warn("PairPlugin: failed serving '$relative' (${it.message})") } + } + sendComplete() + } + } + + fun onManifest(manifest: ProtocolMessage.ProjectManifest): ProtocolMessage.FileRequest { + reset() + val currentRoot = manifestBuilder.rootDir() + pullTargetRoot = resolvePullTargetRoot(manifest.projectName) + PairLog.d("[PULL] manifest: ${manifest.entries.size} files, project='${manifest.projectName}'") + PairLog.d("[PULL] guest currentProject=${currentRoot?.absolutePath ?: "(none open)"} base=${currentRoot?.parentFile?.absolutePath ?: "(none)"}") + PairLog.d("[PULL] writing into target=${pullTargetRoot?.absolutePath ?: "(falling back to current project via PathMapper)"}") + val needed = ArrayList() + for (entry in manifest.entries) { + val local = resolveLocalForPull(entry.path) + val alreadyHave = local != null && local.isFile && manifestBuilder.sha256(local) == entry.sha256 + if (!alreadyHave) { + needed.add(entry.path) + expectedSizes[entry.path] = entry.size + } + } + totalRequested = needed.size + onProgress(0, totalRequested) + if (needed.isEmpty()) onComplete(pullTargetRoot) + return ProtocolMessage.FileRequest(localPeerId, needed) + } + + private fun resolvePullTargetRoot(projectName: String): File? { + val base = manifestBuilder.rootDir()?.parentFile ?: return null + val safeName = File(projectName).name.takeIf { it.isNotBlank() && it != "." && it != ".." } ?: return null + return File(base, safeName) + } + + private fun resolveLocalForPull(relative: String): File? { + val root = pullTargetRoot ?: return pathMapper.toLocalChecked(relative) + if (File(relative).isAbsolute) return null + return runCatching { + val rootFile = root.canonicalFile + val resolved = File(rootFile, relative).canonicalFile + val boundary = rootFile.path + File.separator + if (resolved.path == rootFile.path || resolved.path.startsWith(boundary)) resolved else null + }.getOrNull() + } + + fun onChunk(buffer: ByteBuffer) { + val chunk = runCatching { FileChunkCodec.decode(buffer) }.getOrNull() ?: return + val expected = expectedSizes[chunk.path] ?: return + val sink = buffers.getOrPut(chunk.path) { ByteArrayOutputStream() } + val complete: ByteArray? + synchronized(sink) { + sink.write(chunk.data) + complete = if (sink.size().toLong() >= expected) { + buffers.remove(chunk.path) + sink.toByteArray() + } else { + null + } + } + if (complete != null) materialize(chunk.path, complete) + } + + fun onTransferComplete() { + onComplete(pullTargetRoot) + } + + fun reset() { + expectedSizes.clear() + buffers.clear() + completedPaths.clear() + totalRequested = 0 + } + + private fun materialize(relative: String, bytes: ByteArray) { + scope.launch { + withContext(Dispatchers.Main) { + val local = resolveLocalForPull(relative) ?: run { + logger.warn("PairPlugin: rejected materialize for unsafe path '$relative'") + return@withContext + } + suppression.enter() + try { + if (fileService.writeBinary(local, bytes)) { + PairLog.d("[PULL] wrote ${local.absolutePath} (${bytes.size} bytes)") + EventBus.getDefault().post(FileCreationEvent(local)) + } else { + PairLog.w("[PULL] writeBinary FAILED for ${local.absolutePath}") + logger.warn("PairPlugin: failed to materialize '$relative'") + } + } finally { + suppression.exit() + } + if (completedPaths.add(relative)) { + onProgress(completedPaths.size, totalRequested) + } + } + } + } + + companion object { + private const val CHUNK_BYTES = 64 * 1024 + private val EMPTY = ByteArray(0) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/RemoteMarkerController.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/RemoteMarkerController.kt new file mode 100644 index 00000000..b1e5333a --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/RemoteMarkerController.kt @@ -0,0 +1,50 @@ +package com.appdevforall.pair.plugin.domain + +import com.itsaky.androidide.plugins.services.IdeEditorService +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +class RemoteMarkerController( + private val editorService: IdeEditorService, + private val pathMapper: PathMapper, +) { + + private val peerFile: ConcurrentHashMap = ConcurrentHashMap() + + fun show(peerId: String, peerName: String, colorIndex: Int, wireFile: String, line: Int, column: Int) { + val local = File(pathMapper.toLocal(wireFile)) + val previous = peerFile.put(peerId, local) + if (previous != null && previous != local) { + runCatching { editorService.hidePeerCursor(previous, peerId) } + } + runCatching { + editorService.showPeerCursor(local, line, column, peerId, peerName, peerColorArgb(colorIndex)) + } + } + + fun remove(peerId: String) { + val file = peerFile.remove(peerId) ?: return + runCatching { editorService.hidePeerCursor(file, peerId) } + } + + fun clearAll() { + val files = peerFile.values.toSet() + peerFile.clear() + files.forEach { file -> runCatching { editorService.clearPeerCursors(file) } } + } + + private fun peerColorArgb(colorIndex: Int): Int { + val safe = ((colorIndex % PALETTE.size) + PALETTE.size) % PALETTE.size + return PALETTE[safe] + } + + private companion object { + val PALETTE = intArrayOf( + 0xFF0F766E.toInt(), + 0xFFC2410C.toInt(), + 0xFF1E40AF.toInt(), + 0xFFA16207.toInt(), + 0xFF7E22CE.toInt(), + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/ViewModelFactory.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/ViewModelFactory.kt new file mode 100644 index 00000000..91507116 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/ViewModelFactory.kt @@ -0,0 +1,16 @@ +package com.appdevforall.pair.plugin.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +inline fun viewModelFactory( + crossinline creator: () -> VM, +): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(VM::class.java)) { + "${VM::class.java.simpleName} factory cannot create ${modelClass.name}" + } + return creator() as T + } +} \ No newline at end of file diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ConnectingLine.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ConnectingLine.kt new file mode 100644 index 00000000..5996decf --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ConnectingLine.kt @@ -0,0 +1,72 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.dp + +@Composable +fun ConnectingLine( + visible: Boolean, + modifier: Modifier = Modifier, +) { + val infinite = rememberInfiniteTransition(label = "connecting-line") + val progress by infinite.animateFloat( + initialValue = -0.3f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(1400, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "connecting-line-progress", + ) + val color = MaterialTheme.colorScheme.primary + val trackColor = MaterialTheme.colorScheme.outlineVariant + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(150)), + exit = fadeOut(tween(150)), + modifier = modifier, + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(1.dp), + ) { + drawRect(color = trackColor, size = Size(size.width, size.height)) + val segmentWidth = size.width * 0.3f + val x = progress * size.width + drawRect( + color = color, + topLeft = Offset(x, 0f), + size = Size(segmentWidth, size.height), + ) + } + } +} + +@ThemePreviews +@Composable +private fun ConnectingLinePreview() { + PluginPreview { + ConnectingLine(visible = true) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/DiscoveredHostRow.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/DiscoveredHostRow.kt new file mode 100644 index 00000000..88456840 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/DiscoveredHostRow.kt @@ -0,0 +1,73 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.appdevforall.pair.plugin.data.DiscoveredHost +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginExtraColors +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles +import com.appdevforall.pair.plugin.ui.theme.peerColorFor + +@Composable +fun DiscoveredHostRow( + host: DiscoveredHost, + onJoin: () -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val extras = LocalPluginExtraColors.current + + PluginCard(onClick = onJoin, modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = dimens.rowHeightMin) + .padding(horizontal = dimens.spaceLg, vertical = dimens.spaceMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceMd), + ) { + StatusDot(color = peerColorFor(0)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = host.displayName, + style = styles.body.copy(color = MaterialTheme.colorScheme.onSurface), + ) + Text( + text = "${host.host}:${host.port}", + style = styles.small.copy( + color = extras.textMuted, + fontFamily = styles.mono.fontFamily, + ), + ) + } + Text( + text = "JOIN", + style = styles.label.copy(color = MaterialTheme.colorScheme.primary), + ) + } + } +} + +@ThemePreviews +@Composable +private fun DiscoveredHostRowPreview() { + PluginPreview { + DiscoveredHostRow( + host = PreviewSamples.discoveredHosts[0], + onJoin = {}, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/HistoryRow.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/HistoryRow.kt new file mode 100644 index 00000000..b53b6685 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/HistoryRow.kt @@ -0,0 +1,167 @@ +package com.appdevforall.pair.plugin.ui.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +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.res.painterResource +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.R +import com.appdevforall.pair.plugin.data.SessionRole +import com.appdevforall.pair.plugin.data.StoredSession +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginExtraColors +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles +import com.appdevforall.pair.plugin.ui.theme.peerColorFor + +@Composable +fun HistoryRow( + session: StoredSession, + onReconnect: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val extras = LocalPluginExtraColors.current + var menuExpanded by remember { mutableStateOf(false) } + + val dotColor = if (session.role == SessionRole.HOST) { + peerColorFor(0) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + PluginCard(onClick = onReconnect, modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = dimens.rowHeightMin) + .padding(horizontal = dimens.spaceLg, vertical = dimens.spaceMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceMd), + ) { + StatusDot(color = dotColor) + Column(modifier = Modifier.weight(1f)) { + Text( + text = session.customName ?: session.address, + style = styles.body.copy(color = MaterialTheme.colorScheme.onSurface), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "${session.address}:${session.port}", + style = styles.small.copy( + color = extras.textMuted, + fontFamily = styles.mono.fontFamily, + ), + ) + Text( + text = relativeTime(session.lastConnectedMillis), + style = styles.small.copy(color = extras.textSubtle), + ) + } + + } + + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = "Session options", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("Rename", style = styles.body) }, + onClick = { + menuExpanded = false + onRename() + }, + ) + DropdownMenuItem( + text = { + Text( + text = "Delete", + style = styles.body.copy(color = MaterialTheme.colorScheme.error), + ) + }, + onClick = { + menuExpanded = false + onDelete() + }, + ) + } + } + } + } +} + +@ThemePreviews +@Composable +private fun HistoryRowHostPreview() { + PluginPreview { + HistoryRow( + session = PreviewSamples.storedSessions[0], + onReconnect = {}, + onRename = {}, + onDelete = {}, + ) + } +} + +@ThemePreviews +@Composable +private fun HistoryRowGuestPreview() { + PluginPreview { + HistoryRow( + session = PreviewSamples.storedSessions[1], + onReconnect = {}, + onRename = {}, + onDelete = {}, + ) + } +} + +private fun relativeTime(millis: Long): String { + val delta = System.currentTimeMillis() - millis + if (delta < 0L) return "just now" + val minutes = delta / 60_000L + val hours = delta / 3_600_000L + val days = delta / 86_400_000L + return when { + minutes < 1L -> "just now" + minutes < 60L -> "${minutes}m ago" + hours < 24L -> "${hours}h ago" + else -> "${days}d ago" + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/InviteCard.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/InviteCard.kt new file mode 100644 index 00000000..5cd9d696 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/InviteCard.kt @@ -0,0 +1,142 @@ +package com.appdevforall.pair.plugin.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +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.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles +import com.appdevforall.pair.plugin.util.NetUtil +import kotlinx.coroutines.delay + +@Composable +fun InviteCard( + address: String, + port: Int, + token: String?, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val invite = if (token.isNullOrEmpty()) "ws://$address:$port" else NetUtil.buildInvite(address, port, token) + + PluginCard(modifier = modifier.widthIn(max = 360.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dimens.spaceLg), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Address + passcode (read out / typed on the other device). + IpHero(address = address, port = port, copyValue = invite) + if (!token.isNullOrEmpty()) { + Spacer(Modifier.height(dimens.spaceMd)) + PasscodeRow(token = token) + } + } + } +} + +@Composable +private fun PasscodeRow( + token: String, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val context = LocalContext.current + val interactionSource = remember { MutableInteractionSource() } + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(1500) + copied = false + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { + copyToClipboard(context, token) + copied = true + }, + ) + .semantics { contentDescription = "Tap to copy passcode" } + .padding(vertical = dimens.spaceSm), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + targetState = copied, + transitionSpec = { fadeIn(tween(150)) togetherWith fadeOut(tween(150)) }, + label = "passcode-copy-indicator", + ) { isCopied -> + Text( + text = if (isCopied) "COPIED" else "PASSCODE", + style = styles.label.copy( + color = if (isCopied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) + } + Spacer(Modifier.height(dimens.spaceXs)) + Text( + text = token.chunked(4).joinToString(" "), + style = styles.monoLarge.copy(color = MaterialTheme.colorScheme.onSurface), + textAlign = TextAlign.Center, + ) + } +} + +private fun copyToClipboard(context: Context, value: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return + clipboard.setPrimaryClip(ClipData.newPlainText("Pair passcode", value)) +} + +@ThemePreviews +@Composable +private fun InviteCardPreview() { + PluginPreview { + InviteCard( + address = "192.168.1.42", + port = 7050, + token = "4821", + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/IpHero.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/IpHero.kt new file mode 100644 index 00000000..b2fb6160 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/IpHero.kt @@ -0,0 +1,115 @@ +package com.appdevforall.pair.plugin.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import androidx.compose.runtime.LaunchedEffect +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles +import kotlinx.coroutines.delay + +@Composable +fun IpHero( + address: String, + port: Int, + modifier: Modifier = Modifier, + copyValue: String = "$address:$port", +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val context = LocalContext.current + val interactionSource = remember { MutableInteractionSource() } + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(1500) + copied = false + } + } + + val full = copyValue + + Column( + modifier = modifier + .fillMaxWidth() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { + copyToClipboard(context, full) + copied = true + }, + ) + .semantics { contentDescription = "Tap to copy $full" } + .padding(vertical = dimens.spaceLg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "$address:$port", + style = styles.monoLarge.copy(color = MaterialTheme.colorScheme.onSurface), + ) + Spacer(Modifier.height(dimens.spaceMd)) + AnimatedContent( + targetState = copied, + transitionSpec = { + (fadeIn(tween(150)) togetherWith fadeOut(tween(150))) + }, + label = "ip-hero-copy-indicator", + ) { isCopied -> + Text( + text = if (isCopied) "COPIED" else "TAP TO COPY", + style = styles.label.copy( + color = if (isCopied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) + } + } +} + +private fun copyToClipboard(context: Context, value: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + ?: return + clipboard.setPrimaryClip(ClipData.newPlainText("Pair address", value)) +} + +@ThemePreviews +@Composable +private fun IpHeroPreview() { + PluginPreview { + IpHero(address = "192.168.1.42", port = 7050) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LabeledSwitchRow.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LabeledSwitchRow.kt new file mode 100644 index 00000000..53550e43 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LabeledSwitchRow.kt @@ -0,0 +1,64 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +@Composable +fun LabeledSwitchRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceMd), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = styles.body.copy(color = MaterialTheme.colorScheme.onSurface), + ) + Text( + text = subtitle, + style = styles.small.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = Modifier.semantics { contentDescription = title }, + ) + } +} + +@ThemePreviews +@Composable +private fun LabeledSwitchRowPreview() { + PluginPreview { + LabeledSwitchRow( + title = "Show others' cursors", + subtitle = "Display each peer's caret in your editor.", + checked = true, + onCheckedChange = {}, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/OutOfSyncBanner.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/OutOfSyncBanner.kt new file mode 100644 index 00000000..c436b317 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/OutOfSyncBanner.kt @@ -0,0 +1,92 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +@Composable +fun OutOfSyncBanner( + visible: Boolean, + canResync: Boolean, + onResync: () -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val shape = RoundedCornerShape(dimens.radiusMd) + + AnimatedVisibility( + visible = visible, + enter = slideInVertically(animationSpec = tween(200)) { -it } + fadeIn(tween(200)), + exit = slideOutVertically(animationSpec = tween(200)) { -it } + fadeOut(tween(200)), + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimens.spaceLg, vertical = dimens.spaceSm) + .height(40.dp) + .border(BorderStroke(dimens.borderHairline, MaterialTheme.colorScheme.error), shape) + .padding(horizontal = dimens.spaceMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "OUT OF SYNC", + style = styles.label.copy(color = MaterialTheme.colorScheme.error), + ) + if (canResync) { + PluginButtonText( + text = "RESYNC", + onClick = onResync, + contentColor = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@ThemePreviews +@Composable +private fun OutOfSyncBannerPreview() { + PluginPreview { + OutOfSyncBanner( + visible = true, + canResync = true, + onResync = {}, + ) + } +} + +@ThemePreviews +@Composable +private fun OutOfSyncBannerNoResyncPreview() { + PluginPreview { + OutOfSyncBanner( + visible = true, + canResync = false, + onResync = {}, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PeerRow.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PeerRow.kt new file mode 100644 index 00000000..ae305651 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PeerRow.kt @@ -0,0 +1,134 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles +import com.appdevforall.pair.plugin.ui.theme.peerColorFor +import java.io.File + +@Composable +fun PeerRow( + displayName: String, + isHost: Boolean, + color: Color, + filePath: String?, + line: Int?, + column: Int?, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val pulseKey = Triple(filePath, line, column) + val alpha = remember { Animatable(1f) } + + LaunchedEffect(pulseKey) { + if (filePath != null) { + alpha.snapTo(0.55f) + alpha.animateTo(1f, animationSpec = tween(1200)) + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = dimens.rowHeightMin) + .padding(horizontal = dimens.spaceLg, vertical = dimens.spaceMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceMd), + ) { + Box( + modifier = Modifier + .size(dimens.liveDotSize) + .alpha(alpha.value) + .clip(CircleShape) + .background(color), + ) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = displayName, + style = styles.body.copy(color = MaterialTheme.colorScheme.onSurface), + ) + if (isHost) { + Text( + text = " HOST", + style = styles.label.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + } + } + Text( + text = peerSubtitle(filePath, line, column), + style = styles.mono.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = styles.small.fontSize, + ), + ) + } + } +} + +private fun peerSubtitle(filePath: String?, line: Int?, column: Int?): String { + if (filePath.isNullOrBlank()) return "idle" + val name = File(filePath).name + return when { + line != null && column != null -> "$name:${line + 1}:${column + 1}" + line != null -> "$name:${line + 1}" + else -> name + } +} + +@ThemePreviews +@Composable +private fun PeerRowHostPreview() { + val peer = PreviewSamples.hostPeer + PluginPreview { + PeerRow( + displayName = peer.displayName, + isHost = peer.isHost, + color = peerColorFor(peer.colorIndex), + filePath = peer.currentFile, + line = peer.cursorLine, + column = peer.cursorColumn, + ) + } +} + +@ThemePreviews +@Composable +private fun PeerRowIdlePreview() { + val peer = PreviewSamples.idlePeer + PluginPreview { + PeerRow( + displayName = peer.displayName, + isHost = peer.isHost, + color = peerColorFor(peer.colorIndex), + filePath = peer.currentFile, + line = peer.cursorLine, + column = peer.cursorColumn, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginButton.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginButton.kt new file mode 100644 index 00000000..e3a99e58 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginButton.kt @@ -0,0 +1,127 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews + +// These wrap the stock Material3 buttons with no shape/typography overrides, so they render exactly +// like the host IDE's buttons (Material3 pill shape, default text), themed by the host color scheme. + +@Composable +fun PluginButtonFilled( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: Painter? = null, + contentPadding: androidx.compose.foundation.layout.PaddingValues = ButtonDefaults.ContentPadding, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier, + contentPadding = contentPadding, + ) { + ButtonContent(text, leadingIcon) + } +} + +@Composable +fun PluginButtonOutlined( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: Painter? = null, + contentColor: Color = Color.Unspecified, +) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + colors = if (contentColor != Color.Unspecified) { + ButtonDefaults.outlinedButtonColors(contentColor = contentColor) + } else { + ButtonDefaults.outlinedButtonColors() + }, + ) { + ButtonContent(text, leadingIcon) + } +} + +@Composable +fun PluginButtonText( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentColor: Color = Color.Unspecified, +) { + TextButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + colors = if (contentColor != Color.Unspecified) { + ButtonDefaults.textButtonColors(contentColor = contentColor) + } else { + ButtonDefaults.textButtonColors() + }, + ) { + Text(text = text) + } +} + +@Composable +fun PluginButtonTonal( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + FilledTonalButton( + onClick = onClick, + enabled = enabled, + modifier = modifier, + ) { + Text(text = text) + } +} + +@Composable +private fun ButtonContent(text: String, leadingIcon: Painter?) { + if (leadingIcon != null) { + Icon(painter = leadingIcon, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + } + Text(text = text) +} + +@ThemePreviews +@Composable +private fun PluginButtonPreview() { + PluginPreview { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + PluginButtonFilled(text = "HOST A SESSION", onClick = {}) + PluginButtonFilled(text = "HOST A SESSION", onClick = {}, enabled = false) + PluginButtonOutlined(text = "JOIN A SESSION", onClick = {}) + PluginButtonText(text = "RESYNC", onClick = {}) + PluginButtonTonal(text = "CONNECT", onClick = {}) + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginCard.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginCard.kt new file mode 100644 index 00000000..fe9c7bce --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginCard.kt @@ -0,0 +1,74 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens + +@Composable +fun PluginCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val dimens = LocalPluginDimens.current + val shape = RoundedCornerShape(dimens.radiusLg) + val border = BorderStroke(dimens.borderHairline, MaterialTheme.colorScheme.outlineVariant) + val colors: CardColors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, + ) + if (onClick != null) { + Card( + onClick = onClick, + modifier = modifier, + shape = shape, + colors = colors, + border = border, + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { content() } + } else { + OutlinedCard( + modifier = modifier, + shape = shape, + colors = colors, + border = border, + ) { content() } + } +} + +@ThemePreviews +@Composable +private fun PluginCardPreview() { + PluginPreview { + PluginCard { + Text( + text = "Card content", + modifier = Modifier.padding(16.dp), + ) + } + } +} + +@ThemePreviews +@Composable +private fun PluginCardClickablePreview() { + PluginPreview { + PluginCard(onClick = {}) { + Text( + text = "Clickable card content", + modifier = Modifier.padding(16.dp), + ) + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginTextField.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginTextField.kt new file mode 100644 index 00000000..0a0ed985 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginTextField.kt @@ -0,0 +1,110 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens + +@Composable +fun PluginTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + placeholder: String? = null, + error: String? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + singleLine: Boolean = true, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + keyboardType: KeyboardType = KeyboardType.Text, + textStyle: TextStyle = MaterialTheme.typography.bodyLarge, + trailingIcon: (@Composable () -> Unit)? = null, +) { + val dimens = LocalPluginDimens.current + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth(), + label = label?.let { { Text(it) } }, + placeholder = placeholder?.let { { Text(it) } }, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + enabled = enabled, + readOnly = readOnly, + singleLine = singleLine, + maxLines = maxLines, + textStyle = textStyle, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + trailingIcon = trailingIcon, + shape = RoundedCornerShape(dimens.radiusMd), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + disabledBorderColor = MaterialTheme.colorScheme.outlineVariant, + errorBorderColor = MaterialTheme.colorScheme.error, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) +} + +@ThemePreviews +@Composable +private fun PluginTextFieldEmptyPreview() { + PluginPreview { + PluginTextField( + value = "", + onValueChange = {}, + placeholder = "192.168.1.42:7050", + ) + } +} + +@ThemePreviews +@Composable +private fun PluginTextFieldFilledPreview() { + PluginPreview { + PluginTextField( + value = "Studio iMac", + onValueChange = {}, + ) + } +} + +@ThemePreviews +@Composable +private fun PluginTextFieldErrorPreview() { + PluginPreview { + PluginTextField( + value = "", + onValueChange = {}, + error = "Invalid address", + ) + } +} + +@ThemePreviews +@Composable +private fun PluginTextFieldLabelPreview() { + PluginPreview { + PluginTextField( + value = "", + onValueChange = {}, + label = "Address", + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ProjectTransferCard.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ProjectTransferCard.kt new file mode 100644 index 00000000..3f390fdc --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ProjectTransferCard.kt @@ -0,0 +1,186 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +private enum class TransferPhase { Idle, Active, Done } + +@Composable +fun ProjectTransferCard( + received: Int, + total: Int, + onPull: () -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val phase = when { + total <= 0 -> TransferPhase.Idle + received >= total -> TransferPhase.Done + else -> TransferPhase.Active + } + + PluginCard(modifier = modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dimens.spaceLg), + ) { + AnimatedContent( + targetState = phase, + transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) }, + label = "project-transfer", + ) { state -> + when (state) { + TransferPhase.Idle -> IdleContent(onPull) + TransferPhase.Active -> ActiveContent(received, total) + TransferPhase.Done -> DoneContent(total, onPull) + } + } + } + } +} + +@Composable +private fun IdleContent(onPull: () -> Unit) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "PROJECT", + style = styles.label.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + Spacer(Modifier.height(dimens.spaceSm)) + Text( + text = "Pull the host's files you don't have yet.", + style = styles.bodyMuted, + ) + Spacer(Modifier.height(dimens.spaceMd)) + PluginButtonFilled( + text = "PULL FROM HOST", + onClick = onPull, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ActiveContent(received: Int, total: Int) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val fraction = if (total > 0) (received.toFloat() / total).coerceIn(0f, 1f) else 0f + val animated by animateFloatAsState(targetValue = fraction, animationSpec = tween(200), label = "transfer-progress") + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "RECEIVING", + style = styles.label.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + Text( + text = "$received / $total files", + style = styles.mono.copy(color = MaterialTheme.colorScheme.onSurface), + ) + } + Spacer(Modifier.height(dimens.spaceMd)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(dimens.radiusSm)) + .background(MaterialTheme.colorScheme.outlineVariant), + ) { + Box( + modifier = Modifier + .fillMaxWidth(animated) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.primary), + ) + } + } +} + +@Composable +private fun DoneContent(total: Int, onPull: () -> Unit) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceSm), + ) { + StatusDot(color = MaterialTheme.colorScheme.primary) + Text( + text = "PROJECT SYNCED", + style = styles.label.copy(color = MaterialTheme.colorScheme.primary), + ) + Text( + text = "· $total files", + style = styles.small.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + } + PluginButtonText( + text = "SYNC AGAIN", + onClick = onPull, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@ThemePreviews +@Composable +private fun ProjectTransferIdlePreview() { + PluginPreview { + ProjectTransferCard(received = 0, total = 0, onPull = {}) + } +} + +@ThemePreviews +@Composable +private fun ProjectTransferActivePreview() { + PluginPreview { + ProjectTransferCard(received = 23, total = 64, onPull = {}) + } +} + +@ThemePreviews +@Composable +private fun ProjectTransferDonePreview() { + PluginPreview { + ProjectTransferCard(received = 64, total = 64, onPull = {}) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/RenameDialog.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/RenameDialog.kt new file mode 100644 index 00000000..a597a814 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/RenameDialog.kt @@ -0,0 +1,66 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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 com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +@Composable +fun RenameDialog( + initial: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + val styles = LocalPluginTextStyles.current + var value by remember { mutableStateOf(initial) } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + text = "Rename session", + style = styles.subtitle.copy(color = MaterialTheme.colorScheme.onSurface), + ) + }, + text = { + PluginTextField( + value = value, + onValueChange = { value = it }, + placeholder = "Session name", + ) + }, + confirmButton = { + PluginButtonText( + text = "RENAME", + onClick = { onConfirm(value) }, + ) + }, + dismissButton = { + PluginButtonText( + text = "CANCEL", + onClick = onDismiss, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) +} + +@ThemePreviews +@Composable +private fun RenameDialogPreview() { + PluginPreview { + RenameDialog( + initial = "Studio iMac", + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/SectionLabel.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/SectionLabel.kt new file mode 100644 index 00000000..c572c4e5 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/SectionLabel.kt @@ -0,0 +1,68 @@ +package com.appdevforall.pair.plugin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +@Composable +fun SectionLabel( + text: String, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + Text( + text = text.uppercase(), + style = styles.label.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + modifier = modifier.padding( + horizontal = dimens.spaceLg, + vertical = dimens.spaceSm, + ), + ) +} + +@Composable +fun StatusDot( + color: Color, + modifier: Modifier = Modifier, + size: Dp = LocalPluginDimens.current.liveDotSize, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(color), + ) +} + +@ThemePreviews +@Composable +private fun SectionLabelPreview() { + PluginPreview { + SectionLabel("Recent") + } +} + +@ThemePreviews +@Composable +private fun StatusDotPreview() { + PluginPreview { + Row { + StatusDot(color = MaterialTheme.colorScheme.primary) + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/GuestSessionScreen.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/GuestSessionScreen.kt new file mode 100644 index 00000000..fd7cc989 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/GuestSessionScreen.kt @@ -0,0 +1,136 @@ +package com.appdevforall.pair.plugin.ui.main + +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ScreenThemePreviews +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.data.SessionState +import com.appdevforall.pair.plugin.ui.components.ConnectingLine +import com.appdevforall.pair.plugin.ui.components.LabeledSwitchRow +import com.appdevforall.pair.plugin.ui.components.OutOfSyncBanner +import com.appdevforall.pair.plugin.ui.components.PluginButtonOutlined +import com.appdevforall.pair.plugin.ui.components.ProjectTransferCard +import com.appdevforall.pair.plugin.ui.components.StatusDot +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +@Composable +fun GuestSessionScreen( + session: SessionState, + onIntent: (PairIntent) -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val connecting = session.peers.isEmpty() && session.lastError == null + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 112.dp), + ) { + ConnectingLine(visible = connecting) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimens.spaceXl, vertical = dimens.spaceMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceSm), + ) { + StatusDot(color = MaterialTheme.colorScheme.primary) + Text( + text = "CONNECTED TO", + style = styles.label.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + Text( + text = session.remoteAddress ?: "—", + style = styles.mono.copy(color = MaterialTheme.colorScheme.onSurface), + ) + } + + OutOfSyncBanner( + visible = session.outOfSync, + canResync = false, + onResync = {}, + ) + + Spacer(Modifier.height(dimens.spaceLg)) + + LabeledSwitchRow( + title = "Show others' cursors", + subtitle = "Display each peer's caret in your editor.", + checked = session.showPeerCursors, + onCheckedChange = { onIntent(PairIntent.SetShowPeerCursors(it)) }, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + + Spacer(Modifier.height(dimens.spaceLg)) + + ProjectTransferCard( + received = session.transferReceived, + total = session.transferTotal, + onPull = { onIntent(PairIntent.PullProject) }, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + + Spacer(Modifier.height(dimens.spaceLg)) + + PeerListSection(peers = session.peers) + + if (session.lastError != null) { + Spacer(Modifier.height(dimens.spaceMd)) + Text( + text = session.lastError, + style = styles.small.copy(color = MaterialTheme.colorScheme.error), + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(dimens.spaceXl), + ) { + PluginButtonOutlined( + text = "DISCONNECT", + onClick = { onIntent(PairIntent.Disconnect) }, + contentColor = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@ScreenThemePreviews +@Composable +private fun GuestSessionScreenPreview() { + PluginPreview(contentPadding = 0.dp) { + GuestSessionScreen( + session = PreviewSamples.guestSession, + onIntent = {}, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HomeScreen.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HomeScreen.kt new file mode 100644 index 00000000..a7a0dfb8 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HomeScreen.kt @@ -0,0 +1,292 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.ui.components.PluginButtonFilled +import com.appdevforall.pair.plugin.ui.components.PluginButtonOutlined +import com.appdevforall.pair.plugin.ui.components.PluginButtonText +import com.appdevforall.pair.plugin.ui.components.PluginButtonTonal +import com.appdevforall.pair.plugin.ui.components.PluginTextField +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ScreenThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +@Composable +fun HomeScreen( + state: PairUiState, + onIntent: (PairIntent) -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + var editingName by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = dimens.spaceXl), + verticalArrangement = Arrangement.Top, + ) { + Spacer(Modifier.height(dimens.spaceXl)) + + IdentityRow( + name = state.session.localDisplayName, + onEdit = { editingName = true }, + ) + + Spacer(Modifier.height(dimens.spaceLg)) + + Text( + text = "Code together on\nthe same WiFi.", + style = styles.title.copy(color = MaterialTheme.colorScheme.onSurface), + ) + + Spacer(Modifier.height(dimens.spaceXl)) + + // Path 1 — host. + PluginButtonFilled( + text = "HOST A SESSION", + onClick = { onIntent(PairIntent.StartHosting) }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(dimens.spaceMd)) + + // Path 2 — join (expands to manual address + passcode), plus QR scan. + AnimatedVisibility( + visible = state.joinMode, + enter = fadeIn(tween(200)) + expandVertically(tween(200)), + exit = fadeOut(tween(150)) + shrinkVertically(tween(150)), + ) { + Column { + PluginTextField( + value = state.addressInput, + onValueChange = { onIntent(PairIntent.AddressChanged(it)) }, + placeholder = "192.168.1.42:7050", + keyboardType = KeyboardType.Uri, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(dimens.spaceSm)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceSm), + ) { + PluginTextField( + value = state.passcodeInput, + onValueChange = { onIntent(PairIntent.PasscodeChanged(it)) }, + placeholder = "4-digit code", + keyboardType = KeyboardType.NumberPassword, + modifier = Modifier.weight(1f), + ) + PluginButtonTonal( + text = "CONNECT", + onClick = { onIntent(PairIntent.SubmitJoin) }, + enabled = state.addressInput.isNotBlank() && + (state.addressInput.contains("?t=") || state.passcodeInput.isNotBlank()), + ) + } + Spacer(Modifier.height(dimens.spaceMd)) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceSm), + ) { + PluginButtonOutlined( + text = if (state.joinMode) "CANCEL" else "JOIN A SESSION", + onClick = { onIntent(PairIntent.ToggleJoinMode) }, + modifier = Modifier.weight(1f), + ) + } + + Spacer(Modifier.height(dimens.spaceXl)) + + if (state.discoveredHosts.isNotEmpty()) { + NearbySection( + hosts = state.discoveredHosts, + onIntent = onIntent, + ) + Spacer(Modifier.height(dimens.spaceXl)) + } + + if (state.session.connecting) { + Text( + text = "Connecting…", + style = styles.small.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + Spacer(Modifier.height(dimens.spaceSm)) + } + + if (state.session.lastError != null) { + Text( + text = state.session.lastError, + style = styles.small.copy(color = MaterialTheme.colorScheme.error), + ) + Spacer(Modifier.height(dimens.spaceSm)) + } + + Text( + text = "Both devices must be on the same WiFi or hotspot.", + style = styles.small.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + + Spacer(Modifier.height(dimens.spaceXl)) + + RecentSection( + sessions = state.recentSessions, + renamingSession = state.renamingSession, + onIntent = onIntent, + ) + + Spacer(Modifier.height(dimens.spaceXl)) + } + + if (editingName) { + DeviceNameDialog( + initial = state.session.localDisplayName, + onConfirm = { + onIntent(PairIntent.SetDeviceName(it)) + editingName = false + }, + onDismiss = { editingName = false }, + ) + } +} + +@Composable +private fun IdentityRow( + name: String, + onEdit: () -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceMd), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "YOU", + style = styles.label.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + Spacer(Modifier.height(dimens.spaceXs)) + Text( + text = name, + style = styles.subtitle.copy(color = MaterialTheme.colorScheme.onSurface), + ) + } + PluginButtonText( + text = "EDIT", + onClick = onEdit, + contentColor = MaterialTheme.colorScheme.primary, + ) + } +} + +@Composable +private fun DeviceNameDialog( + initial: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + var value by remember { mutableStateOf(initial) } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + text = "Your name", + style = styles.subtitle.copy(color = MaterialTheme.colorScheme.onSurface), + ) + }, + text = { + Column { + Text( + text = "Shown to peers and on your cursor in the editor.", + style = styles.small.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + Spacer(Modifier.height(dimens.spaceMd)) + PluginTextField( + value = value, + onValueChange = { value = it }, + placeholder = "e.g. Daniel's phone", + ) + } + }, + confirmButton = { + PluginButtonText( + text = "SAVE", + onClick = { onConfirm(value) }, + enabled = value.isNotBlank(), + ) + }, + dismissButton = { + PluginButtonText( + text = "CANCEL", + onClick = onDismiss, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) +} + +@ScreenThemePreviews +@Composable +private fun HomeScreenPreview() { + PluginPreview(contentPadding = 0.dp) { + HomeScreen(state = PreviewSamples.homeState, onIntent = {}) + } +} + +@ScreenThemePreviews +@Composable +private fun HomeScreenJoinPreview() { + PluginPreview(contentPadding = 0.dp) { + HomeScreen(state = PreviewSamples.homeJoinState, onIntent = {}) + } +} + +@ScreenThemePreviews +@Composable +private fun HomeScreenErrorPreview() { + PluginPreview(contentPadding = 0.dp) { + HomeScreen(state = PreviewSamples.homeErrorState, onIntent = {}) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HostSessionScreen.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HostSessionScreen.kt new file mode 100644 index 00000000..d107a277 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HostSessionScreen.kt @@ -0,0 +1,192 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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 com.appdevforall.pair.plugin.data.SessionState +import com.appdevforall.pair.plugin.ui.components.ConnectingLine +import com.appdevforall.pair.plugin.ui.components.InviteCard +import com.appdevforall.pair.plugin.ui.components.LabeledSwitchRow +import com.appdevforall.pair.plugin.ui.components.OutOfSyncBanner +import com.appdevforall.pair.plugin.ui.components.PluginButtonOutlined +import com.appdevforall.pair.plugin.ui.components.PluginButtonText +import com.appdevforall.pair.plugin.ui.components.StatusDot +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ScreenThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles + +@Composable +fun HostSessionScreen( + session: SessionState, + discoverable: Boolean, + onIntent: (PairIntent) -> Unit, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + val address = session.localAddress ?: "—" + val port = session.localPort ?: 0 + var inviteExpanded by remember { mutableStateOf(false) } + val showInvite = session.peers.isEmpty() || inviteExpanded + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = 112.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.padding(vertical = dimens.spaceMd), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.spaceSm), + ) { + StatusDot(color = MaterialTheme.colorScheme.primary, size = dimens.liveDotSize) + Text( + text = "HOSTING", + style = styles.label.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + ) + } + + Spacer(Modifier.height(dimens.spaceXl)) + + OutOfSyncBanner( + visible = session.outOfSync, + canResync = true, + onResync = { onIntent(PairIntent.ForceResync) }, + ) + + if (showInvite) { + InviteCard( + address = address, + port = port, + token = session.localToken, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + + Spacer(Modifier.height(dimens.spaceLg)) + + LabeledSwitchRow( + title = "Discoverable", + subtitle = "Nearby devices can join without scanning.", + checked = discoverable, + onCheckedChange = { onIntent(PairIntent.ToggleDiscoverable) }, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + } else { + PluginButtonText( + text = "INVITE ANOTHER DEVICE", + onClick = { inviteExpanded = true }, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + } + + Spacer(Modifier.height(dimens.spaceLg)) + + LabeledSwitchRow( + title = "Show others' cursors", + subtitle = "Display each peer's caret in your editor.", + checked = session.showPeerCursors, + onCheckedChange = { onIntent(PairIntent.SetShowPeerCursors(it)) }, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + + Spacer(Modifier.height(dimens.spaceXl)) + + AnimatedContent( + targetState = session.peers.isEmpty(), + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(200)) + }, + label = "host-peer-area", + ) { isEmpty -> + if (isEmpty) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ConnectingLine( + visible = true, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + Spacer(Modifier.height(dimens.spaceMd)) + Text( + text = "Waiting for someone to scan or connect…", + style = styles.bodyMuted, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + Spacer(Modifier.height(dimens.spaceXs)) + Text( + text = "Share the code above.", + style = styles.small.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = dimens.spaceXl), + ) + } + } else { + PeerListSection(peers = session.peers) + } + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = dimens.spaceXl, vertical = dimens.spaceXl), + ) { + PluginButtonOutlined( + text = "STOP SESSION", + onClick = { onIntent(PairIntent.StopSession) }, + contentColor = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@ScreenThemePreviews +@Composable +private fun HostSessionScreenPreview() { + PluginPreview(contentPadding = 0.dp) { + HostSessionScreen(session = PreviewSamples.hostSession, discoverable = true, onIntent = {}) + } +} + +@ScreenThemePreviews +@Composable +private fun HostSessionScreenWaitingPreview() { + PluginPreview(contentPadding = 0.dp) { + HostSessionScreen(session = PreviewSamples.hostWaitingSession, discoverable = true, onIntent = {}) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/NearbySection.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/NearbySection.kt new file mode 100644 index 00000000..59cc0b40 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/NearbySection.kt @@ -0,0 +1,50 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.appdevforall.pair.plugin.data.DiscoveredHost +import com.appdevforall.pair.plugin.ui.components.DiscoveredHostRow +import com.appdevforall.pair.plugin.ui.components.SectionLabel +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens + +@Composable +fun NearbySection( + hosts: List, + onIntent: (PairIntent) -> Unit, + modifier: Modifier = Modifier, +) { + if (hosts.isEmpty()) return + + val dimens = LocalPluginDimens.current + + Column(modifier = modifier.fillMaxWidth()) { + SectionLabel("NEARBY") + Column( + verticalArrangement = Arrangement.spacedBy(dimens.spaceSm), + ) { + hosts.forEach { host -> + DiscoveredHostRow( + host = host, + onJoin = { onIntent(PairIntent.JoinDiscoveredHost(host)) }, + ) + } + } + } +} + +@ThemePreviews +@Composable +private fun NearbySectionPreview() { + PluginPreview { + NearbySection( + hosts = PreviewSamples.discoveredHosts, + onIntent = {}, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairIntent.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairIntent.kt new file mode 100644 index 00000000..ec0b36ec --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairIntent.kt @@ -0,0 +1,27 @@ +package com.appdevforall.pair.plugin.ui.main + +import com.appdevforall.pair.plugin.data.DiscoveredHost +import com.appdevforall.pair.plugin.data.StoredSession + +sealed interface PairIntent { + data object StartHosting : PairIntent + data class SetDeviceName(val name: String) : PairIntent + data class SetShowPeerCursors(val enabled: Boolean) : PairIntent + data object ToggleJoinMode : PairIntent + data class AddressChanged(val value: String) : PairIntent + data class PasscodeChanged(val value: String) : PairIntent + data object SubmitJoin : PairIntent + data class Reconnect(val session: StoredSession) : PairIntent + data class RequestRename(val sessionId: String) : PairIntent + data class ConfirmRename(val newName: String) : PairIntent + data object DismissRename : PairIntent + data class DeleteSession(val session: StoredSession) : PairIntent + data object StopSession : PairIntent + data object ForceResync : PairIntent + data object PullProject : PairIntent + data object ConfirmOpenPulledProject : PairIntent + data object DismissOpenPulledProject : PairIntent + data object Disconnect : PairIntent + data class JoinDiscoveredHost(val host: DiscoveredHost) : PairIntent + data object ToggleDiscoverable : PairIntent +} \ No newline at end of file diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairMainFragment.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairMainFragment.kt new file mode 100644 index 00000000..f6fa09da --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairMainFragment.kt @@ -0,0 +1,38 @@ +package com.appdevforall.pair.plugin.ui.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import com.appdevforall.pair.plugin.PairPlugin +import com.appdevforall.pair.plugin.ui.theme.PluginTheme +import com.itsaky.androidide.plugins.base.PluginFragmentHelper + +class PairMainFragment : Fragment() { + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val pluginInflater = PluginFragmentHelper.getPluginInflater(PairPlugin.PLUGIN_ID, inflater) + val pluginContext = pluginInflater.context + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + CompositionLocalProvider( + LocalContext provides pluginContext, + ) { + PluginTheme { + PairRoot() + } + } + } + } + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairRoot.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairRoot.kt new file mode 100644 index 00000000..33bd0b01 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairRoot.kt @@ -0,0 +1,120 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.appdevforall.pair.plugin.PairServiceLocator +import com.appdevforall.pair.plugin.data.SessionRole +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ScreenThemePreviews +import com.appdevforall.pair.plugin.ui.viewModelFactory + +@Composable +fun PairRoot() { + val container = remember { PairServiceLocator.get() } + val viewModel: PairViewModel = viewModel( + factory = remember(container) { + viewModelFactory { + PairViewModel(container.broker, container.history, container.discovery, container.deviceSettings) + } + }, + ) + val state by viewModel.state.collectAsStateWithLifecycle() + PairContent(state = state, onIntent = viewModel::onIntent) +} + +@Composable +fun PairContent( + state: PairUiState, + onIntent: (PairIntent) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + AnimatedContent( + targetState = state.session.role, + transitionSpec = { + (fadeIn(tween(200)) + slideInVertically(tween(200)) { it / 4 }) togetherWith + (fadeOut(tween(200)) + slideOutVertically(tween(200)) { -it / 4 }) + }, + label = "pair-root-role-transition", + ) { role -> + when (role) { + SessionRole.IDLE -> HomeScreen(state = state, onIntent = onIntent) + SessionRole.HOST -> HostSessionScreen( + session = state.session, + discoverable = state.discoverable, + onIntent = onIntent, + ) + SessionRole.GUEST -> GuestSessionScreen(session = state.session, onIntent = onIntent) + } + } + + state.session.pendingProjectPath?.let { path -> + val projectName = path.substringAfterLast('/').ifBlank { path } + AlertDialog( + onDismissRequest = { onIntent(PairIntent.DismissOpenPulledProject) }, + title = { Text("Open shared project?") }, + text = { + Text("This closes your current project and opens “$projectName” synced from the host.") + }, + confirmButton = { + TextButton(onClick = { onIntent(PairIntent.ConfirmOpenPulledProject) }) { + Text("Open") + } + }, + dismissButton = { + TextButton(onClick = { onIntent(PairIntent.DismissOpenPulledProject) }) { + Text("Not now") + } + }, + ) + } + } +} + +@ScreenThemePreviews +@Composable +private fun PairContentHomePreview() { + PluginPreview(contentPadding = 0.dp) { + PairContent(state = PreviewSamples.homeState, onIntent = {}) + } +} + +@ScreenThemePreviews +@Composable +private fun PairContentHostPreview() { + PluginPreview(contentPadding = 0.dp) { + PairContent(state = PreviewSamples.hostUiState, onIntent = {}) + } +} + +@ScreenThemePreviews +@Composable +private fun PairContentGuestPreview() { + PluginPreview(contentPadding = 0.dp) { + PairContent(state = PreviewSamples.guestUiState, onIntent = {}) + } +} \ No newline at end of file diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairUiState.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairUiState.kt new file mode 100644 index 00000000..7ac4dddf --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairUiState.kt @@ -0,0 +1,21 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.compose.runtime.Immutable +import com.appdevforall.pair.plugin.data.DiscoveredHost +import com.appdevforall.pair.plugin.data.SessionState +import com.appdevforall.pair.plugin.data.StoredSession + +@Immutable +data class PairUiState( + val session: SessionState, + val recentSessions: List = emptyList(), + val joinMode: Boolean = false, + val addressInput: String = "", + val passcodeInput: String = "", + val renamingSessionId: String? = null, + val discoverable: Boolean = true, + val discoveredHosts: List = emptyList(), +) { + val renamingSession: StoredSession? + get() = renamingSessionId?.let { id -> recentSessions.firstOrNull { it.id == id } } +} \ No newline at end of file diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairViewModel.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairViewModel.kt new file mode 100644 index 00000000..1c49520f --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairViewModel.kt @@ -0,0 +1,210 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.appdevforall.pair.plugin.data.DeviceSettingsStore +import com.appdevforall.pair.plugin.data.DiscoveredHost +import com.appdevforall.pair.plugin.data.PairDiscoveryService +import com.appdevforall.pair.plugin.data.SessionHistoryStore +import com.appdevforall.pair.plugin.data.SessionRole +import com.appdevforall.pair.plugin.data.SessionState +import com.appdevforall.pair.plugin.data.StoredSession +import com.appdevforall.pair.plugin.domain.EditBroker +import com.appdevforall.pair.plugin.util.NetUtil +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +class PairViewModel( + private val broker: EditBroker, + private val history: SessionHistoryStore, + private val discovery: PairDiscoveryService, + private val deviceSettings: DeviceSettingsStore, +) : ViewModel() { + + private data class TransientState( + val joinMode: Boolean = false, + val addressInput: String = "", + val passcodeInput: String = "", + val renamingSessionId: String? = null, + val discoverable: Boolean = true, + ) + + private val transient = MutableStateFlow(TransientState()) + + val state: StateFlow = combine( + broker.state, + history.sessions, + transient, + discovery.hosts, + ) { session, sessions, ui, discoveredHosts -> + PairUiState( + session = session, + recentSessions = sessions, + joinMode = ui.joinMode, + addressInput = ui.addressInput, + passcodeInput = ui.passcodeInput, + renamingSessionId = ui.renamingSessionId, + discoverable = ui.discoverable, + discoveredHosts = discoveredHosts, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT_MILLIS), + initialValue = PairUiState( + session = broker.state.value, + recentSessions = history.sessions.value, + discoveredHosts = discovery.hosts.value, + ), + ) + + init { + when (broker.state.value.role) { + SessionRole.IDLE -> startBrowsing() + SessionRole.HOST -> advertiseIfDiscoverable(broker.state.value) + SessionRole.GUEST -> {} + } + var previousRole = broker.state.value.role + broker.state + .distinctUntilChangedBy { it.role } + .onEach { session -> + val priorRole = previousRole + previousRole = session.role + if (session.role != SessionRole.IDLE) { + transient.update { it.copy(joinMode = false) } + } + when { + priorRole != SessionRole.HOST && session.role == SessionRole.HOST -> { + recordHost(session) + discovery.stopDiscovery() + advertiseIfDiscoverable(session) + } + priorRole != SessionRole.GUEST && session.role == SessionRole.GUEST -> { + recordGuest(session) + discovery.stopDiscovery() + } + priorRole != SessionRole.IDLE && session.role == SessionRole.IDLE -> { + discovery.unregister() + startBrowsing() + } + } + } + .launchIn(viewModelScope) + } + + fun onIntent(intent: PairIntent) { + when (intent) { + PairIntent.StartHosting -> broker.startHosting() + is PairIntent.SetDeviceName -> { + val name = intent.name.trim().take(MAX_DEVICE_NAME) + if (name.isNotEmpty()) { + deviceSettings.setDeviceName(name) + broker.setDisplayName(name) + } + } + is PairIntent.SetShowPeerCursors -> { + deviceSettings.setShowPeerCursors(intent.enabled) + broker.setShowPeerCursors(intent.enabled) + } + PairIntent.ToggleJoinMode -> transient.update { current -> + val enabled = !current.joinMode + current.copy( + joinMode = enabled, + addressInput = if (enabled) current.addressInput else "", + passcodeInput = if (enabled) current.passcodeInput else "", + ) + } + is PairIntent.AddressChanged -> transient.update { it.copy(addressInput = intent.value) } + is PairIntent.PasscodeChanged -> transient.update { + it.copy(passcodeInput = intent.value.filter(Char::isDigit).take(PIN_DIGITS)) + } + PairIntent.SubmitJoin -> submitJoin() + is PairIntent.Reconnect -> reconnect(intent.session) + is PairIntent.RequestRename -> transient.update { it.copy(renamingSessionId = intent.sessionId) } + is PairIntent.ConfirmRename -> confirmRename(intent.newName) + PairIntent.DismissRename -> transient.update { it.copy(renamingSessionId = null) } + is PairIntent.DeleteSession -> history.delete(intent.session.id) + PairIntent.StopSession -> broker.stopSession() + PairIntent.ForceResync -> broker.forceResyncFromMe() + PairIntent.PullProject -> broker.requestProjectFromHost() + PairIntent.ConfirmOpenPulledProject -> broker.confirmOpenPulledProject() + PairIntent.DismissOpenPulledProject -> broker.dismissPulledProject() + PairIntent.Disconnect -> broker.stopSession() + is PairIntent.JoinDiscoveredHost -> broker.joinSession( + intent.host.host, + intent.host.port, + intent.host.token, + ) + PairIntent.ToggleDiscoverable -> toggleDiscoverable() + } + } + + private fun toggleDiscoverable() { + val enabled = !transient.value.discoverable + transient.update { it.copy(discoverable = enabled) } + val session = broker.state.value + if (session.role != SessionRole.HOST) return + if (enabled) advertiseIfDiscoverable(session) else discovery.unregister() + } + + private fun startBrowsing() { + discovery.startDiscovery(broker.state.value.localPeerId) + } + + private fun advertiseIfDiscoverable(session: SessionState) { + if (!transient.value.discoverable) return + val port = session.localPort ?: return + val token = session.localToken ?: return + discovery.register(session.localDisplayName, port, token, session.localPeerId) + } + + override fun onCleared() { + discovery.stopDiscovery() + super.onCleared() + } + + private fun recordHost(session: SessionState) { + val address = session.localAddress ?: return + val port = session.localPort ?: return + history.record(address, port, SessionRole.HOST) + } + + private fun recordGuest(session: SessionState) { + val parsed = NetUtil.parseAddress(session.remoteAddress ?: return) ?: return + history.record(parsed.first, parsed.second, SessionRole.GUEST) + } + + private fun submitJoin() { + val invite = NetUtil.parseInvite(transient.value.addressInput) ?: return + val token = invite.token.ifEmpty { transient.value.passcodeInput.trim() } + broker.joinSession(invite.host, invite.port, token) + } + + private fun reconnect(session: StoredSession) { + if (session.role == SessionRole.HOST) { + broker.startHosting() + } else { + transient.update { + it.copy(joinMode = true, addressInput = "${session.address}:${session.port}") + } + } + } + + private fun confirmRename(newName: String) { + val id = transient.value.renamingSessionId ?: return + history.rename(id, newName) + transient.update { it.copy(renamingSessionId = null) } + } + + private companion object { + const val SUBSCRIPTION_TIMEOUT_MILLIS = 5_000L + const val PIN_DIGITS = 4 + const val MAX_DEVICE_NAME = 40 + } +} \ No newline at end of file diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PeerListSection.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PeerListSection.kt new file mode 100644 index 00000000..e0a88129 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PeerListSection.kt @@ -0,0 +1,77 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.appdevforall.pair.plugin.data.PeerSession +import com.appdevforall.pair.plugin.ui.components.PeerRow +import com.appdevforall.pair.plugin.ui.components.SectionLabel +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens +import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles +import com.appdevforall.pair.plugin.ui.theme.peerColorFor + +@Composable +fun PeerListSection( + peers: List, + modifier: Modifier = Modifier, +) { + val dimens = LocalPluginDimens.current + val styles = LocalPluginTextStyles.current + + Column(modifier = modifier.fillMaxWidth()) { + SectionLabel(text = "PEERS (${peers.size})") + if (peers.isEmpty()) { + Text( + text = "Waiting for someone to join.", + style = styles.small.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + modifier = Modifier.padding(horizontal = dimens.spaceLg, vertical = dimens.spaceSm), + ) + } else { + peers.forEachIndexed { index, peer -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = dimens.borderHairline, + modifier = Modifier.padding(horizontal = dimens.spaceLg), + ) + } + PeerRow( + displayName = peer.displayName, + isHost = peer.isHost, + color = peerColorFor(peer.colorIndex), + filePath = peer.currentFile, + line = peer.cursorLine, + column = peer.cursorColumn, + ) + } + } + Spacer(Modifier.height(dimens.spaceSm)) + } +} + +@ThemePreviews +@Composable +private fun PeerListSectionPreview() { + PluginPreview { + PeerListSection(peers = PreviewSamples.peers) + } +} + +@ThemePreviews +@Composable +private fun PeerListSectionEmptyPreview() { + PluginPreview { + PeerListSection(peers = emptyList()) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/RecentSection.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/RecentSection.kt new file mode 100644 index 00000000..9d6266ed --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/RecentSection.kt @@ -0,0 +1,77 @@ +package com.appdevforall.pair.plugin.ui.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.appdevforall.pair.plugin.data.StoredSession +import com.appdevforall.pair.plugin.ui.components.HistoryRow +import com.appdevforall.pair.plugin.ui.components.RenameDialog +import com.appdevforall.pair.plugin.ui.components.SectionLabel +import com.appdevforall.pair.plugin.ui.preview.PluginPreview +import com.appdevforall.pair.plugin.ui.preview.PreviewSamples +import com.appdevforall.pair.plugin.ui.preview.ThemePreviews +import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens + +@Composable +fun RecentSection( + sessions: List, + renamingSession: StoredSession?, + onIntent: (PairIntent) -> Unit, + modifier: Modifier = Modifier, +) { + if (sessions.isEmpty()) return + + val dimens = LocalPluginDimens.current + + Column(modifier = modifier.fillMaxWidth()) { + SectionLabel("RECENT") + Column( + modifier = Modifier, + verticalArrangement = Arrangement.spacedBy(dimens.spaceSm), + ) { + sessions.forEach { session -> + HistoryRow( + session = session, + onReconnect = { onIntent(PairIntent.Reconnect(session)) }, + onRename = { onIntent(PairIntent.RequestRename(session.id)) }, + onDelete = { onIntent(PairIntent.DeleteSession(session)) }, + ) + } + } + } + + if (renamingSession != null) { + RenameDialog( + initial = renamingSession.customName ?: renamingSession.address, + onConfirm = { name -> onIntent(PairIntent.ConfirmRename(name)) }, + onDismiss = { onIntent(PairIntent.DismissRename) }, + ) + } +} + +@ThemePreviews +@Composable +private fun RecentSectionPreview() { + PluginPreview { + RecentSection( + sessions = PreviewSamples.storedSessions, + renamingSession = null, + onIntent = {}, + ) + } +} + +@ThemePreviews +@Composable +private fun RecentSectionRenamingPreview() { + PluginPreview { + RecentSection( + sessions = PreviewSamples.storedSessions, + renamingSession = PreviewSamples.storedSessions[0], + onIntent = {}, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/preview/PreviewSupport.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/preview/PreviewSupport.kt new file mode 100644 index 00000000..3e4ecdf1 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/preview/PreviewSupport.kt @@ -0,0 +1,186 @@ +package com.appdevforall.pair.plugin.ui.preview + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.appdevforall.pair.plugin.data.DiscoveredHost +import com.appdevforall.pair.plugin.data.PeerSession +import com.appdevforall.pair.plugin.data.SessionRole +import com.appdevforall.pair.plugin.data.SessionState +import com.appdevforall.pair.plugin.data.StoredSession +import com.appdevforall.pair.plugin.ui.main.PairUiState +import com.appdevforall.pair.plugin.ui.theme.PluginTheme + +@Preview(name = "Light", showBackground = true, backgroundColor = 0xFFFAFAF9) +@Preview( + name = "Dark", + showBackground = true, + backgroundColor = 0xFF0A0A0A, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +annotation class ThemePreviews + +@Preview(name = "Light", showBackground = true, widthDp = 360, heightDp = 760) +@Preview( + name = "Dark", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, + widthDp = 360, + heightDp = 760, +) +annotation class ScreenThemePreviews + +@Composable +fun PluginPreview( + contentPadding: Dp = 16.dp, + content: @Composable () -> Unit, +) { + PluginTheme(useHostTheme = false) { + Surface(color = MaterialTheme.colorScheme.surface) { + Box(modifier = Modifier.padding(contentPadding)) { + content() + } + } + } +} + +object PreviewSamples { + + private val now: Long = System.currentTimeMillis() + + val hostPeer = PeerSession( + peerId = "peer-host", + displayName = "Pixel 8 Pro", + colorIndex = 0, + isHost = true, + joinedAtMillis = now - 240_000L, + currentFile = "/project/app/src/main/java/MainActivity.kt", + cursorLine = 41, + cursorColumn = 8, + ) + + val guestPeer = PeerSession( + peerId = "peer-guest", + displayName = "Galaxy S24", + colorIndex = 1, + isHost = false, + joinedAtMillis = now - 90_000L, + currentFile = "/project/app/src/main/res/values/strings.xml", + cursorLine = 12, + cursorColumn = 2, + ) + + val idlePeer = PeerSession( + peerId = "peer-idle", + displayName = "Galaxy Tab", + colorIndex = 2, + isHost = false, + joinedAtMillis = now - 30_000L, + currentFile = null, + cursorLine = null, + cursorColumn = null, + ) + + val peers: List = listOf(hostPeer, guestPeer, idlePeer) + + val storedSessions: List = listOf( + StoredSession( + id = "192.168.1.42:7050", + customName = "Studio iMac", + address = "192.168.1.42", + port = 7050, + role = SessionRole.HOST, + lastConnectedMillis = now - 300_000L, + ), + StoredSession( + id = "192.168.1.77:7050", + customName = null, + address = "192.168.1.77", + port = 7050, + role = SessionRole.GUEST, + lastConnectedMillis = now - 7_200_000L, + ), + StoredSession( + id = "10.0.0.5:7050", + customName = "Alex laptop", + address = "10.0.0.5", + port = 7050, + role = SessionRole.GUEST, + lastConnectedMillis = now - 172_800_000L, + ), + ) + + val discoveredHosts: List = listOf( + DiscoveredHost( + serviceName = "Pixel 8 Pro", + peerId = "disc-pixel", + displayName = "Daniel's Pixel 8 Pro", + host = "192.168.4.21", + port = 7050, + token = "preview-token-1", + protocolVersion = 1, + ), + DiscoveredHost( + serviceName = "Classroom Tablet", + peerId = "disc-tablet", + displayName = "Classroom Tablet", + host = "192.168.4.37", + port = 7050, + token = "preview-token-2", + protocolVersion = 1, + ), + ) + + val idleSession = SessionState( + role = SessionRole.IDLE, + localPeerId = "local", + localDisplayName = "Pixel 8 Pro", + ) + + val hostSession = SessionState( + role = SessionRole.HOST, + localPeerId = "local", + localDisplayName = "Pixel 8 Pro", + localAddress = "192.168.1.42", + localPort = 7050, + peers = listOf(guestPeer, idlePeer), + ) + + val hostWaitingSession = hostSession.copy(peers = emptyList()) + + val guestSession = SessionState( + role = SessionRole.GUEST, + localPeerId = "local", + localDisplayName = "Galaxy S24", + remoteAddress = "192.168.1.42:7050", + peers = listOf(hostPeer), + ) + + val homeState = PairUiState( + session = idleSession, + recentSessions = storedSessions, + discoveredHosts = discoveredHosts, + ) + + val homeJoinState = homeState.copy( + joinMode = true, + addressInput = "192.168.1.42:7050", + passcodeInput = "4821", + ) + + val homeErrorState = PairUiState( + session = idleSession.copy(lastError = "Could not reach host. Check the address."), + recentSessions = storedSessions, + ) + + val hostUiState = PairUiState(session = hostSession) + + val guestUiState = PairUiState(session = guestSession) +} \ No newline at end of file diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Color.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Color.kt new file mode 100644 index 00000000..3ddf1365 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Color.kt @@ -0,0 +1,147 @@ +package com.appdevforall.pair.plugin.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +private val LightPrimary = Color(0xFF0F766E) +private val LightOnPrimary = Color(0xFFFFFFFF) +private val LightPrimaryContainer = Color(0xFFCCFBF1) +private val LightOnPrimaryContainer = Color(0xFF042F2C) +private val LightSecondary = Color(0xFF404040) +private val LightOnSecondary = Color(0xFFFAFAF9) +private val LightSecondaryContainer = Color(0xFFF5F5F4) +private val LightOnSecondaryContainer = Color(0xFF0A0A0A) +private val LightSurface = Color(0xFFFAFAF9) +private val LightOnSurface = Color(0xFF0A0A0A) +private val LightSurfaceVariant = Color(0xFFF5F5F4) +private val LightOnSurfaceVariant = Color(0xFF525252) +private val LightSurfaceDim = Color(0xFFF0F0EE) +private val LightOutline = Color(0xFFA3A3A3) +private val LightOutlineVariant = Color(0xFFE5E5E5) +private val LightError = Color(0xFFB91C1C) +private val LightOnError = Color(0xFFFFFFFF) +private val LightErrorContainer = Color(0xFFFEE2E2) +private val LightOnErrorContainer = Color(0xFF7F1D1D) +private val LightSuccess = Color(0xFF15803D) +private val LightSuccessContainer = Color(0xFFDCFCE7) +private val LightTextMuted = Color(0xFF737373) +private val LightTextSubtle = Color(0xFFA3A3A3) +private val LightRipple = Color(0x1F0A0A0A) + +private val DarkPrimary = Color(0xFF5EEAD4) +private val DarkOnPrimary = Color(0xFF042F2C) +private val DarkPrimaryContainer = Color(0xFF134E48) +private val DarkOnPrimaryContainer = Color(0xFFCCFBF1) +private val DarkSecondary = Color(0xFFD4D4D4) +private val DarkOnSecondary = Color(0xFF0A0A0A) +private val DarkSecondaryContainer = Color(0xFF171717) +private val DarkOnSecondaryContainer = Color(0xFFFAFAFA) +private val DarkSurface = Color(0xFF0A0A0A) +private val DarkOnSurface = Color(0xFFFAFAFA) +private val DarkSurfaceVariant = Color(0xFF171717) +private val DarkOnSurfaceVariant = Color(0xFFA3A3A3) +private val DarkSurfaceDim = Color(0xFF000000) +private val DarkOutline = Color(0xFF525252) +private val DarkOutlineVariant = Color(0xFF262626) +private val DarkError = Color(0xFFFCA5A5) +private val DarkOnError = Color(0xFF5A1F1F) +private val DarkErrorContainer = Color(0xFF5A1F1F) +private val DarkOnErrorContainer = Color(0xFFFECACA) +private val DarkSuccess = Color(0xFF86EFAC) +private val DarkSuccessContainer = Color(0xFF14532D) +private val DarkTextMuted = Color(0xFFA3A3A3) +private val DarkTextSubtle = Color(0xFF525252) +private val DarkRipple = Color(0x1FFAFAFA) + +internal val PluginLightColors: ColorScheme = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + primaryContainer = LightPrimaryContainer, + onPrimaryContainer = LightOnPrimaryContainer, + secondary = LightSecondary, + onSecondary = LightOnSecondary, + secondaryContainer = LightSecondaryContainer, + onSecondaryContainer = LightOnSecondaryContainer, + tertiary = LightSecondary, + onTertiary = LightOnSecondary, + tertiaryContainer = LightSecondaryContainer, + onTertiaryContainer = LightOnSecondaryContainer, + background = LightSurface, + onBackground = LightOnSurface, + surface = LightSurface, + onSurface = LightOnSurface, + surfaceVariant = LightSurfaceVariant, + onSurfaceVariant = LightOnSurfaceVariant, + surfaceTint = LightPrimary, + outline = LightOutline, + outlineVariant = LightOutlineVariant, + error = LightError, + onError = LightOnError, + errorContainer = LightErrorContainer, + onErrorContainer = LightOnErrorContainer, +) + +internal val PluginDarkColors: ColorScheme = darkColorScheme( + primary = DarkPrimary, + onPrimary = DarkOnPrimary, + primaryContainer = DarkPrimaryContainer, + onPrimaryContainer = DarkOnPrimaryContainer, + secondary = DarkSecondary, + onSecondary = DarkOnSecondary, + secondaryContainer = DarkSecondaryContainer, + onSecondaryContainer = DarkOnSecondaryContainer, + tertiary = DarkSecondary, + onTertiary = DarkOnSecondary, + tertiaryContainer = DarkSecondaryContainer, + onTertiaryContainer = DarkOnSecondaryContainer, + background = DarkSurface, + onBackground = DarkOnSurface, + surface = DarkSurface, + onSurface = DarkOnSurface, + surfaceVariant = DarkSurfaceVariant, + onSurfaceVariant = DarkOnSurfaceVariant, + surfaceTint = DarkPrimary, + outline = DarkOutline, + outlineVariant = DarkOutlineVariant, + error = DarkError, + onError = DarkOnError, + errorContainer = DarkErrorContainer, + onErrorContainer = DarkOnErrorContainer, +) + +@Immutable +data class PluginExtraColors( + val accent: Color, + val success: Color, + val successContainer: Color, + val textMuted: Color, + val textSubtle: Color, + val surfaceDim: Color, + val ripple: Color, +) + +internal val PluginLightExtras = PluginExtraColors( + accent = LightPrimary, + success = LightSuccess, + successContainer = LightSuccessContainer, + textMuted = LightTextMuted, + textSubtle = LightTextSubtle, + surfaceDim = LightSurfaceDim, + ripple = LightRipple, +) + +internal val PluginDarkExtras = PluginExtraColors( + accent = DarkPrimary, + success = DarkSuccess, + successContainer = DarkSuccessContainer, + textMuted = DarkTextMuted, + textSubtle = DarkTextSubtle, + surfaceDim = DarkSurfaceDim, + ripple = DarkRipple, +) + +val LocalPluginExtraColors = staticCompositionLocalOf { PluginLightExtras } diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Dimens.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Dimens.kt new file mode 100644 index 00000000..b5951b03 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Dimens.kt @@ -0,0 +1,47 @@ +package com.appdevforall.pair.plugin.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class PluginDimens( + val spaceXs: Dp, + val spaceSm: Dp, + val spaceMd: Dp, + val spaceLg: Dp, + val spaceXl: Dp, + val spaceXxl: Dp, + val spaceXxxl: Dp, + val radiusSm: Dp, + val radiusMd: Dp, + val radiusLg: Dp, + val radiusXl: Dp, + val borderHairline: Dp, + val borderAccent: Dp, + val touchTargetMin: Dp, + val liveDotSize: Dp, + val rowHeightMin: Dp, +) + +internal val PluginDefaultDimens = PluginDimens( + spaceXs = 4.dp, + spaceSm = 8.dp, + spaceMd = 12.dp, + spaceLg = 16.dp, + spaceXl = 24.dp, + spaceXxl = 32.dp, + spaceXxxl = 48.dp, + radiusSm = 4.dp, + radiusMd = 8.dp, + radiusLg = 12.dp, + radiusXl = 16.dp, + borderHairline = 1.dp, + borderAccent = 3.dp, + touchTargetMin = 48.dp, + liveDotSize = 10.dp, + rowHeightMin = 56.dp, +) + +val LocalPluginDimens = staticCompositionLocalOf { PluginDefaultDimens } diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/HostColors.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/HostColors.kt new file mode 100644 index 00000000..1bc92268 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/HostColors.kt @@ -0,0 +1,78 @@ +package com.appdevforall.pair.plugin.ui.theme + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import com.google.android.material.R as MaterialR + +@Composable +internal fun rememberHostColorScheme(darkTheme: Boolean): ColorScheme { + val pluginContext = LocalContext.current + val themeContext = remember(pluginContext) { + findActivityContext(pluginContext) ?: pluginContext + } + return remember(themeContext, darkTheme) { + buildHostColorScheme(themeContext, darkTheme) + } +} + +private fun buildHostColorScheme(context: Context, darkTheme: Boolean): ColorScheme { + // Start from the Material3 defaults and override with the host IDE's theme attributes, so the + // plugin's buttons and text take the host's exact colors (resolved from the host Activity theme). + val base = if (darkTheme) darkColorScheme() else lightColorScheme() + fun attr(@AttrRes attrId: Int, fallback: Color): Color = resolveAttrColor(context, attrId, fallback) + return base.copy( + primary = attr(MaterialR.attr.colorPrimary, base.primary), + onPrimary = attr(MaterialR.attr.colorOnPrimary, base.onPrimary), + primaryContainer = attr(MaterialR.attr.colorPrimaryContainer, base.primaryContainer), + onPrimaryContainer = attr(MaterialR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), + secondary = attr(MaterialR.attr.colorSecondary, base.secondary), + onSecondary = attr(MaterialR.attr.colorOnSecondary, base.onSecondary), + secondaryContainer = attr(MaterialR.attr.colorSecondaryContainer, base.secondaryContainer), + onSecondaryContainer = attr(MaterialR.attr.colorOnSecondaryContainer, base.onSecondaryContainer), + tertiary = attr(MaterialR.attr.colorTertiary, base.tertiary), + onTertiary = attr(MaterialR.attr.colorOnTertiary, base.onTertiary), + background = attr(android.R.attr.colorBackground, base.background), + onBackground = attr(MaterialR.attr.colorOnBackground, base.onBackground), + surface = attr(MaterialR.attr.colorSurface, base.surface), + onSurface = attr(MaterialR.attr.colorOnSurface, base.onSurface), + surfaceVariant = attr(MaterialR.attr.colorSurfaceVariant, base.surfaceVariant), + onSurfaceVariant = attr(MaterialR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), + outline = attr(MaterialR.attr.colorOutline, base.outline), + outlineVariant = attr(MaterialR.attr.colorOutlineVariant, base.outlineVariant), + error = attr(MaterialR.attr.colorError, base.error), + onError = attr(MaterialR.attr.colorOnError, base.onError), + errorContainer = attr(MaterialR.attr.colorErrorContainer, base.errorContainer), + onErrorContainer = attr(MaterialR.attr.colorOnErrorContainer, base.onErrorContainer), + surfaceTint = Color.Transparent, + ) +} + +private fun resolveAttrColor(context: Context, @AttrRes attrId: Int, fallback: Color): Color { + val tv = TypedValue() + if (!context.theme.resolveAttribute(attrId, tv, true)) return fallback + return when { + tv.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT -> Color(tv.data) + tv.resourceId != 0 -> runCatching { Color(ContextCompat.getColor(context, tv.resourceId)) }.getOrElse { fallback } + else -> fallback + } +} + +private fun findActivityContext(start: Context): Context? { + var ctx: Context? = start + while (ctx != null) { + if (ctx is Activity) return ctx + ctx = (ctx as? ContextWrapper)?.baseContext + } + return null +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/PeerColors.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/PeerColors.kt new file mode 100644 index 00000000..3892d92c --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/PeerColors.kt @@ -0,0 +1,30 @@ +package com.appdevforall.pair.plugin.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +private val LightPeerPalette: List = listOf( + Color(0xFF0F766E), + Color(0xFFC2410C), + Color(0xFF1E40AF), + Color(0xFFA16207), + Color(0xFF7E22CE), +) + +private val DarkPeerPalette: List = listOf( + Color(0xFF5EEAD4), + Color(0xFFFB923C), + Color(0xFF60A5FA), + Color(0xFFFACC15), + Color(0xFFC084FC), +) + +@Composable +@ReadOnlyComposable +fun peerColorFor(colorIndex: Int): Color { + val palette = if (isSystemInDarkTheme()) DarkPeerPalette else LightPeerPalette + val safeIndex = ((colorIndex % palette.size) + palette.size) % palette.size + return palette[safeIndex] +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Theme.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Theme.kt new file mode 100644 index 00000000..087e1964 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Theme.kt @@ -0,0 +1,35 @@ +package com.appdevforall.pair.plugin.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember + +@Composable +fun PluginTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + useHostTheme: Boolean = true, + content: @Composable () -> Unit, +) { + val colors = if (useHostTheme) { + rememberHostColorScheme(darkTheme) + } else { + if (darkTheme) PluginDarkColors else PluginLightColors + } + val extras = if (darkTheme) PluginDarkExtras else PluginLightExtras + val textStyles = remember(colors, extras) { buildPluginTextStyles(colors, extras) } + val typography = remember(textStyles) { buildPluginTypography(textStyles) } + + CompositionLocalProvider( + LocalPluginExtraColors provides extras, + LocalPluginDimens provides PluginDefaultDimens, + LocalPluginTextStyles provides textStyles, + ) { + MaterialTheme( + colorScheme = colors, + typography = typography, + content = content, + ) + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Type.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Type.kt new file mode 100644 index 00000000..442fc81f --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Type.kt @@ -0,0 +1,130 @@ +package com.appdevforall.pair.plugin.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +@Immutable +data class PluginTextStyles( + val display: TextStyle, + val title: TextStyle, + val subtitle: TextStyle, + val body: TextStyle, + val bodyMuted: TextStyle, + val small: TextStyle, + val label: TextStyle, + val mono: TextStyle, + val monoLarge: TextStyle, + val monoHero: TextStyle, +) + +internal fun buildPluginTextStyles( + scheme: ColorScheme, + extras: PluginExtraColors, +): PluginTextStyles { + val display = TextStyle( + fontSize = 34.sp, + lineHeight = 40.sp, + letterSpacing = (-0.02).em, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + color = scheme.onSurface, + ) + val title = TextStyle( + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = (-0.015).em, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + color = scheme.onSurface, + ) + val subtitle = TextStyle( + fontSize = 17.sp, + lineHeight = 24.sp, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + color = scheme.onSurface, + ) + val body = TextStyle( + fontSize = 15.sp, + lineHeight = 21.sp, + fontFamily = FontFamily.SansSerif, + color = scheme.onSurface, + ) + val bodyMuted = body.copy(color = scheme.onSurfaceVariant) + val small = TextStyle( + fontSize = 13.sp, + lineHeight = 18.sp, + fontFamily = FontFamily.SansSerif, + color = scheme.onSurfaceVariant, + ) + val label = TextStyle( + fontSize = 11.sp, + lineHeight = 14.sp, + letterSpacing = 0.08.em, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Medium, + color = scheme.onSurfaceVariant, + ) + val mono = body.copy( + fontFamily = FontFamily.Monospace, + fontFeatureSettings = "tnum", + ) + val monoLarge = title.copy( + fontFamily = FontFamily.Monospace, + fontFeatureSettings = "tnum", + letterSpacing = 0.em, + ) + val monoHero = display.copy( + fontFamily = FontFamily.Monospace, + fontFeatureSettings = "tnum", + letterSpacing = (-0.01).em, + ) + return PluginTextStyles( + display = display, + title = title, + subtitle = subtitle, + body = body, + bodyMuted = bodyMuted, + small = small, + label = label, + mono = mono, + monoLarge = monoLarge, + monoHero = monoHero, + ) +} + +internal fun buildPluginTypography(styles: PluginTextStyles): Typography { + return Typography( + displayLarge = styles.display, + displayMedium = styles.display, + displaySmall = styles.display, + headlineLarge = styles.title, + headlineMedium = styles.title, + headlineSmall = styles.title, + titleLarge = styles.title, + titleMedium = styles.subtitle, + titleSmall = styles.subtitle, + bodyLarge = styles.body, + bodyMedium = styles.body, + bodySmall = styles.small, + // labelLarge drives button text — keep it at the Material3 default size so the plugin's + // buttons match the host app's buttons rather than rendering oversized. + labelLarge = styles.body.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium), + labelMedium = styles.label, + labelSmall = styles.label, + ) +} + +private val FallbackTextStyles = buildPluginTextStyles( + scheme = PluginLightColors, + extras = PluginLightExtras, +) + +val LocalPluginTextStyles = staticCompositionLocalOf { FallbackTextStyles } diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/util/NetUtil.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/util/NetUtil.kt new file mode 100644 index 00000000..898f6fc5 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/util/NetUtil.kt @@ -0,0 +1,100 @@ +package com.appdevforall.pair.plugin.util + +import java.net.Inet4Address +import java.net.NetworkInterface + +object NetUtil { + + const val DEFAULT_PAIR_PORT: Int = 7050 + const val PAIR_TOKEN_HEADER: String = "X-Pair-Token" + + data class Invite(val host: String, val port: Int, val token: String) + + fun findLanIpv4(): String? { + return runCatching { + val interfaces = NetworkInterface.getNetworkInterfaces().asSequence().toList() + PairLog.d("[NET] enumerating ${interfaces.size} interfaces") + val candidates = mutableListOf>() + for (iface in interfaces) { + val v4 = iface.inetAddresses.asSequence() + .filterIsInstance() + .filter { !it.isLoopbackAddress && !it.isLinkLocalAddress } + .toList() + val excluded = isExcludedInterface(iface.name) + PairLog.d( + "[NET] iface=${iface.name} up=${iface.isUp} loopback=${iface.isLoopback} " + + "virtual=${iface.isVirtual} excluded=$excluded ipv4=${v4.map { it.hostAddress }}" + ) + if (!iface.isUp || iface.isLoopback || iface.isVirtual || excluded) continue + for (address in v4) candidates += iface to address + } + for ((iface, address) in candidates) { + PairLog.d( + "[NET] candidate ${address.hostAddress} on ${iface.name} " + + "siteLocal=${address.isSiteLocalAddress} score=${addressScore(iface.name, address)}" + ) + } + val chosen = candidates.maxByOrNull { (iface, address) -> addressScore(iface.name, address) } + PairLog.d("[NET] chosen=${chosen?.second?.hostAddress} on ${chosen?.first?.name}") + chosen?.second?.hostAddress + }.onFailure { PairLog.e("[NET] findLanIpv4 failed", it) }.getOrNull() + } + + private fun isExcludedInterface(name: String): Boolean { + val lower = name.lowercase() + return EXCLUDED_INTERFACE_PREFIXES.any { lower.startsWith(it) } + } + + private fun addressScore(interfaceName: String, address: Inet4Address): Int { + var score = 0 + if (address.isSiteLocalAddress) score += 100 + val lower = interfaceName.lowercase() + score += when { + lower.startsWith("wlan") -> 50 + lower.startsWith("ap") || lower.startsWith("swlan") -> 40 + lower.startsWith("eth") -> 30 + else -> 0 + } + return score + } + + private val EXCLUDED_INTERFACE_PREFIXES = listOf( + "rmnet", "ccmni", "pdp", "ppp", "rndis", "tun", "tap", "dummy", "docker", "p2p", + ) + + fun parseAddress(input: String): Pair? { + val trimmed = input.trim() + if (trimmed.isEmpty()) return null + val parts = trimmed.split(":") + val host = parts.getOrNull(0)?.takeIf { it.isNotBlank() } ?: return null + return when (parts.size) { + 1 -> host to DEFAULT_PAIR_PORT + 2 -> host to (parts[1].toIntOrNull()?.takeIf { it in 1..65535 } ?: return null) + else -> null + } + } + + fun buildInvite(host: String, port: Int, token: String): String = + "ws://$host:$port?t=$token" + + fun parseInvite(input: String): Invite? { + val withoutScheme = input.trim().removePrefix("ws://").removePrefix("wss://") + if (withoutScheme.isEmpty()) return null + val queryIndex = withoutScheme.indexOf('?') + val authority = if (queryIndex >= 0) withoutScheme.substring(0, queryIndex) else withoutScheme + val query = if (queryIndex >= 0) withoutScheme.substring(queryIndex + 1) else "" + val address = parseAddress(authority) ?: return null + return Invite(address.first, address.second, tokenFromQuery(query)) + } + + private fun tokenFromQuery(query: String): String { + if (query.isEmpty()) return "" + for (pair in query.split("&")) { + val separator = pair.indexOf('=') + if (separator > 0 && pair.substring(0, separator) == "t") { + return pair.substring(separator + 1) + } + } + return "" + } +} diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/util/PairLog.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/util/PairLog.kt new file mode 100644 index 00000000..30087471 --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/util/PairLog.kt @@ -0,0 +1,20 @@ +package com.appdevforall.pair.plugin.util + +import android.util.Log + +object PairLog { + + const val TAG: String = "PairTrace" + + fun d(message: String) { + Log.d(TAG, message) + } + + fun w(message: String) { + Log.w(TAG, message) + } + + fun e(message: String, throwable: Throwable? = null) { + if (throwable != null) Log.e(TAG, message, throwable) else Log.e(TAG, message) + } +} diff --git a/pair/src/main/res/drawable/ic_close.xml b/pair/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000..95cc170f --- /dev/null +++ b/pair/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/pair/src/main/res/drawable/ic_more_vert.xml b/pair/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 00000000..cdb8ea68 --- /dev/null +++ b/pair/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,10 @@ + + + diff --git a/pair/src/main/res/drawable/ic_pair.xml b/pair/src/main/res/drawable/ic_pair.xml new file mode 100644 index 00000000..b6b90459 --- /dev/null +++ b/pair/src/main/res/drawable/ic_pair.xml @@ -0,0 +1,10 @@ + + + diff --git a/pair/src/main/res/values-night/colors.xml b/pair/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..45bc1c44 --- /dev/null +++ b/pair/src/main/res/values-night/colors.xml @@ -0,0 +1,40 @@ + + + #5EEAD4 + #042F2C + #134E48 + #CCFBF1 + + #D4D4D4 + #0A0A0A + #262626 + #FAFAF9 + + #0A0A0A + #FAFAF9 + #171717 + #A3A3A3 + #000000 + + #525252 + #262626 + + #F87171 + #7F1D1D + #7F1D1D + #FEE2E2 + + #4ADE80 + #14532D + + #A3A3A3 + #737373 + + #1FFAFAF9 + + #5EEAD4 + #FB923C + #60A5FA + #FACC15 + #C084FC + diff --git a/pair/src/main/res/values/colors.xml b/pair/src/main/res/values/colors.xml new file mode 100644 index 00000000..f0c51729 --- /dev/null +++ b/pair/src/main/res/values/colors.xml @@ -0,0 +1,40 @@ + + + #0F766E + #FFFFFF + #CCFBF1 + #042F2C + + #404040 + #FAFAF9 + #F5F5F4 + #0A0A0A + + #FAFAF9 + #0A0A0A + #F5F5F4 + #525252 + #F0F0EE + + #A3A3A3 + #E5E5E5 + + #B91C1C + #FFFFFF + #FEE2E2 + #7F1D1D + + #15803D + #DCFCE7 + + #737373 + #A3A3A3 + + #1F0A0A0A + + #0F766E + #C2410C + #1E40AF + #A16207 + #7E22CE + diff --git a/pair/src/main/res/values/dimens.xml b/pair/src/main/res/values/dimens.xml new file mode 100644 index 00000000..777088fc --- /dev/null +++ b/pair/src/main/res/values/dimens.xml @@ -0,0 +1,24 @@ + + + 4dp + 8dp + 12dp + 16dp + 24dp + 32dp + + 6dp + 10dp + 14dp + 20dp + + 1dp + 44dp + + 28sp + 20sp + 16sp + 14sp + 12sp + 11sp + diff --git a/pair/src/main/res/values/strings.xml b/pair/src/main/res/values/strings.xml new file mode 100644 index 00000000..2a0fafd2 --- /dev/null +++ b/pair/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ + + + Pair + Pair + Pair + + Home + Session + + Host session + Join session + Stop session + Disconnect + Force resync from me + + Your address + Enter address + 192.168.1.42:7050 + %1$d peer(s) online + Not connected + Hosting + Connected to %1$s + Out of sync + + You + host + editing %1$s + idle + + No peers yet + Share your address with someone on the same WiFi to start pair programming. + + Port %1$d is in use. Stop the running session or restart the IDE. + No local network address. Check your WiFi connection. + Couldn\'t reach %1$s. Verify the address and WiFi. + diff --git a/pair/src/main/res/values/styles.xml b/pair/src/main/res/values/styles.xml new file mode 100644 index 00000000..bbb05868 --- /dev/null +++ b/pair/src/main/res/values/styles.xml @@ -0,0 +1,30 @@ + + + + + +