From 7ca6bbea97d54e7dfe5dbbcadfe5aa69354fe480 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 8 Jul 2026 22:08:55 +0100 Subject: [PATCH 01/10] ADFA-4395: Update name of NDK plugin to indicate 64bit ONLY --- ndk-installer-plugin/src/main/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)" /> Date: Thu, 9 Jul 2026 23:06:05 +0100 Subject: [PATCH 02/10] Add pair programming plugin --- README.md | 1 + pair/.gitignore | 9 + pair/README.md | 55 ++ pair/build.gradle.kts | 105 +++ pair/gradle.properties | 6 + pair/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes pair/gradle/wrapper/gradle-wrapper.properties | 7 + pair/gradlew | 251 +++++++ pair/gradlew.bat | 94 +++ pair/pair-documentation.html | 457 ++++++++++++ pair/proguard-rules.pro | 7 + pair/settings.gradle.kts | 32 + pair/src/main/AndroidManifest.xml | 54 ++ pair/src/main/assets/icon_day.png | Bin 0 -> 8849 bytes pair/src/main/assets/icon_night.png | Bin 0 -> 9712 bytes .../appdevforall/pair/plugin/PairPlugin.kt | 91 +++ .../pair/plugin/PairServiceLocator.kt | 67 ++ .../pair/plugin/data/DeviceSettingsStore.kt | 46 ++ .../pair/plugin/data/DiscoveredHost.kt | 11 + .../pair/plugin/data/FileChunkCodec.kt | 32 + .../pair/plugin/data/ManifestEntry.kt | 7 + .../pair/plugin/data/MessageCodec.kt | 249 +++++++ .../pair/plugin/data/PairDiscoveryService.kt | 215 ++++++ .../pair/plugin/data/PairWebSocketClient.kt | 58 ++ .../pair/plugin/data/PairWebSocketServer.kt | 99 +++ .../pair/plugin/data/PeerSession.kt | 36 + .../pair/plugin/data/ProtocolMessage.kt | 112 +++ .../pair/plugin/data/SessionHistoryStore.kt | 106 +++ .../pair/plugin/data/StoredSession.kt | 10 + .../pair/plugin/domain/EditApplier.kt | 157 ++++ .../pair/plugin/domain/EditBroker.kt | 678 ++++++++++++++++++ .../pair/plugin/domain/EditObserver.kt | 140 ++++ .../pair/plugin/domain/FileSystemApplier.kt | 127 ++++ .../pair/plugin/domain/FileSystemObserver.kt | 80 +++ .../pair/plugin/domain/LoopbackSuppression.kt | 55 ++ .../pair/plugin/domain/OutboundSink.kt | 7 + .../pair/plugin/domain/PathMapper.kt | 33 + .../pair/plugin/domain/PeerRegistry.kt | 72 ++ .../plugin/domain/ProjectManifestBuilder.kt | 56 ++ .../plugin/domain/ProjectSyncCoordinator.kt | 179 +++++ .../plugin/domain/RemoteMarkerController.kt | 50 ++ .../pair/plugin/ui/ViewModelFactory.kt | 16 + .../plugin/ui/components/ConnectingLine.kt | 72 ++ .../plugin/ui/components/DiscoveredHostRow.kt | 73 ++ .../pair/plugin/ui/components/HistoryRow.kt | 167 +++++ .../pair/plugin/ui/components/InviteCard.kt | 142 ++++ .../pair/plugin/ui/components/IpHero.kt | 115 +++ .../plugin/ui/components/LabeledSwitchRow.kt | 64 ++ .../plugin/ui/components/OutOfSyncBanner.kt | 92 +++ .../pair/plugin/ui/components/PeerRow.kt | 134 ++++ .../pair/plugin/ui/components/PluginButton.kt | 127 ++++ .../pair/plugin/ui/components/PluginCard.kt | 74 ++ .../plugin/ui/components/PluginTextField.kt | 110 +++ .../ui/components/ProjectTransferCard.kt | 186 +++++ .../pair/plugin/ui/components/RenameDialog.kt | 66 ++ .../pair/plugin/ui/components/SectionLabel.kt | 68 ++ .../pair/plugin/ui/main/GuestSessionScreen.kt | 136 ++++ .../pair/plugin/ui/main/HomeScreen.kt | 292 ++++++++ .../pair/plugin/ui/main/HostSessionScreen.kt | 192 +++++ .../pair/plugin/ui/main/NearbySection.kt | 50 ++ .../pair/plugin/ui/main/PairIntent.kt | 27 + .../pair/plugin/ui/main/PairMainFragment.kt | 38 + .../pair/plugin/ui/main/PairRoot.kt | 120 ++++ .../pair/plugin/ui/main/PairUiState.kt | 21 + .../pair/plugin/ui/main/PairViewModel.kt | 210 ++++++ .../pair/plugin/ui/main/PeerListSection.kt | 77 ++ .../pair/plugin/ui/main/RecentSection.kt | 77 ++ .../pair/plugin/ui/preview/PreviewSupport.kt | 186 +++++ .../pair/plugin/ui/theme/Color.kt | 147 ++++ .../pair/plugin/ui/theme/Dimens.kt | 47 ++ .../pair/plugin/ui/theme/HostColors.kt | 78 ++ .../pair/plugin/ui/theme/PeerColors.kt | 30 + .../pair/plugin/ui/theme/Theme.kt | 35 + .../appdevforall/pair/plugin/ui/theme/Type.kt | 130 ++++ .../appdevforall/pair/plugin/util/NetUtil.kt | 100 +++ .../appdevforall/pair/plugin/util/PairLog.kt | 20 + pair/src/main/res/drawable/ic_close.xml | 10 + pair/src/main/res/drawable/ic_more_vert.xml | 10 + pair/src/main/res/drawable/ic_pair.xml | 10 + pair/src/main/res/values-night/colors.xml | 40 ++ pair/src/main/res/values/colors.xml | 40 ++ pair/src/main/res/values/dimens.xml | 24 + pair/src/main/res/values/strings.xml | 36 + pair/src/main/res/values/styles.xml | 30 + 84 files changed, 7570 insertions(+) create mode 100644 pair/.gitignore create mode 100644 pair/README.md create mode 100644 pair/build.gradle.kts create mode 100644 pair/gradle.properties create mode 100755 pair/gradle/wrapper/gradle-wrapper.jar create mode 100644 pair/gradle/wrapper/gradle-wrapper.properties create mode 100755 pair/gradlew create mode 100644 pair/gradlew.bat create mode 100644 pair/pair-documentation.html create mode 100644 pair/proguard-rules.pro create mode 100644 pair/settings.gradle.kts create mode 100644 pair/src/main/AndroidManifest.xml create mode 100644 pair/src/main/assets/icon_day.png create mode 100644 pair/src/main/assets/icon_night.png create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/PairServiceLocator.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DeviceSettingsStore.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/DiscoveredHost.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/FileChunkCodec.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ManifestEntry.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/MessageCodec.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairDiscoveryService.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketClient.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PairWebSocketServer.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/PeerSession.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/ProtocolMessage.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/SessionHistoryStore.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/data/StoredSession.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditApplier.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditBroker.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/EditObserver.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemApplier.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/FileSystemObserver.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/LoopbackSuppression.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/OutboundSink.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PathMapper.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/PeerRegistry.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectManifestBuilder.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/ProjectSyncCoordinator.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/domain/RemoteMarkerController.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/ViewModelFactory.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ConnectingLine.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/DiscoveredHostRow.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/HistoryRow.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/InviteCard.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/IpHero.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LabeledSwitchRow.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/OutOfSyncBanner.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PeerRow.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginButton.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginCard.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/PluginTextField.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/ProjectTransferCard.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/RenameDialog.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/SectionLabel.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/GuestSessionScreen.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HomeScreen.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/HostSessionScreen.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/NearbySection.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairIntent.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairMainFragment.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairRoot.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairUiState.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PairViewModel.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/PeerListSection.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/main/RecentSection.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/preview/PreviewSupport.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Color.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Dimens.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/HostColors.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/PeerColors.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Theme.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/theme/Type.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/util/NetUtil.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/util/PairLog.kt create mode 100644 pair/src/main/res/drawable/ic_close.xml create mode 100644 pair/src/main/res/drawable/ic_more_vert.xml create mode 100644 pair/src/main/res/drawable/ic_pair.xml create mode 100644 pair/src/main/res/values-night/colors.xml create mode 100644 pair/src/main/res/values/colors.xml create mode 100644 pair/src/main/res/values/dimens.xml create mode 100644 pair/src/main/res/values/strings.xml create mode 100644 pair/src/main/res/values/styles.xml 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/pair/.gitignore b/pair/.gitignore new file mode 100644 index 00000000..5cc02dcd --- /dev/null +++ b/pair/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +.idea/ +local.properties +*.iml +.DS_Store +captures/ +.externalNativeBuild/ +.cxx/ diff --git a/pair/README.md b/pair/README.md new file mode 100644 index 00000000..89bf36fd --- /dev/null +++ b/pair/README.md @@ -0,0 +1,55 @@ +# Pair + +A plugin for [CodeOnTheGo](https://github.com/appdevforall/CodeOnTheGo) that turns the IDE into a real-time collaborative editor. Two phones on the same WiFi share one editing session: one device **hosts**, others **join** by typing the host's `ip:port` or scanning its QR code, and from then on edits, cursors, and file opens flow between devices as they happen — no server, no cloud, no signup. + +It surfaces as a **Pair** tab in the editor. Host a session and an invite card shows the address and a QR code; join one and the peer list fills in. When the host types in `MainActivity.kt`, 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. + +## Building + +```sh +cd pair +./gradlew clean assemblePlugin +``` + +The `.cgp` lands in `build/plugin/`. Install it from inside CodeOnTheGo via the Plugin Manager. 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. + +## How it works + +- **Pair tab** — contributes an editor tab and sidebar entry via `EditorTabExtension` + `UIExtension`; the whole UI is Jetpack Compose (Home, Host, Guest, and QR-scan screens). +- **Edit sync** — subscribes to the host IDE's `DocumentChangeEvent` on the EventBus for local edits and replays remote edits through `IdeEditorService.replaceRange(...)`. A single loopback guard stops an applied remote edit from rebroadcasting as a local one. +- **File-relative identity** — a file's wire identity is its path **relative to the project root** (`IdeProjectService.getCurrentProject().rootDir`). The sender strips the root; the receiver re-anchors to its own, so a session works across two devices whose projects live at different absolute paths. +- **Presence** — cursor positions ride alongside edits; each peer gets a color, shown in the peer list and (with the extended API below) as an inline caret inside the editor. +- **Transport** — a star topology over `ws://`: the host runs a `WebSocketServer`, each guest opens one `WebSocketClient`, and the host echoes messages to the other guests. Messages are compact JSON (`hi` / `edit` / `cur` / `fo` / `fc` / `ff` / `sync` / `bye`). +- **Conflict handling** — per-file sequence numbers with the host as authority; a divergence flags the session *out of sync* rather than dropping the edit, and the host can push an authoritative resync of the full file. +- **Session history** — past sessions persist as JSON under `IdeEnvironmentService.getPluginDataDirectory()`; the Home screen lists them to rename, delete, or reconnect. + +## Source 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 (190×190 interlocking-rings mark) + └── kotlin/com/appdevforall/pair/plugin/ + ├── PairPlugin.kt IPlugin + EditorTabExtension + UIExtension entry + ├── data/ wire protocol, WebSocket server/client, session store + ├── domain/ EditBroker orchestrator, observer/applier, path mapping, peer registry + ├── ui/ Compose theme, components, and screens + └── util/ LAN discovery, QR decode +``` + +## Dependencies from `libs/` + +- `../libs/plugin-api.jar` — the plugin API surface (`IPlugin`, extensions, `Ide*Service`); `compileOnly`, provided by the IDE at runtime. +- `../libs/gradle-plugin.jar` — the `com.itsaky.androidide.plugins.build` Gradle plugin that packages the `.cgp`. +- `../libs/eventbus-events.jar` — the editor and file `*Event` types Pair subscribes to on the EventBus. +- `../libs/shared.jar` — `com.itsaky.androidide.models.Range`, the type of `DocumentChangeEvent.changeRange` that Pair reads. + +Jetpack Compose is linked `compileOnly` (host-provided), not bundled. + +> **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 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..06c43be4fe0c57f2cf50e53cf59b9f9846607db3 GIT binary patch literal 8849 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 nY=B6fH;|O%|08U9QBP?O literal 0 HcmV?d00001 diff --git a/pair/src/main/assets/icon_night.png b/pair/src/main/assets/icon_night.png new file mode 100644 index 0000000000000000000000000000000000000000..b4658f71b00fab325733e5c29fdb68fa750ec2a1 GIT binary patch literal 9712 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 Dyc { + 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 @@ + + + + + + From 39fc318c7b5f8928719e847dccc8bd9e3a593825 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 7 Aug 2026 00:34:15 +0100 Subject: [PATCH 03/10] ADFA-4395: Bundle shared.jar inside the pair plugin pair depends on com.itsaky.androidide.models.Range (shared.jar), which was referenced from the common libs/ but never committed, so a clean checkout could not build. Ship the jar plugin-locally under pair/libs/ instead of the stage-tracked common libs/. Also drops the ndk abiFilters block: the plugin has no native code. Jar built from CodeOnTheGo :shared:jar. --- pair/build.gradle.kts | 6 +----- pair/libs/shared.jar | Bin 0 -> 108645 bytes 2 files changed, 1 insertion(+), 5 deletions(-) create mode 100644 pair/libs/shared.jar diff --git a/pair/build.gradle.kts b/pair/build.gradle.kts index bf9da177..e3bdaa77 100644 --- a/pair/build.gradle.kts +++ b/pair/build.gradle.kts @@ -19,10 +19,6 @@ android { targetSdk = 34 versionCode = 1 versionName = "1.0.0" - - ndk { - abiFilters += listOf("arm64-v8a", "armeabi-v7a") - } } buildTypes { @@ -62,7 +58,7 @@ android { dependencies { compileOnly(files("../libs/plugin-api.jar")) compileOnly(files("../libs/eventbus-events.jar")) - compileOnly(files("../libs/shared.jar")) + compileOnly(files("libs/shared.jar")) implementation("com.google.android.material:material:1.10.0") implementation("androidx.fragment:fragment-ktx:1.8.8") diff --git a/pair/libs/shared.jar b/pair/libs/shared.jar new file mode 100644 index 0000000000000000000000000000000000000000..5dc546b896722d7fc9163fdcbec8803129fb8932 GIT binary patch literal 108645 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 (7JCGVoL1nPn|+L{C{>5l$p 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 literal 0 HcmV?d00001 From b354a92b09d8ee0be90bde2e0b547189301691ad Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 7 Aug 2026 01:15:44 +0100 Subject: [PATCH 04/10] =?UTF-8?q?ADFA-4395:=20Address=20plugin=20review=20?= =?UTF-8?q?=E2=80=94=20permissions,=20manifest,=20in-app=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the unused native.code permission (no native code ships in the plugin) - Declare plugin.editor_tabs for the contributed Pair editor tab - Pin the supported IDE range: min_ide_version = max_ide_version = 26.32, the release that ships the peer-cursor/openProject plugin API - Implement DocumentationExtension: tooltip entry on the sidebar item (tag pair.sidebar) with summary, detail, and a User guide button, plus a Tier-3 offline guide at assets/docs/index.html --- pair/src/main/AndroidManifest.xml | 12 +- pair/src/main/assets/docs/index.html | 155 ++++++++++++++++++ .../appdevforall/pair/plugin/PairPlugin.kt | 40 ++++- 3 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 pair/src/main/assets/docs/index.html diff --git a/pair/src/main/AndroidManifest.xml b/pair/src/main/AndroidManifest.xml index c14f2f0b..86a2df17 100644 --- a/pair/src/main/AndroidManifest.xml +++ b/pair/src/main/AndroidManifest.xml @@ -27,11 +27,15 @@ + android:value="26.32" /> + + + android:value="filesystem.read,filesystem.write,network.access" /> + + diff --git a/pair/src/main/assets/docs/index.html b/pair/src/main/assets/docs/index.html new file mode 100644 index 00000000..c07a279d --- /dev/null +++ b/pair/src/main/assets/docs/index.html @@ -0,0 +1,155 @@ + + + + + +Pair — User Guide + + + + +
+

Pair

+

Real-time pair programming between devices on the same WiFi — shared editing, live cursors, no server and no account.

+
+ +
+

What Pair does

+

Pair turns Code on the Go into a collaborative editor. One device + hosts a session, others join, and from then on + edits, cursor positions, and file opens flow between the devices as they happen. + The devices talk directly to each other over your local network — nothing leaves + the WiFi you are on.

+

Everything lives in the Pair editor tab. Open it from the + tab bar or from Pair in the side menu.

+
+ +
+

Host a session

+
    +
  1. Open the Pair tab.
  2. +
  3. Tap Host session.
  4. +
  5. An invite card appears with your address and a QR code:
  6. +
+
+
192.168.1.23:9376
+

Your partner scans the QR code, or types this address on their device.

+
+

Keep the session running while your partner joins. Each connected peer shows + up in the peer list with their name, role, and color.

+
+ +
+

Join a session

+
    +
  1. Make sure you are on the same WiFi network as the host.
  2. +
  3. Open the Pair tab and tap Join.
  4. +
  5. Scan the host's QR code, or type the ip:port address shown on + the host's invite card.
  6. +
+

Once connected, the file the host is working in opens on your device with the + exact same contents, and you start editing together immediately.

+
+ +
+

During a session

+ + + + + + + + + + +
You seeWhat it means
Text changes by itselfYour partner is typing. Their edits are applied to your file as they happen, and yours to theirs.
A colored caret in the editorYour partner's live cursor, drawn in their peer color.
Peer list entry Main.kt:41:8The file, line, and column where that peer currently is.
A file opens on its ownThe host switched files; your editor follows so you both look at the same code.
+
+ +
+

Past sessions

+

The Pair Home screen lists your recent sessions, newest first. Tap one to + reconnect with the same address, or use its menu to rename or delete it. + Sessions are stored on your device only.

+
+ +
+

Troubleshooting

+

The join fails or times out

+
    +
  • Confirm both devices are on the same WiFi network — mobile data or a + guest network on one device breaks the connection.
  • +
  • Re-check the address: it must match the host's invite card exactly.
  • +
  • Restart the session on the host to get a fresh invite card.
  • +
+

"Version mismatch" when joining

+

The two devices run different versions of the Pair plugin. Update both to the + same version and host the session again.

+

The files no longer match

+

If a change cannot be applied cleanly, Pair marks the file out of sync rather + than guessing. Have the host reopen the file — reopening sends a full snapshot + and both sides converge again.

+
+ +
+ Pair plugin · App Dev For All · com.appdevforall.pair.plugin +
+ + + diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt index d2c32574..4aa766cb 100644 --- a/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt @@ -3,13 +3,16 @@ 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.DocumentationExtension 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.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.services.IdeEditorTabService -class PairPlugin : IPlugin, UIExtension, EditorTabExtension { +class PairPlugin : IPlugin, UIExtension, EditorTabExtension, DocumentationExtension { private lateinit var pluginContext: PluginContext @@ -67,10 +70,44 @@ class PairPlugin : IPlugin, UIExtension, EditorTabExtension { group = "tools", order = 0, action = { openPairTab() }, + tooltipTag = TOOLTIP_TAG_SIDEBAR, ) ) } + override fun getTooltipCategory(): String = "plugin_pair" + + override fun getTooltipEntries(): List = listOf( + PluginTooltipEntry( + tag = TOOLTIP_TAG_SIDEBAR, + summary = "Pair programming over local WiFi: one device hosts, others join, edits and cursors sync live.", + detail = """ +

Opens the Pair tab, which runs a real-time collaboration + session between devices on the same network — no server, + no account.

+
    +
  • Host session — shows an invite card with your + ip:port and a QR code.
  • +
  • Join — scan the host's QR code or type the address.
  • +
  • Edits, cursor positions, and file opens flow between peers as + they happen; each peer appears in the peer list with a colored + cursor in the editor.
  • +
  • Past sessions are listed on the Home screen to rename, delete, + or reconnect.
  • +
+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "User guide", + uri = "index.html", + order = 0, + ) + ), + ), + ) + + override fun getTier3DocsAssetPath(): String? = "docs" + private fun openPairTab() { val tabService = pluginContext.services.get(IdeEditorTabService::class.java) ?: run { pluginContext.logger.error("PairPlugin: editor tab service unavailable") @@ -87,5 +124,6 @@ class PairPlugin : IPlugin, UIExtension, EditorTabExtension { companion object { const val PLUGIN_ID: String = "com.appdevforall.pair.plugin" const val TAB_ID: String = "pair_main" + const val TOOLTIP_TAG_SIDEBAR: String = "pair.sidebar" } } From 4f7127f8131ffbdd27a6e947f17f6e69146197e2 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 10 Aug 2026 21:40:41 +0100 Subject: [PATCH 05/10] ADFA-4395: Fix tooltip category to full plugin.id, widen max_ide_version --- pair/src/main/AndroidManifest.xml | 2 +- pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pair/src/main/AndroidManifest.xml b/pair/src/main/AndroidManifest.xml index 86a2df17..1fc04c25 100644 --- a/pair/src/main/AndroidManifest.xml +++ b/pair/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ + android:value="26.99" /> = listOf( PluginTooltipEntry( From e65d8507519022491b20c4e6d7714cb4dbcf6232 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 12 Aug 2026 15:51:08 +0100 Subject: [PATCH 06/10] =?UTF-8?q?ADFA-4395:=20Fix=20black=20button=20text?= =?UTF-8?q?=20in=20light=20mode=20=E2=80=94=20strip=20baked=20colors=20fro?= =?UTF-8?q?m=20M3=20Typography?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typography built from PluginTextStyles carried explicit colors (labelLarge had onSurface baked in), which override Material3 per-slot content colors — filled buttons rendered onSurface (black in light mode) instead of onPrimary. Typography is now colorless and PairContent's root Box is a Surface so LocalContentColor provides onSurface to plain text. Verified on emulator in light and dark mode. --- .../pair/plugin/ui/main/PairRoot.kt | 83 ++++++++++--------- .../appdevforall/pair/plugin/ui/theme/Type.kt | 34 ++++---- 2 files changed, 61 insertions(+), 56 deletions(-) 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 index 33bd0b01..db3192e4 100644 --- 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 @@ -7,11 +7,11 @@ 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.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -48,49 +48,50 @@ fun PairContent( onIntent: (PairIntent) -> Unit, modifier: Modifier = Modifier, ) { - Box( - modifier = modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface), + Surface( + modifier = modifier.fillMaxSize(), + color = 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) + Box(modifier = Modifier.fillMaxSize()) { + 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") - } - }, - ) + 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") + } + }, + ) + } } } } 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 index 442fc81f..ff19cb49 100644 --- 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 @@ -4,6 +4,7 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.Typography import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -101,24 +102,27 @@ internal fun buildPluginTextStyles( } internal fun buildPluginTypography(styles: PluginTextStyles): Typography { + // Typography must stay colorless: Material3 components provide per-slot content colors + // (e.g. onPrimary inside a filled Button), and a color baked into the style overrides them. + fun TextStyle.colorless() = copy(color = Color.Unspecified) 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, + displayLarge = styles.display.colorless(), + displayMedium = styles.display.colorless(), + displaySmall = styles.display.colorless(), + headlineLarge = styles.title.colorless(), + headlineMedium = styles.title.colorless(), + headlineSmall = styles.title.colorless(), + titleLarge = styles.title.colorless(), + titleMedium = styles.subtitle.colorless(), + titleSmall = styles.subtitle.colorless(), + bodyLarge = styles.body.colorless(), + bodyMedium = styles.body.colorless(), + bodySmall = styles.small.colorless(), // 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, + labelLarge = styles.body.colorless().copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium), + labelMedium = styles.label.colorless(), + labelSmall = styles.label.colorless(), ) } From a784412904835bcb5abd47bb9b483790093a18cb Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 12 Aug 2026 19:59:39 +0100 Subject: [PATCH 07/10] ADFA-4395: Add long-press tooltips to every meaningful pair plugin control PairTooltips registers one entry per control (16 total) under the plugin category; the longPressTooltip modifier intercepts long-press in the initial pointer pass, shows the tooltip via IdeTooltipService with the 3-arg overload, and consumes the rest of the gesture so the underlying control does not also fire onClick. Verified on emulator: tooltips render with See more, taps and text input unaffected. --- .../appdevforall/pair/plugin/PairPlugin.kt | 35 +--- .../appdevforall/pair/plugin/PairTooltips.kt | 193 ++++++++++++++++++ .../plugin/ui/components/DiscoveredHostRow.kt | 3 +- .../pair/plugin/ui/components/HistoryRow.kt | 3 +- .../pair/plugin/ui/components/InviteCard.kt | 3 +- .../plugin/ui/components/LongPressTooltip.kt | 50 +++++ .../plugin/ui/components/OutOfSyncBanner.kt | 2 + .../ui/components/ProjectTransferCard.kt | 3 +- .../pair/plugin/ui/main/GuestSessionScreen.kt | 10 +- .../pair/plugin/ui/main/HomeScreen.kt | 20 +- .../pair/plugin/ui/main/HostSessionScreen.kt | 18 +- 11 files changed, 294 insertions(+), 46 deletions(-) create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/PairTooltips.kt create mode 100644 pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LongPressTooltip.kt diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt index 10e490b1..fa8b036c 100644 --- a/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairPlugin.kt @@ -7,7 +7,6 @@ import com.itsaky.androidide.plugins.extensions.DocumentationExtension 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.PluginTooltipButton import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.services.IdeEditorTabService @@ -70,41 +69,14 @@ class PairPlugin : IPlugin, UIExtension, EditorTabExtension, DocumentationExtens group = "tools", order = 0, action = { openPairTab() }, - tooltipTag = TOOLTIP_TAG_SIDEBAR, + tooltipTag = PairTooltips.SIDEBAR, ) ) } - override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID" + override fun getTooltipCategory(): String = PairTooltips.CATEGORY - override fun getTooltipEntries(): List = listOf( - PluginTooltipEntry( - tag = TOOLTIP_TAG_SIDEBAR, - summary = "Pair programming over local WiFi: one device hosts, others join, edits and cursors sync live.", - detail = """ -

Opens the Pair tab, which runs a real-time collaboration - session between devices on the same network — no server, - no account.

-
    -
  • Host session — shows an invite card with your - ip:port and a QR code.
  • -
  • Join — scan the host's QR code or type the address.
  • -
  • Edits, cursor positions, and file opens flow between peers as - they happen; each peer appears in the peer list with a colored - cursor in the editor.
  • -
  • Past sessions are listed on the Home screen to rename, delete, - or reconnect.
  • -
- """.trimIndent(), - buttons = listOf( - PluginTooltipButton( - description = "User guide", - uri = "index.html", - order = 0, - ) - ), - ), - ) + override fun getTooltipEntries(): List = PairTooltips.entries() override fun getTier3DocsAssetPath(): String? = "docs" @@ -124,6 +96,5 @@ class PairPlugin : IPlugin, UIExtension, EditorTabExtension, DocumentationExtens companion object { const val PLUGIN_ID: String = "com.appdevforall.pair.plugin" const val TAB_ID: String = "pair_main" - const val TOOLTIP_TAG_SIDEBAR: String = "pair.sidebar" } } diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairTooltips.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairTooltips.kt new file mode 100644 index 00000000..5e2302ef --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/PairTooltips.kt @@ -0,0 +1,193 @@ +package com.appdevforall.pair.plugin + +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry + +object PairTooltips { + + const val CATEGORY: String = "plugin_" + PairPlugin.PLUGIN_ID + + const val SIDEBAR: String = "pair.sidebar" + const val HOST_SESSION: String = "pair.host_session" + const val JOIN_SESSION: String = "pair.join_session" + const val JOIN_ADDRESS: String = "pair.join_address" + const val JOIN_PASSCODE: String = "pair.join_passcode" + const val JOIN_CONNECT: String = "pair.join_connect" + const val DEVICE_NAME: String = "pair.device_name" + const val NEARBY_HOST: String = "pair.nearby_host" + const val RECENT_SESSION: String = "pair.recent_session" + const val INVITE_CARD: String = "pair.invite_card" + const val DISCOVERABLE: String = "pair.discoverable" + const val PEER_CURSORS: String = "pair.peer_cursors" + const val RESYNC: String = "pair.resync" + const val STOP_SESSION: String = "pair.stop_session" + const val PULL_PROJECT: String = "pair.pull_project" + const val DISCONNECT: String = "pair.disconnect" + + fun entries(): List = listOf( + entry( + tag = SIDEBAR, + summary = "Pair programming over local WiFi: one device hosts, others join, edits and cursors sync live.", + detail = """ +

Opens the Pair tab, which runs a real-time collaboration + session between devices on the same network. No server, no account.

+
    +
  • Host a session: your device shows an invite card with its + address and a 4 digit passcode.
  • +
  • Join a session: type the host's address and passcode, or + pick the host from the Nearby list.
  • +
  • Edits, cursor positions, and file opens flow between peers as + they happen; each peer appears in the peer list with a colored + cursor in the editor.
  • +
  • Past sessions are listed on the Home screen to rename, delete, + or reconnect.
  • +
+ """.trimIndent(), + ), + entry( + tag = HOST_SESSION, + summary = "Start hosting so other devices on this WiFi can join your editor.", + detail = """ +

Starts a session on this device and shows an invite card with your + address and a 4 digit passcode. Share those with the person joining. + Your edits, cursor moves, and open files sync to every peer while the + session runs.

+ """.trimIndent(), + ), + entry( + tag = JOIN_SESSION, + summary = "Open the join form to connect to a host on this network.", + detail = """ +

Shows fields for the host's address and passcode. Both values are on + the host's invite card. While the form is open the same button closes + it again.

+ """.trimIndent(), + ), + entry( + tag = JOIN_ADDRESS, + summary = "The host's address in ip:port form, shown on their invite card.", + detail = """ +

Type it exactly as the host sees it, for example + 192.168.1.42:7050. A full invite link that carries a token + also works, and then no passcode is needed.

+ """.trimIndent(), + ), + entry( + tag = JOIN_PASSCODE, + summary = "The 4 digit code from the host's invite card.", + detail = """ +

Confirms that the host invited you. Leave it empty only when the + address already contains an invite token.

+ """.trimIndent(), + ), + entry( + tag = JOIN_CONNECT, + summary = "Connect to the host using the address and passcode above.", + detail = """ +

Active once the address is filled and either a passcode is entered + or the address carries an invite token. On success this screen switches + to the connected session view.

+ """.trimIndent(), + ), + entry( + tag = DEVICE_NAME, + summary = "Change the name other peers see for this device.", + detail = """ +

The name appears in every peer list and on your cursor label in the + other editors.

+ """.trimIndent(), + ), + entry( + tag = NEARBY_HOST, + summary = "A device hosting on this network. Tap the row to join it.", + detail = """ +

Hosts show up here while they have Discoverable turned on. Joining + from this list needs no address and no passcode.

+ """.trimIndent(), + ), + entry( + tag = RECENT_SESSION, + summary = "A past session. Tap to reconnect, use the menu to rename or delete.", + detail = """ +

Tapping the row connects to the same address and port again. The + menu renames the entry or deletes it from the list. Deleting does not + touch any files.

+ """.trimIndent(), + ), + entry( + tag = INVITE_CARD, + summary = "The address and passcode another device needs to join this session.", + detail = """ +

Tap the address or the passcode to copy it. The person joining + enters both on their Join form. When Discoverable is on they can pick + this device from their Nearby list instead.

+ """.trimIndent(), + ), + entry( + tag = DISCOVERABLE, + summary = "Announce this session so nearby devices see it in their Nearby list.", + detail = """ +

Uses local network discovery on the current WiFi. Devices joining + from the Nearby list connect without typing the address or passcode. + Turn it off to allow joining by address and passcode only.

+ """.trimIndent(), + ), + entry( + tag = PEER_CURSORS, + summary = "Show or hide other peers' carets in your editor.", + detail = """ +

Each peer gets a colored caret with their name at their position in + the file. This switch only affects your device.

+ """.trimIndent(), + ), + entry( + tag = RESYNC, + summary = "Push the host's current content to all peers to repair drift.", + detail = """ +

Appears when a peer's content no longer matches the host. Resync + replaces every peer's copy of the open files with the host version.

+ """.trimIndent(), + ), + entry( + tag = STOP_SESSION, + summary = "End the session and disconnect all peers.", + detail = """ +

Peers are disconnected and stop receiving edits. Files on every + device stay as they are. The invite address and passcode stop + working.

+ """.trimIndent(), + ), + entry( + tag = PULL_PROJECT, + summary = "Copy the host's project files that are missing on this device.", + detail = """ +

Fetches the host's project over the session connection and shows + progress as a file count. When it finishes, a prompt offers to open + the synced project.

+ """.trimIndent(), + ), + entry( + tag = DISCONNECT, + summary = "Leave this session. The host and other peers keep working.", + detail = """ +

Only this device disconnects. Files already pulled stay on this + device. Reconnect later from the Recent list or with the address and + passcode.

+ """.trimIndent(), + ), + ) + + private fun entry(tag: String, summary: String, detail: String): PluginTooltipEntry = + PluginTooltipEntry( + tag = tag, + summary = summary, + detail = detail, + buttons = listOf( + PluginTooltipButton( + description = "User guide", + uri = "index.html", + order = 0, + ) + ), + ) +} 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 index 88456840..63cd0573 100644 --- 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 @@ -11,6 +11,7 @@ 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.PairTooltips import com.appdevforall.pair.plugin.data.DiscoveredHost import com.appdevforall.pair.plugin.ui.preview.PluginPreview import com.appdevforall.pair.plugin.ui.preview.PreviewSamples @@ -30,7 +31,7 @@ fun DiscoveredHostRow( val styles = LocalPluginTextStyles.current val extras = LocalPluginExtraColors.current - PluginCard(onClick = onJoin, modifier = modifier) { + PluginCard(onClick = onJoin, modifier = modifier.longPressTooltip(PairTooltips.NEARBY_HOST)) { Row( modifier = Modifier .fillMaxWidth() 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 index b53b6685..d2da9a89 100644 --- 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 @@ -27,6 +27,7 @@ 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.PairTooltips import com.appdevforall.pair.plugin.R import com.appdevforall.pair.plugin.data.SessionRole import com.appdevforall.pair.plugin.data.StoredSession @@ -54,7 +55,7 @@ fun HistoryRow( MaterialTheme.colorScheme.onSurfaceVariant } - PluginCard(onClick = onReconnect, modifier = modifier) { + PluginCard(onClick = onReconnect, modifier = modifier.longPressTooltip(PairTooltips.RECENT_SESSION)) { Row( modifier = Modifier .fillMaxWidth() 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 index 5cd9d696..2e3debbd 100644 --- 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 @@ -31,6 +31,7 @@ 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.PairTooltips import com.appdevforall.pair.plugin.ui.preview.PluginPreview import com.appdevforall.pair.plugin.ui.preview.ThemePreviews import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens @@ -48,7 +49,7 @@ fun InviteCard( 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)) { + PluginCard(modifier = modifier.widthIn(max = 360.dp).longPressTooltip(PairTooltips.INVITE_CARD)) { Column( modifier = Modifier .fillMaxWidth() diff --git a/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LongPressTooltip.kt b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LongPressTooltip.kt new file mode 100644 index 00000000..a29e767f --- /dev/null +++ b/pair/src/main/kotlin/com/appdevforall/pair/plugin/ui/components/LongPressTooltip.kt @@ -0,0 +1,50 @@ +package com.appdevforall.pair.plugin.ui.components + +import android.view.HapticFeedbackConstants +import android.view.View +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import com.appdevforall.pair.plugin.PairServiceLocator +import com.appdevforall.pair.plugin.PairTooltips +import com.itsaky.androidide.plugins.services.IdeTooltipService + +fun Modifier.longPressTooltip(tag: String): Modifier = composed { + val view = LocalView.current + pointerInput(tag) { + awaitEachGesture { + awaitFirstDown(pass = PointerEventPass.Initial) + val longPressed = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation(PointerEventPass.Initial) + false + } ?: true + if (longPressed) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + showPluginTooltip(view, tag) + // Consume the rest of the gesture in the initial pass so the control + // underneath cancels its press and does not also fire onClick on release. + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + if (event.changes.none { it.pressed }) break + } + } + } + } +} + +private fun showPluginTooltip(anchor: View, tag: String) { + val context = PairServiceLocator.get().pluginContext + val service = context.services.get(IdeTooltipService::class.java) + if (service == null) { + context.logger.error("PairPlugin: IdeTooltipService unavailable, cannot show tooltip $tag") + return + } + runCatching { service.showTooltip(anchor, PairTooltips.CATEGORY, tag) } + .onFailure { context.logger.error("PairPlugin: showTooltip failed for $tag", it) } +} 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 index c436b317..08a996f9 100644 --- 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 @@ -20,6 +20,7 @@ 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.PairTooltips import com.appdevforall.pair.plugin.ui.preview.PluginPreview import com.appdevforall.pair.plugin.ui.preview.ThemePreviews import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens @@ -61,6 +62,7 @@ fun OutOfSyncBanner( text = "RESYNC", onClick = onResync, contentColor = MaterialTheme.colorScheme.error, + modifier = Modifier.longPressTooltip(PairTooltips.RESYNC), ) } } 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 index 3f390fdc..ce62a14f 100644 --- 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 @@ -25,6 +25,7 @@ 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.PairTooltips import com.appdevforall.pair.plugin.ui.preview.PluginPreview import com.appdevforall.pair.plugin.ui.preview.ThemePreviews import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens @@ -46,7 +47,7 @@ fun ProjectTransferCard( else -> TransferPhase.Active } - PluginCard(modifier = modifier.fillMaxWidth()) { + PluginCard(modifier = modifier.fillMaxWidth().longPressTooltip(PairTooltips.PULL_PROJECT)) { Column( modifier = Modifier .fillMaxWidth() 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 index fd7cc989..d4432bef 100644 --- 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 @@ -21,6 +21,7 @@ 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.PairTooltips import com.appdevforall.pair.plugin.data.SessionState import com.appdevforall.pair.plugin.ui.components.ConnectingLine import com.appdevforall.pair.plugin.ui.components.LabeledSwitchRow @@ -28,6 +29,7 @@ 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.components.longPressTooltip import com.appdevforall.pair.plugin.ui.theme.LocalPluginDimens import com.appdevforall.pair.plugin.ui.theme.LocalPluginTextStyles @@ -81,7 +83,9 @@ fun GuestSessionScreen( subtitle = "Display each peer's caret in your editor.", checked = session.showPeerCursors, onCheckedChange = { onIntent(PairIntent.SetShowPeerCursors(it)) }, - modifier = Modifier.padding(horizontal = dimens.spaceXl), + modifier = Modifier + .padding(horizontal = dimens.spaceXl) + .longPressTooltip(PairTooltips.PEER_CURSORS), ) Spacer(Modifier.height(dimens.spaceLg)) @@ -118,7 +122,9 @@ fun GuestSessionScreen( text = "DISCONNECT", onClick = { onIntent(PairIntent.Disconnect) }, contentColor = MaterialTheme.colorScheme.error, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .longPressTooltip(PairTooltips.DISCONNECT), ) } } 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 index a7a0dfb8..e8abb4d1 100644 --- 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 @@ -28,11 +28,13 @@ 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.PairTooltips 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.components.longPressTooltip import com.appdevforall.pair.plugin.ui.preview.PluginPreview import com.appdevforall.pair.plugin.ui.preview.PreviewSamples import com.appdevforall.pair.plugin.ui.preview.ScreenThemePreviews @@ -76,7 +78,9 @@ fun HomeScreen( PluginButtonFilled( text = "HOST A SESSION", onClick = { onIntent(PairIntent.StartHosting) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .longPressTooltip(PairTooltips.HOST_SESSION), ) Spacer(Modifier.height(dimens.spaceMd)) @@ -93,7 +97,9 @@ fun HomeScreen( onValueChange = { onIntent(PairIntent.AddressChanged(it)) }, placeholder = "192.168.1.42:7050", keyboardType = KeyboardType.Uri, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .longPressTooltip(PairTooltips.JOIN_ADDRESS), ) Spacer(Modifier.height(dimens.spaceSm)) Row( @@ -106,13 +112,16 @@ fun HomeScreen( onValueChange = { onIntent(PairIntent.PasscodeChanged(it)) }, placeholder = "4-digit code", keyboardType = KeyboardType.NumberPassword, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .longPressTooltip(PairTooltips.JOIN_PASSCODE), ) PluginButtonTonal( text = "CONNECT", onClick = { onIntent(PairIntent.SubmitJoin) }, enabled = state.addressInput.isNotBlank() && (state.addressInput.contains("?t=") || state.passcodeInput.isNotBlank()), + modifier = Modifier.longPressTooltip(PairTooltips.JOIN_CONNECT), ) } Spacer(Modifier.height(dimens.spaceMd)) @@ -127,7 +136,9 @@ fun HomeScreen( PluginButtonOutlined( text = if (state.joinMode) "CANCEL" else "JOIN A SESSION", onClick = { onIntent(PairIntent.ToggleJoinMode) }, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .longPressTooltip(PairTooltips.JOIN_SESSION), ) } @@ -213,6 +224,7 @@ private fun IdentityRow( text = "EDIT", onClick = onEdit, contentColor = MaterialTheme.colorScheme.primary, + modifier = Modifier.longPressTooltip(PairTooltips.DEVICE_NAME), ) } } 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 index d107a277..55837ecf 100644 --- 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 @@ -28,6 +28,7 @@ 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.PairTooltips import com.appdevforall.pair.plugin.data.SessionState import com.appdevforall.pair.plugin.ui.components.ConnectingLine import com.appdevforall.pair.plugin.ui.components.InviteCard @@ -36,6 +37,7 @@ 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.components.longPressTooltip import com.appdevforall.pair.plugin.ui.preview.PluginPreview import com.appdevforall.pair.plugin.ui.preview.PreviewSamples import com.appdevforall.pair.plugin.ui.preview.ScreenThemePreviews @@ -99,13 +101,17 @@ fun HostSessionScreen( subtitle = "Nearby devices can join without scanning.", checked = discoverable, onCheckedChange = { onIntent(PairIntent.ToggleDiscoverable) }, - modifier = Modifier.padding(horizontal = dimens.spaceXl), + modifier = Modifier + .padding(horizontal = dimens.spaceXl) + .longPressTooltip(PairTooltips.DISCOVERABLE), ) } else { PluginButtonText( text = "INVITE ANOTHER DEVICE", onClick = { inviteExpanded = true }, - modifier = Modifier.padding(horizontal = dimens.spaceXl), + modifier = Modifier + .padding(horizontal = dimens.spaceXl) + .longPressTooltip(PairTooltips.INVITE_CARD), ) } @@ -116,7 +122,9 @@ fun HostSessionScreen( subtitle = "Display each peer's caret in your editor.", checked = session.showPeerCursors, onCheckedChange = { onIntent(PairIntent.SetShowPeerCursors(it)) }, - modifier = Modifier.padding(horizontal = dimens.spaceXl), + modifier = Modifier + .padding(horizontal = dimens.spaceXl) + .longPressTooltip(PairTooltips.PEER_CURSORS), ) Spacer(Modifier.height(dimens.spaceXl)) @@ -169,7 +177,9 @@ fun HostSessionScreen( text = "STOP SESSION", onClick = { onIntent(PairIntent.StopSession) }, contentColor = MaterialTheme.colorScheme.error, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .longPressTooltip(PairTooltips.STOP_SESSION), ) } } From 84855df16e505efd734f7d6961232950cdb68236 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 12 Aug 2026 20:00:45 +0100 Subject: [PATCH 08/10] ADFA-4395: New Plugin Manager tile icon, code brackets with WiFi badge Rounded teal tile matching the other plugins' card style: big < > centered with a small WiFi fan at the top right. Day tile is deep teal with a white glyph, night tile is bright teal with a dark glyph. Sidebar and tab keep the existing people glyph. --- pair/src/main/assets/icon_day.png | Bin 8849 -> 8838 bytes pair/src/main/assets/icon_night.png | Bin 9712 -> 9224 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/pair/src/main/assets/icon_day.png b/pair/src/main/assets/icon_day.png index 06c43be4fe0c57f2cf50e53cf59b9f9846607db3..01af29fc69c1a64ef8bb8074bb7b9837b9cfe2c3 100644 GIT binary patch literal 8838 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 literal 8849 zcmc(l^;Z<$)5q!VM!LHdkWMKH0VPCIx;u84=0le#C=C+Q4N^-;NP~1Py}%Od61z)2 ze*cPR&Y8Kt-g%#U&V9Y-Ornv27Aes)A`A=+QXOp#<9{dke@TG%uOu&BcK(>oS}#M=5#t>&u+PiZ2cEiR7$++wyF~YVfsst8T(lBDzSyY zkB4_Mk2x0ldOu(;Z_ONo`ovs{YigPi6Ue@&hl@Ve$f*B^7y#l8-eahV#`_!@^#`vu zpY)Kkl)^zc520AG+enlx_^&5}{~I`EvqcF$0L>8{GeLj|U2Ju4&LANZR7pwlnUn$R z4{T7}TNz+P&c-}{zF&lb)~6~mHqr$M_FooT@u~pDw9%|rAWqH)5{gU$%BTebiXM!# zm_OHKcU7A4T9@d|XOOYzCAopG-au!}zqI$`D*Km@3#zj6%UMc?K|qQxUCfkX+|kIW zhHT0|SUcRtDmd&KaR26}y}r979_$}hH<&&o6gC_P8+@9X@1+s!hO7+h;D;kZq=PF33b^414!P`dRH}N?Rx#u3FOLinT?-PQ)x``A7w^}NE820) zv-wIV%p*(^jf+=K5{Hke!1g;ZooW{|3ia809+GMUA8DCY6u=;evB2DHsicK6LD0ge zFY)qKALuqh*U^#(_mrp;Irdv|UMebn=71S!6xmiyjz3lHoKt^?WQHJ0SwsP=jK;+R z!x-bshj28H+N~%ddIe>64hTcc$HY)IubQ-^Tg-C}?vi#CUaVH7e}_F#vWI`1MtD++ zzr!k;8lNt|c*MpDWa9rxmu0}Ypsm1AXx*SntXn+bM2vHaHd3(^=U$O+wmrJTK;avm zz_R`ROY_*2Q*x!dLf%PH5l9~+LQmFa?=Hrg!-pW6)VN@>bv|(@Y7j_&@A1xHExofg zOQ7(QQN>rDsE48qNAL@loii!D@Z#B?+R%i;llE&_OZ6;h9~lNYz|r@q$#Xqis*19^ z`+E-7aZ$?=qN5!Mjvf|Y+`%TyL29H zDn~X<;ppiGcocuSb5{wO6{0^^Za*7<#NIdBL#vRl9a1R9` z7Oz@yoW+HNob)TarxtC;8j2kv7_8E93vG%X9D6MYCb8JcTE#QBX-`PmZ~q)+7o;Ec zLEo8YK!Wq*E4+3tx;9%2SJ?Om1ZDb)>MCEA4uHq_>B*UMAVur{nGm}xv<=?Gh6JkYX z)N^NQrl*28w#}#gw`FCbVS<7=t7Vz`RAEG(CWYGIEw=nCzIy=N|70!ES*&!pkY12R zk>g--aD>fKdTWbZF=xe+AVkW1x$Wv9VXm8v0S_`$vIlpvp0WV9B;2?Wg=3(5I?668 z;=o@z$vt3BXQG#&t=7WvUpuM|Axc<==@j;Vl&3Q0CwYQEmwbtlz@iB9`d`cLBd>B3 zrl_N;XpF1wk>f~jW=I$8V7%o=4C7@KF3ih})1)bXi2dzXZS%5wuKm!Ok#k0d1t*mVWk^Ke6jPX< z++7$O<=DP+m8m_lG`4d*IE^5j0>ttRAqDD{iE_A5>5p5}lb{x{X%e7p(`W3GLt84V z4>^w^BN8+kLX+v3!(e<3%%0psg{!Sq$+q?aoM0weYaJCci~+ybvaX8RJ#qpEaanIC zwV8AU0)2jqTrjLQ!_vA=w`qlhS2|{9YHcnF=N8c&ZXP|8u6X|0-lw?A8~Lt9SO2Xq zmwI|GPt;tH$f&R1|qdpxs(xJy*_r*BtmSl~S_EnW05E0+eeQ zvVUhjFfHfT7+I5Q7h&L<*$p1!7y=5+{aqnJF$z~u@q$lAg36q|b8TAfN|M&<3<3LY z1BYAlA_0#>)LYcvA3}z$4w)PVoJ%gqo`awxF94c?y`Ok_L*^I1x>B#5yQ~|#2QPyP zD4r}MpQbXqgC6GPH@@Si>(!B-mpxvn9i7u<;La|;LMQoj7W3-vd74-X<3>X((()eAR%!2b6F{H~ORL$Y z0$$z|8E>rjh2meR_B-$1>TD|U+>T}mNbX!5e}c&pooNG@ESE^-vNAWLuiex+>E-{l z=kkXGP74c`wX*%C3xU4vmtK;=^|vOkEBjRIVOW`OmxEcMH;AAV1^;65o3qR4faM;x z82h9C8#4Xq$&ogh7#bvp^ilg_uDIiy5_sEO9kz{2@T{}w-vX8aa?jTK)EY6P!B9MH zo&mFK5s+%J`X@yE*d@3ao8`U=R#_%6it9m59taDIrp5|P{R&{^&7xzG51Kz}SA-B` z^_KGAQ}BW9-zmt(lkI$iEd}zoJ`%-g$217;Ts$k{Aaz1t+(LZ8j z&fNEyx0-&o*#!-KCTgwE6P$6jTI^FnMxlKsp(Bf(yaWj=p^NSSUCv>R#EVU5C(d_* zk(6_pT}|h!N`b-3#$7>In7!TUCx60q+kJj*iD(j0r?Jlf=8X5un9;*191^lB?L6SB z%O!0K=M^lhTQlrGHN%qG%eGzit7|tdI;`*u+jnW6tBc>0;^M^%>J6!hXgq`*BtxUL zSb|~hq>kUKt{nzDLZ2y*X7S@!vfZ6AI!=URVi@jmSLRYGwjXB^>J-u|_60u73gAc) zAM%(<+hPM;*6F_JE~jjD-#xLK6*9gxdXAR%rW0XXP_336@0Q4v#Rm2nvzo7KU4)5^ z7#*!d`n3Ock<}lc_(q8xYl4iflgAUy1zxz9^?%^k~=0eom-g-)MG4JAfo)LeL zi-=A7jr!GGJIo7^61Fk}H$6S_4dL(@!A`e$?7ymehhWn{6`_dekl zI3eLOC^rV-F-70%yw?ezsDBx)G0Dr+SrVdlFQF(&g?bkH1=;1=59qxBd(sd8v zVP|VCZL=+h&tDC>)3@Zpy{}fz z@!9p~p~?&vA{gUU5m`4gYSLh@Qy%lMyaV@pvqsf3UbABXoga(Mvp9^Mc1vkp)}hPK zeFOQ-@55g! zqh6u?SfV`T-2zU_Gi{i+>NdMo?|M#b=lSLNM>@{$>ES(n10S2a&L*mVUCJUr74jU) z^>Gyun$InXmSUt41tlZo*`Amv!q@OBrj6k8@kw=dFR$1Hn>Zl2whgpEB$zvyL%j1Q zL42kRkh`aC5bcPsF2WDi_3EU^HJOii$Jcwpb1*+ZY&NV_(wcekTED<2@lDY_*~nXy zw9byM)-pjla=soe2-*0Oec9RjDKqzh+NJ3ITf-yi4OPAKV{kfkS7hu@&ff4vi`z?I z$za6Uj*wI9DfH59ple+TLoJ-&WBr3;Cr42?Cqe%c8s-U+s}Ks@{kYzdE=DxefSXfG zMF>5x5wPLTH+XLz@xunTTtL5z=fPtmG4E)j`#1jUzJb9G<@2}d(+85J^sUMD(s^8o z((nTlTteF@zM_BhE4k7>Yu$9*XTpS!1OK}>U6)GypXE%4?*MsUY(_ZS&pwyyalsq+ z7CvY{ToK8Z^5)R78%^cjwA|q>M)BjR9AIGpp?zuo*g48Gz+;83K+^u}qxc%gM19|g zV-&$S;JA}xc6pfL^^xf6j1gqhJ!b&^LWmFVFbaCR=>8XRamOtTJ1spcY_QpTm+CuM zvV`#TupLaM*Z2z|oKQ;c1FoqP{-B!O;YA|k2a+Y`Z_f6aUI9l zB7{lxDdnFS-9#X-*bKIp)O(nj^_Cigzf6OhMqhWEt~5L_Zl0v;414mgO(C}jieWXz zcY|itoo_yhBeOY*ZDY8**HtSuIc6b&GE;|L0tTet z)HtcIqWS&4i9t0;`ES)>Bs8Gs%Lz%#cl(>kOOd_zecP8L6VSy^iBHQ>7+*6 za9|uk!-(H;Di-9?cFO1DjyUR(fnXrZJ(roI4)aT6Et%Gu-nF|G4K?3YN1FEod4=*< zeO=)rtf=E^9tU8H8o>d8`rz|jQg|wH+FmR_{J?JJU?}sdJDqa3hQF*{EBEn%VFQ2G zfqfQ=N-VB!08V?16n(2N9di+a(8o1#Q*SWTZ%KG2jI z_?DjF-eW}$4c{UiC+wY0R3=c;z{AfOrRM%c^phIkmR}i96P*133zA(P)SDfqb6T98 z86nCE%WPpzTK0O`m1p2@$mFj z_9_gxMjK`@Q`s27)g|tuGwt3IkP&^BS*ZCUE)6HEC#FL`y~t{R8>|od!@eCoJ+$zW z>b$I}D^JI_vVrT&G&?8#^C=|uXmNX56E3vbV&R&K>tLfGP>GCYZiv+G8f&bILfKfl>%BM;NqmQ7OvZh{>NW>0%3_0ZYot6c> zKVC1Yp`bYHHNSN4SIYberXP9mXx!KC!5{vN&GJ`=1XEx4`~Iz7QTE=7OS5Y9&(J-} zfSC7`>#6U$(f`HKDkbX8HFfg#7b(4vqroxOGR{V>SH-b3sRqMP(!8i0TcSZ|;E4V8 zWwJ)d22?9B`Rwr#)ryl&1R38~4SYML(b;jA1umQUwp*y=dt$z94_+bx6rGN4+r|n~ z`jxX3_k5d{3qy=sQlCoEM$jHNTN4GAm5hPUrWJ0w!s zvgb2{Pcb^r#gU6Omef_B(-Sq~(-FDmHvt}eHs`B}+QfQ&?}-Mx*pF5%uD|kND!fcA zOexpmTebw-^S;P*SZ8D9jVvm5T&zwc31b#KA+t%$!E+OWG`{FeXB&+zAUN9;WXUZr zZU%h*vqaof>-*mlqCjUndTW5AKRr5Zv|4jTRnJ*KIc%-y7<+CPI^)d-fh(BRZssxv zN(q6-=tmLjG4nfnAC23iE>iMFh~gj(HKQfQh1zlj^)QfN_Mw413UMe~R5?AEsW;aw z%5|=5UiYGtY?Iia0Ts^hG1;suwEY@5{2IVa7TohUePgfDNzXYb=_jiYnCV&|-uLpn zHLgJv#lzmih~l5Lrczx z!AVn`fIE<@BI3(G%zBJ2a9a0viiguKjJf;!8tOZX3nwI2ATE z7V46QGJg2r(L`T=SxZ?(yEiAqM0VBcPG3TYOWiaeg7i=ImSRf!N3k5x>q^lv?xQ{& zt>)3+zvhX+3QZI)r{{MzjJ@u6&iy8AT;(?og2TP&Mftw;fEo{#Hm2SGtVCtyL*`GA zF|A%D_By?LWvJldP@uCYN?{%kKTc_td?Ds`0pXSh-d5Kx|AaNsRpYQpKTQDakZ96* zKS96B^}H)^nfm+mC0m?vXuNb@bjn;3-2byV|1!u=7_@dts~U|`If@YI#1}~;S@NJ; zF$Guh2CRD3;eIj2HsxojdiKZJN9ZktFCf1~c!vZhFRMQ&4cBrshrI|H{jr1ZAyY*D zoP8Na7|RBs3ooD$MBH}y>fTKs2p=wYhF`nA=23oCZhZYn?qkT4_ z5#45cwkMvfsg&I--{N(h8WxFG9m1?ToOqUsq*heCIZ~8oEA?x_U!6%kD%sP1E2{jm z%MqH@^ykKcr4~U~*LSVfTF_l(>a2yz^P}|UTDiNGdX8P$ zoG2K8Jo#dOy7eJckT&9VA+k@a{q&xLgluG3B*jOMJWtlmp3_%ZE|ECoa;C7Rx`-1Y z@$K_|7i7U}hxe~%T5%vbx_01|@drqm{a=CC+~&mZ`d&sw>pGHCClYJFBzNsjCC5!r zr+N=SA@+&f{wiFAO$as24KCCnS?071(}#u3oYXHYPqib`h;Y-V`m2iwo5eH75A5Ra z^}9xHY=ccU+NS0kYX<_POZ&1v`~6Z;zs{p^nbU~M>QY+m^qsOkI=^Z2mLVI2kb!pu zj=9#)pAP2EZ0;66$`t+J${~(>Z2#gSzm8^Hpa~;IgsK}BK9xhZ08e>cG_5+v3{%uV z*3Sc~FS`O~E{z01v?gPg&_O(v)*nV!8j@|s#MojE!X$5G0Bd785T+b(nWCcfg{vgY z`+cH;pIE2pDA&g`ybo(bO{Puop_N{2KrYL%_xUe}so|KFMIOoIPlhFXK=X^WCQ-95 z@yE}pK`tH>^{fw;3-iwonINx!@L#d&2vGF}ytnHtzSWUntPv6J}G@1S%lSfS^CQ%}2A zsjm8I^hk%bsWGAXkxYFjwx@f;bW?-Y5o3aqWp=S&mGkLVl$l+Ie*R&F<;=9-%dcN@ zue#nYJIE<&I4rh?jV&AMjape)$d}QSivDPh-{J6*mCgra&5S7-J7A-<0o&0JJKVY!m4I35(G) z#(J{UuJF|5vbSda2}P^XlrerV605YMjp(7T;;w%NJ7u2pWOf!<4+v7k2ChH6RA3_G z<4Zc`ld!WHVNJzj{V!0YWs z7dH?09?32nSCw(qy@)ZP9G&FE2MV@wB>{EJf7BKE?;E`P_N#c23LWP82Y+aAQ4B9{(c2wD@F4VJv+eAn;SxW)lOa!cP#g^&OFPOHvQeIGqmzN=1J zx_#u-Ev0cCadbXEN#c-WfJdo>{%-;{QQj<-a$`m#AUP%*SbI+}A9VYuUmPSL3@QIAe|IPv1I6|uUnZMA=Qa#eFKSdlo5Y3( zDai7vyMK=`iNpLvUg{8N5|#6+dZzDAT9&UsD2SUke><)jX@YgD{Bdk_MC|r=cmbKu zZm=0%i{(V;UN>`1mg{xB*p_b;B5Cy1Mfmm$Pnf~7W@sAxLr-V$H$#Ln=4@c(uHX4{ zRYw_$5NpC`dsGA0n z+)3Bhx;bU5?X#P0Ryk~^92zTzYDzqBLzSzp8vg6>U@RCr^CR+X`l_I>6G}++pd@fj z`I=id#eH(zwZj~|?yHDn9Dr)xWz=;#S0aEh~!~e3;7eBYlmaYWv z_Yv~ArJgGUlGEW$SF!H?-df93O}e9Kl0YtxdF~ct=Xi3t%~M7u@sJfV z$JEYXu+29sV1uxjR88KGpYEOGv)4f>+~ilT@4@;_NjJQ5ab$nWZibd?F?r(LzPPOVYpZWW5a8#!4+0D^@?L2V5U5Me%Xu z)irSPgMq^-jyyhdC$Un9-aYJq#mOOyR{$yYUmG;jKA+Ezi2Nq7f9w7H<$0Sh75`0< zhK_ToEJ{Gt3ONq)S*iOi&~zIgA(pc!A1-(y00)dKg%lFu8~(TUjq5c|_Wta+F6bf5 zP&rY^=4;a|Am1>(>a%7c68o*NO?{lwZ!NdHS*g;@HMwE3^9`T7HvS>$fYXB#5v#Gy zw2@u|PX@BkoQ`3zH14m#d}%x`5qDs;2D8X)S(Jy=W9 z6yaM1M@)bG`$9g#3S~;O!zg>>U3SBBje(70hBLYP18L?*Wre?c6lLr?){ zA5I`H=7&TaDD|H{N;Rh|3_NHl-d=6Ro8JmIsx=wgiW_Asfy5ecdG31w3pl4I|S<`iK|EH&#`uwl|s zFZ%&Nc2-K08tmXlk?L}* nY=B6fH;|O%|08U9QBP?O diff --git a/pair/src/main/assets/icon_night.png b/pair/src/main/assets/icon_night.png index b4658f71b00fab325733e5c29fdb68fa750ec2a1..c22f899078c622a2f097a5e90b6b07b79cc8d5bd 100644 GIT binary patch literal 9224 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 Dyc Date: Wed, 12 Aug 2026 20:00:45 +0100 Subject: [PATCH 09/10] ADFA-4395: Rename plugin to Code Together plugin.name, app label, and the user guide now say Code Together; the tab and sidebar keep the short Pair label and plugin.id is unchanged so existing installs update in place. Docs prose also drops its em dashes. --- pair/src/main/AndroidManifest.xml | 2 +- pair/src/main/assets/docs/index.html | 24 ++++++++++++------------ pair/src/main/res/values/strings.xml | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pair/src/main/AndroidManifest.xml b/pair/src/main/AndroidManifest.xml index 1fc04c25..f31e575e 100644 --- a/pair/src/main/AndroidManifest.xml +++ b/pair/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ + android:value="Code Together" /> -Pair — User Guide +Code Together User Guide