Merge: protobuf + sync client skeleton

This commit is contained in:
2026-03-24 20:56:17 -07:00
5 changed files with 401 additions and 65 deletions

View File

@@ -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<String> =
Metadata.Key.of("x-engpad-username", Metadata.ASCII_STRING_MARSHALLER)
private val PASSWORD_KEY: Metadata.Key<String> =
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 <ReqT, RespT> interceptCall(
method: MethodDescriptor<ReqT, RespT>,
callOptions: CallOptions,
next: Channel,
): ClientCall<ReqT, RespT> {
return object : SimpleForwardingClientCall<ReqT, RespT>(next.newCall(method, callOptions)) {
override fun start(responseListener: Listener<RespT>, headers: Metadata) {
headers.put(USERNAME_KEY, username)
headers.put(PASSWORD_KEY, password)
super.start(responseListener, headers)
}
}
}
}

View File

@@ -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<NotebookSummary> {
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()
}

View File

@@ -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;
}