diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 416c061..db4d078 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import javax.inject.Inject + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) @@ -48,6 +50,179 @@ kotlin { jvmToolchain(17) } +// --- Protobuf / gRPC code generation via protoc --- +// +// The protobuf-gradle-plugin (0.9.6) does not yet support AGP 9, so we drive +// protoc directly. A custom task exposes DirectoryProperty outputs that the +// AGP Variant API can consume. + +abstract class GenerateProtoTask : DefaultTask() { + @get:Inject + abstract val execOps: ExecOperations + @get:InputDirectory + abstract val protoSourceDir: DirectoryProperty + + @get:InputFiles + abstract val protocFile: ConfigurableFileCollection + + @get:InputFiles + abstract val grpcJavaPluginFile: ConfigurableFileCollection + + @get:InputFiles + abstract val grpcKotlinPluginFile: ConfigurableFileCollection + + /** Protobuf JAR containing well-known type .proto files (google/protobuf/*.proto). */ + @get:InputFiles + abstract val protobufIncludesJar: ConfigurableFileCollection + + /** Temp directory for extracting well-known proto includes. */ + @get:OutputDirectory + abstract val includesExtractDir: DirectoryProperty + + /** Java sources generated by protoc (messages). */ + @get:OutputDirectory + abstract val javaOutputDir: DirectoryProperty + + /** Kotlin sources generated by protoc (Kotlin DSL builders). */ + @get:OutputDirectory + abstract val kotlinOutputDir: DirectoryProperty + + /** Java sources generated by protoc-gen-grpc-java (service stubs). */ + @get:OutputDirectory + abstract val grpcOutputDir: DirectoryProperty + + /** Kotlin sources generated by protoc-gen-grpc-kotlin (coroutine stubs). */ + @get:OutputDirectory + abstract val grpcKtOutputDir: DirectoryProperty + + @TaskAction + fun generate() { + val javaOut = javaOutputDir.get().asFile + val kotlinOut = kotlinOutputDir.get().asFile + val grpcOut = grpcOutputDir.get().asFile + val grpcKtOut = grpcKtOutputDir.get().asFile + + listOf(javaOut, kotlinOut, grpcOut, grpcKtOut).forEach { it.mkdirs() } + + // Extract well-known .proto files from the protobuf JAR. + val extractDir = includesExtractDir.get().asFile + extractDir.mkdirs() + val jar = protobufIncludesJar.singleFile + java.util.zip.ZipFile(jar).use { zip -> + zip.entries().asSequence() + .filter { it.name.endsWith(".proto") } + .forEach { entry -> + val outFile = File(extractDir, entry.name) + outFile.parentFile.mkdirs() + zip.getInputStream(entry).use { input -> + outFile.outputStream().use { output -> input.copyTo(output) } + } + } + } + + val protoc = protocFile.singleFile.also { it.setExecutable(true) } + val grpcJava = grpcJavaPluginFile.singleFile.also { it.setExecutable(true) } + val grpcKt = grpcKotlinPluginFile.singleFile.also { it.setExecutable(true) } + + val protoDir = protoSourceDir.get().asFile + val protoFiles = protoDir.walkTopDown().filter { it.extension == "proto" }.toList() + if (protoFiles.isEmpty()) return + + execOps.exec { + commandLine( + protoc.absolutePath, + "--proto_path=${protoDir.absolutePath}", + "--proto_path=${extractDir.absolutePath}", + "--java_out=lite:${javaOut.absolutePath}", + "--kotlin_out=lite:${kotlinOut.absolutePath}", + "--plugin=protoc-gen-grpc=${grpcJava.absolutePath}", + "--grpc_out=lite:${grpcOut.absolutePath}", + "--plugin=protoc-gen-grpckt=${grpcKt.absolutePath}", + "--grpckt_out=lite:${grpcKtOut.absolutePath}", + *protoFiles.map { it.absolutePath }.toTypedArray(), + ) + } + } +} + +val protobufIncludes: Configuration by configurations.creating { + isTransitive = false +} +val protocArtifact: Configuration by configurations.creating { + isTransitive = false +} +val grpcJavaPlugin: Configuration by configurations.creating { + isTransitive = false +} +val grpcKotlinPlugin: Configuration by configurations.creating { + isTransitive = false +} + +// Determine the OS/arch classifier for native protoc binaries. +val protocClassifier: String = run { + val os = System.getProperty("os.name").lowercase() + val arch = System.getProperty("os.arch").lowercase() + val osName = when { + os.contains("mac") || os.contains("darwin") -> "osx" + os.contains("linux") -> "linux" + os.contains("windows") -> "windows" + else -> error("Unsupported OS: $os") + } + val archName = when { + arch.contains("aarch64") || arch.contains("arm64") -> "aarch_64" + arch.contains("amd64") || arch.contains("x86_64") -> "x86_64" + else -> error("Unsupported architecture: $arch") + } + "$osName-$archName" +} + +dependencies { + protobufIncludes("com.google.protobuf:protobuf-java:${libs.versions.protobuf.get()}") + protocArtifact("com.google.protobuf:protoc:${libs.versions.protobuf.get()}:$protocClassifier@exe") + grpcJavaPlugin("io.grpc:protoc-gen-grpc-java:${libs.versions.grpc.get()}:$protocClassifier@exe") + grpcKotlinPlugin("io.grpc:protoc-gen-grpc-kotlin:${libs.versions.grpcKotlin.get()}:jdk8@jar") +} + +val protoGenBase = layout.buildDirectory.dir("generated/source/proto/main") + +val generateProto by tasks.registering(GenerateProtoTask::class) { + description = "Generate Kotlin/gRPC sources from .proto files" + group = "build" + + protoSourceDir.set(layout.projectDirectory.dir("src/main/proto")) + protocFile.from(protocArtifact) + grpcJavaPluginFile.from(grpcJavaPlugin) + grpcKotlinPluginFile.from(grpcKotlinPlugin) + + protobufIncludesJar.from(protobufIncludes) + includesExtractDir.set(protoGenBase.map { it.dir("includes") }) + + javaOutputDir.set(protoGenBase.map { it.dir("java") }) + kotlinOutputDir.set(protoGenBase.map { it.dir("kotlin") }) + grpcOutputDir.set(protoGenBase.map { it.dir("grpc") }) + grpcKtOutputDir.set(protoGenBase.map { it.dir("grpckt") }) +} + +// Register generated directories with AGP via the Variant API (AGP 9+). +androidComponents { + onVariants { variant -> + variant.sources.java?.addGeneratedSourceDirectory( + generateProto, GenerateProtoTask::javaOutputDir, + ) + variant.sources.java?.addGeneratedSourceDirectory( + generateProto, GenerateProtoTask::grpcOutputDir, + ) + variant.sources.kotlin?.addGeneratedSourceDirectory( + generateProto, GenerateProtoTask::kotlinOutputDir, + ) + variant.sources.kotlin?.addGeneratedSourceDirectory( + generateProto, GenerateProtoTask::grpcKtOutputDir, + ) + } +} + +// --- end protobuf generation --- + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) @@ -70,6 +245,12 @@ dependencies { implementation(libs.coroutines.android) implementation(libs.reorderable) + implementation(libs.protobuf.kotlin.lite) + implementation(libs.grpc.okhttp) + implementation(libs.grpc.protobuf.lite) + implementation(libs.grpc.stub) + implementation(libs.grpc.kotlin.stub) + testImplementation(libs.junit) testImplementation(libs.coroutines.test) testImplementation(libs.room.testing) diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/sync/SyncClient.kt b/app/src/main/kotlin/net/metacircular/engpad/data/sync/SyncClient.kt new file mode 100644 index 0000000..b1c0990 --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/data/sync/SyncClient.kt @@ -0,0 +1,70 @@ +package net.metacircular.engpad.data.sync + +import io.grpc.CallOptions +import io.grpc.Channel +import io.grpc.ClientCall +import io.grpc.ClientInterceptor +import io.grpc.ForwardingClientCall.SimpleForwardingClientCall +import io.grpc.ManagedChannel +import io.grpc.Metadata +import io.grpc.MethodDescriptor +import io.grpc.okhttp.OkHttpChannelBuilder +import net.metacircular.engpad.proto.v1.EngPadSyncGrpcKt +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import java.security.KeyStore + +/** + * gRPC client for the eng-pad sync service. + * + * Uses OkHttp transport (suitable for Android) with TLS. + * Authentication credentials are sent as gRPC metadata on every call. + */ +class SyncClient( + host: String, + port: Int, + private val username: String, + private val password: String, +) { + private val channel: ManagedChannel = OkHttpChannelBuilder + .forAddress(host, port) + .useTransportSecurity() + .intercept(AuthInterceptor(username, password)) + .build() + + val stub: EngPadSyncGrpcKt.EngPadSyncCoroutineStub = + EngPadSyncGrpcKt.EngPadSyncCoroutineStub(channel) + + fun shutdown() { + channel.shutdown().awaitTermination(5, TimeUnit.SECONDS) + } +} + +private val USERNAME_KEY: Metadata.Key = + Metadata.Key.of("x-engpad-username", Metadata.ASCII_STRING_MARSHALLER) + +private val PASSWORD_KEY: Metadata.Key = + Metadata.Key.of("x-engpad-password", Metadata.ASCII_STRING_MARSHALLER) + +/** + * Interceptor that attaches username and password metadata to every outgoing call. + */ +private class AuthInterceptor( + private val username: String, + private val password: String, +) : ClientInterceptor { + override fun interceptCall( + method: MethodDescriptor, + callOptions: CallOptions, + next: Channel, + ): ClientCall { + return object : SimpleForwardingClientCall(next.newCall(method, callOptions)) { + override fun start(responseListener: Listener, headers: Metadata) { + headers.put(USERNAME_KEY, username) + headers.put(PASSWORD_KEY, password) + super.start(responseListener, headers) + } + } + } +} diff --git a/app/src/main/kotlin/net/metacircular/engpad/data/sync/SyncManager.kt b/app/src/main/kotlin/net/metacircular/engpad/data/sync/SyncManager.kt new file mode 100644 index 0000000..ef52c1f --- /dev/null +++ b/app/src/main/kotlin/net/metacircular/engpad/data/sync/SyncManager.kt @@ -0,0 +1,97 @@ +package net.metacircular.engpad.data.sync + +import com.google.protobuf.ByteString +import com.google.protobuf.timestamp +import net.metacircular.engpad.data.db.NotebookDao +import net.metacircular.engpad.data.db.PageDao +import net.metacircular.engpad.data.db.StrokeDao +import net.metacircular.engpad.data.db.toFloatArray +import net.metacircular.engpad.proto.v1.DeleteNotebookRequestKt +import net.metacircular.engpad.proto.v1.ListNotebooksRequestKt +import net.metacircular.engpad.proto.v1.NotebookSummary +import net.metacircular.engpad.proto.v1.deleteNotebookRequest +import net.metacircular.engpad.proto.v1.listNotebooksRequest +import net.metacircular.engpad.proto.v1.notebook +import net.metacircular.engpad.proto.v1.page +import net.metacircular.engpad.proto.v1.stroke +import net.metacircular.engpad.proto.v1.syncNotebookRequest +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Coordinates syncing local notebook data to the remote eng-pad sync service. + */ +class SyncManager( + private val client: SyncClient, + private val notebookDao: NotebookDao, + private val pageDao: PageDao, + private val strokeDao: StrokeDao, +) { + /** + * Upload a notebook with all its pages and strokes to the sync server. + */ + suspend fun syncNotebook(notebookId: Long) { + val localNotebook = notebookDao.getById(notebookId) + ?: throw IllegalArgumentException("Notebook $notebookId not found") + val localPages = pageDao.getByNotebookIdList(notebookId) + + val protoNotebook = notebook { + id = localNotebook.id + title = localNotebook.title + pageSize = localNotebook.pageSize + createdAt = millisToTimestamp(localNotebook.createdAt) + updatedAt = millisToTimestamp(localNotebook.updatedAt) + lastPageId = localNotebook.lastPageId + for (localPage in localPages) { + val localStrokes = strokeDao.getByPageId(localPage.id) + pages += page { + id = localPage.id + notebookId = localPage.notebookId + pageNumber = localPage.pageNumber + createdAt = millisToTimestamp(localPage.createdAt) + for (localStroke in localStrokes) { + strokes += stroke { + id = localStroke.id + pageId = localStroke.pageId + penSize = localStroke.penSize + color = localStroke.color + pointData = ByteString.copyFrom(localStroke.pointData) + strokeOrder = localStroke.strokeOrder + createdAt = millisToTimestamp(localStroke.createdAt) + style = localStroke.style + } + } + } + } + } + + val request = syncNotebookRequest { + notebook = protoNotebook + } + client.stub.syncNotebook(request) + } + + /** + * Delete a notebook on the sync server. + */ + suspend fun deleteNotebook(notebookId: Long) { + val request = deleteNotebookRequest { + this.notebookId = notebookId + } + client.stub.deleteNotebook(request) + } + + /** + * List all notebooks available on the sync server. + */ + suspend fun listRemoteNotebooks(): List { + val request = listNotebooksRequest {} + val response = client.stub.listNotebooks(request) + return response.notebooksList + } +} + +private fun millisToTimestamp(millis: Long) = timestamp { + seconds = millis / 1000 + nanos = ((millis % 1000) * 1_000_000).toInt() +} diff --git a/app/src/main/proto/engpad/v1/sync.proto b/app/src/main/proto/engpad/v1/sync.proto index 1cbe28d..41e71e0 100644 --- a/app/src/main/proto/engpad/v1/sync.proto +++ b/app/src/main/proto/engpad/v1/sync.proto @@ -1,47 +1,27 @@ syntax = "proto3"; + package engpad.v1; -option go_package = "git.wntrmute.dev/kyle/eng-pad-server/gen/engpad/v1;engpadv1"; +option go_package = "git.wntrmute.dev/kyle/engpad/gen/engpad/v1;engpadv1"; +option java_package = "net.metacircular.engpad.proto.v1"; +option java_multiple_files = true; import "google/protobuf/timestamp.proto"; service EngPadSync { - rpc SyncNotebook(SyncNotebookRequest) returns (SyncNotebookResponse); - rpc DeleteNotebook(DeleteNotebookRequest) returns (DeleteNotebookResponse); - rpc ListNotebooks(ListNotebooksRequest) returns (ListNotebooksResponse); - rpc CreateShareLink(CreateShareLinkRequest) returns (CreateShareLinkResponse); - rpc RevokeShareLink(RevokeShareLinkRequest) returns (RevokeShareLinkResponse); - rpc ListShareLinks(ListShareLinksRequest) returns (ListShareLinksResponse); + rpc SyncNotebook(SyncNotebookRequest) returns (SyncNotebookResponse); + rpc DeleteNotebook(DeleteNotebookRequest) returns (DeleteNotebookResponse); + rpc ListNotebooks(ListNotebooksRequest) returns (ListNotebooksResponse); } message SyncNotebookRequest { - int64 notebook_id = 1; - string title = 2; - string page_size = 3; - repeated PageData pages = 4; + Notebook notebook = 1; } -message PageData { - int64 page_id = 1; - int32 page_number = 2; - repeated StrokeData strokes = 3; -} - -message StrokeData { - float pen_size = 1; - int32 color = 2; - string style = 3; - bytes point_data = 4; - int32 stroke_order = 5; -} - -message SyncNotebookResponse { - int64 server_notebook_id = 1; - google.protobuf.Timestamp synced_at = 2; -} +message SyncNotebookResponse {} message DeleteNotebookRequest { - int64 notebook_id = 1; + int64 notebook_id = 1; } message DeleteNotebookResponse {} @@ -49,46 +29,42 @@ message DeleteNotebookResponse {} message ListNotebooksRequest {} message ListNotebooksResponse { - repeated NotebookSummary notebooks = 1; + repeated NotebookSummary notebooks = 1; +} + +message Notebook { + int64 id = 1; + string title = 2; + string page_size = 3; + google.protobuf.Timestamp created_at = 4; + google.protobuf.Timestamp updated_at = 5; + int64 last_page_id = 6; + repeated Page pages = 7; } message NotebookSummary { - int64 server_id = 1; - int64 remote_id = 2; - string title = 3; - string page_size = 4; - int32 page_count = 5; - google.protobuf.Timestamp synced_at = 6; + int64 id = 1; + string title = 2; + string page_size = 3; + google.protobuf.Timestamp updated_at = 4; + int32 page_count = 5; } -message CreateShareLinkRequest { - int64 notebook_id = 1; - int64 expires_in_seconds = 2; +message Page { + int64 id = 1; + int64 notebook_id = 2; + int32 page_number = 3; + google.protobuf.Timestamp created_at = 4; + repeated Stroke strokes = 5; } -message CreateShareLinkResponse { - string token = 1; - string url = 2; - google.protobuf.Timestamp expires_at = 3; -} - -message RevokeShareLinkRequest { - string token = 1; -} - -message RevokeShareLinkResponse {} - -message ListShareLinksRequest { - int64 notebook_id = 1; -} - -message ListShareLinksResponse { - repeated ShareLinkInfo links = 1; -} - -message ShareLinkInfo { - string token = 1; - string url = 2; - google.protobuf.Timestamp created_at = 3; - google.protobuf.Timestamp expires_at = 4; +message Stroke { + int64 id = 1; + int64 page_id = 2; + float pen_size = 3; + int32 color = 4; + bytes point_data = 5; + int32 stroke_order = 6; + google.protobuf.Timestamp created_at = 7; + string style = 8; } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e0a8691..286aff4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,9 @@ core-ktx = "1.18.0" activity-compose = "1.13.0" junit = "4.13.2" reorderable = "3.0.0" +protobuf = "4.29.3" +grpc = "1.69.0" +grpcKotlin = "1.4.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } @@ -38,6 +41,15 @@ coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines- junit = { group = "junit", name = "junit", version.ref = "junit" } reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } +protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } +grpc-okhttp = { group = "io.grpc", name = "grpc-okhttp", version.ref = "grpc" } +grpc-protobuf-lite = { group = "io.grpc", name = "grpc-protobuf-lite", version.ref = "grpc" } +grpc-stub = { group = "io.grpc", name = "grpc-stub", version.ref = "grpc" } +grpc-kotlin-stub = { group = "io.grpc", name = "grpc-kotlin-stub", version.ref = "grpcKotlin" } +grpc-protoc-gen-java = { group = "io.grpc", name = "protoc-gen-grpc-java", version.ref = "grpc" } +grpc-protoc-gen-kotlin = { group = "io.grpc", name = "protoc-gen-grpc-kotlin", version.ref = "grpcKotlin" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }