initial commit

This commit is contained in:
2026-08-27 15:38:55 +02:00
commit 0426b6eeec
23 changed files with 1635 additions and 0 deletions
View File
+31
View File
@@ -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.
+49
View File
@@ -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")
}
+1
View File
@@ -0,0 +1 @@
# clipsync Android app ProGuard rules
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.ClipSync">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.SyncService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -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
)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TextView
android:id="@+id/titleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/statusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/status_stopped"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/titleText" />
<Button
android:id="@+id/startServiceButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/start_service"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/statusText" />
<Button
android:id="@+id/stopServiceButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/stop_service"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/startServiceButton" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,13 @@
<resources>
<string name="app_name">ClipSync</string>
<string name="status_running">Service running (discovery active)</string>
<string name="status_stopped">Service stopped</string>
<string name="start_service">Start Sync Service</string>
<string name="stop_service">Stop Sync Service</string>
<string name="channel_name">ClipSync Background Sync</string>
<string name="channel_description">Keeps discovery and sync session active</string>
<string name="notification_title">ClipSync running</string>
<string name="notification_text">Discovery and sync service active</string>
</resources>
@@ -0,0 +1,3 @@
<resources>
<style name="Theme.ClipSync" parent="Theme.Material3.DayNight.NoActionBar" />
</resources>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "clipsync-android"
include(":app")