From 4b81caee2de433c52e9e77ac7ab683facaa9fd51 Mon Sep 17 00:00:00 2001 From: neelts Date: Wed, 12 Aug 2026 07:48:59 +0200 Subject: [PATCH 1/2] Add data logging delivery to companion apps Data logging sessions of watchapps now get to companion apps. The server sends batches and the session-finish event over the bound listener service (new DATA_LOG_RECEIVED / DATA_LOG_SESSION_FINISHED actions). A client overrides onDataLogReceived() / onDataLogSessionFinished() and stores the data. The Ack/Nack result makes the delivery safe. The companion sends Ack only after it stored the data. So the Pebble app knows if it can discard the data or must send it again. An old client library answers with an empty bundle. That decodes as ReceiveResult.Unknown. --- README.MD | 43 ++++++ .../java/BaseJavaPebbleListenerService.kt | 88 ++++++++++++ .../client/BasePebbleListenerService.kt | 127 ++++++++++++++++++ .../pebblekit2/common/model/DataLogSession.kt | 28 ++++ .../rebble/pebblekit2/PebbleKitBundleKeys.kt | 7 + .../model/DataLogSessionSerialization.kt | 25 ++++ docs/SERVER.MD | 37 +++++ .../sample/PebbleListenerService.java | 24 ++++ .../sample/PebbleListenerService.kt | 24 ++++ .../server/PebbleListenerConnector.kt | 47 +++++++ .../server/DefaultPebbleListenerConnector.kt | 59 ++++++++ 11 files changed, 509 insertions(+) create mode 100644 common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt create mode 100644 common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt diff --git a/README.MD b/README.MD index 4de1552..9c4ee19 100644 --- a/README.MD +++ b/README.MD @@ -117,6 +117,49 @@ with the `io.rebble.pebblekit2.RECEIVE_DATA_FROM_WATCH` intent filter: That's it. When your watchapp is opened on the watch, the listener service should be bounded and the start callback called. +### Receive data logs + +Data logging is the store-and-forward alternative to messages. The watchapp writes fixed-size items to +the watch storage with +[data_logging_log()](https://developer.rebble.io/guides/communication/datalogging/), also when the +phone is out of range. The watch sends the items when it is connected. + +To receive the logged data, override the data log callbacks of the `BasePebbleListenerService`: + +```kotlin +override suspend fun onDataLogReceived( + watchappUUID: UUID, + session: DataLogSession, + data: ByteArray, + itemsLeft: Long, + watch: WatchIdentifier, +): ReceiveResult { + // data contains data.size / session.itemSize items, in the sequence the watchapp logged them + return ReceiveResult.Ack +} + +override suspend fun onDataLogSessionFinished( + watchappUUID: UUID, + session: DataLogSession, + watch: WatchIdentifier, +): ReceiveResult { + // the watchapp called data_logging_finish() and the watch sent all the data of the session + return ReceiveResult.Ack +} +``` + +The watchapp UUID, the `tag` from `data_logging_create()` and the `timestamp` of the session start +identify a session (see `DataLogSession`). Two recordings come as two different sessions. + +Return `ReceiveResult.Ack` only after you stored the data. The Pebble app can then discard it. Return +`ReceiveResult.Nack` if you could not store the data. The Pebble app can then try the delivery again +later. The Pebble app can send the same batch more than one time; store the items so that a repeated +batch does not add duplicate data. + +Data logs are different from messages: they are not connected to an open watchapp. The watch can send +stored data at all times, for example when it connects again after it was out of range. Android can +thus bind your service at all times. + ### Starting/stopping the app You can call `sender.startAppOnTheWatch()` and `sender.stopAppOnTheWatch()` to start/stop your app on the watch diff --git a/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt b/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt index 21e02dc..832a195 100644 --- a/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt +++ b/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt @@ -1,11 +1,13 @@ package io.rebble.pebblekit2.client.java import io.rebble.pebblekit2.client.BasePebbleListenerService +import io.rebble.pebblekit2.common.model.DataLogSession import io.rebble.pebblekit2.common.model.PebbleDictionary import io.rebble.pebblekit2.common.model.PebbleDictionaryItem import io.rebble.pebblekit2.common.model.ReceiveResult import io.rebble.pebblekit2.common.model.WatchIdentifier import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import java.util.function.Consumer @@ -33,6 +35,46 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( return completableDeferred.await() } + final override suspend fun onDataLogReceived( + watchappUUID: UUID, + session: DataLogSession, + data: ByteArray, + itemsLeft: Long, + watch: WatchIdentifier, + ): ReceiveResult { + val completableDeferred = CompletableDeferred() + + onDataLogReceived( + watchappUUID, + session, + data, + itemsLeft, + watch.value, + { completableDeferred.complete(it) }, + ) + + return withTimeoutOrNull(DATA_LOG_RESPONDER_TIMEOUT_MS) { completableDeferred.await() } + ?: ReceiveResult.Nack + } + + final override suspend fun onDataLogSessionFinished( + watchappUUID: UUID, + session: DataLogSession, + watch: WatchIdentifier, + ): ReceiveResult { + val completableDeferred = CompletableDeferred() + + onDataLogSessionFinished( + watchappUUID, + session, + watch.value, + { completableDeferred.complete(it) }, + ) + + return withTimeoutOrNull(DATA_LOG_RESPONDER_TIMEOUT_MS) { completableDeferred.await() } + ?: ReceiveResult.Nack + } + final override fun onAppOpened(watchappUUID: UUID, watch: WatchIdentifier) { onAppOpened(watchappUUID, watch.value) } @@ -63,6 +105,50 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( responder.accept(ReceiveResult.Nack) } + /** + * The watch sent a batch of items from a data logging [session] of one of the registered apps. + * + * [data] contains `data.size / session.itemSize` full items, in the sequence the watchapp + * logged them. [itemsLeft] is the number of items that stay on the watch after this batch. + * + * Passed [watch] parameter corresponds to the [WatchIdentifier.value]. + * + * You MUST call [responder] after you are done processing this callback. Use + * [ReceiveResult.Ack] only after you stored the data; the Pebble app can then discard it. Use + * [ReceiveResult.Nack] if you could not store the data; the Pebble app can then try the + * delivery again later. If you do not call [responder] in 30 seconds, the library answers + * [ReceiveResult.Nack]. + */ + protected open fun onDataLogReceived( + watchappUUID: UUID, + session: DataLogSession, + data: ByteArray, + itemsLeft: Long, + watch: String, + responder: Consumer, + ) { + responder.accept(ReceiveResult.Nack) + } + + /** + * A data logging [session] of one of the registered apps is complete. The watchapp called + * `data_logging_finish()`, and the Pebble app sends this event after you acknowledged all the + * batches of the session. + * + * Passed [watch] parameter corresponds to the [WatchIdentifier.value]. + * + * You MUST call [responder] after you are done processing this callback. If you do not call + * [responder] in 30 seconds, the library answers [ReceiveResult.Nack]. + */ + protected open fun onDataLogSessionFinished( + watchappUUID: UUID, + session: DataLogSession, + watch: String, + responder: Consumer, + ) { + responder.accept(ReceiveResult.Nack) + } + /** * One of registered apps for this companion app has been opened on a watch * @@ -80,3 +166,5 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( protected open fun onAppClosed(watchappUUID: UUID, watch: String) { } } + +private const val DATA_LOG_RESPONDER_TIMEOUT_MS = 30_000L diff --git a/client/src/main/kotlin/io/rebble/pebblekit2/client/BasePebbleListenerService.kt b/client/src/main/kotlin/io/rebble/pebblekit2/client/BasePebbleListenerService.kt index 928e205..b514d3b 100644 --- a/client/src/main/kotlin/io/rebble/pebblekit2/client/BasePebbleListenerService.kt +++ b/client/src/main/kotlin/io/rebble/pebblekit2/client/BasePebbleListenerService.kt @@ -7,10 +7,12 @@ import android.os.IBinder import androidx.core.os.bundleOf import co.touchlab.kermit.Logger import io.rebble.pebblekit2.PebbleKitBundleKeys +import io.rebble.pebblekit2.common.model.DataLogSession import io.rebble.pebblekit2.common.model.PebbleDictionary import io.rebble.pebblekit2.common.model.PebbleDictionaryItem import io.rebble.pebblekit2.common.model.ReceiveResult import io.rebble.pebblekit2.common.model.WatchIdentifier +import io.rebble.pebblekit2.common.model.fromBundle import io.rebble.pebblekit2.common.model.mapFromBundle import io.rebble.pebblekit2.common.model.toBundle import io.rebble.pebblekit2.common.util.UniversalRequestResponseSuspending @@ -40,6 +42,47 @@ public abstract class BasePebbleListenerService : Service() { return ReceiveResult.Nack } + /** + * The watch sent a batch of items from a data logging [session] of one of the registered apps. + * + * Data logging is the store-and-forward alternative to messages. The watchapp writes items to + * the watch storage, also when the phone is out of range. The watch sends the items when it is + * connected. + * + * [data] contains `data.size / session.itemSize` full items, in the sequence the watchapp + * logged them. [itemsLeft] is the number of items that stay on the watch after this batch. + * + * Return [ReceiveResult.Ack] only after you stored the data. The Pebble app can then discard + * it. Return [ReceiveResult.Nack] if you could not store the data. The Pebble app can then try + * the delivery again later. The Pebble app can send the same batch more than one time; store + * the items so that a repeated batch does not add duplicate data. + */ + public open suspend fun onDataLogReceived( + watchappUUID: UUID, + session: DataLogSession, + data: ByteArray, + itemsLeft: Long, + watch: WatchIdentifier, + ): ReceiveResult { + return ReceiveResult.Nack + } + + /** + * A data logging [session] of one of the registered apps is complete. The watchapp called + * `data_logging_finish()`, and the Pebble app sends this event after you acknowledged all the + * batches of the session. + * + * Return [ReceiveResult.Ack] after you processed the event. Return [ReceiveResult.Nack] if you + * could not process it. The Pebble app can then send the event again later. + */ + public open suspend fun onDataLogSessionFinished( + watchappUUID: UUID, + session: DataLogSession, + watch: WatchIdentifier, + ): ReceiveResult { + return ReceiveResult.Nack + } + /** * One of registered apps for this companion app has been opened on a watch */ @@ -83,6 +126,14 @@ public abstract class BasePebbleListenerService : Service() { handleReceiveData(data, callingPackage) } + PebbleKitBundleKeys.ACTION_DATA_LOG_RECEIVED -> { + handleDataLogReceived(data, callingPackage) + } + + PebbleKitBundleKeys.ACTION_DATA_LOG_SESSION_FINISHED -> { + handleDataLogSessionFinished(data, callingPackage) + } + PebbleKitBundleKeys.ACTION_APP_OPENED -> { handleAppOpened(data, callingPackage) } @@ -125,6 +176,82 @@ public abstract class BasePebbleListenerService : Service() { return bundleOf(PebbleKitBundleKeys.KEY_TRANSMISSION_RESULTS to result.toBundle()) } + private suspend fun handleDataLogReceived(input: Bundle, callingPackage: String?): Bundle { + val watchappUuid = input.getString(PebbleKitBundleKeys.KEY_WATCHAPP_UUID) + ?.let { UUID.fromString(it) } + if (watchappUuid == null) { + LOGGER.w { "Got a missing watchapp UUID from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return Bundle() + } + + val watchId = input.getString(PebbleKitBundleKeys.KEY_WATCH_ID) + ?.let { WatchIdentifier(it) } + if (watchId == null) { + LOGGER.w { "Got a missing watch ID from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return Bundle() + } + + val data = input.getByteArray(PebbleKitBundleKeys.KEY_DATA_LOG_DATA) + if (data == null) { + LOGGER.w { "Got missing data log data from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return Bundle() + } + + val session = validDataLogSession(input, data, callingPackage) ?: return Bundle() + + if (!input.containsKey(PebbleKitBundleKeys.KEY_DATA_LOG_ITEMS_LEFT)) { + LOGGER.w { "Got a missing data log items-left from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return Bundle() + } + val itemsLeft = input.getLong(PebbleKitBundleKeys.KEY_DATA_LOG_ITEMS_LEFT) + + val result = onDataLogReceived(watchappUuid, session, data, itemsLeft, watchId) + + return bundleOf(PebbleKitBundleKeys.KEY_RECEIVE_RESULT to result.toBundle()) + } + + private fun validDataLogSession(input: Bundle, data: ByteArray, callingPackage: String?): DataLogSession? { + val sessionBundle = input.getBundle(PebbleKitBundleKeys.KEY_DATA_LOG_SESSION) + if (sessionBundle == null) { + LOGGER.w { "Got a missing data log session from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return null + } + + val session = DataLogSession.fromBundle(sessionBundle) + if (session.itemSize <= 0 || data.size % session.itemSize != 0) { + LOGGER.w { "Got an invalid data log batch from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return null + } + + return session + } + + private suspend fun handleDataLogSessionFinished(input: Bundle, callingPackage: String?): Bundle { + val watchappUuid = input.getString(PebbleKitBundleKeys.KEY_WATCHAPP_UUID) + ?.let { UUID.fromString(it) } + if (watchappUuid == null) { + LOGGER.w { "Got a missing watchapp UUID from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return Bundle() + } + + val watchId = input.getString(PebbleKitBundleKeys.KEY_WATCH_ID) + ?.let { WatchIdentifier(it) } + if (watchId == null) { + LOGGER.w { "Got a missing watch ID from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return Bundle() + } + + val sessionBundle = input.getBundle(PebbleKitBundleKeys.KEY_DATA_LOG_SESSION) + if (sessionBundle == null) { + LOGGER.w { "Got a missing data log session from ${callingPackage ?: "UNKNOWN"}. Ignoring event..." } + return Bundle() + } + + val result = onDataLogSessionFinished(watchappUuid, DataLogSession.fromBundle(sessionBundle), watchId) + + return bundleOf(PebbleKitBundleKeys.KEY_RECEIVE_RESULT to result.toBundle()) + } + private fun handleAppOpened(input: Bundle, callingPackage: String?): Bundle { val watchappUuid = input.getString(PebbleKitBundleKeys.KEY_WATCHAPP_UUID) ?.let { UUID.fromString(it) } diff --git a/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt new file mode 100644 index 0000000..d21f936 --- /dev/null +++ b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt @@ -0,0 +1,28 @@ +package io.rebble.pebblekit2.common.model + +/** + * A data logging session. The watch makes a session when a watchapp calls `data_logging_create()`. + * + * The watchapp UUID, the [tag] and the [timestamp] identify a session. All items in a session + * have the same [itemSize]. + */ +public data class DataLogSession( + /** + * The tag that the watchapp gave to `data_logging_create()`. The watchapp uses different tags + * for different types of data. + */ + val tag: Long, + + /** + * The Unix time, in seconds, when the watch made the session. It separates two sessions with + * the same [tag], unless the watchapp made both in the same second. + */ + val timestamp: Long, + + /** + * The size, in bytes, of one data item. + */ + val itemSize: Int, +) { + public companion object +} diff --git a/common/src/main/kotlin/io/rebble/pebblekit2/PebbleKitBundleKeys.kt b/common/src/main/kotlin/io/rebble/pebblekit2/PebbleKitBundleKeys.kt index 7fb2c4a..a5597a2 100644 --- a/common/src/main/kotlin/io/rebble/pebblekit2/PebbleKitBundleKeys.kt +++ b/common/src/main/kotlin/io/rebble/pebblekit2/PebbleKitBundleKeys.kt @@ -9,6 +9,10 @@ public object PebbleKitBundleKeys { public const val KEY_TRANSMISSION_RESULTS: String = "TRANSMISSION_RESULTS" public const val KEY_RECEIVE_RESULT: String = "TRANSMISSION_RESULTS" + public const val KEY_DATA_LOG_SESSION: String = "DATA_LOG_SESSION" + public const val KEY_DATA_LOG_DATA: String = "DATA_LOG_DATA" + public const val KEY_DATA_LOG_ITEMS_LEFT: String = "DATA_LOG_ITEMS_LEFT" + public const val KEY_TIMELINE_PIN: String = "TIMELINE_PIN" public const val KEY_TIMELINE_PIN_ID: String = "TIMELINE_PIN_ID" @@ -19,6 +23,9 @@ public object PebbleKitBundleKeys { public const val ACTION_APP_OPENED: String = "APP_OPENED" public const val ACTION_APP_CLOSED: String = "APP_CLOSED" + public const val ACTION_DATA_LOG_RECEIVED: String = "DATA_LOG_RECEIVED" + public const val ACTION_DATA_LOG_SESSION_FINISHED: String = "DATA_LOG_SESSION_FINISHED" + public const val ACTION_START_APP: String = "START_APP" public const val ACTION_STOP_APP: String = "STOP_APP" diff --git a/common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt new file mode 100644 index 0000000..49fe262 --- /dev/null +++ b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt @@ -0,0 +1,25 @@ +package io.rebble.pebblekit2.common.model + +import android.os.Bundle + +public fun DataLogSession.Companion.fromBundle(bundle: Bundle): DataLogSession { + return DataLogSession( + tag = bundle.getLong(BUNDLE_KEY_TAG), + timestamp = bundle.getLong(BUNDLE_KEY_TIMESTAMP), + itemSize = bundle.getInt(BUNDLE_KEY_ITEM_SIZE), + ) +} + +public fun DataLogSession.toBundle(): Bundle { + val bundle = Bundle() + + bundle.putLong(BUNDLE_KEY_TAG, tag) + bundle.putLong(BUNDLE_KEY_TIMESTAMP, timestamp) + bundle.putInt(BUNDLE_KEY_ITEM_SIZE, itemSize) + + return bundle +} + +private const val BUNDLE_KEY_TAG = "TAG" +private const val BUNDLE_KEY_TIMESTAMP = "TIMESTAMP" +private const val BUNDLE_KEY_ITEM_SIZE = "ITEM_SIZE" diff --git a/docs/SERVER.MD b/docs/SERVER.MD index ccb08f8..b2fc081 100644 --- a/docs/SERVER.MD +++ b/docs/SERVER.MD @@ -62,6 +62,43 @@ delay(5.seconds) connector.close() ``` +# Forward data logging to apps + +The watch sends data logging items of third-party watchapps (see +[Datalogging](https://developer.rebble.io/guides/communication/datalogging/)). Forward each batch to +the companion app of the watchapp through the same `PebbleListenerConnector`: + +```kotlin +val result = connector.sendOnDataLogReceived(watchappUUID, session, data, itemsLeft, watch) +``` + +A batch must contain only whole items: `data.size` must be a multiple of `session.itemSize`. Send +the batches of one session in sequence: await the result of a call before you send the next batch +of that session. Keep one call well below the 1 MB Android binder transaction limit; do not put +more than 100 KB of data into one call. + +The watch closes a session after it sent all of its data. When the companion app has acknowledged +all the batches of the session, call: + +```kotlin +val result = connector.sendOnDataLogSessionFinished(watchappUUID, session, watch) +``` + +Data logging is different from messages: it is not connected to an open watchapp. The watch sends +stored data each time it is connected. The bind operation wakes the companion app. Thus make these +calls also outside of the app opened/closed window. + +The result tells you what to do with the data. `ReceiveResult.Ack` means that the companion app +stored the data. You can then discard it. `ReceiveResult.Nack`, `ReceiveResult.Unknown` (the +companion app has an old PebbleKit library) and `null` (no connection to the app) mean that the +companion app did not store the data. Keep the data and send it again later, a limited number of +times. Discard the data when the attempts are used up or when the companion app is no longer +installed; do not retry without a limit. The companion app must tolerate a batch that it gets more +than one time, so a retry after an unclear result is safe. + +The watch deletes its copy when the Pebble app confirms the receipt on the Pebble protocol level. +Data that the companion app did not acknowledge lives only in the Pebble app; treat it with care. + # Implementing status provider To allow apps to access various infos about the status of the watch and the connection, you have to implement the diff --git a/sample/android-java/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.java b/sample/android-java/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.java index 8f2bf01..832778e 100644 --- a/sample/android-java/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.java +++ b/sample/android-java/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.java @@ -2,6 +2,7 @@ import android.util.Log; import io.rebble.pebblekit2.client.java.BaseJavaPebbleListenerService; +import io.rebble.pebblekit2.common.model.DataLogSession; import io.rebble.pebblekit2.common.model.PebbleDictionaryItem; import io.rebble.pebblekit2.common.model.ReceiveResult; import org.jetbrains.annotations.NotNull; @@ -21,6 +22,29 @@ public void onMessageReceived(@NotNull UUID watchappUUID, responder.accept(ReceiveResult.Ack.INSTANCE); } + @Override + protected void onDataLogReceived(@NotNull UUID watchappUUID, + @NotNull DataLogSession session, + @NotNull byte[] data, + long itemsLeft, + @NotNull String watch, + @NotNull Consumer<@NotNull ReceiveResult> responder) { + Log.d("PebbleListenerService", "Received " + (data.length / session.getItemSize()) + + " data log items of session " + session + " from app " + watchappUUID); + + responder.accept(ReceiveResult.Ack.INSTANCE); + } + + @Override + protected void onDataLogSessionFinished(@NotNull UUID watchappUUID, + @NotNull DataLogSession session, + @NotNull String watch, + @NotNull Consumer<@NotNull ReceiveResult> responder) { + Log.d("PebbleListenerService", "Data log session " + session + " from app " + watchappUUID + " finished"); + + responder.accept(ReceiveResult.Ack.INSTANCE); + } + @Override protected void onAppOpened(@NotNull UUID watchappUUID, @NotNull String watch) { Log.d("PebbleListenerService", "App " + watchappUUID + " on the watch " + watch + " opened"); diff --git a/sample/android/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.kt b/sample/android/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.kt index fd7bd44..16e1245 100644 --- a/sample/android/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.kt +++ b/sample/android/app/src/main/java/io/rebble/pebblekit2/sample/PebbleListenerService.kt @@ -2,6 +2,7 @@ package io.rebble.pebblekit2.sample import android.util.Log import io.rebble.pebblekit2.client.BasePebbleListenerService +import io.rebble.pebblekit2.common.model.DataLogSession import io.rebble.pebblekit2.common.model.PebbleDictionary import io.rebble.pebblekit2.common.model.ReceiveResult import io.rebble.pebblekit2.common.model.WatchIdentifier @@ -17,6 +18,29 @@ class PebbleListenerService : BasePebbleListenerService() { return ReceiveResult.Ack } + override suspend fun onDataLogReceived( + watchappUUID: UUID, + session: DataLogSession, + data: ByteArray, + itemsLeft: Long, + watch: WatchIdentifier, + ): ReceiveResult { + Log.d( + "PebbleListenerService", + "Received ${data.size / session.itemSize} data log items of session $session from app $watchappUUID" + ) + return ReceiveResult.Ack + } + + override suspend fun onDataLogSessionFinished( + watchappUUID: UUID, + session: DataLogSession, + watch: WatchIdentifier, + ): ReceiveResult { + Log.d("PebbleListenerService", "Data log session $session from app $watchappUUID finished") + return ReceiveResult.Ack + } + override fun onAppOpened(watchappUUID: UUID, watch: WatchIdentifier) { Log.d("PebbleListenerService", "App $watchappUUID on the watch $watch opened") } diff --git a/server-api/src/main/kotlin/io/rebble/pebblekit2/server/PebbleListenerConnector.kt b/server-api/src/main/kotlin/io/rebble/pebblekit2/server/PebbleListenerConnector.kt index 8fc738d..00f03fa 100644 --- a/server-api/src/main/kotlin/io/rebble/pebblekit2/server/PebbleListenerConnector.kt +++ b/server-api/src/main/kotlin/io/rebble/pebblekit2/server/PebbleListenerConnector.kt @@ -1,5 +1,6 @@ package io.rebble.pebblekit2.server +import io.rebble.pebblekit2.common.model.DataLogSession import io.rebble.pebblekit2.common.model.PebbleDictionary import io.rebble.pebblekit2.common.model.ReceiveResult import io.rebble.pebblekit2.common.model.WatchIdentifier @@ -18,6 +19,52 @@ public interface PebbleListenerConnector : AutoCloseable { watch: WatchIdentifier, ): ReceiveResult? + /** + * Send a batch of items from a data logging [session] of one of the registered apps to the + * target app. + * + * [data] must contain only whole items: `data.size` must be a multiple of `session.itemSize`. + * [itemsLeft] is the number of items that stay on the watch after this batch. Send the batches + * of one session in sequence: await the result of a call before you send the next batch of + * that session. Keep one call well below the 1 MB Android binder transaction limit; do not put + * more than 100 KB of data into one call. + * + * [ReceiveResult.Ack] means that the target app stored the data. You can then discard it. + * [ReceiveResult.Nack], [ReceiveResult.Unknown] (the target app has an old PebbleKit library) + * and `null` mean that the target app did not store the data. Keep the data and send it again + * later, a limited number of times. Discard the data when the attempts are used up or when the + * target app is no longer installed. The target app must tolerate a batch that it gets more + * than one time, so a retry after an unclear result is safe. + * + * @return null if the target app could not be reached + */ + public suspend fun sendOnDataLogReceived( + watchappUUID: UUID, + session: DataLogSession, + data: ByteArray, + itemsLeft: Long, + watch: WatchIdentifier, + ): ReceiveResult? { + return null + } + + /** + * Tell the target app that a data logging [session] of one of the registered apps is complete. + * Send this only after the target app acknowledged all the batches of the session. + * + * The result has the same meaning as in [sendOnDataLogReceived]: on [ReceiveResult.Nack], + * [ReceiveResult.Unknown] or `null`, send the event again later, a limited number of times. + * + * @return null if the target app could not be reached + */ + public suspend fun sendOnDataLogSessionFinished( + watchappUUID: UUID, + session: DataLogSession, + watch: WatchIdentifier, + ): ReceiveResult? { + return null + } + /** * One of registered apps for this companion app has been opened on a watch * diff --git a/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt b/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt index b2d234e..1989c84 100644 --- a/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt +++ b/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt @@ -3,10 +3,12 @@ package io.rebble.pebblekit2.server import android.content.Context import android.content.Intent import android.os.Bundle +import android.os.RemoteException import androidx.core.os.bundleOf import io.rebble.pebblekit2.PebbleKitBundleKeys import io.rebble.pebblekit2.common.PebbleKitIntents import io.rebble.pebblekit2.common.UniversalRequestResponse +import io.rebble.pebblekit2.common.model.DataLogSession import io.rebble.pebblekit2.common.model.PebbleDictionary import io.rebble.pebblekit2.common.model.ReceiveResult import io.rebble.pebblekit2.common.model.WatchIdentifier @@ -59,6 +61,60 @@ public class DefaultPebbleListenerConnector( PebbleKitBundleKeys.KEY_WATCH_ID to watch.value ) + return requestReceiveResult(connection, bundle) + } + + /** + * Send a batch of items from a data logging [session] of one of the registered apps to the + * target app. + * + * @return null if the target app could not be reached + */ + override suspend fun sendOnDataLogReceived( + watchappUUID: UUID, + session: DataLogSession, + data: ByteArray, + itemsLeft: Long, + watch: WatchIdentifier, + ): ReceiveResult? { + val connection = connector.getOrConnect() ?: return null + + val bundle = bundleOf( + PebbleKitBundleKeys.KEY_ACTION to PebbleKitBundleKeys.ACTION_DATA_LOG_RECEIVED, + PebbleKitBundleKeys.KEY_WATCHAPP_UUID to watchappUUID.toString(), + PebbleKitBundleKeys.KEY_DATA_LOG_SESSION to session.toBundle(), + PebbleKitBundleKeys.KEY_DATA_LOG_DATA to data, + PebbleKitBundleKeys.KEY_DATA_LOG_ITEMS_LEFT to itemsLeft, + PebbleKitBundleKeys.KEY_WATCH_ID to watch.value + ) + + return requestReceiveResult(connection, bundle) + } + + /** + * Tell the target app that a data logging [session] of one of the registered apps is complete. + * Send this only after the target app acknowledged all the batches of the session. + * + * @return null if the target app could not be reached + */ + override suspend fun sendOnDataLogSessionFinished( + watchappUUID: UUID, + session: DataLogSession, + watch: WatchIdentifier, + ): ReceiveResult? { + val connection = connector.getOrConnect() ?: return null + + val bundle = bundleOf( + PebbleKitBundleKeys.KEY_ACTION to PebbleKitBundleKeys.ACTION_DATA_LOG_SESSION_FINISHED, + PebbleKitBundleKeys.KEY_WATCHAPP_UUID to watchappUUID.toString(), + PebbleKitBundleKeys.KEY_DATA_LOG_SESSION to session.toBundle(), + PebbleKitBundleKeys.KEY_WATCH_ID to watch.value + ) + + return requestReceiveResult(connection, bundle) + } + + private suspend fun requestReceiveResult(connection: UniversalRequestResponse, bundle: Bundle): ReceiveResult? { val returnBundle = try { connection.request(bundle) ?: return null } catch (e: IllegalArgumentException) { @@ -69,6 +125,9 @@ public class DefaultPebbleListenerConnector( } else { throw e } + } catch (ignored: RemoteException) { + // A binder failure, for example TransactionTooLargeException for an oversized bundle + return null } val resultBundle = returnBundle.getBundle(PebbleKitBundleKeys.KEY_RECEIVE_RESULT) ?: Bundle() From eb6e9394c136b53b09fcda245129db6672244b21 Mon Sep 17 00:00:00 2001 From: neelts Date: Wed, 19 Aug 2026 08:20:47 +0200 Subject: [PATCH 2/2] Address review comments The session timestamp is now a kotlin.time.Instant. The bundle still carries epoch seconds. The Java bridges no longer apply a 30-second responder timeout. They await the responder, the same as the message bridge. The connector no longer catches RemoteException. A binder failure now propagates to the caller. --- .../client/java/BaseJavaPebbleListenerService.kt | 15 ++++----------- .../pebblekit2/common/model/DataLogSession.kt | 8 +++++--- .../common/model/DataLogSessionSerialization.kt | 5 +++-- .../server/DefaultPebbleListenerConnector.kt | 4 ---- 4 files changed, 12 insertions(+), 20 deletions(-) diff --git a/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt b/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt index 832a195..6352fd9 100644 --- a/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt +++ b/client-java/src/main/kotlin/io/rebble/pebblekit2/client/java/BaseJavaPebbleListenerService.kt @@ -7,7 +7,6 @@ import io.rebble.pebblekit2.common.model.PebbleDictionaryItem import io.rebble.pebblekit2.common.model.ReceiveResult import io.rebble.pebblekit2.common.model.WatchIdentifier import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID import java.util.function.Consumer @@ -53,8 +52,7 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( { completableDeferred.complete(it) }, ) - return withTimeoutOrNull(DATA_LOG_RESPONDER_TIMEOUT_MS) { completableDeferred.await() } - ?: ReceiveResult.Nack + return completableDeferred.await() } final override suspend fun onDataLogSessionFinished( @@ -71,8 +69,7 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( { completableDeferred.complete(it) }, ) - return withTimeoutOrNull(DATA_LOG_RESPONDER_TIMEOUT_MS) { completableDeferred.await() } - ?: ReceiveResult.Nack + return completableDeferred.await() } final override fun onAppOpened(watchappUUID: UUID, watch: WatchIdentifier) { @@ -116,8 +113,7 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( * You MUST call [responder] after you are done processing this callback. Use * [ReceiveResult.Ack] only after you stored the data; the Pebble app can then discard it. Use * [ReceiveResult.Nack] if you could not store the data; the Pebble app can then try the - * delivery again later. If you do not call [responder] in 30 seconds, the library answers - * [ReceiveResult.Nack]. + * delivery again later. */ protected open fun onDataLogReceived( watchappUUID: UUID, @@ -137,8 +133,7 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( * * Passed [watch] parameter corresponds to the [WatchIdentifier.value]. * - * You MUST call [responder] after you are done processing this callback. If you do not call - * [responder] in 30 seconds, the library answers [ReceiveResult.Nack]. + * You MUST call [responder] after you are done processing this callback. */ protected open fun onDataLogSessionFinished( watchappUUID: UUID, @@ -166,5 +161,3 @@ public abstract class BaseJavaPebbleListenerService : BasePebbleListenerService( protected open fun onAppClosed(watchappUUID: UUID, watch: String) { } } - -private const val DATA_LOG_RESPONDER_TIMEOUT_MS = 30_000L diff --git a/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt index d21f936..cd813da 100644 --- a/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt +++ b/common-api/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSession.kt @@ -1,5 +1,7 @@ package io.rebble.pebblekit2.common.model +import kotlin.time.Instant + /** * A data logging session. The watch makes a session when a watchapp calls `data_logging_create()`. * @@ -14,10 +16,10 @@ public data class DataLogSession( val tag: Long, /** - * The Unix time, in seconds, when the watch made the session. It separates two sessions with - * the same [tag], unless the watchapp made both in the same second. + * The time when the watch made the session, with one-second resolution. It separates two + * sessions with the same [tag], unless the watchapp made both in the same second. */ - val timestamp: Long, + val timestamp: Instant, /** * The size, in bytes, of one data item. diff --git a/common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt index 49fe262..b16075c 100644 --- a/common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt +++ b/common/src/main/kotlin/io/rebble/pebblekit2/common/model/DataLogSessionSerialization.kt @@ -1,11 +1,12 @@ package io.rebble.pebblekit2.common.model import android.os.Bundle +import kotlin.time.Instant public fun DataLogSession.Companion.fromBundle(bundle: Bundle): DataLogSession { return DataLogSession( tag = bundle.getLong(BUNDLE_KEY_TAG), - timestamp = bundle.getLong(BUNDLE_KEY_TIMESTAMP), + timestamp = Instant.fromEpochSeconds(bundle.getLong(BUNDLE_KEY_TIMESTAMP)), itemSize = bundle.getInt(BUNDLE_KEY_ITEM_SIZE), ) } @@ -14,7 +15,7 @@ public fun DataLogSession.toBundle(): Bundle { val bundle = Bundle() bundle.putLong(BUNDLE_KEY_TAG, tag) - bundle.putLong(BUNDLE_KEY_TIMESTAMP, timestamp) + bundle.putLong(BUNDLE_KEY_TIMESTAMP, timestamp.epochSeconds) bundle.putInt(BUNDLE_KEY_ITEM_SIZE, itemSize) return bundle diff --git a/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt b/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt index 1989c84..42c381e 100644 --- a/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt +++ b/server/src/main/kotlin/io/rebble/pebblekit2/server/DefaultPebbleListenerConnector.kt @@ -3,7 +3,6 @@ package io.rebble.pebblekit2.server import android.content.Context import android.content.Intent import android.os.Bundle -import android.os.RemoteException import androidx.core.os.bundleOf import io.rebble.pebblekit2.PebbleKitBundleKeys import io.rebble.pebblekit2.common.PebbleKitIntents @@ -125,9 +124,6 @@ public class DefaultPebbleListenerConnector( } else { throw e } - } catch (ignored: RemoteException) { - // A binder failure, for example TransactionTooLargeException for an oversized bundle - return null } val resultBundle = returnBundle.getBundle(PebbleKitBundleKeys.KEY_RECEIVE_RESULT) ?: Bundle()