From 0426b6eeec5077a76ccf66c487483f6ebcc35045 Mon Sep 17 00:00:00 2001 From: IllusionMan1212 Date: Thu, 27 Aug 2026 15:38:55 +0200 Subject: [PATCH] initial commit --- .vscode/settings.json | 5 + README.md | 28 + android-app/.gitkeep | 0 android-app/README.md | 31 + android-app/app/build.gradle.kts | 49 + android-app/app/proguard-rules.pro | 1 + android-app/app/src/main/AndroidManifest.xml | 33 + .../java/org/clipsync/android/MainActivity.kt | 58 ++ .../android/discovery/UdpDiscoveryListener.kt | 38 + .../clipsync/android/service/SyncService.kt | 91 ++ .../app/src/main/res/layout/activity_main.xml | 49 + .../app/src/main/res/values/strings.xml | 13 + .../app/src/main/res/values/themes.xml | 3 + android-app/build.gradle.kts | 4 + android-app/gradle.properties | 4 + android-app/settings.gradle.kts | 18 + desktop-daemon/README.md | 11 + desktop-daemon/src/main.odin | 881 ++++++++++++++++++ desktop-daemon/src/net/framing.odin | 98 ++ desktop-daemon/src/protocol/messages.odin | 39 + docs/protocol-v1.md | 133 +++ docs/repo-layout.md | 39 + main.odin | 9 + 23 files changed, 1635 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 android-app/.gitkeep create mode 100644 android-app/README.md create mode 100644 android-app/app/build.gradle.kts create mode 100644 android-app/app/proguard-rules.pro create mode 100644 android-app/app/src/main/AndroidManifest.xml create mode 100644 android-app/app/src/main/java/org/clipsync/android/MainActivity.kt create mode 100644 android-app/app/src/main/java/org/clipsync/android/discovery/UdpDiscoveryListener.kt create mode 100644 android-app/app/src/main/java/org/clipsync/android/service/SyncService.kt create mode 100644 android-app/app/src/main/res/layout/activity_main.xml create mode 100644 android-app/app/src/main/res/values/strings.xml create mode 100644 android-app/app/src/main/res/values/themes.xml create mode 100644 android-app/build.gradle.kts create mode 100644 android-app/gradle.properties create mode 100644 android-app/settings.gradle.kts create mode 100644 desktop-daemon/README.md create mode 100644 desktop-daemon/src/main.odin create mode 100644 desktop-daemon/src/net/framing.odin create mode 100644 desktop-daemon/src/protocol/messages.odin create mode 100644 docs/protocol-v1.md create mode 100644 docs/repo-layout.md create mode 100644 main.odin diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0dad338 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "odin": true + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4b400e --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# clipsync + +Open source clipboard sync between Android and Linux/Windows over LAN. + +## Monorepo layout + +- `android-app/` — Kotlin Android app (UI + background sync service) +- `desktop-daemon/` — Odin desktop daemon implementation +- `docs/` — protocol and architecture docs + +## Current phase + +This repository is currently in **scaffolding + protocol definition** phase. + +## MVP target + +1. LAN discovery +2. Pairing + trust persistence +3. Encrypted session +4. Text clipboard sync in both directions +5. Loop prevention and reconnect + +## Next implementation steps + +1. Initialize Android project in `android-app/`. +2. Implement desktop daemon network listener + discovery broadcaster in `desktop-daemon/`. +3. Implement protocol v1 message framing and handshake from [docs/protocol-v1.md](docs/protocol-v1.md). +4. Add clipboard watcher backends (Linux and Windows). diff --git a/android-app/.gitkeep b/android-app/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android-app/README.md b/android-app/README.md new file mode 100644 index 0000000..e668328 --- /dev/null +++ b/android-app/README.md @@ -0,0 +1,31 @@ +# clipsync Android App + +Android client scaffold is now initialized in this directory. + +## Implemented scaffold + +- Kotlin Android app module (`app/`) +- Launcher activity with service controls +- Foreground service (`SyncService`) with persistent notification +- UDP discovery listener loop (`UdpDiscoveryListener`) on default port `45821` + +## Current package layout + +- `app/src/main/java/org/clipsync/android/` + - `MainActivity.kt` + - `service/SyncService.kt` + - `discovery/UdpDiscoveryListener.kt` + +## Build files + +- `settings.gradle.kts` +- `build.gradle.kts` +- `app/build.gradle.kts` + +## Next Android steps + +1. Parse discovery JSON and keep an in-memory peer list for UI. +2. Add manual connect screen (`IP:port`) and connect action. +3. Implement TCP framing + HELLO/PAIR_REQUEST flow. +4. Add clipboard monitor/apply path. +5. Persist paired peers in local storage. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts new file mode 100644 index 0000000..dff6983 --- /dev/null +++ b/android-app/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "org.clipsync.android" + compileSdk = 34 + + defaultConfig { + applicationId = "org.clipsync.android" + minSdk = 28 + targetSdk = 34 + versionCode = 1 + versionName = "0.1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-service:2.8.4") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") +} diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..e83b393 --- /dev/null +++ b/android-app/app/proguard-rules.pro @@ -0,0 +1 @@ +# clipsync Android app ProGuard rules diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f616e1e --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/java/org/clipsync/android/MainActivity.kt b/android-app/app/src/main/java/org/clipsync/android/MainActivity.kt new file mode 100644 index 0000000..7b58113 --- /dev/null +++ b/android-app/app/src/main/java/org/clipsync/android/MainActivity.kt @@ -0,0 +1,58 @@ +package org.clipsync.android + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import org.clipsync.android.service.SyncService + +class MainActivity : AppCompatActivity() { + + private lateinit var statusText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + statusText = findViewById(R.id.statusText) + val startButton: Button = findViewById(R.id.startServiceButton) + val stopButton: Button = findViewById(R.id.stopServiceButton) + + requestNotificationPermissionIfNeeded() + + startButton.setOnClickListener { + val intent = Intent(this, SyncService::class.java).apply { + action = SyncService.ACTION_START + } + ContextCompat.startForegroundService(this, intent) + statusText.text = getString(R.string.status_running) + } + + stopButton.setOnClickListener { + val intent = Intent(this, SyncService::class.java).apply { + action = SyncService.ACTION_STOP + } + startService(intent) + statusText.text = getString(R.string.status_stopped) + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED + ) return + + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1001 + ) + } +} diff --git a/android-app/app/src/main/java/org/clipsync/android/discovery/UdpDiscoveryListener.kt b/android-app/app/src/main/java/org/clipsync/android/discovery/UdpDiscoveryListener.kt new file mode 100644 index 0000000..e342377 --- /dev/null +++ b/android-app/app/src/main/java/org/clipsync/android/discovery/UdpDiscoveryListener.kt @@ -0,0 +1,38 @@ +package org.clipsync.android.discovery + +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +class UdpDiscoveryListener( + private val port: Int = 45821 +) { + @Volatile + private var running: Boolean = true + + fun runLoop() { + running = true + DatagramSocket(port, InetAddress.getByName("0.0.0.0")).use { socket -> + socket.soTimeout = 2000 + + val buffer = ByteArray(4096) + while (running && !Thread.currentThread().isInterrupted) { + try { + val packet = DatagramPacket(buffer, buffer.size) + socket.receive(packet) + + val payload = String(packet.data, 0, packet.length) + if (payload.contains("\"type\":\"DISCOVERY_ANNOUNCE\"")) { + // TODO: parse JSON and publish peer to app state. + } + } catch (_: Exception) { + // Timeout/interruption expected in loop. + } + } + } + } + + fun stop() { + running = false + } +} diff --git a/android-app/app/src/main/java/org/clipsync/android/service/SyncService.kt b/android-app/app/src/main/java/org/clipsync/android/service/SyncService.kt new file mode 100644 index 0000000..aab24c5 --- /dev/null +++ b/android-app/app/src/main/java/org/clipsync/android/service/SyncService.kt @@ -0,0 +1,91 @@ +package org.clipsync.android.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import org.clipsync.android.R +import org.clipsync.android.discovery.UdpDiscoveryListener +import java.util.concurrent.Executors +import java.util.concurrent.Future + +class SyncService : Service() { + + private val executor = Executors.newSingleThreadExecutor() + private var discoveryFuture: Future<*>? = null + private val discoveryListener = UdpDiscoveryListener() + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + stopDiscovery() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + else -> { + startForeground(NOTIFICATION_ID, buildNotification()) + startDiscovery() + } + } + return START_STICKY + } + + override fun onDestroy() { + stopDiscovery() + executor.shutdownNow() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun startDiscovery() { + if (discoveryFuture?.isDone == false) return + discoveryFuture = executor.submit { discoveryListener.runLoop() } + } + + private fun stopDiscovery() { + discoveryListener.stop() + discoveryFuture?.cancel(true) + discoveryFuture = null + } + + private fun buildNotification(): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(getString(R.string.notification_text)) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setOngoing(true) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.channel_description) + } + manager.createNotificationChannel(channel) + } + + companion object { + const val ACTION_START = "org.clipsync.android.action.START" + const val ACTION_STOP = "org.clipsync.android.action.STOP" + + private const val CHANNEL_ID = "clipsync_sync" + private const val NOTIFICATION_ID = 4001 + } +} diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..fae7d78 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,49 @@ + + + + + + + +