From 7ca6bbea97d54e7dfe5dbbcadfe5aa69354fe480 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 8 Jul 2026 22:08:55 +0100 Subject: [PATCH 1/2] 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 2/2] 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 zcma&NW0YlEwk;ePwr$(aux;D69T}N{9ky*d!_2U4+qUuIRNZ#Jck8}7U+vcB{`IjNZqX3eq5;s6ddAkU&5{L|^Ow`ym2B0m+K02+~Q)i807X3X94qi>j)C0e$=H zm31v`=T&y}ACuKx7G~yWSYncG=NFB>O2);i9EmJ(9jSamq?Crj$g~1l3m-4M7;BWn zau2S&sSA0b0Rhg>6YlVLQa;D#)1yw+eGs~36Q$}5?avIRne3TQZXb<^e}?T69w<9~ zUmx1cG0uZ?Kd;Brd$$>r>&MrY*3$t^PWF1+J+G_xmpHW=>mly$<>~wHH+Bt3mzN7W zhR)g{_veH6>*KxLJ~~s{9HZm!UeC86d_>42NRqd$ev8zSMq4kt)q*>8kJ8p|^wuKx zq2Is_HJPoQ_apSoT?zJj7vXBp!xejBc^7F|zU0rhy%Ub*Dy#jJs!>1?CmJ-gulPVX zKit>RVmjL=G?>jytf^U@mfnC*1-7EVag@%ROu*#kA+)Rxq?MGK0v-dp^kM?nyMngb z_poL>GLThB7xAO*I7&?4^Nj`<@O@>&0M-QxIi zD@n}s%CYI4Be19C$lAb9Bbm6!R{&A;=yh=#fnFyb`s7S5W3?arZf?$khCwkGN!+GY~GT8-`!6pFr zbFBVEF`kAgtecfjJ`flN2Z!$$8}6hV>Tu;+rN%$X^t8fI>tXQnRn^$UhXO8Gu zt$~QON8`doV&{h}=2!}+xJKrNPcIQid?WuHUC-i%P^F(^z#XB`&&`xTK&L+i8a3a@ zkV-Jy;AnyQ`N=&KONV_^-0WJA{b|c#_l=v!19U@hS~M-*ix16$r01GN3#naZ|DxY2 z76nbjbOnFcx4bKbEoH~^=EikiZ)_*kOb>nW6>_vjf-UCf0uUy~QBb7~WfVO6qN@ns zz=XEG0s5Yp`mlmUad)8!(QDgIzY=OK%_hhPStbyYYd|~zDIc3J4 zy9y%wZOW>}eG4&&;Z>vj&Mjg+>4gL! z(@oCTFf-I^54t=*4AhKRoE-0Ky=qg3XK2Mu!Bmw@z>y(|a#(6PcfbVTw-dUqyx4x4 z3O#+hW1ANwSv-U+9otHE#U9T>(nWx>^7RO_aI>${jvfZQ{mUwiaxHau!H z0Nc}ucJu+bKux?l!dQ2QA(r@(5KZl(Or=U!=2K*8?D=ZT-IAcAX!5OI3w@`sF@$($ zbDk0p&3X0P%B0aKdijO|s})70K&mk1DC|P##b=k@fcJ|lo@JNWRUc>KL?6dJpvtSUK zxR|w8Bo6K&y~Bd}gvuz*3z z@sPJr{(!?mi@okhudaM{t3gp9TJ!|@j4eO1C&=@h#|QLCUKLaKVL z!lls$%N&ZG7yO#jK?U>bJ+^F@K#A4d&Jz4boGmptagnK!Qu{Ob>%+60xRYK>iffd_ z>6%0K)p!VwP$^@Apm%NrS6TpKJwj_Q=k~?4=_*NIe~eh_QtRaqX4t-rJAGYdB{pGq zSXX)-dR8mQ)X|;8@_=J6Dk7MfMp;x)^aZeCtScHs12t3vL+p-6!qhPkOM1OYQ z8YXW5tWp)Th(+$m7SnV_hNGKAP`JF4URkkNc@YV9}FK$9k zR&qgi$Cj#4bC1VK%#U)f%(+oQJ+EqvV{uAq1YG0riLvGxW@)m;*ayU-BSW61COFy0 z(-l>GJqYl;*x1PnRZ(p3Lm}* zlkpWyCoYtg9pAZ5RU^%w=vN{3Y<6WImxj(*SCcJsFj?o6CZ~>cWW^foliM#qN#We{ zwsL!u1$rzC1#4~bILZm*a!T{^kCci$XOJADm)P;y^%x5)#G#_!2uNp^S;cE`*ASCn;}H7pP^RRA z6lfXK(r4dy<_}R|(7%Lyo>QFP#s31E8zsYA${gSUykUV@?lyDNF=KhTeF^*lu7C*{ zBCIjy;bIE;9inJ$IT8_jL%)Q{7itmncYlkf2`lHl(gTwD%LmEPo^gskydVxMd~Do` zO8EzF!yn!r|BEgPjhW#>g(unY#n}=#4J;3FD2ThN5LpO0tI2~pqICaFAGT%%;3Xx$ z>~Ng(64xH-RV^Rj4=A_q1Ee8kcF}8HN{5kjYX0ADh}jq{q18x(pV!23pVsK5S}{M#p8|+LvfKx|_3;9{+6cu7%5o-+R@z>TlTft#kcJ`s2-j zUe4dgpInZU!<}aTGuwgdWJZ#8TPiV9QW<-o!ibBn&)?!ZDomECehvT7GSCRyF#VN2&5GShch9*}4p;8TX~cW*<#( zv-HmU7&+YUWO__NN3UbTFJ&^#3vxW4U9q5=&ORa+2M$4rskA4xV$rFSEYBGy55b{z z!)$_fYXiY?-GWDhGZXgTw}#ilrw=BiN(DGO*W7Vw(} zjUexksYLt_Nq?pl_nVa@c1W#edQKbT>VSN1NK?DulHkFpI-LXl7{;dl@z0#v?x%U& z8k8M1X6%TwR4BQ_eEWJASvMTy?@fQubBU__A_US567I-~;_VcX^NJ-E(ZPR^NASj1 zVP!LIf8QKtcdeH#w6ak50At)e={eF_Ns6J2Iko6dn8Qwa6!NQHZMGsD zhzWeSFK<{hJV*!cIHxjgR+e#lkUHCss-j)$g zF}DyS531TUXKPPIoePo{yH%qEr-dLMOhv^sC&@9YI~uvl?rBp^A-57{aH_wLg0&a|UxKLlYZQ24fpb24Qjil`4OCyt0<1eu>5i1Acv zaZtQRF)Q;?Aw3idg;8Yg9Cb#)03?pQ@O*bCloG zC^|TnJl`GXN*8iI;Ql&_QIY0ik}rqB;cNZ-qagp=qmci9eScHsRXG$zRNdf4SleJ} z7||<#PCW~0>3u8PP=-DjNhD(^(B0AFF+(oKOiQyO5#v4nI|v_D5@c2;zE`}DK!%;H zUn|IZ6P;rl*5`E(srr6@-hpae!jW=-G zC<*R?RLwL;#+hxN4fJ!oP4fX`vC3&)o!#l4y@MrmbmL{t;VP%7tMA-&vju_L zhtHbOL4`O;h*5^e3F{b9(mDwY6JwL8w`oi28xOyj`pVo!75hngQDNg7^D$h4t&1p2 ziWD_!ap3GM(S)?@UwWk=Szym^eDxSx3NaR}+l1~(@0car6tfP#sZRTb~w!WAS{+|SgUN3Tv`J4OMf z9ta_f>-`!`I@KA=CXj_J>CE7T`yGmej0}61sE(%nZa1WC_tV6odiysHA5gzfWN-`uXF46mhJGLpvNTBmx$!i zF67bAz~E|P{L6t1B+K|Cutp&h$fDjyq9JFy$7c_tB(Q$sR)#iMQH3{Og1AyD^lyQwX6#B|*ecl{-_;*B>~WSFInaRE_q6 zpK#uCprrCb`MU^AGddA#SS{P7-OS9h%+1`~9v-s^{s8faWNpt*Pmk_ECjt(wrpr{C_xdAqR(@!ERTSs@F%^DkE@No}wqol~pS^e7>ksF_NhL0?6R4g`P- zk8lMrVir~b(KY+hk5LQngwm`ZQT5t1^7AzHB2My6o)_ejR0{VxU<*r-Gld`l6tfA` zKoj%x9=>Ce|1R|1*aC}|F0R32^KMLAHN}MA<8NNaZ^j?HKxSwxz`N2hK8lEb{jE0& zg4G_6F@#NyDN?=i@=)eidKhlg!nQoA{`PgaH{;t|M#5z}a`u?^gy{5L~I2smLR z*4RmNxHqf9>D>sXSemHK!h4uPwMRb+W`6F>Q6j@isZ>-F=)B2*sTCD9A^jjUy)hjAw71B&$u}R(^R; zY9H3k8$|ounk>)EOi_;JAKV8U8ICSD@NrqB!&=)Ah_5hzp?L9Sw@c>>#f_kUhhm=p z1jRz8X7)~|VwO(MF3PS(|CL++1n|KT3*dhGjg!t_vR|8Yg($ z+$S$K=J`K6eG#^(J54=4&X#+7Car=_aeAuC>dHE+%v9HFu>r%ry|rwkrO-XPhR_#K zS{2Unv!_CvS7}Mb6IIT$D4Gq5v$Pvi5nbYB+1Yc&RY;3;XDihlvhhIG6AhAHsBYsm zK@MgSzs~y|+f|j-lsXKT0(%E2SkEb)p+|EkV5w8=F^!r1&0#0^tGhf9yPZ)iLJ^ zIXOg)HW_Vt{|r0W(`NmMLF$?3ZQpq+^OtjR-DaVLHpz%1+GZ7QGFA?(BIqBlVQ;)k zu)oO|KG&++gD9oL7aK4Zwjwi~5jqk6+w%{T$1`2>3Znh=OFg|kZ z>1cn>CZ>P|iQO%-Pic8wE9c*e%=3qNYKJ+z1{2=QHHFe=u3rqCWNhV_N*qzneN8A5 zj`1Ir7-5`33rjDmyIGvTx4K3qsks(I(;Kgmn%p#p3K zn8r9H8kQu+n@D$<#RZtmp$*T4B&QvT{K&qx(?>t@mX%3Lh}sr?gI#vNi=vV5d(D<=Cp5-y!a{~&y|Uz*PU{qe zI7g}mt!txT)U(q<+Xg_sSY%1wVHy;Dv3uze zJ>BIdSB2a|aK+?o63lR8QZhhP)KyQvV`J3)5q^j1-G}fq=E4&){*&hiam>ssYm!ya z#PsY0F}vT#twY1mXkGYmdd%_Uh12x0*6lN-HS-&5XWbJ^%su)-vffvKZ%rvLHVA<; zJP=h13;x?$v30`T)M)htph`=if#r#O5iC^ZHeXc6J8gewn zL!49!)>3I-q6XOZRG0=zjyQc`tl|RFCR}f-sNtc)I^~?Vv2t7tZZHvgU2Mfc9$LqG z!(iz&xb=q#4otDBO4p)KtEq}8NaIVcL3&pbvm@0Kk-~C@y3I{K61VDF_=}c`VN)3P z+{nBy^;=1N`A=xH$01dPesY_na*zrcnssA}Ix60C=sWg9EY=2>-yH&iqhhm28qq9Z z;}znS4ktr40Lf~G@6D5QxW&?q^R|=1+h!1%G4LhQs54c2Wo~4% zCA||d==lv2bP=9%hd0Dw_a$cz9kk)(Vo}NpSPx!vnV*0Bh9$CYP~ia#lEoLRJ8D#5 zSJS?}ABn1LX>8(Mfg&eefX*c0I5bf4<`gCy6VC{e>$&BbwFSJ0CgVa;0-U7=F81R+ zUmzz&c;H|%G&mSQ0K16Vosh?sjJW(Gp+1Yw+Yf4qOi|BFVbMrdO6~-U8Hr|L@LHeZ z0ALmXHsVm137&xnt#yYF$H%&AU!lf{W436Wq87nC16b%)p?r z70Wua59%7Quak50G7m3lOjtvcS>5}YL_~?Pti_pfAfQ!OxkX$arHRg|VrNx>R_Xyi z`N|Y7KV`z3(ZB2wT9{Dl8mtl zg^UOBv~k>Z(E)O>Z;~Z)W&4FhzwiPjUHE9&T#nlM)@hvAZL>cha-< zQ8_RL#P1?&2Qhk#c9fK9+xM#AneqzE-g(>chLp_Q2Xh$=MAsW z2ScEKr+YOD*R~mzy{bOJjs;X2y1}DVFZi7d_df^~((5a2%p%^4cf>vM_4Sn@@ssVJ z9ChGhs zbanJ+h74)3tWOviXI|v!=HU2mE%3Th$Mpx&lEeGFEBWRy8ogJY`BCXj@7s~bjrOY! z4nIU5S>_NrpN}|waZBC)$6ST8x91U2n?FGV8lS{&LFhHbuHU?SVU{p7yFSP_f#Eyh zJhI@o9lAeEwbZYC=~<(FZ$sJx^6j@gtl{yTOAz`Gj!Ab^y})eG&`Qt2cXdog2^~oOH^K@oHcE(L;wu2QiMv zJuGdhNd+H{t#Tjd<$PknMSfbI>L1YIdZ+uFf*Z=BEM)UPG3oDFe@8roB0h(*XAqRc zoxw`wQD@^nxGFxQXN9@GpkLqd?9@(_ZRS@EFRCO8J5{iuNAQO=!Lo5cCsPtt4=1qZN8z`EA2{ge@SjTyhiJE%ttk{~`SEl%5>s=9E~dUW0uws>&~3PwXJ!f>ShhP~U9dLvE8ElNt3g(6-d zdgtD;rgd^>1URef?*=8BkE&+HmzXD-4w61(p6o~Oxm`XexcHmnR*B~5a|u-Qz$2lf zXc$p91T~E4psJxhf^rdR!b_XmNv*?}!PK9@-asDTaen;p{Rxsa=1E}4kZ*}yQPoT0 zvM}t!CpJvk<`m~^$^1C^o1yM(BzY-Wz2q7C^+wfg-?}1bF?5Hk?S{^#U%wX4&lv0j zkNb)byI+nql(&65xV?_L<0tj!KMHX8Hmh2(udEG>@OPQ}KPtdwEuEb$?acp~yT1&r z|7YU<(v!0as6Xff5^XbKQIR&MpjSE)pmub+ECMZzn7c!|hnm_Rl&H_oXWU2!h7hhf zo&-@cLkZr#eNgUN9>b=QLE1V^b`($EX3RQIyg#45A^=G!jMY`qJ z8qjZ$*-V|?y0=zIM>!2q!Gi*t4J5Otr^OT3XzQ_GjATc(*eM zqllux#QtHhc>YtnswBNiS^t(dTDn|RYSI%i%-|sv1wh&|9jfeyx|IHowW)6uZWR<%n8I}6NidBm zJ>P7#5m`gnXLu;?7jQZ!PwA80d|AS*+mtrU6z+lzms6^vc4)6Zf+$l+Lk3AsEK7`_ zQ9LsS!2o#-pK+V`g#3hC$6*Z~PD%cwtOT8;7K3O=gHdC=WLK-i_DjPO#WN__#YLX|Akw3LnqUJUw8&7pUR;K zqJ98?rKMXE(tnmT`#080w%l1bGno7wXHQbl?QFU=GoK@d!Ov=IgsdHd-iIs4ahcgSj(L@F96=LKZ zeb5cJOVlcKBudawbz~AYk@!^p+E=dT^UhPE`96Q5J~cT-8^tp`J43nLbFD*Nf!w;6 zs>V!5#;?bwYflf0HtFvX_6_jh4GEpa0_s8UUe02@%$w^ym&%wI5_APD?9S4r9O@4m zq^Z5Br8#K)y@z*fo08@XCs;wKBydn+60ks4Z>_+PFD+PVTGNPFPg-V-|``!0l|XrTyUYA@mY?#bJYvD>jX&$o9VAbo?>?#Z^c+Y4Dl zXU9k`s74Sb$OYh7^B|SAVVz*jEW&GWG^cP<_!hW+#Qp|4791Od=HJcesFo?$#0eWD z8!Ib_>H1WQE}shsQiUNk!uWOyAzX>r(-N7;+(O333_ES7*^6z4{`p&O*q8xk{0xy@ zB&9LkW_B}_Y&?pXP-OYNJfqEWUVAPBk)pTP^;f+75Wa(W>^UO_*J05f1k{ zd-}j!4m@q#CaC6mLsQHD1&7{tJ*}LtE{g9LB>sIT7)l^ucm8&+L0=g1E_6#KHfS>A_Z?;pFP96*nX=1&ejZ+XvZ=ML`@oVu>s^WIjn^SY}n zboeP%`O9|dhzvnw%?wAsCw*lvVcv%bmO5M4cas>b%FHd;A6Z%Ej%;jgPuvL$nk=VQ=$-OTwslYg zJQtDS)|qkIs%)K$+r*_NTke8%Rv&w^v;|Ajh5QXaVh}ugccP}3E^(oGC5VO*4`&Q0 z&)z$6i_aKI*CqVBglCxo#9>eOkDD!voCJRFkNolvA2N&SAp^4<8{Y;#Kr5740 za|G`dYGE!9NGU3Ge6C)YByb6Wy#}EN`Ao#R!$LQ&SM#hifEvZp>1PAX{CSLqD4IuO z4#N4AjMj5t2|!yTMrl5r)`_{V6DlqVeTwo|tq4MHLZdZc5;=v9*ibc;IGYh+G|~PB zx2}BAv6p$}?7YpvhqHu7L;~)~Oe^Y)O(G(PJQB<&2AhwMw!(2#AHhjSsBYUd8MDeM z+UXXyV@@cQ`w}mJ2PGs>=jHE{%i44QsPPh(=yorg>jHic+K+S*q3{th6Ik^j=@%xo zXfa9L_<|xTL@UZ?4H`$vt9MOF`|*z&)!mECiuenMW`Eo2VE#|2>2ET7th6+VAmU(o zq$Fz^TUB*@a<}kr6I>r;6`l%8NWtVtkE?}Q<<$BIm*6Z(1EhDtA29O%5d1$0q#C&f zFhFrrss{hOsISjYGDOP*)j&zZUf9`xvR8G)gwxE$HtmKsezo`{Ta~V5u+J&Tg+{bh zhLlNbdzJNF6m$wZNblWNbP6>dTWhngsu=J{);9D|PPJ96aqM4Lc?&6H-J1W15uIpQ ziO{&pEc2}-cqw+)w$`p(k(_yRpmbp-Xcd`*;Y$X=o(v2K+ISW)B1(ZnkV`g4rHQ=s z+J?F9&(||&86pi}snC07Lxi1ja>6kvnut;|Ql3fD)%k+ASe^S|lN69+Ek3UwsSx=2EH)t}K>~ z`Mz-SSVH29@DWyl`ChuGAkG>J;>8ZmLhm>uEmUvLqar~vK3lS;4s<{+ehMsFXM(l- zRt=HT>h9G)JS*&(dbXrM&z;)66C=o{=+^}ciyt8|@e$Y}IREAyd_!2|CqTg=eu}yG z@sI9T;Tjix*%v)c{4G84|0j@8wX^Iig_JsPU|T%(J&KtJ>V zsAR+dcmyT5k&&G{!)VXN`oRS{n;3qd`BgAE9r?%AHy_Gf8>$&X$=>YD7M911?<{qX zkJ;IOfY$nHdy@kKk_+X%g3`T(v|jS;>`pz`?>fqMZ>Fvbx1W=8nvtuve&y`JBfvU~ zr+5pF!`$`TUVsx3^<)48&+XT92U0DS|^X6FwSa-8yviRkZ*@Wu|c*lX!m?8&$0~4T!DB0@)n}ey+ew}T1U>|fH3=W5I!=nfoNs~OkzTY7^x^G&h>M7ewZqmZ=EL0}3#ikWg+(wuoA{7hm|7eJz zNz78l-K81tP16rai+fvXtspOhN-%*RY3IzMX6~8k9oFlXWgICx9dp;`)?Toz`fxV@&m8< z{lzWJG_Y(N1nOox>yG^uDr}kDX_f`lMbtxfP`VD@l$HR*B(sDeE(+T831V-3d3$+% zDKzKnK_W(gLwAK{Saa2}zaV?1QmcuhDu$)#;*4gU(l&rgNXB^WcMuuTki*rt>|M)D zoI;l$FTWIUp}euuZjDidpVw6AS-3dal2TJJaVMGj#CROWr|;^?q>PAo2k^u-27t~v zCv10IL~E)o*|QgdM!GJTaT&|A?oW)m9qk2{=y*7qb@BIAlYgDIe)k(qVH@)#xx6%7 z@)l%aJwz5Joc84Q2jRp71d;=a@NkjSdMyN%L6OevML^(L0_msbef>ewImS=+DgrTk z4ON%Y$mYgcZ^44O*;ctP>_7=}=pslsu>~<-bw=C(jeQ-X`kUo^BS&JDHy%#L32Cj_ zXRzDCfCXKXxGSW9yOGMMOYqPKnU zTF6gDj47!7PoL%z?*{1eyc2IVF*RXX?mj1RS}++hZg_%b@6&PdO)VzvmkXxJ*O7H} z6I7XmJqwX3<>z%M@W|GD%(X|VOZ7A+=@~MxMt8zhDw`yz?V>H%C0&VY+ZZ>9AoDVZeO1c~z$r~!H zA`N_9p`X?z>jm!-leBjW1R13_i2(0&aEY2$l_+-n#powuRO;n2Fr#%jp{+3@`h$c< zcFMr;18Z`UN#spXv+3Ks_V_tSZ1!FY7H(tdAk!v}SkoL9RPYSD3O5w>A3%>7J+C-R zZfDmu=9<1w1CV8rCMEm{qyErCUaA3Q zRYYw_z!W7UDEK)8DF}la9`}8z*?N32-6c-Bwx^Jf#Muwc67sVW24 zJ4nab%>_EM8wPhL=MAN)xx1tozAl zmhXN;*-X%)s>(L=Q@vm$qmuScku>PV(W_x-6E?SFRjSk)A1xVqnml_92fbj0m};UC zcV}lRW-r*wY106|sshV`n#RN{)D9=!>XVH0vMh>od=9!1(U+sWF%#B|eeaKI9RpaW z8Ol_wAJX%j0h5fkvF)WMZ1}?#R(n-OT0CtwsL)|qk;*(!a)5a5ku2nCR9=E*iOZ`9 zy4>LHKt-BgHL@R9CBSG!v4wK zvjF8DORRva)@>nshE~VM@i2c$PKw?3nz(6-iVde;-S~~7R<5r2t$0U8k2_<5C0!$j zQg#lsRYtI#Q1YRs(-%(;F-K7oY~!m&zhuU4LL}>jbLC>B`tk8onRRcmIm{{0cpkD|o@Ixu#x9Wm5J)3oFkbfi62BX8IX1}VTe#{C(d@H|#gy5#Sa#t>sH@8v1h8XFgNGs?)tyF_S^ueJX_-1%+LR`1X@C zS3Oc)o)!8Z9!u9d!35YD^!aXtH;IMNzPp`NS|EcdaQw~<;z`lmkg zE|tQRF7!S!UCsbag%XlQZXmzAOSs= zIUjgY2jcN9`xA6mzG{m|Zw=3kZC4@XY=Bj%k8%D&iadvne$pYNfZI$^2BAB|-MnZW zU4U?*qE3`ZDx-bH})>wz~)a z_SWM!E=-BS#wdrfh;EfPNOS*9!;*+wp-zDthj<>P0a2n?$xfe;YmX~5a;(mNV5nKx zYR86%WtAPsOMIg&*o9uUfD!v&4(mpS6P`bFohPP<&^fZzfA|SvVzPQgbtwwM>IO>Z z75ejU$1_SB1tn!Y-9tajZ~F=Fa~{cnj%Y|$;%z6fJV1XC0080f)Pj|87j142q6`i>#)BCIi+x&jAH9|H#iMvS~?w;&E`y zoarJ)+5HWmZ{&OqlzbdQU=SE3GKmnQq zI{h6f$C@}Mbqf#JDsJyi&7M0O2ORXtEB`#cZ;#AcB zkao0`&|iH8XKvZ_RH|VaK@tAGKMq9x{sdd%p-o`!cJzmd&hb86N!KKxp($2G?#(#BJn5%hF0(^`= z2qRg5?82({w-HyjbffI>eqUXavp&|D8(I6zMOfM}0;h%*D_Dr@+%TaWpIEQX3*$vQ z8_)wkNMDi{rW`L+`yN^J*Gt(l7PExu3_hrntgbW0s}7m~1K=(mFymoU87#{|t*fJ?w8&>Uh zcS$Ny$HNRbT!UCFldTSp2*;%EoW+yhJD8<3FUt8@XSBeJM2dSEz+5}BWmBvdYK(OA zlm`nDDsjKED{$v*jl(&)H7-+*#jWI)W|_X)!em1qpjS_CBbAiyMt;tx*+0P%*m&v< zxV9rlslu8#cS!of#^1O$(ds8aviMFiT`6W+FzMHW{YS+SieJ^?TQb%NT&pasw^kbc znd`=%(bebvrNx3#7vq@vAX-G`4|>cY0svIXopH02{v;GZ{wJM#psz4!m8(IZu<)9D zqR~U7@cz-6H{724_*}-DWwE8Sk+dYBb*O-=c z+wdchFcm6$$^Z0_qGnv0P`)h1=D$_eg8!2-|7Y;o*c)4ax!Me0*EVcioh{wI#!qcb z1&xhOotXMrlo7P6{+C8m;E#4*=8(2y!r0d<6 zKi$d2X;O*zS(&Xiz_?|`ympxITf|&M%^WHp=694g6W@k+BL_T1JtSYX0OZ}o%?Pzu zJ{%P8A$uq?4F!NWGtq>_GLK3*c6dIcGH)??L`9Av&0k$A*14ED9!e9z_SZd3OH6ER zg%5^)3^gw;4DFw(RC;~r`bPJOR}H}?2n60=g4ESUTud$bkBLPyI#4#Ye{5x3@Yw<* z;P5Up>Yn(QdP#momCf=kOzZYzg9E330=67WOPbCMm2-T1%8{=or9L8+HGL{%83lri zODB;Y|LS`@mn#Wmez7t6-x`a2{}U9hE|xY7|BVcFCqoAZQzsEi=dYHB z(bqG3J5?teVSBqTj{aiqe<9}}CEc$HdsJSMp#I;4(EXRy_k|Y8X#5hwkqAaIGKARF zX?$|UO{>3-FU;IlFi80O^t+WMNw4So2nsg}^T1`-Ox&C%Gn_AZ-49Nir=2oYX6 z`uVke@L5PVh)YsvAgFMZfKi{DuSgWnlAaag{RN6t6oLm6{4)H~4xg#Xfcq-e@ALk& z@UP4;uCe(Yjg4jaJZ4pu*+*?4#+XCi%sTrqaT*jNY7|WQ!oR;S8nt)cI27W$Sz!94 z01zoTW`C*P3E?1@6thPe(QpIue$A54gp#C7pmfwRj}GxIw$!!qQetn`nvuwIvMBQ; zfF8K-D~O4aJKmLbNRN1?AZsWY&rp?iy`LP^3KT0UcGNy=Z@7qVM(#5u#Du#w>a&Bs z@f#zU{wk&5n!YF%D11S9*CyaI8%^oX=vq$Ei9cL1&kvv9|8vZD;Mhs1&slm`$A%ED zvz6SQ8aty~`IYp2Xd~G$z%Jf4zwVPKkCtqObrnc2gHKj^jg&-NH|xdNK_;+2d4ZXw zN9j)`jcp7y65&6P@}LsD_OLSi(#GW#hC*qF5KpmeXuQDNS%ZYpuW<;JI<>P6ln!p@ z>KPAM>8^cX|2!n@tV=P)f2Euv?!}UM`^RJ~nTT@W>KC2{{}xXS{}WH{|3najkiEUj z7l;fUWDPCtzQ$?(f)6RvzW~Tqan$bXibe%dv}**BqY!d4J?`1iX`-iy8nPo$s4^mQ z5+@=3xuZAl#KoDF*%>bJ4UrEB2EE8m7sQn!r7Z-ggig`?yy`p~3;&NFukc$`_>?}a z?LMo2LV^n>m!fv^HKKRrDn|2|zk?~S6i|xOHt%K(*TGWkq3{~|9+(G3M-L=;U-YRa zp{kIXZ8P!koE;BN2A;nBx!={yg4v=-xGOMC#~MA07zfR)yZtSF_2W^pDLcXg->*WD zY7Sz5%<_k+lbS^`y)=vX|KaN!gEMQob|(`%nP6huwr$%^?%0^vwr$(CZQD*Jc5?E( zb-q9E`OfoWSJ$rUs$ILfSFg3Mb*-!Ozgaz^%7ZkX@=3km0G;?+e?FQT_l5A9vKr<> z_CoemDo@6YIyl57l*gnJ^7+8xLW5oEGzjLv2P8vj*Q%O1^KOfrsC6eHvk{+$BMLGu z%goP8UY?J7Lj=@jcI$4{m2Sw?1E%_0C7M$lj}w{E#hM4%3QX|;tH6>RJf-TI_1A0w z@KcTEFx(@uitbo?UMMqUaSgt=n`Bu*;$4@cbg9JIS})3#2T;B7S

Z?HZkSa`=MM?n)?|XcM)@e1qmzJ$_4K^?-``~Oi&38`2}sjmP?kK z$yT)K(UU3fJID@~3R;)fU%k%9*4f>oq`y>#t90$(y*sZTzWcW$H=Xv|%^u^?2*n)Csx;35O0v7Nab-REgxDZNf5`cI69k$` zx(&pP6zVxlK5Apn5hAhui}b)(IwZD}D?&)_{_yTL7QgTxL|_X!o@A`)P#!%t9al+# zLD(Rr+?HHJEOl545~m1)cwawqY>cf~9hu-L`crI^5p~-9Mgp9{U5V&dJSwolnl_CM zwAMM1Tl$D@>v?LN2PLe0IZrQL1M zcA%i@Lc)URretFJhtw7IaZXYC6#8slg|*HfUF2Z5{3R_tw)YQ94=dprT`SFAvHB+7 z)-Hd1yE8LB1S+4H7iy$5XruPxq6pc_V)+VO{seA8^`o5{T5s<8bJ`>I3&m%R4cm1S z`hoNk%_=KU2;+#$Y!x7L%|;!Nxbu~TKw?zSP(?H0_b8Qqj4EPrb@~IE`~^#~C%D9k zvJ=ERh`xLgUwvusQbo6S=I5T+?lITYsVyeCCwT9R>DwQa&$e(PxF<}RpLD9Vm2vV# zI#M%ksVNFG1U?;QR{Kx2sf>@y$7sop6SOnBC4sv8S0-`gEt0eHJ{`QSW(_06Uwg*~ zIw}1dZ9c=K$a$N?;j`s3>)AqC$`ld?bOs^^stmYmsWA$XEVhUtGlx&OyziN1~2 z)s5fD(d@gq7htIGX!GCxKT=8aAOHW&DAP=$MpZ)SpeEZhk83}K) z0(Uv)+&pE?|4)D2PX4r6gOGHDY}$8FSg$3eDb*nEVmkFQ#lFpcH~IPeatiH3nPTkP z*xDN7l}r2GM9jwSsl=*!547nRPCS0pb;uE#myTqV+=se>bU=#e)f2}wCp%f-cIrh`FHA$2`monVy?qvJ~o2B6I7IE28bCY4=c#^){*essLG zXUH50W&SWmi{RIG9G^p;PohSPtC}djjXSoC)kyA8`o+L}SjE{i?%;Vh=h;QC{s`T7 zLmmHCr8F}#^O8_~lR)^clv$mMe`e*{MW#Sxd`rDckCnFBo9sC*vw2)dA9Q3lUi*Fy zgDsLt`xt|7G=O6+ms=`_FpD4}37uvelFLc^?snyNUNxbdSj2+Mpv<67NR{(mdtSDNJ3gSD@>gX_7S5 zCD)JP5Hnv!llc-9fwG=4@?=%qu~(4j>YXtgz%gZ#+A9i^H!_R!MxWlFsH(ClP3dU} za&`m(cM0xebj&S170&KLU%39I+XVWOJ_1XpF^ip}3|y()Fn5P@$pP5rvtiEK6w&+w z7uqIxZUj$#qN|<_LFhE@@SAdBy8)xTu>>`xC>VYU@d}E)^sb9k0}YKr=B8-5M?3}d z7&LqQWQ`a&=ihhANxe3^YT>yj&72x#X4NXRTc#+sk;K z=VUp#I(YIRO`g7#;5))p=y=MQ54JWeS(A^$qt>Y#unGRT$0BG=rI(tr>YqSxNm+-x z6n;-y8B>#FnhZX#mhVOT30baJ{47E^j-I6EOp;am;FvTlYRR2_?CjCWY+ypoUD-2S zqnFH6FS+q$H$^7>>(nd^WE+?Zn#@HU3#t|&=JnEDgIU+;CgS+krs+Y8vMo6U zHVkPoReZ-Di3z!xdBu#aW1f{8sC)etjN90`2|Y@{2=Os`(XLL9+ z1$_PE$GgTQrVx`^sx=Y(_y-SvquMF5<`9C=vM52+e+-r=g?D z+E|97MyoaK5M^n1(mnWeBpgtMs8fXOu4Q$89C5q4@YY0H{N47VANA1}M2e zspor6LdndC=kEvxs3YrPGbc;`q}|zeg`f;t3-8na)dGdZ9&d(n{|%mNaHaKJOA~@8 zgP?nkzV-=ULb)L3r`p)vj4<702a5h~Y%byo4)lh?rtu1YXYOY+qyTwzs!59I zL}XLe=q$e<+Wm7tvB$n88#a9LzBkgHhfT<&i#%e*y|}@I z!N~_)vodngB7%CI2pJT*{GX|cI5y>ZBN)}mezK~fFv@$*L`84rb0)V=PvQ2KN}3lTpT@$>a=CP?kcC0S_^PZ#Vd9#CF4 zP&`6{Y!hd^qmL!zr#F~FB0yag-V;qrmW9Jnq~-l>Sg$b%%TpO}{Q+*Pd-@n2suVh_ zSYP->P@# z&gQ^f{?}m(u5B9xqo63pUvDsJDQJi5B~ak+J{tX8$oL!_{Dh zL@=XFzWb+83H3wPbTic+osVp&~UoW3SqK0#P6+BKbOzK65tz)-@AW#g}Ew+pE3@ zVbdJkJ}EM@-Ghxp_4a)|asEk* z5)mMI&EK~BI^aaTMRl)oPJRH^Ld{;1FC&#pS`gh;l3Y;DF*`pR%OSz8U@B@zJxPNX zwyP_&8GsQ7^eYyUO3FEE|9~I~X8;{WTN=DJW0$2OH=3-!KZG=X6TH?>URr(A0l@+d zj^B9G-ACel;yYGZc}G`w9sR$Mo{tzE7&%XKuW$|u7DM<6_z}L>I{o`(=!*1 z{5?1p3F^aBONr6Ws!6@G?XRxJxXt_6b}2%Bp=0Iv5ngnpU^P+?(?O0hKwAK z*|wAisG&8&Td1XY+6qI~-5&+4DE2p|Dj8@do;!40o)F)QuoeUY;*I&QZ0*4?u)$s`VTkNl1WG`}g@J_i zjjmv4L%g&>@U9_|l>8^CN}`@4<D2aMN&?XXD-HNnsVM`irjv$ z^YVNUx3r1{-o6waQfDp=OG^P+vd;qEvd{UUYc;gF0UwaeacXkw32He^qyoYHjZeFS zo(#C9#&NEdFRcFrj7Q{CJgbmDejNS!H%aF6?;|KJQn_*Ps3pkq9yE~G{0wIS*mo0XIEYH zzIiJ>rbmD;sGXt#jlx7AXSGGcjty)5z5lTGp|M#5DCl0q0|~pNQ%1dP!-1>_7^BA~ zwu+uumJmTCcd)r|Hc)uWm7S!+Dw4;E|5+bwPb4i17Ued>NklnnsG+A{T-&}0=sLM- zY;sA9v@YH>b9#c$Vg{j@+>UULBX=jtu~N^%Y#BB5)pB|$?0Mf7msMD<7eACoP1(XY zPO^h5Brvhn$%(0JSo3KFwEPV&dz8(P41o=mo7G~A*P6wLJ@-#|_A z7>k~4&lbqyP1!la!qmhFBfIfT?nIHQ0j2WlohXk^sZ`?8-vwEwV0~uu{RDE^0yfl$ znua{^`VTZ)-h#ch_6^e2{VPaE@o&55|3dx$z_b6gbqduXJ(Lz(zq&ZbJ6qA4Ac4RT zhJO4KBLN!t;h(eW(?cZJw^swf8lP@tWMZ8GD)zg)siA3!2EJYI(j>WI$=pK!mo!Ry z?q&YkTIbTTr<>=}+N8C_EAR0XQL2&O{nNAXb?33iwo8{M``rUHJgnk z8KgZzZLFf|(O6oeugsm<;5m~4N$2Jm5#dph*@TgXC2_k&d%TG0LPY=Fw)=gf(hy9QmY*D6jCAiq44 zo-k2C+?3*+Wu7xm1w*LEAl`Vsq(sYPUMw|MiXrW)92>rVOAse5Pmx^OSi{y%EwPAE zx|csvE{U3c{vA>@;>xcjdCW15pE31F3aoIBsz@OQRvi%_MMfgar2j3Ob`9e@gLQk# zlzznEHgr|Ols%f*a+B-0klD`czi@RWGPPpR1tE@GB|nwe`td1OwG#OjGlTH zfT#^r?%3Ocp^U0F8Kekck6-Vg2gWs|sD_DTJ%2TR<5H3a$}B4ZYpP=p)oAoHxr8I! z1SYJ~v-iP&mNm{ra7!KP^KVpkER>-HFvq*>eG4J#kz1|eu;=~u2|>}TE_5nv2=d!0 z3P~?@blSo^uumuEt{lBsGcx{_IXPO8s01+7DP^yt&>k;<5(NRrF|To2h7hTWBFQ_A z+;?Q$o5L|LlIB>PH(4j)j3`JIb1xA_C@HRFnPnlg{zGO|-RO7Xn}!*2U=Z2V?{5Al z9+iL+n^_T~6Uu{law`R&fFadSVi}da8G>|>D<{(#vi{OU;}1ZnfXy8=etC7)Ae<2S zAlI`&=HkNiHhT0|tQztSLNsRR6v8bmf&$6CI|7b8V4kyJ{=pG#h{1sVeC28&Ho%Fh zwo_FIS}ST-2OF6jNQ$(pjrq)P)@sie#tigN1zSclxJLb-O9V|trp^G8<1rpsj8@+$ z2y27iiM>H8kfd%AMlK|9C>Lkvfs9iSk>k2}tCFlqF~Z_>-uWVQDd$5{3sM%2$du9; z*ukNSo}~@w@DPF)_vS^VaZ)7Mk&8ijX2hNhKom$#PM%bzSA-s$ z0O!broj`!Nuk)Qcp3(>dL|5om#XMx2RUSDMDY9#1|+~fxwP}1I4iYy4j$CGx3jD&eKhf%z`Jn z7mD!y6`nVq%&Q#5yqG`|+e~1$Zkgu!O(~~pWSDTw2^va3u!DOMVRQ8ycq)sk&H%vb z;$a`3gp74~I@swI!ILOkzVK3G&SdTcVe~RzN<+z`u(BY=yuwez{#T3a_83)8>2!X?`^02zVjqx-fN+tW`zCqH^XG>#Ies$qxa!n4*FF0m zxgJlPPYl*q4ylX;DVu3G*I6T&JyWvs`A(*u0+62=+ylt2!u)6LJ=Qe1rA$OWcNCmH zLu7PwMDY#rYQA1!!ONNcz~I^uMvi6N&Lo4dD&HF?1Su5}COTZ-jwR)-zLq=6@bN}X zSP(-MY`TOJ@1O`bLPphMMSWm+YL{Ger>cA$KT~)DuTl+H)!2Lf`c+lZ0ipxd>KfKn zIv;;eEmz(_(nwW24a+>v{K}$)A?=tp+?>zAmfL{}@0r|1>iFQfJ5C*6dKdijK=j16 zQpl4gl93ttF5@d<9e2LoZ~cqkH)aFMgt(el_)#OG4R4Hnqm(@D*Uj>2ZuUCy)o-yy z_J|&S-@o5#2IMcL(}qWF3EL<4n(`cygenA)G%Ssi7k4w)LafelpV5FvS9uJES+(Ml z?rzZ={vYrB#mB-Hd#ID{KS5dKl-|Wh_~v+Lvq3|<@w^MD-RA{q!$gkUUNIvAaex5y z)jIGW{#U=#UWyku7FIAB=TES8>L%Y9*h2N`#Gghie+a?>$CRNth?ORq)!Tde24f5K zKh>cz5oLC;ry*tHIEQEL>8L=zsjG7+(~LUN5K1pT`_Z-4Z}k^m%&H%g3*^e(FDCC{ zBh~eqx%bY?qqu_2qa+9A+oS&yFw^3nLRsN#?FcZvt?*dZhRC_a%Jd{qou(p5AG_Q6 ziOJMu8D~kJ7xEkG(69$Dl3t1J592=Olom%;13uZvYDda08YwzqFlND-;YodmA!SL) z!AOSI=(uCnG#Yo&BgrH(muUemmhQW7?}IHfxI~T`44wuLGFOMdKreQO!a=Z-LkH{T z@h;`A_l2Pp>Xg#`Vo@-?WJn-0((RR4uKM6P2*^-qprHgQhMzSd32@ho>%fFMbp9Y$ zx-#!r8gEu;VZN(fDbP7he+Nu7^o3<+pT!<<>m;m z=FC$N)wx)asxb_KLs}Z^;x*hQM}wQGr((&=%+=#jW^j|Gjn$(qqXwt-o-|>kL!?=T zh0*?m<^>S*F}kPiq@)Cp+^fnKi2)%<-Tw4K3oHwmI-}h}Kc^+%1P!D8aWp!hB@-ZT zybHrRdeYlYulEj>Bk zEIi|PU0eGg&~kWQ{q)gw%~bFT0`Q%k5S|tt!JIZXVXX=>er!7R^w>zeQ%M-(C|eOQG>5i|}i3}X#?aqAg~b1t{-fqwKd(&CyA zmyy)et*E}+q_lEqgbClewiJ=u@bFX}LKe)5o26K9fS;R`!er~a?lUCKf60`4Zq7{2q$L?k?IrAdcDu+ z4A0QJBUiGx&$TBASI2ASM_Wj{?fjv=CORO3GZz;1X*AYY`anM zI`M6C%8OUFSc$tKjiFJ|V74Yj-lK&Epi7F^Gp*rLeDTokfW#o6sl33W^~4V|edbS1 zhx%1PTdnI!C96iYqSA=qu6;p&Dd%)Skjjw0fyl>3k@O?I@x5|>2_7G#_Yc2*1>=^# z|H43bJDx$SS2!vkaMG!;VRGMbY{eJhT%FR{(a+RXDbd4OT?DRoE(`NhiVI6MsUCsT z1gc^~Nv>i;cIm2~_SYOfFpkUvV)(iINXEep;i4>&8@N#|h+_;DgzLqh3I#lzhn>cN zjm;m6U{+JXR2Mi)=~WxM&t9~WShlyA$Pnu+VIW2#;0)4J*C!{1W|y1TP{Q;!tldR< zI7aoH&cMm*apW}~BabBT;`fQ1-9q|!?6nTzmhiIo6fGQlcP{pu)kJh- zUK&Ei9lArSO6ep_SN$Lt_01|Y#@Ksznl@f<+%ku1F|k#Gcwa`(^M<2%M3FAZVb99?Ez4d9O)rqM< zCbYsdZlSo{X#nKqiRA$}XG}1Tw@)D|jGKo1ITqmvE4;ovYH{NAk{h8*Ysh@=nZFiF zmDF`@4do#UDKKM*@wDbwoO@tPx4aExhPF_dvlR&dB5>)W=wG6Pil zq{eBzw%Ov!?D+%8&(uK`m7JV7pqNp-krMd>ECQypq&?p#_3wy){eW{(2q}ij{6bfmyE+-ZO z)G4OtI;ga9;EVyKF6v3kO1RdQV+!*>tV-ditH-=;`n|2T zu(vYR*BJSBsjzFl1Oy#DpL=|pfEY4NM;y5Yly__T*Eg^3Mb_()pHwn)mAsh!7Yz-Z zY`hBLDXS4F^{>x=oOphq|LMo;G!C(b2hS9A6lJqb+e$2af}7C>zW2p{m18@Bdd>iL zoEE$nFUnaz_6p${cMO|;(c1f9nm5G5R;p)m4dcC1?1YD=2Mi&20=4{nu>AV#R^d%A zsmm_RlT#`;g~an9mo#O1dYV)2{mgUWEqb*a@^Ok;ckj;uqy{%*YB^({d{^V)P9VvP zC^qbK&lq~}TWm^RF8d4zbo~bJuw zFV!!}b^4BlJ0>5S3Q>;u*BLC&G6Fa5V|~w&bRZ*-YU>df6%qAvK?%Qf+#=M-+JqLw&w*l4{v7XTstY4j z26z69U#SVzSbY9HBXyD;%P$#vVU7G*Yb-*fy)Qpx?;ed;-P24>-L6U+OAC9Jj63kg zlY`G2+5tg1szc#*9ga3%f9H9~!(^QjECetX-PlacTR+^g8L<#VRovPGvsT)ln3lr= zm5WO@!NDuw+d4MY;K4WJg3B|Sp|WdumpFJO>I2tz$72s4^uXljWseYSAd+vGfjutO z-x~Qlct+BnlI+Iun)fOklxPH?30i&j9R$6g5^f&(x7bIom|FLKq9CUE);w2G>}vye zxWvEaXhx8|~2j)({Rq>0J9}lzdE`yhQ(l$z! z;x%d%_u?^4vlES_>JaIjJBN|N8z5}@l1#PG_@{mh`oWXQOI41_kPG}R_pV+jd^PU) zEor^SHo`VMul*80-K$0mSk|FiI+tHdWt-hzt~S>6!2-!R&rdL_^gGGUzkPe zEZkUKU=EY(5Ex)zeTA4-{Bkbn!Gm?nuaI4jLE%X;zMZ7bwn4FXz(?az;9(Uv;38U6 zi)}rA3xAcD2&6BY<~Pj9Q1~4Dyjs&!$)hyHiiTI@%qXd~+>> zW}$_puSSJ^uWv$jtWakn}}@eX6_LGz|7M#$!3yjY ztS{>HmQ%-8u0@|ig{kzD&CNK~-dIK5e{;@uWOs8$r>J7^c2P~Pwx%QVX0e8~oXK0J zM4HCNK?%t6?v~#;eP#t@tM$@SXRt;(b&kU7uDzlzUuu;+LQ5g%=FqpJPGrX8HJ8CS zITK|(fjhs3@CR}H4@)EjL@J zV_HPexOQ!@k&kvsQG)n;7lZaUh>{87l4NS_=Y-O9Ul3CaKG8iy+xD=QXZSr57a-hb z7jz3Ts-NVsMI783OPEdlE|e&a2;l^h@e>oYMh5@=Lte-9A+20|?!9>Djl~{XkAo>0p9`n&nfWGdGAfT-mSYW z1cvG>GT9dRJdcm7M_AG9JX5AqTCdJ6MRqR3p?+FvMxp(oB-6MZ`lRzSAj%N(1#8@_ zDnIIo9Rtv12(Eo}k_#FILhaZQ`yRD^Vn5tm+IK@hZO>s=t5`@p1#k?Umz2y*R64CF zGM-v&*k}zZ%Xm<_?1=g~<*&3KAy;_^QfccIp~CS7NW24Tn|mSDxb%pvvi}S}(~`2# z3I|kD@||l@lAW06K2%*gHd4x9YKeXWpwU%!ozYcJ+KJeX!s6b94j!Qyy7>S!wb?{qaMa`rpbU1phn0EpF}L zsBdZc|Im#iRiQmJjZwb5#n;`_O{$Zu$I zMXqbfu0yVmt!!Y`Fzl}QV7HUSOPib#da4i@vM$0u2FEYytsvrbR#ui9lrMkZ(AVVJ zMVl^Wi_fSRsEXLA_#rdaG%r(@UCw#o7*yBN)%22b)VSNyng6Lxk|2;XK3Qb=C_<`F zN##8MLHz-s%&O6JE~@P1=iHpj8go@4sC7*AWe99tuf$f7?2~wC&RA^UjB*2`K!%$y zSDzMd7}!vvN|#wDuP%%nuGk8&>N)7eRxtqdMXHD1W%hP7tYW{W>^DJp`3WS>3}i+$ z_li?4AlEj`r=!SPiIc+NNUZ9NCrMv&G0BdQHBO&S7d48aB)LfGi@D%5CC1%)1hVcJ zB~=yNC}LBn(K?cHkPmAX$5^M7JSnNkcc!X!0kD&^F$cJmRP(SJ`9b7}b)o$rj=BZ- zC;BX3IG94%Qz&(V$)7O~v|!=jd-yU1(6wd1u;*$z4DDe6+BFLhz>+8?59?d2Ngxck zm92yR!jk@MP@>>9FtAY2L+Z|MaSp{MnL-;fm}W3~fg!9TRr3;S@ysLf@#<)keHDRO zsJI1tP`g3PNL`2(8hK3!4;r|E-ZQbU0e-9u{(@du`4wjGj|A!QB&9w~?OI1r}M? zw)6tvsknfPfmNijZ;3VZX&HM6=|&W zy6GIe3a?_(pRxdUc==do9?C&v7+6cgIoL4)Ka^bOG9`l;S|QmVzjv%)3^PDi@=-cp z=!R0bU<@_;#*D}e1m@0!%k=VPtyRAkWYW(VFl|eu0LteWH7eDB%P|uF7BQ-|D4`n; z)UpuY1)*s32UwW756>!OoAq#5GAtfrjo*^7YUv^(eiySE?!TQzKxzqXE@jM_bq3Zq zg#1orE*Zd5ZWEpDXW9$=NzuadNSO*NW)ZJ@IDuU`w}j_FRE4-QS*rD4mPVQPH(jGg z+-Ye?3%G%=DT5U1b+TnNHHv(nz-S?3!M4hXtEB@J4WK%%p zkv=Bb`1DHmgUdYo>3kwB(T>Ba#DKv%cLp2h4r8v}p=Np}wL!&PB5J-w4V4REM{kMD z${oSuAw9?*yo3?tNp~X5WF@B^P<6L0HtIW0H7^`R8~9zAXgREH`6H{ntGu$aQ;oNq zig;pB^@KMHNoJcEb0f1fz+!M6sy?hQjof-QoxJgBM`!k^T~cykcmi^s_@1B9 z)t1)Y-ZsV9iA&FDrVoF=L7U#4&inXk{3+Xm9A|R<=ErgxPW~Fq zqu-~x0dIBlR+5_}`IK^*5l3f5$&K@l?J{)_d_*459pvsF*e*#+2guls(cid4!N%DG zl3(2`az#5!^@HNRe3O4(_5nc+){q?ENQG2|uKW0U0$aJ5SQ6hg>G4OyN6os76y%u8qNNHi;}XnRNwpsfn^!6Qt(-4tE`uxaDZ`hQp#aFX373|F?vjEiSEkV>K)cTBG+UL#wDj0_ zM9$H&-86zP=9=5_Q7d3onkqKNr4PAlF<>U^^yYAAEso|Ak~p$3NNZ$~4&kE9Nj^As zQPoo!m*uZ;z1~;#g(?zFECJ$O2@EBy<;F)fnQxOKvH`MojG5T?7thbe%F@JyN^k1K zn3H*%Ymoim)ePf)xhl2%$T)vq3P=4ty%NK)@}po&7Q^~o3l))Zm4<75Y!fFihsXJc z9?vecovF^nYfJVg#W~R3T1*PK{+^YFgb*7}Up2U#)oNyzkfJ#$)PkFxrq_{Ai?0zk zWnjq_ixF~Hs7YS9Y6H&8&k0#2cAj~!Vv4{wCM zi2f1FjQf+F@=BOB)pD|T41a4AEz+8hnH<#_PT#H|Vwm7iQ0-Tw()WMN za0eI-{B2G{sZ7+L+^k@BA)G;mOFWE$O+2nS|DzPSGZ)ede(9%+8kqu4W^wTn!yZPN z7u!Qu0u}K5(0euRZ$7=kn9DZ+llruq5A_l) zOK~wof7_^8Yeh@Qd*=P!gM)lh`Z@7^M?k8Z?t$$vMAuBG>4p56Dt!R$p{)y>QG}it zGG;Ei```7ewXrbGo6Z=!AJNQ!GP8l13m7|FIQTFZTpIg#kpZkl1wj)s1eySXjAAWy zfl;;@{QQ;Qnb$@LY8_Z&7 z6+d98F?z2Zo)sS)z$YoL(zzF>Ey8u#S_%n7)XUX1Pu(>e8gEUU1S;J=EH(#`cWi1+ zoL$5TN+?#NM8=4E7HOk)bf5MXvEo%he5QcB%_5YQ$cu_j)Pd^@5hi}d%nG}x9xXtD-JMQxr;KkC=r_dS-t`lf zF&CS?Lk~>U^!)Y0LZqNVJq+*_#F7W~!UkvZfQhzvW`q;^X&iv~ zEDDGIQ&(S;#Hb(Ej4j+#D#sDS_uHehlY0kZsQpktc?;O z22W1b%wNcdfNza<1M2{*mAkM<{}@(w`VuQ<^lG|iYSuWBD#lYK9+jsdA+&#;Y@=zXLVr840Nq_t5))#7}2s9pK* zg42zd{EY|#sIVMDhg9>t6_Y#O>JoG<{GO&OzTa;iA9&&^6=5MT21f6$7o@nS=w;R) znkgu*7Y{UNPu7B9&B&~q+N@@+%&cO0N`TZ-qQ|@f@e0g2BI+9xO$}NzMOzEbSSJ@v z1uNp(S z-dioXc$5YyA6-My@gW~1GH($Q?;GCHfk{ej-{Q^{iTFs1^Sa67RNd5y{cjX1tG+$& zbGrUte{U1{^Z_qpzW$-V!pJz$dQZrL5i(1MKU`%^= z^)i;xua4w)evDBrFVm)Id5SbXMx2u7M5Df<2L4B`wy4-Y+Wec#b^QJO|J9xF{x#M8 zuLUer`%ZL^m3gy?U&dI+`kgNZ+?bl3H%8)&k84*-=aMfADh&@$xr&IS|4{3$v&K3q zZTn&f{N(#L6<-BZYNs4 zB*Kl*@_IhGXI^_8zfXT^XNmjJ@5E~H*wFf<&er?p7suz85)$-Hqz@C zGMFg1NKs;otNViu)r-u{SOLcqwqc7$poPvm(-^ag1m71}HL#cj5t4Hw(W?*fi4GSH z9962NZ>p^ECPqVc$N}phy>N8rQsWWm%%rc5B4XLATFEtffX&TM2%|8S2Lh_q; zCytXua84HBnSybW-}(j z3Zwv4CaK)jC!{oUvdsFRXK&Sx@t)yGm(h65$!WZ!-jL52no}NX6=E<=H!aZ74h_&> zZ+~c@k!@}Cs84l{u+)%kg4fq~pOeTK3S4)gX~FKJw4t9ba!Ai{_gkKQYQvafZIyKq zX|r4xgC(l%JgmW!tvR&yNt$6uME({M`uNIi7HFiPEQo_UMRkl~12&4c& z^se;dbZWKu7>dLMg`IZq%@b@ME?|@{&xEIZEU(omKNUY? z`JszxNghuO-VA;MrZKEC0|Gi0tz3c#M?aO?WGLy64LkG4T%|PBIt_?bl{C=L@9e;A zia!35TZI7<`R8hr06xF62*rNH5T3N0v^acg+;ENvrLYo|B4!c^eILcn#+lxDZR!%l zjL6!6h9zo)<5GrSPth7+R(rLAW?HF4uu$glo?w1U-y}CR@%v+wSAlsgIXn>e%bc{FE;j@R0AoNIWf#*@BSngZ)HmNqkB z)cs3yN%_PT4f*K+Y1wFl)be=1iq+bb1G-}b|72|gJ|lMt`tf~0Jk}zMbS0+M-Mq}R z>Bv}-W6J%}j#dIz`Z0}zD(DGKn`R;E8A`)$a6qDfr(c@iHKZcCVY_nJEDpcUddGH* z*ct2$&)RelhmV}@jGXY>3Y~vp;b*l9M+hO}&x`e~q*heO8GVkvvJTwyxFetJC8VnhjR`5*+qHEDUNp16g`~$TbdliLLd}AFf}U+Oda1JXwwseRFbj?DN96;VSX~z?JxJSuA^BF}262%Z0)nv<6teKK`F zfm9^HsblS~?Xrb1_~^=5=PD!QH$Y1hD_&qe1HTQnese8N#&C(|Q)CvtAu6{{0Q%ut8ESVdn&& z4y%nsCs!$(#9d{iVjXDR##3UyoMNeY@_W^%qyuZ^K3Oa4(^!tDXOUS?b2P)yRtJ8j zSX}@qGBj+gKf;|6Kb&rq`!}S*cSu-3&S>=pM$eEB{K>PP~I}N|uGE|`3U#{Q6v^kO4nIsaq zfPld}c|4tVPI4!=!ETCNW+LjcbmEoxm0RZ%ieV0`(nVlWKClZW5^>f&h79-~CF(%+ zv|KL(^xQ7$#a}&BSGr9zf{xJ(cCfq>UR*>^-Ou_pmknCt6Y--~!duL{k2D{yLMl__ z!KeMRRg&EsD2s|cmy?xgK&XcGIKeos`&UEVhBTw;mqy|8DlP1M7PYS2z{YmTJ;n!h znPe(Qu?c7+xZz!Tm1AnE8|;&tf7fW$2dArX7ck1Jd(S1+91YB8bjISRZ`UL*?vb{b zMp*!Xq7VaLc0Ogqj5qmop8NREQ{9_iC$;tviZlubGLy1jLlIFBxAymMr@SDLAcx+) z5YRkl$bW**X)W0JzWNcLx9>fTqJj00ipY6Ua?mUlsgQrVVgpmaheE;RgA5U_+WsPh z9+X|PU4zFyNxZ2?Q+V`Mo{xH~(m}OMRZa<&$nCl7o4x`^^|V4?aPz8#KwFm=8T6_} z8=P_4$_rD2a%7}}HT6VQ>ZGKW=QF7zI-2=6oBNZR$HVn|gq`>l$HZ`48lkM7%R$>MS& zghR`WZ9Xrd_6FaDedH6_aKVJhYev*2)UQ>!CRH3PQ_d9nXlO;c z9PeqiKD@aGz^|mvD-tV<{BjfA;)B+76!*+`$CZOJ=#)}>{?!9fAg(Xngbh||n=q*C zU0mGP`NxHn$uY#@)gN<0xr)%Ue80U{-`^FX1~Q@^>WbLraiB|c#4v$5HX)0z!oA#jOXPyWg! z8EC}SBmG7j3T&zCenPLYA{kN(3l62pu}91KOWZl? zg~>T4gQ%1y3AYa^J|>ba$7F5KlVx}_&*~me*q-SYLBCXZFU=U8mHQD4K!?;B61NoX z?VS41SS&jHyhmB~+bC=w0a06V``ZXCkC~}oM9pM{$hU~-s_elYPmT1L!%B`?*<+?( zFQ@TP%y+QL`_&Y0A3679pe5~iL=z)$b)k!oSbJRyw+K};SGAvvE=|<~*aiwJc?uE@2?7a1i9|3=^N%*9smt3ZIhjY>gIsr{Q2rX(NovZ7I1n^V{ z#~(1ze-%`C>fM`^hCV**9BA-04lNuu&3=reevNOMwmX(A{yh`^c8%0mjAKMj{Th05 zXrM(zILwyL-Pcdw^(=gj(ZLVMA95zlzmLa^skb8tQq%8SV&4vp?S>L3+P4^tp`$xA zr38jBw0ItR`VbO5vB1`<3d})}aorkIU1z3*ifYN&Lpp)}|}QJS60th_v-EEkAM zyOREuj!Ou|pVeZEWg;$Hf!x;xAmFu7gB^UR$=L0BuZ~thLC@#moJ(@@wejR|`t_K@ zuQ{XmpAWz%o&~2dk!SIGR$EmpZY)@+r^gvX26%)y>1u2bt~JUPTQzQu&_tB)|{19)&n$m5Fhw0A-8S1^%XpAD%`#a z_ModVxsM|x!m3N1vRt_XEL`O-+J3cMsM1l*dbjT&S0c@}Xxl3I&AeMNT97G3c6%3C zbrZS?2EAKcEq@@Pw?r%eh0YM6z0>&Qe#n+e9hEHK?fzig3v5S#O2IxVLu;a>~c~ZfHVbgLox%_tg)bsC8Rl35P=Jhl+Y=w6zb$ z;*uO%i^U z^mp_QggBILLF$AyjPD41Z0SFdbDj&z&xjq~X|OoM7bCuBfma1CEd!4RKGqPR)K)e}+7^JfFUI_fy63cMyq#&)Z*#w18{S zhC@f9U5k#2S2`d$-)cEoH-eAz{2Qh>YF1Xa)E$rWd52N-@{#lrw3lRqr)z?BGThgO z-Mn>X=RPHQ)#9h{3ciF)<>s{uf_&XdKb&kC!a373l2OCu&y8&n#P%$7YwAVJ_lD-G zX7tgMEV8}dY^mz`R6_0tQ5Eu@CdSOyaI63Vb*mR+rCzxgsjCXLSHOmzt0tA zGoA0Cp&l>rtO@^uQayrkoe#d2@}|?SlQl9W{fmcxY(0*y zHTZ6>FL;$8FEzbb;M(o%mBe-X?o<0+1dH?ZVjcf8)Kyqb07*a zLfP1blbt)=W)TN}4M#dUnt8Gdr4p$QRA<0W)JhWLK3-g82Q~2Drmx4J z;6m4re%igus136VL}MDI-V;WmSfs4guF_(7ifNl#M~Yx5HB!UF)>*-KDQl0U?u4UXV2I*qMhEfsxb%87fi+W;mW5{h?o8!52}VUs*Fpo#aSuXk(Ug z>r>xC#&2<9Uwmao@iJQ|{Vr__?eRT2NB$OcoXQ-jZ{t|?Uy{7q$nU-i|&-R6fHPWJDgHZ69iVbK#Ab@2@y zPD*Gj=hib?PWr8NGf;g$o5I!*n>94Z!IfqRm zLvM>Gx$Y*rEL3Z-+lS42=cnEfXR)h1z`h8a+I%E_ss%qXsrgIV%qv9d|KT>fV5=3e zw>P#ju>2naGc{=6!)9TeHq$S9Pk|>$UCEl}H}lE@;0(jbNT9TXUXyss>al>S4DuGi zVCy;Qt=a2`iu2;TvrIkh2NTvNV}0)qun~9y1yEQMdOf#V#3(e(C?+--8bCsJu={Q1z5qNJIk&yW>ZnVm;A=fL~29lvXQ*4j(SLau?P zi8LC7&**O!6B6=vfY%M;!p2L2tQ+w3Y!am{b?14E`h4kN$1L0XqT5=y=DW8GI_yi% zlIWsjmf0{l#|ei>)>&IM4>jXH)?>!fK?pfWIQn9gT9N(z&w3SvjlD|u*6T@oNQRF6 zU5Uo~SA}ml5f8mvxzX>BGL}c2#AT^6Lo-TM5XluWoqBRin$tiyRQK0wJ!Ro+7S!-K z=S95p-(#IDKOZsRd{l65N(Xae`wOa4Dg9?g|Jx97N-7OfHG(rN#k=yNGW0K$Tia5J zMMX1+!ulc1%8e*FNRV8jL|OSL-_9Nv6O=CH>Ty(W@sm`j=NFa1F3tT$?wM1}GZekB z6F_VLMCSd7(b9T%IqUMo$w9sM5wOA7l8xW<(1w0T=S}MB+9X5UT|+nemtm_;!|bxX z_bnOKN+F30ehJ$459k@=69yTz^_)-hNE4XMv$~_%vlH_y^`P1pLxYF6#_IZyteO`9wpuS> z#%Vyg5mMDt?}j!0}MoBX|9PS0#B zSVo6xLVjujMN57}IVc#A{VB*_yx;#mgM4~yT6wO;Qtm8MV6DX?u(JS~JFA~PvEl%9 z2XI}c>OzPoPn_IoyXa2v}BA(M+sWq=_~L0rZ_yR17I5c^m4;?2&KdCc)3lCs!M|0OzH@(PbG8T6w%N zKzR>%SLxL_C6~r3=xm9VG8<9yLHV6rJOjFHPaNdQHHflp><44l>&;)&7s)4lX%-er znWCv8eJJe1KAi_t1p%c4`bgxD2(1v)jm(gvQLp2K-=04oaIJu{F7SIu8&)gyw7x>+ zbzYF7KXg;T71w!-=C0DjcnF^JP$^o_N>*BAjtH!^HD6t1o?(O7IrmcodeQVDD<*+j zN)JdgB6v^iiJ1q`bZ(^WvN{v@sDqG$M9L`-UV!3q&sWZUnQ{&tAkpX(nZ_L#rMs}>p7l0fU5I5IzArncQi6TWjP#1B=QZ|Uqm-3{)YPn=XFqHW-~Fb z^!0CvIdelQbgcac9;By79%T`uvNhg9tS><pLzXePP=JZzcO@?5GRAdF4)sY*)YGP* zyioMa3=HRQz(v}+cqXc0%2*Q%CQi%e2~$a9r+X*u3J8w^Shg#%4I&?!$})y@ zzg8tQ6_-`|TBa_2v$D;Q(pFutj7@yos0W$&__9$|Yn3DFe*)k{g^|JIV4bqI@2%-4kpb_p? zQ4}qQcA>R6ihbxnVa{c;f7Y)VPV&mRY-*^qm~u3HB>8lf3P&&#GhQk8uIYYgwrugY zei>mp`YdC*R^Cxuv@d0V?$~d*=m-X?1Fqd9@*IM^wQ_^-nQEuc0!OqMr#TeT=8W`JbjjXc-Dh3NhnTj8e82yP;V_B<7LIejij+B{W1ViaJ_)+q?$BaLJpxt_4@&(?rWC3NC-_Z9Sg4JJWc( zX!Y34j67vCMHKB=JcJ1|#UI^D^mn(i=A5rf-iV7y4bR5HhC=I`rFPZv4F>q+h?l34 z4(?KYwZYHwkPG%kK7$A&M#=lpIn3Qo<>s6UFy|J$Zca-s(oM7??dkuKh?f5b2`m57 zJhs4BTcVVmwsswlX?#70uQb*k1Fi3q4+9`V+ikSk{L3K=-5HgN0JekQ=J~549Nd*+H%5+fi6aJuR=K zyD3xW{X$PL7&iR)=wumlTq2gY{LdrngAaPC;Qw_xLfVE0c0Z>y918TQpL!q@?`8{L!el18Qxiki3WZONF=eK$N3)p>36EW)I@Y z7QxbWW_9_7a*`VS&5~4-9!~&g8M+*U9{I2Bz`@TJ@E(YL$l+%<=?FyR#&e&v?Y@@G zqFF`J*v;l$&(A=s`na2>4ExKnxr`|OD+Xd-b4?6xl4mQ94xuk!-$l8*%+1zQU{)!= zTooUhjC0SNBh!&Ne}Q=1%`_r=Vu1c8RuE!|(g4BQGcd5AbpLbvKv_Z~Y`l!mr!sCc zDBupoc{W@U(6KWqW@xV_`;J0~+WDx|t^WeMri#=q0U5ZN7@@FAv<1!hP6!IYX z>UjbhaEv2Fk<6C0M^@J`lH#LgKJ(`?6z5=uH+ImggSQaZtvh52WTK+EBN~-op#EQKYW`$yBmq z4wgLTJPn3;mtbs0m0RO&+EG>?rb*ZECE0#eeSOFL!2YQ$w}cae>sun`<=}m!=go!v zO2jn<0tNh4E-4)ZA(ixh5nIUuXF-qYl>0I_1)K%EAw`D7~la$=gc@6g{iWF=>i_76?Mc zh#l9h7))<|EY=sK!E|54;c!b;Zp}HLd5*-w^6^whxB98v`*P>cj!Nfu1R%@bcp{cb zUZ24(fUXn3d&oc{6H%u(@4&_O?#HO(qd^YH=V`WJ=u*u6Zie8mE^r_Oz zDw`DaXeq4G#m@EK5+p40Xe!Lr!-jTQLCV3?R1|3#`%45h8#WSA!XoLDMS7=t!SluZ4H56;G z6C9D(B6>k^ur_DGfJ@Y-=3$5HkrI zO+3P>R@$6QZ#ATUI3$)xRBEL#5IKs}yhf&fK;ANA#Qj~G zdE|k|`puh$%dyE4R0$7dZd)M*#e7s%*PKPyrS;d%&S(d{_Ktq^!Hpi&bxZx`?9pEw z%sPjo&adHm95F7Z1{RdY#*a!&LcBZVRe{qhn8d{pOUJ{fOu`_kFg7ZVeRYZ(!ezNktT5{Ab z4BZI$vS0$vm3t9q`ECjDK;pmS{8ZTKs`Js~PYv2|=VkDv{Dtt)cLU@9%K6_KqtqfM zaE*e$f$Xm=;IAURNUXw8g%=?jzG2}10ZA5qXzAaJ@eh)yv5B=ETyVwC-a*CD;GgRJ z4J1~zMUey?4iVlS0zW|F-~0nenLiN3S0)l!T2}D%;<}Z9DzeVgcB+MSj;f$KY;uP%UR#f`0u*@6U@tk@jO3N?Fjq< z{cUUhjrr$rmo>qE?52zKe+>6iP5P_tcUfxsLSy{9*)shB(w`UUveNH`a`kr$VEF@} zKh&|lTD;4;m_H6C&)9#D`kRh;S(NTa=Ve^~xe_0~x$6h8Q@B_qu#ee=(lkI9@F6$0m=z@H=4&h%Q{htM>uHs(Sr@2ry`fgLA zKj8lVXdGPyy)2J%A${}Rm_a{){wHnlM?yGPQ7#KO{8*(_l0QZHuV};nO?c%h?qwSL z3wem|w*2tdxW5&PxC(Wd0QG_w|GPbw|0UFK`u$~U%!`QKcME;=Q@?*erh4_>FP~1n zAldwG9h$$u_$RFK6Uxo20GHqJzc}Rl-EwVz3h4n z;3~%DwD84i>)-8#&#y3k)3BG5cNaP3?t4q}F%yfv?*yEiC>sSo}$f>nh0QNZXH1N)-Q7kbk=2uL9OrF)nXrE@F1y%_8Yn 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 @@ + + + + + +