From d0b4dd5249704030f7fc99ae088e968b411c7eca Mon Sep 17 00:00:00 2001 From: otsmr Date: Sat, 5 Sep 2026 01:59:17 +0200 Subject: [PATCH] improving widget and apps --- android/app/src/main/AndroidManifest.xml | 7 + .../src/main/kotlin/eu/twonly/MainActivity.kt | 11 + .../kotlin/eu/twonly/webxdc/WebxdcChannel.kt | 165 + .../kotlin/eu/twonly/webxdc/WebxdcView.kt | 425 +++ .../widget/TwonlyWidgetConfigureActivity.kt | 7 +- .../eu/twonly/widget/TwonlyWidgetProvider.kt | 107 +- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 1 + ios/Runner/WebxdcHost.swift | 470 +++ ios/Shared/TwonlyWidgetShared.swift | 36 +- ios/TwonlyWidget/TwonlyWidget.swift | 112 +- lib/core/bridge/api.dart | 4 + lib/core/bridge/webxdc.dart | 260 ++ lib/core/frb_generated.dart | 1065 +++++- lib/core/frb_generated.io.dart | 120 + lib/core/frb_generated.web.dart | 120 + lib/main.dart | 6 +- lib/src/constants/routes.keys.dart | 1 - lib/src/database/daos/messages.dao.dart | 15 +- lib/src/database/tables/messages.table.dart | 9 +- lib/src/database/tables/webxdc.table.dart | 82 + lib/src/database/twonly.db.dart | 4 + lib/src/database/twonly.db.g.dart | 3146 +++++++++++++++++ .../generated/app_localizations.dart | 192 +- .../generated/app_localizations_de.dart | 112 +- .../generated/app_localizations_en.dart | 111 +- lib/src/localization/translations/de.arb | 65 +- lib/src/localization/translations/en.arb | 65 +- .../protobuf/client/generated/data.pb.dart | 311 ++ .../client/generated/data.pbenum.dart | 11 +- .../client/generated/data.pbjson.dart | 119 +- lib/src/providers/routing.provider.dart | 5 - lib/src/services/home_widget.service.dart | 129 +- .../notifications/native.notifications.dart | 7 +- lib/src/services/webxdc/webxdc.service.dart | 259 ++ lib/src/services/webxdc/webxdc_host.dart | 495 +++ .../views/chats/chat_messages.view.dart | 18 +- .../share_additional.bottom_sheet.dart | 68 +- .../webxdc_store.bottom_sheet.dart | 183 + .../chat_list_entry.dart | 20 + .../entries/chat_webxdc.entry.dart | 214 ++ .../entries/chat_webxdc_message.entry.dart | 211 ++ .../entries/common.dart | 8 + .../entries/webxdc_app_icon.comp.dart | 61 + .../message_context_menu.dart | 21 + .../developer/home_widget_developer.view.dart | 14 +- .../views/settings/settings_main.view.dart | 5 - .../settings/widgets/widget_detail.view.dart | 185 - .../widgets/widget_setup_guide.comp.dart | 69 - .../views/settings/widgets/widgets.view.dart | 161 - .../visual/views/webxdc/webxdc_app.view.dart | 153 + ...a73fd0f0424de549a69739d6d0ff16b57710a.json | 26 + ...e7ffe617df1295c20b4f0fa457bfda6fa7785.json | 21 + ...49ef313f0d4c8e3dbddf38e6d67868be1f5a9.json | 28 + ...df878ad6deb92bd6eee6d23cdba4ea2eb108c.json | 50 + ...3fd46f3fbde9940e6a221fb54671f446ca596.json | 12 + ...01248900b5a59eebf04ff5ede8f45dd5d9398.json | 110 + ...dc44463a335faef338601689ce148d48d1f5d.json | 12 + ...761fe69934fb2118cf48ff845a3524c7ffead.json | 12 + ...6c56d77bbe74c67cba8ae457844de4c6237c4.json | 12 + ...be4ac1574be1610596b9f3fce0fee39761e87.json | 21 + ...9fd22ab22b783f8d985fe2ce92501503dc83.json} | 11 +- ...64319eb8768e2c631f493a53f5b997a040761.json | 26 + ...fd3f287b1d8507c4d759c1fc97be01cb9872c.json | 62 + ...c55a77dec6c9192bcd72c3f02858fa2b23657.json | 12 + ...3f103dbe5ea938cc6454b64d1b3e67ab9f772.json | 12 + ...6b1f15d3b580adca9adfe27b507cd0f046164.json | 38 + ...fc3eee919224d56a6d3232c44de29d51eb2cb.json | 12 + ...19e553fa4d20a474dc862c3f9c1d518ec2397.json | 21 + ...868d0ac30145736156003e223d3ab54830fa6.json | 12 + ...d9a5586a1f75b84a609c61fd2f18faa67832f.json | 12 - ...1ec86c4b99309441a4506e998beedfcead834.json | 110 + ...ef89416ff0cfb32139abff226eec319053a68.json | 12 - ...b6ad23cff6c8932f61b48645c431dd7adaf48.json | 12 + ...3e9ad70f36f2616333a71d69bc5cc7a2781e3.json | 12 + ...b64e9e55d162dd5b8b3b3791c82150e4a73f.json} | 6 +- ...f94add71edc00fe4625dc143948b7cb76daf2.json | 12 + ...7ff27ca739f11202c2ea91e429bb8598ce928.json | 12 + ...2805fd8ebaaf5400b87b73b4439e43080c19.json} | 6 +- ...33c53e52f20e6deca7054fd1161277a9e27e.json} | 4 +- ...9c6163d5ba4daeced9f699aa6a9d9b15b1d3.json} | 11 +- ...c8a0e8a8b054c1aac8ad2526694a3148fac04.json | 74 + ...d930a8e009746f50f69ac68bd2ff7d24858ef.json | 12 + ...4d2dee95adc3d9a9f3d023f7d7673d31ba039.json | 21 + rust/src/api/groups.rs | 5 + .../api/messages/incoming/additional_data.rs | 53 +- rust/src/api/messages/incoming/messages.rs | 76 +- .../src/api/messages/incoming/text_message.rs | 1 + rust/src/api/messages/outgoing.rs | 59 +- .../api/proto/api/http/http_requests.proto | 27 + rust/src/api/proto/client/data.proto | 38 + rust/src/api/proto/client/messages.proto | 19 +- rust/src/bridge/api.rs | 6 +- rust/src/bridge/mod.rs | 1 + rust/src/bridge/webxdc.rs | 213 ++ rust/src/context.rs | 26 +- .../database/app/migrations/0014_webxdc.sql | 71 + .../app/migrations/0015_webxdc_store.sql | 39 + .../app/migrations/0016_webxdc_app_names.sql | 8 + rust/src/error.rs | 6 + rust/src/frb_generated.rs | 1022 +++++- rust/src/services/direct_media_upload.rs | 5 +- rust/src/services/groups/mod.rs | 204 +- rust/src/services/home_widget.rs | 267 +- rust/src/services/media_upload.rs | 6 +- rust/src/services/messages.rs | 93 +- rust/src/services/mod.rs | 1 + rust/src/services/notifications.rs | 188 +- rust/src/services/webxdc/bundle.rs | 276 ++ rust/src/services/webxdc/mod.rs | 909 +++++ rust/src/services/webxdc/store.rs | 378 ++ rust/src/services/webxdc/webxdc.js | 169 + rust/tests/api.rs | 32 +- rust/tests/api/contacts.rs | 9 +- rust/tests/api/direct_media_upload.rs | 11 +- rust/tests/api/group_resilience.rs | 22 +- rust/tests/api/notifications.rs | 67 +- rust/tests/api/session_recovery.rs | 4 +- rust/tests/api/tester.rs | 54 +- rust/tests/api/user_discovery.rs | 2 +- 120 files changed, 12800 insertions(+), 1842 deletions(-) create mode 100644 android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcChannel.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcView.kt create mode 100644 ios/Runner/WebxdcHost.swift create mode 100644 lib/core/bridge/webxdc.dart create mode 100644 lib/src/database/tables/webxdc.table.dart create mode 100644 lib/src/services/webxdc/webxdc.service.dart create mode 100644 lib/src/services/webxdc/webxdc_host.dart create mode 100644 lib/src/visual/views/chats/chat_messages_components/bottom_sheets/webxdc_store.bottom_sheet.dart create mode 100644 lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc.entry.dart create mode 100644 lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc_message.entry.dart create mode 100644 lib/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart delete mode 100644 lib/src/visual/views/settings/widgets/widget_detail.view.dart delete mode 100644 lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart delete mode 100644 lib/src/visual/views/settings/widgets/widgets.view.dart create mode 100644 lib/src/visual/views/webxdc/webxdc_app.view.dart create mode 100644 rust/.sqlx/query-035624a9d81984e031f6d69d0b0a73fd0f0424de549a69739d6d0ff16b57710a.json create mode 100644 rust/.sqlx/query-03b9944c6234e269f00c123d5d0e7ffe617df1295c20b4f0fa457bfda6fa7785.json create mode 100644 rust/.sqlx/query-1690c503c4d633e891b40671af249ef313f0d4c8e3dbddf38e6d67868be1f5a9.json create mode 100644 rust/.sqlx/query-19c1f52003002eee3c02a7c1bd3df878ad6deb92bd6eee6d23cdba4ea2eb108c.json create mode 100644 rust/.sqlx/query-2066610ac883966a5d38cb80c193fd46f3fbde9940e6a221fb54671f446ca596.json create mode 100644 rust/.sqlx/query-287521b40159e737315505b74bb01248900b5a59eebf04ff5ede8f45dd5d9398.json create mode 100644 rust/.sqlx/query-2f40db61376840cb99ba77cc908dc44463a335faef338601689ce148d48d1f5d.json create mode 100644 rust/.sqlx/query-3c6a9504fe958ab1dfd2330078d761fe69934fb2118cf48ff845a3524c7ffead.json create mode 100644 rust/.sqlx/query-47006731399a7be81a9fff3982a6c56d77bbe74c67cba8ae457844de4c6237c4.json create mode 100644 rust/.sqlx/query-563a47296cffb2edfe6dd5c4b15be4ac1574be1610596b9f3fce0fee39761e87.json rename rust/.sqlx/{query-cf821c9b1530db447a1ba030c3be50c5eeeb78cb28fe74481d6493a6a0af4161.json => query-674895e43478d91c66caeb1cee349fd22ab22b783f8d985fe2ce92501503dc83.json} (57%) create mode 100644 rust/.sqlx/query-87e855a064b824324330f60b0fb64319eb8768e2c631f493a53f5b997a040761.json create mode 100644 rust/.sqlx/query-8b5cf3984e457319657888e04e5fd3f287b1d8507c4d759c1fc97be01cb9872c.json create mode 100644 rust/.sqlx/query-8c0e68f56a7d625bfc87bb5991ac55a77dec6c9192bcd72c3f02858fa2b23657.json create mode 100644 rust/.sqlx/query-8f5e08747a47ed6707dddc09aeb3f103dbe5ea938cc6454b64d1b3e67ab9f772.json create mode 100644 rust/.sqlx/query-90ff8eee4b84514f67c14673e986b1f15d3b580adca9adfe27b507cd0f046164.json create mode 100644 rust/.sqlx/query-9b2c09fdedde17a608aac9b80d6fc3eee919224d56a6d3232c44de29d51eb2cb.json create mode 100644 rust/.sqlx/query-a0236f0973f6b96b9546e53df8619e553fa4d20a474dc862c3f9c1d518ec2397.json create mode 100644 rust/.sqlx/query-a2b3853b9a075ef533e61c0630b868d0ac30145736156003e223d3ab54830fa6.json delete mode 100644 rust/.sqlx/query-a2e8523eb0be989585370d3d8b1d9a5586a1f75b84a609c61fd2f18faa67832f.json create mode 100644 rust/.sqlx/query-b71a58e0499f8b69debddba47cf1ec86c4b99309441a4506e998beedfcead834.json delete mode 100644 rust/.sqlx/query-c6818f3b395db59a7fbe80435c4ef89416ff0cfb32139abff226eec319053a68.json create mode 100644 rust/.sqlx/query-cc6625aa285f9e6dcdc1a73d7bfb6ad23cff6c8932f61b48645c431dd7adaf48.json create mode 100644 rust/.sqlx/query-cfa0b29edf334cdec34ade89ff33e9ad70f36f2616333a71d69bc5cc7a2781e3.json rename rust/.sqlx/{query-fedeeeaee2ad31906726430055edac23359bb61ff80d53632c95b40837d504f3.json => query-cffb25fc9ca6a574dbafd9a4f661b64e9e55d162dd5b8b3b3791c82150e4a73f.json} (51%) create mode 100644 rust/.sqlx/query-d2977db7648dfba60704ffa01ecf94add71edc00fe4625dc143948b7cb76daf2.json create mode 100644 rust/.sqlx/query-d90e5fe15c88c30f9f4ffc90fd87ff27ca739f11202c2ea91e429bb8598ce928.json rename rust/.sqlx/{query-732496b1ad12c49c3aca6925d45abc4c801838e606d9bfd3f728f4e1dd91dd3a.json => query-e1154392dd25c96e3ac0e5dd3c5a2805fd8ebaaf5400b87b73b4439e43080c19.json} (54%) rename rust/.sqlx/{query-7ba6ad25a53db6d9ee725ad8dd06a58c119e4f445edf966b97016fb6d598f09a.json => query-e6a42d65c955aa13817fd01b4be533c53e52f20e6deca7054fd1161277a9e27e.json} (55%) rename rust/.sqlx/{query-4ad8ea916d6113c45fef05150f2e619e06ac351ac0b7d0a9b340835ac7109fdf.json => query-eac6f09e21a49428007bb59568259c6163d5ba4daeced9f699aa6a9d9b15b1d3.json} (55%) create mode 100644 rust/.sqlx/query-f436f2290486b44d631dd516159c8a0e8a8b054c1aac8ad2526694a3148fac04.json create mode 100644 rust/.sqlx/query-f8f16df05f05636feb28a5c783ad930a8e009746f50f69ac68bd2ff7d24858ef.json create mode 100644 rust/.sqlx/query-f98e1d298509f8febb8e8bd00344d2dee95adc3d9a9f3d023f7d7673d31ba039.json create mode 100644 rust/src/bridge/webxdc.rs create mode 100644 rust/src/database/app/migrations/0014_webxdc.sql create mode 100644 rust/src/database/app/migrations/0015_webxdc_store.sql create mode 100644 rust/src/database/app/migrations/0016_webxdc_app_names.sql create mode 100644 rust/src/services/webxdc/bundle.rs create mode 100644 rust/src/services/webxdc/mod.rs create mode 100644 rust/src/services/webxdc/store.rs create mode 100644 rust/src/services/webxdc/webxdc.js diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3e129247..3953f290 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,6 +59,13 @@ + + + when (call.method) { @@ -107,6 +117,7 @@ class MainActivity : FlutterFragmentActivity() { override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { NotificationTapChannel.detach() WidgetRuntimeChannel.detach() + WebxdcChannel.detach() super.cleanUpFlutterEngine(flutterEngine) } } diff --git a/android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcChannel.kt b/android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcChannel.kt new file mode 100644 index 00000000..bf0887ed --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcChannel.kt @@ -0,0 +1,165 @@ +package eu.twonly.webxdc + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import android.webkit.WebStorage +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * The Dart side of the webxdc runtime. + * + * The webview is a platform view embedded in an ordinary Flutter route, so the + * app runs inside twonly rather than on a screen of its own. Everything the + * page asks for is answered by Dart, which asks Rust: nothing here reads a + * bundle, decides a limit, or trusts a value the page produced. + * + * Shares its channel name with the iOS implementation, so Dart talks to one + * channel on both platforms. + */ +object WebxdcChannel { + private const val CHANNEL = "eu.twonly/webxdc" + + private var channel: MethodChannel? = null + private val main = Handler(Looper.getMainLooper()) + + fun configure(flutterEngine: FlutterEngine, activity: Activity) { + val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + channel.setMethodCallHandler { call, result -> handle(call, result) } + this.channel = channel + + flutterEngine.platformViewsController.registry.registerViewFactory( + WebxdcViewFactory.VIEW_TYPE, + WebxdcViewFactory(activity), + ) + } + + fun detach() { + channel?.setMethodCallHandler(null) + channel = null + } + + private fun handle(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "deliver" -> { + val instanceId = call.argument("instanceId") + val message = call.argument("message") + if (instanceId != null && message != null) { + WebxdcView.deliver(instanceId, message) + } + result.success(null) + } + + "setPaused" -> { + // The screen showing the app is animating in or out, and a page + // that keeps drawing competes with the animation for the very + // frames it needs. + val instanceId = call.argument("instanceId") + val paused = call.argument("paused") + if (instanceId != null && paused != null) { + WebxdcView.setPaused(instanceId, paused) + } + result.success(null) + } + + "clearOrigin" -> { + // The update log is only half an app's state; the rest is + // whatever it put in localStorage or IndexedDB, which only the + // WebView can delete. + val origin = call.argument("origin") + if (origin != null) { + WebStorage.getInstance().deleteOrigin(WebxdcView.originUrl(origin)) + } + result.success(null) + } + + else -> result.notImplemented() + } + } + + /** + * Asks Dart for one file out of the bundle. + * + * Called on the WebView's background thread, which needs an answer before + * it returns, so the caller blocks while the channel call runs on the main + * thread. Different threads, so the wait cannot deadlock; the timeout + * covers an engine that has gone away. + */ + fun serve(instanceId: String, path: String): ServedResponse? { + var response: ServedResponse? = null + awaitReply("serve", mapOf("instanceId" to instanceId, "path" to path), 10) { value -> + @Suppress("UNCHECKED_CAST") + val map = value as? Map ?: return@awaitReply + response = ServedResponse( + status = (map["status"] as? Number)?.toInt() ?: 500, + mime = map["mime"] as? String ?: "application/octet-stream", + headerNames = (map["headerNames"] as? List<*>)?.map { it.toString() } ?: emptyList(), + headerValues = (map["headerValues"] as? List<*>)?.map { it.toString() } ?: emptyList(), + body = map["body"] as? ByteArray ?: ByteArray(0), + ) + } + return response + } + + /** Forwards one `webxdc.js` call and returns the JSON reply for the page. */ + fun bridge(instanceId: String, message: String): String { + var reply = "{\"error\":\"unavailable\"}" + awaitReply( + "bridge", + mapOf("instanceId" to instanceId, "message" to message), + 30, + ) { value -> + reply = value as? String ?: reply + } + return reply + } + + /** + * Hands a link the user tapped to Dart, which shows the whole URL and asks + * before anything opens. Fire and forget: the page is not told. + */ + fun openLink(url: String) { + main.post { channel?.invokeMethod("openLink", mapOf("url" to url)) } + } + + private fun awaitReply( + method: String, + arguments: Map, + timeoutSeconds: Long, + onSuccess: (Any?) -> Unit, + ) { + val channel = this.channel ?: return + val latch = java.util.concurrent.CountDownLatch(1) + main.post { + channel.invokeMethod( + method, + arguments, + object : MethodChannel.Result { + override fun success(value: Any?) { + onSuccess(value) + latch.countDown() + } + + override fun error(code: String, message: String?, details: Any?) { + latch.countDown() + } + + override fun notImplemented() { + latch.countDown() + } + }, + ) + } + latch.await(timeoutSeconds, java.util.concurrent.TimeUnit.SECONDS) + } + + data class ServedResponse( + val status: Int, + val mime: String, + val headerNames: List, + val headerValues: List, + val body: ByteArray, + ) +} diff --git a/android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcView.kt b/android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcView.kt new file mode 100644 index 00000000..b1a4c551 --- /dev/null +++ b/android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcView.kt @@ -0,0 +1,425 @@ +package eu.twonly.webxdc + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.net.Uri +import android.view.View +import android.webkit.JavascriptInterface +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory +import java.io.ByteArrayInputStream +import org.json.JSONArray +import org.json.JSONObject + +class WebxdcViewFactory(private val activity: Activity) : + PlatformViewFactory(StandardMessageCodec.INSTANCE) { + + companion object { + const val VIEW_TYPE = "eu.twonly/webxdc_webview" + } + + override fun create(context: Context, viewId: Int, args: Any?): PlatformView { + @Suppress("UNCHECKED_CAST") + val params = args as? Map ?: emptyMap() + return WebxdcView( + activity, + instanceId = params["instanceId"] as? String ?: "", + origin = params["origin"] as? String ?: "", + ) + } +} + +/** + * The one WebView twonly ever creates, embedded in a Flutter route. + * + * A webxdc app is third-party code, so this view is built around denying it + * things: + * + * - every request is answered from the bundle or refused; nothing reaches the + * network, because [WebViewClient.shouldInterceptRequest] never returns null + * for a request it did not serve itself, + * - the origin is unique per instance, so the browser's own origin model keeps + * one app's storage out of reach of every other app, + * - every permission the page can ask for is denied without a prompt, + * - navigation away from the app's own origin is cancelled, and a link the + * user taps goes to Dart for confirmation before it reaches a browser. + * + * The bundle is read in Rust and arrives here as bytes with the headers already + * attached; nothing here parses a zip or decides a policy. + */ +class WebxdcView( + private val activity: Activity, + private val instanceId: String, + private val origin: String, +) : PlatformView { + + companion object { + private const val IMPORT_REQUEST = 4711 + + /** Refuses to read more than this in one import, however many files. */ + private const val MAX_IMPORT_BYTES = 32 * 1024 * 1024 + + private var current: WebxdcView? = null + + /** + * `https` rather than a custom scheme, so the page is a secure context + * and `crypto.subtle`, IndexedDB and workers behave as apps expect. + * `.localhost` can never resolve, and nothing is ever fetched anyway. + */ + fun originUrl(origin: String): String = "https://$origin.webxdc.localhost" + + fun deliver(instanceId: String, message: String) { + val view = current ?: return + if (view.instanceId != instanceId) return + view.webView.post { view.deliverToPage(message) } + } + + /** Stops or restarts the app while its screen is animating. */ + fun setPaused(instanceId: String, paused: Boolean) { + val view = current ?: return + if (view.instanceId != instanceId) return + view.webView.post { view.setPaused(paused) } + } + + /** + * Routes a picker result back to the view that opened it. Called by the + * activity, which is the only thing that receives one. + */ + fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode != IMPORT_REQUEST) return false + current?.finishImport(resultCode, data) + return true + } + } + + private val webView: WebView + /** The `importFiles` call waiting on the picker, if one is open. */ + private var importCallId: Int? = null + private var importExtensions: List = emptyList() + + init { + current = this + webView = WebView(activity) + + @SuppressLint("SetJavaScriptEnabled") + webView.settings.apply { + javaScriptEnabled = true + // Apps keep their state here, and the unique origin is what keeps + // it to themselves. + domStorageEnabled = true + + // No path a page could use to read the device's files. + allowFileAccess = false + allowContentAccess = false + @Suppress("DEPRECATION") + allowFileAccessFromFileURLs = false + @Suppress("DEPRECATION") + allowUniversalAccessFromFileURLs = false + + setGeolocationEnabled(false) + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(false) + mediaPlaybackRequiresUserGesture = true + cacheMode = WebSettings.LOAD_NO_CACHE + mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + builtInZoomControls = false + displayZoomControls = false + } + + // A remote debugger attached to a page running somebody else's code is + // not something a release build should offer. + WebView.setWebContentsDebuggingEnabled( + (activity.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0, + ) + + webView.addJavascriptInterface(Bridge(), "__twonlyWebxdcBridge") + webView.webViewClient = Client() + webView.webChromeClient = ChromeClient() + + // Nothing is injected into the document. `webxdc.js` is served with its + // values already in it, and `__twonlyWebxdcBridge` exists before any + // page script runs, so there is no window in which app code could + // observe or race the setup. + webView.loadUrl("${originUrl(origin)}/index.html") + } + + override fun getView(): View = webView + + override fun dispose() { + if (current === this) { + current = null + } + // Stopped now, destroyed on the next turn of the main loop. A + // synchronous destroy spends its milliseconds on the frame that removes + // the view, which is the last frame of the closing animation and the + // one place the cost is visible. + webView.onPause() + webView.post { + // Timers are a process-wide setting, so they are handed back before + // this view goes: the next app opened must not start frozen. + webView.resumeTimers() + webView.destroy() + } + } + + /** + * Rendering and timers, stopped while the route is animating away. + * + * A game redraws every frame it is given, and those are exactly the frames + * the transition needs. Nothing is lost by this: the page keeps its state, + * it is the webview's own pause rather than a reload. + */ + private fun setPaused(paused: Boolean) { + if (paused) { + webView.onPause() + webView.pauseTimers() + } else { + webView.onResume() + webView.resumeTimers() + } + } + + private fun quote(value: String): String = JSONObject.quote(value) + + private fun deliverToPage(message: String) { + webView.evaluateJavascript( + "window.__twonlyWebxdcDeliver(JSON.parse(${quote(message)}))", + null, + ) + } + + /** The single entry point from the page into twonly. */ + private inner class Bridge { + @JavascriptInterface + fun call(message: String): String { + // `importFiles` is answered here rather than in Dart: the picker + // is an activity result, and the bytes the user chose have no + // reason to travel any further than the page that asked for them. + val parsed = try { + JSONObject(message) + } catch (error: Exception) { + null + } + if (parsed != null && parsed.optString("method") == "importFiles") { + webView.post { startImport(parsed) } + // Empty means "answered later"; the page keeps waiting for a + // delivery rather than treating this as the reply. + return "" + } + + // The instance is the one this view was created for. A page cannot + // name a different one, whatever it puts in the message. + return WebxdcChannel.bridge(instanceId, message) + } + } + + private fun startImport(call: JSONObject) { + if (importCallId != null) { + // One picker at a time; a second request while one is open is + // answered empty rather than queued. + deliverImportResult(call.optInt("id"), JSONArray()) + return + } + importCallId = call.optInt("id") + + val params = call.optJSONObject("params") ?: JSONObject() + importExtensions = params.optJSONArray("extensions").toStringList() + .map { it.lowercase().removePrefix(".") } + val mimeTypes = params.optJSONArray("mimeTypes").toStringList() + + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + if (mimeTypes.isNotEmpty()) { + putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray()) + } + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.optBoolean("multiple", false)) + } + try { + activity.startActivityForResult(intent, IMPORT_REQUEST) + } catch (error: Exception) { + deliverImportResult(importCallId ?: 0, JSONArray()) + importCallId = null + } + } + + private fun finishImport(resultCode: Int, data: Intent?) { + val callId = importCallId ?: return + importCallId = null + + val files = JSONArray() + if (resultCode == Activity.RESULT_OK && data != null) { + var budget = MAX_IMPORT_BYTES + for (uri in data.selectedUris()) { + val entry = readImportedFile(uri, budget) ?: continue + budget -= entry.second + files.put(entry.first) + if (budget <= 0) break + } + } + // A cancelled picker resolves with nothing rather than rejecting: the + // app asked to import, the user declined, and that is not an error. + deliverImportResult(callId, files) + } + + private fun Intent.selectedUris(): List { + val clip = clipData + if (clip != null) { + return (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } + } + return listOfNotNull(data) + } + + /** Returns the JSON for one file and how many bytes it cost. */ + private fun readImportedFile(uri: Uri, budget: Int): Pair? { + val resolver = activity.contentResolver + val name = displayName(uri) ?: return null + if (importExtensions.isNotEmpty() && + !importExtensions.contains(name.substringAfterLast('.', "").lowercase()) + ) { + return null + } + // Grown as the file is read rather than allocated at the ceiling: the + // budget is what a file may not exceed, not what every file costs. + val bytes = try { + resolver.openInputStream(uri)?.use { stream -> + val collected = java.io.ByteArrayOutputStream() + val chunk = ByteArray(64 * 1024) + while (true) { + val read = stream.read(chunk) + if (read <= 0) break + if (collected.size() + read > budget) return null + collected.write(chunk, 0, read) + } + collected.toByteArray() + } + } catch (error: Exception) { + null + } ?: return null + + val entry = JSONObject() + .put("name", name) + .put("type", resolver.getType(uri) ?: "") + .put("base64", android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)) + return entry to bytes.size + } + + private fun displayName(uri: Uri): String? { + activity.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) { + // Only the file's own name; a document provider is free to put + // anything in here, and it must not read as a path. + return cursor.getString(index)?.substringAfterLast('/') + } + } + return uri.lastPathSegment?.substringAfterLast('/') + } + + private fun deliverImportResult(callId: Int, files: JSONArray) { + deliverToPage(JSONObject().put("id", callId).put("result", files).toString()) + } + + private fun JSONArray?.toStringList(): List { + if (this == null) return emptyList() + return (0 until length()).mapNotNull { optString(it).takeIf { value -> value.isNotEmpty() } } + } + + private inner class Client : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest, + ): WebResourceResponse? { + val url = request.url + if (!isOwnOrigin(url)) { + // Every other host, including the ones a CSS `url()` or an + // injected script might reach for. Refused here rather than by + // CSP, which has too much history of gaps to be the only + // control. + return refused() + } + + val served = WebxdcChannel.serve(instanceId, url.encodedPath ?: "/") + ?: return refused() + + val headers = LinkedHashMap() + served.headerNames.forEachIndexed { index, name -> + served.headerValues.getOrNull(index)?.let { headers[name] = it } + } + + return WebResourceResponse( + served.mime.substringBefore(';'), + "utf-8", + served.status, + if (served.status == 200) "OK" else "Error", + headers, + ByteArrayInputStream(served.body), + ) + } + + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + if (isOwnOrigin(request.url)) { + return false + } + // A link out of the app. Never followed here: Dart shows the whole + // URL and says it leaves twonly before anything opens. + if (request.hasGesture()) { + WebxdcChannel.openLink(request.url.toString()) + } + return true + } + + private fun refused(): WebResourceResponse = WebResourceResponse( + "text/plain", + "utf-8", + 403, + "Forbidden", + emptyMap(), + ByteArrayInputStream(ByteArray(0)), + ) + + private fun isOwnOrigin(url: Uri): Boolean = + url.scheme == "https" && url.host == "$origin.webxdc.localhost" + } + + private inner class ChromeClient : WebChromeClient() { + /** Camera, microphone, midi, protected media: all of it, denied. */ + override fun onPermissionRequest(request: PermissionRequest) = request.deny() + + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: android.webkit.GeolocationPermissions.Callback, + ) = callback.invoke(origin, false, false) + + override fun onCreateWindow( + view: WebView, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: android.os.Message, + ): Boolean = false + + /** No file chooser: files cross the boundary only through importFiles. */ + override fun onShowFileChooser( + webView: WebView, + filePathCallback: android.webkit.ValueCallback>, + fileChooserParams: FileChooserParams, + ): Boolean { + filePathCallback.onReceiveValue(null) + return true + } + } +} diff --git a/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt index 441d9d0d..7b907da2 100644 --- a/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt +++ b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt @@ -85,12 +85,7 @@ class TwonlyWidgetConfigureActivity : AppCompatActivity() { TwonlyWidgetProvider.preferences(this) .edit().putStringSet(TwonlyWidgetProvider.groupsKey(widgetId), selected).apply() persistNativeConfiguration(this) - TwonlyWidgetProvider.update( - this, - AppWidgetManager.getInstance(this), - widgetId, - advance = false, - ) + TwonlyWidgetProvider.update(this, AppWidgetManager.getInstance(this), widgetId) setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)) finish() } diff --git a/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt index 8d618335..988a8d46 100644 --- a/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt +++ b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt @@ -5,7 +5,6 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.ComponentName import android.content.Context -import android.content.Intent import android.graphics.BitmapFactory import android.view.View import android.widget.RemoteViews @@ -15,108 +14,72 @@ import org.json.JSONObject class TwonlyWidgetProvider : AppWidgetProvider() { override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { - ids.forEach { update(context, manager, it, advance = true) } - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - if (intent.action != ACTION_ADVANCE) return - val widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) - if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { - update(context, AppWidgetManager.getInstance(context), widgetId, advance = true) - } + ids.forEach { update(context, manager, it) } } override fun onDeleted(context: Context, appWidgetIds: IntArray) { val preferences = preferences(context) - appWidgetIds.forEach { id -> - preferences.edit().remove(groupsKey(id)).remove(indexKey(id)).remove(newestKey(id)).apply() - } + appWidgetIds.forEach { id -> preferences.edit().remove(groupsKey(id)).apply() } TwonlyWidgetConfigureActivity.persistNativeConfiguration(context) } companion object { - const val ACTION_ADVANCE = "eu.twonly.widget.ADVANCE" private const val PREFERENCES = "twonly_home_widgets" fun preferences(context: Context) = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) fun groupsKey(widgetId: Int) = "groups_$widgetId" - private fun indexKey(widgetId: Int) = "index_$widgetId" - - /** The newest image this widget has already drawn. */ - private fun newestKey(widgetId: Int) = "newest_$widgetId" fun refreshAll(context: Context) { val manager = AppWidgetManager.getInstance(context) val ids = manager.getAppWidgetIds(ComponentName(context, TwonlyWidgetProvider::class.java)) - ids.forEach { update(context, manager, it, advance = false) } + ids.forEach { update(context, manager, it) } } - fun update(context: Context, manager: AppWidgetManager, widgetId: Int, advance: Boolean) { + /** + * Draws the one image this widget has: the most recent one the app + * published for any of its contact groups. The app keeps a single image + * per group and deletes the one it replaces, so the manifest β€” written + * newest first β€” offers nothing else to fall back on. + */ + fun update(context: Context, manager: AppWidgetManager, widgetId: Int) { val views = RemoteViews(context.packageName, R.layout.twonly_widget) val selected = preferences(context).getStringSet(groupsKey(widgetId), emptySet()).orEmpty() val manifestFile = File(context.filesDir, "widget/manifest.json") - val matching = runCatching { + val image = runCatching { val images = JSONObject(manifestFile.readText()).getJSONArray("images") - buildList { - for (index in 0 until images.length()) { - val image = images.getJSONObject(index) - val groups = image.getJSONArray("group_ids") - val visible = (0 until groups.length()).any { selected.contains(groups.getLong(it).toString()) } - if (visible && image.optLong("expires_at") > System.currentTimeMillis() / 1000) add(image) + (0 until images.length()) + .map { images.getJSONObject(it) } + .firstOrNull { candidate -> + val groups = candidate.getJSONArray("group_ids") + (0 until groups.length()).any { selected.contains(groups.getLong(it).toString()) } } - } - }.getOrDefault(emptyList()) + }.getOrNull() - if (matching.isEmpty()) { + val bitmap = image?.let { BitmapFactory.decodeFile(it.getString("path")) } + if (bitmap == null) { views.setImageViewResource(R.id.twonly_widget_image, R.drawable.logo) views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110) views.setViewVisibility(R.id.twonly_widget_sender, View.GONE) } else { - // The manifest is newest first, so an arriving image is prepended - // and the stored index keeps pointing at an older one: a redraw - // alone would never show what just came in. Remembering which - // image was newest last time is what separates an arrival from - // every other reason this widget is asked to redraw, and an - // arrival outranks the rotation β€” including a tap that has not - // seen the new image yet. - val preferences = preferences(context) - val newest = matching[0].optString("media_id") - val hasArrived = preferences.getString(newestKey(widgetId), null) != newest - val oldIndex = preferences.getInt(indexKey(widgetId), -1) - val index = when { - hasArrived -> 0 - advance -> (oldIndex + 1).mod(matching.size) - else -> oldIndex.coerceAtLeast(0).mod(matching.size) - } - preferences.edit().putInt(indexKey(widgetId), index).putString(newestKey(widgetId), newest).apply() - val image = matching[index] - val bitmap = BitmapFactory.decodeFile(image.getString("path")) - if (bitmap == null) { - views.setImageViewResource(R.id.twonly_widget_image, R.drawable.logo) - views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110) - views.setViewVisibility(R.id.twonly_widget_sender, View.GONE) - } else { - views.setImageViewBitmap(R.id.twonly_widget_image, bitmap) - views.setInt(R.id.twonly_widget_image, "setImageAlpha", 255) - views.setTextViewText(R.id.twonly_widget_sender, image.optString("sender")) - views.setViewVisibility(R.id.twonly_widget_sender, View.VISIBLE) - } + views.setImageViewBitmap(R.id.twonly_widget_image, bitmap) + views.setInt(R.id.twonly_widget_image, "setImageAlpha", 255) + views.setTextViewText(R.id.twonly_widget_sender, image.optString("sender")) + views.setViewVisibility(R.id.twonly_widget_sender, View.VISIBLE) } - val intent = Intent(context, TwonlyWidgetProvider::class.java).apply { - action = ACTION_ADVANCE - putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + // Nothing to rotate through any more, so a tap opens twonly. + val launch = context.packageManager.getLaunchIntentForPackage(context.packageName) + if (launch != null) { + views.setOnClickPendingIntent( + R.id.twonly_widget_root, + PendingIntent.getActivity( + context, + widgetId, + launch, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ), + ) } - views.setOnClickPendingIntent( - R.id.twonly_widget_root, - PendingIntent.getBroadcast( - context, - widgetId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ), - ) manager.updateAppWidget(widgetId, views) } } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5c6133a6..c704c950 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; }; D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; }; D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100082F70000100D1A004 /* NativeGallery.swift */; }; + D3A1000B2F70000100D1A0FE /* WebxdcHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */; }; F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ @@ -134,6 +135,7 @@ D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = ""; }; D3A100062F70000100D1A003 /* NativeVideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoCodec.swift; sourceTree = ""; }; D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = ""; }; + D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebxdcHost.swift; sourceTree = ""; }; DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E190E82D9973B318A389650B /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -303,6 +305,7 @@ D3A100042F70000100D1A002 /* NativeImageCodec.swift */, D3A100062F70000100D1A003 /* NativeVideoCodec.swift */, D3A100082F70000100D1A004 /* NativeGallery.swift */, + D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */, D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */, D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */, D2265DD42D920142000D99BB /* Runner.entitlements */, @@ -736,6 +739,7 @@ D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */, D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */, D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */, + D3A1000B2F70000100D1A0FE /* WebxdcHost.swift in Sources */, D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6a8a4eb1..c6097586 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -63,6 +63,7 @@ import flutter_sharing_intent GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) RuntimeStorageChannel.register(with: engineBridge.pluginRegistry) NativeNotificationChannel.register(with: engineBridge.pluginRegistry) + WebxdcHostChannel.register(with: engineBridge.pluginRegistry) } override func userNotificationCenter( diff --git a/ios/Runner/WebxdcHost.swift b/ios/Runner/WebxdcHost.swift new file mode 100644 index 00000000..93205cb5 --- /dev/null +++ b/ios/Runner/WebxdcHost.swift @@ -0,0 +1,470 @@ +import Flutter +import UIKit +import UniformTypeIdentifiers +import WebKit + +/// The Dart side of the webxdc runtime. +/// +/// The webview is a platform view embedded in an ordinary Flutter route, so the +/// app runs inside twonly rather than on a screen of its own. Everything the +/// page asks for is answered by Dart, which asks Rust: nothing here reads a +/// bundle, decides a limit, or trusts a value the page produced. +/// +/// Shares its channel name with the Android implementation, so Dart talks to +/// one channel on both platforms. +class WebxdcHostChannel: NSObject { + private static let channelName = "eu.twonly/webxdc" + static private(set) var shared: WebxdcHostChannel? + + private var channel: FlutterMethodChannel? + + static func register(with registry: FlutterPluginRegistry) { + guard let registrar = registry.registrar(forPlugin: "TwonlyWebxdc") else { + return + } + let host = WebxdcHostChannel() + let channel = FlutterMethodChannel( + name: channelName, + binaryMessenger: registrar.messenger() + ) + host.channel = channel + channel.setMethodCallHandler { call, result in + host.handle(call, result: result) + } + shared = host + + registrar.register( + WebxdcViewFactory(), + withId: WebxdcViewFactory.viewType + ) + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let arguments = call.arguments as? [String: Any] ?? [:] + + switch call.method { + case "deliver": + if let instanceId = arguments["instanceId"] as? String, + let message = arguments["message"] as? String + { + WebxdcPlatformView.deliver(instanceId: instanceId, message: message) + } + result(nil) + + case "clearOrigin": + // The update log is only half an app's state; the rest is whatever it put + // in localStorage or IndexedDB, which only WebKit can delete. + guard let origin = arguments["origin"] as? String else { + result(nil) + return + } + let store = WKWebsiteDataStore.default() + store.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in + let matching = records.filter { $0.displayName.contains(origin) } + store.removeData( + ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: matching + ) { + result(nil) + } + } + + default: + result(FlutterMethodNotImplemented) + } + } + + /// Asks Dart for one file out of the bundle. + func serve( + instanceId: String, path: String, completion: @escaping ([String: Any]?) -> Void + ) { + guard let channel = channel else { + completion(nil) + return + } + channel.invokeMethod("serve", arguments: ["instanceId": instanceId, "path": path]) { reply in + completion(reply as? [String: Any]) + } + } + + /// Forwards one `webxdc.js` call; the JSON reply goes back to the page. + func bridge(instanceId: String, message: String, completion: @escaping (String) -> Void) { + guard let channel = channel else { + completion("{\"error\":\"unavailable\"}") + return + } + channel.invokeMethod("bridge", arguments: ["instanceId": instanceId, "message": message]) { + reply in + completion(reply as? String ?? "{\"error\":\"unavailable\"}") + } + } + + /// Hands a link the user tapped to Dart, which shows the whole URL and asks + /// before anything opens. Fire and forget: the page is not told. + func openLink(_ url: String) { + channel?.invokeMethod("openLink", arguments: ["url": url]) + } +} + +class WebxdcViewFactory: NSObject, FlutterPlatformViewFactory { + static let viewType = "eu.twonly/webxdc_webview" + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any? + ) -> FlutterPlatformView { + let params = args as? [String: Any] ?? [:] + return WebxdcPlatformView( + frame: frame, + instanceId: params["instanceId"] as? String ?? "", + origin: params["origin"] as? String ?? "" + ) + } +} + +/// The one WKWebView twonly ever creates, embedded in a Flutter route. +/// +/// A webxdc app is third-party code, so this view is built around denying it +/// things: +/// +/// - it is served from a custom scheme handled in-process, so every request it +/// makes is answered from the bundle or refused, and nothing reaches the +/// network, +/// - its host is unique per instance, so WebKit's own origin model keeps one +/// app's storage out of reach of every other app, +/// - navigation away from that origin is cancelled, and a link the user taps +/// goes to Dart for confirmation before it reaches Safari, +/// - `webxdc.js` is served, not injected, so it carries the same CSP as the +/// rest of the app and cannot race the page's own scripts. +/// +/// Nothing here parses a bundle or decides a limit: the bytes and the headers +/// come from Rust, by way of Dart. +class WebxdcPlatformView: NSObject, FlutterPlatformView { + /// Handled in-process, so a request can never leave the device. + /// + /// It is not `https`, which WebKit will not let an app handle, so pages here + /// are not a secure context. That is an accepted limitation: webxdc apps do + /// not require one. + static let scheme = "twonly-webxdc" + + /// Refuses to read more than this in one import, however many files. + private static let maxImportBytes = 32 * 1024 * 1024 + + private static weak var current: WebxdcPlatformView? + + let instanceId: String + private let origin: String + private var webView: WKWebView! + + /// The `importFiles` call waiting on the picker, if one is open. + private var importCallId: Int? + private var importExtensions: [String] = [] + + init(frame: CGRect, instanceId: String, origin: String) { + self.instanceId = instanceId + self.origin = origin + super.init() + + let configuration = WKWebViewConfiguration() + configuration.setURLSchemeHandler( + SchemeHandler(view: self), forURLScheme: WebxdcPlatformView.scheme) + configuration.userContentController.add(BridgeHandler(view: self), name: "twonlyWebxdc") + // Nothing plays without the user asking for it, and no page may take over + // the screen on its own. + configuration.mediaTypesRequiringUserActionForPlayback = .all + configuration.allowsInlineMediaPlayback = true + configuration.allowsPictureInPictureMediaPlayback = false + + let webView = WKWebView(frame: frame, configuration: configuration) + webView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + webView.navigationDelegate = self + webView.uiDelegate = self + webView.allowsBackForwardNavigationGestures = false + webView.allowsLinkPreview = false + self.webView = webView + + WebxdcPlatformView.current = self + webView.load(URLRequest(url: URL(string: "\(originUrl())/index.html")!)) + } + + func view() -> UIView { + webView + } + + private func originUrl() -> String { + "\(WebxdcPlatformView.scheme)://\(origin)" + } + + static func deliver(instanceId: String, message: String) { + guard let view = current, view.instanceId == instanceId else { return } + view.deliverToPage(message) + } + + func deliverToPage(_ message: String) { + guard let data = try? JSONSerialization.data(withJSONObject: [message]), + let quoted = String(data: data, encoding: .utf8) + else { return } + // `quoted` is a JSON array of one string; taking element zero hands the page + // a correctly escaped literal without building one by hand. + webView.evaluateJavaScript( + "window.__twonlyWebxdcDeliver(JSON.parse((\(quoted))[0]))", completionHandler: nil) + } + + fileprivate func isOwnOrigin(_ url: URL?) -> Bool { + url?.scheme == WebxdcPlatformView.scheme && url?.host == origin + } + + fileprivate func askHostToServe( + path: String, completion: @escaping ([String: Any]?) -> Void + ) { + guard let host = WebxdcHostChannel.shared else { + completion(nil) + return + } + host.serve(instanceId: instanceId, path: path, completion: completion) + } + + fileprivate func askHostToBridge(_ message: String, completion: @escaping (String) -> Void) { + guard let host = WebxdcHostChannel.shared else { + completion("{\"error\":\"unavailable\"}") + return + } + host.bridge(instanceId: instanceId, message: message, completion: completion) + } + + /// The controller a picker can be presented from. A platform view has no + /// controller of its own, so it borrows the one showing the Flutter route it + /// is embedded in. + private func presenter() -> UIViewController? { + var controller = UIApplication.shared.delegate?.window??.rootViewController + while let presented = controller?.presentedViewController { + controller = presented + } + return controller + } + + /// Answers `importFiles` here rather than in Dart: the picker belongs to this + /// screen, and the bytes the user chose have no reason to travel any further + /// than the page that asked for them. + fileprivate func startImport(_ call: [String: Any]) { + let callId = call["id"] as? Int ?? 0 + guard importCallId == nil else { + // One picker at a time; a second request while one is open is answered + // empty rather than queued. + deliverImportResult(callId: callId, files: []) + return + } + importCallId = callId + + let params = call["params"] as? [String: Any] ?? [:] + importExtensions = (params["extensions"] as? [String] ?? []).map { + let lowered = $0.lowercased() + return lowered.hasPrefix(".") ? String(lowered.dropFirst()) : lowered + } + let mimeTypes = params["mimeTypes"] as? [String] ?? [] + + var types: [UTType] = mimeTypes.compactMap { UTType(mimeType: $0) } + types += importExtensions.compactMap { UTType(filenameExtension: $0) } + if types.isEmpty { + types = [.item] + } + + let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) + picker.allowsMultipleSelection = params["multiple"] as? Bool ?? false + picker.delegate = self + guard let presenter = presenter() else { + finishImport(urls: []) + return + } + presenter.present(picker, animated: true) + } + + fileprivate func finishImport(urls: [URL]) { + guard let callId = importCallId else { return } + importCallId = nil + + var files: [[String: Any]] = [] + var budget = WebxdcPlatformView.maxImportBytes + + for url in urls { + let needsScope = url.startAccessingSecurityScopedResource() + defer { if needsScope { url.stopAccessingSecurityScopedResource() } } + + // Only the file's own name; it must never read as a path. + let name = url.lastPathComponent + if !importExtensions.isEmpty + && !importExtensions.contains(url.pathExtension.lowercased()) + { + continue + } + guard let data = try? Data(contentsOf: url), data.count <= budget else { continue } + budget -= data.count + + files.append([ + "name": name, + "type": UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "", + "base64": data.base64EncodedString(), + ]) + if budget <= 0 { break } + } + + deliverImportResult(callId: callId, files: files) + } + + private func deliverImportResult(callId: Int, files: [[String: Any]]) { + let reply: [String: Any] = ["id": callId, "result": files] + guard let data = try? JSONSerialization.data(withJSONObject: reply), + let json = String(data: data, encoding: .utf8) + else { return } + deliverToPage(json) + } +} + +extension WebxdcPlatformView: UIDocumentPickerDelegate { + func documentPicker( + _ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL] + ) { + finishImport(urls: urls) + } + + /// A cancelled picker resolves with nothing rather than rejecting: the app + /// asked to import, the user declined, and that is not an error. + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + finishImport(urls: []) + } +} + +extension WebxdcPlatformView: WKNavigationDelegate { + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + let url = navigationAction.request.url + if isOwnOrigin(url) { + decisionHandler(.allow) + return + } + // A link out of the app. Never followed here: Dart shows the whole URL and + // says it leaves twonly before anything opens. + decisionHandler(.cancel) + if navigationAction.navigationType == .linkActivated, let url = url { + WebxdcHostChannel.shared?.openLink(url.absoluteString) + } + } +} + +extension WebxdcPlatformView: WKUIDelegate { + /// No second window, ever: `target="_blank"` and `window.open` both end here. + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + nil + } + + /// Camera and microphone, denied without a prompt. + @available(iOS 15.0, *) + func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + decisionHandler(.deny) + } +} + +/// Answers every request the page makes, or refuses it. There is no path from +/// here to the network. +private class SchemeHandler: NSObject, WKURLSchemeHandler { + private weak var view: WebxdcPlatformView? + + init(view: WebxdcPlatformView) { + self.view = view + } + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + guard let view = view, view.isOwnOrigin(urlSchemeTask.request.url) else { + finish(urlSchemeTask, status: 403, mime: "text/plain", headers: [:], body: Data()) + return + } + let url = urlSchemeTask.request.url! + let path = url.path.isEmpty ? "/" : url.path + + view.askHostToServe(path: path) { [weak self] served in + guard let self = self else { return } + guard let served = served else { + self.finish(urlSchemeTask, status: 500, mime: "text/plain", headers: [:], body: Data()) + return + } + + var headers: [String: String] = [:] + let names = served["headerNames"] as? [String] ?? [] + let values = served["headerValues"] as? [String] ?? [] + for (index, name) in names.enumerated() where index < values.count { + headers[name] = values[index] + } + + let body = (served["body"] as? FlutterStandardTypedData)?.data ?? Data() + self.finish( + urlSchemeTask, + status: served["status"] as? Int ?? 500, + mime: served["mime"] as? String ?? "application/octet-stream", + headers: headers, + body: body + ) + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {} + + private func finish( + _ task: WKURLSchemeTask, status: Int, mime: String, headers: [String: String], body: Data + ) { + var allHeaders = headers + allHeaders["Content-Type"] = mime + allHeaders["Content-Length"] = String(body.count) + guard let url = task.request.url, + let response = HTTPURLResponse( + url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: allHeaders) + else { return } + task.didReceive(response) + task.didReceive(body) + task.didFinish() + } +} + +/// The single entry point from the page into twonly. +private class BridgeHandler: NSObject, WKScriptMessageHandler { + private weak var view: WebxdcPlatformView? + + init(view: WebxdcPlatformView) { + self.view = view + } + + func userContentController( + _ userContentController: WKUserContentController, didReceive message: WKScriptMessage + ) { + guard let view = view, let body = message.body as? String else { return } + + if let data = body.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + parsed["method"] as? String == "importFiles" + { + view.startImport(parsed) + return + } + + // The instance is the one this view was created for. A page cannot name a + // different one, whatever it puts in the message. + view.askHostToBridge(body) { reply in + view.deliverToPage(reply) + } + } +} diff --git a/ios/Shared/TwonlyWidgetShared.swift b/ios/Shared/TwonlyWidgetShared.swift index 03531453..f43e9d22 100644 --- a/ios/Shared/TwonlyWidgetShared.swift +++ b/ios/Shared/TwonlyWidgetShared.swift @@ -44,14 +44,17 @@ struct ManifestImage: Decodable { let mediaId: String let path: String let sender: String + /// The contact groups this image is the current one for. The app publishes + /// one image per group, so a widget shows the newest image whose groups meet + /// its own selection and never has a second one to fall back on. let groupIds: [Int64] - let expiresAt: Int64 + let receivedAt: Int64 enum CodingKeys: String, CodingKey { case path, sender case mediaId = "media_id" case groupIds = "group_ids" - case expiresAt = "expires_at" + case receivedAt = "received_at" } } @@ -114,35 +117,6 @@ enum WidgetStorage { ids.sorted().map(String.init).joined(separator: "-") } - static func index(for ids: [Int64]) -> Int { - UserDefaults(suiteName: runtimeAppGroup)?.integer(forKey: "widget-index-\(selectionKey(ids))") ?? 0 - } - - /// Where the rotation starts, sent back to the front whenever an image has - /// arrived since this selection was last drawn. - /// - /// The manifest is newest first, so an arriving image is prepended and a - /// stored index keeps pointing at an older one: a timeline reload alone would - /// never show what just came in. Recording which image was newest last time - /// is what separates an arrival from every other reason a timeline is rebuilt. - static func startIndex(for ids: [Int64], newestMediaId: String?) -> Int { - guard let newestMediaId else { return index(for: ids) } - let defaults = UserDefaults(suiteName: runtimeAppGroup) - let newestKey = "widget-newest-\(selectionKey(ids))" - guard defaults?.string(forKey: newestKey) != newestMediaId else { - return index(for: ids) - } - defaults?.set(newestMediaId, forKey: newestKey) - defaults?.set(0, forKey: "widget-index-\(selectionKey(ids))") - return 0 - } - - static func advance(_ ids: [Int64]) { - let defaults = UserDefaults(suiteName: runtimeAppGroup) - let key = "widget-index-\(selectionKey(ids))" - defaults?.set((defaults?.integer(forKey: key) ?? 0) + 1, forKey: key) - } - /// Records this widget's contact groups for Rust to import. /// /// Each placed widget builds its own timeline, so this only ever knows about diff --git a/ios/TwonlyWidget/TwonlyWidget.swift b/ios/TwonlyWidget/TwonlyWidget.swift index bb0f7c0b..067ceefb 100644 --- a/ios/TwonlyWidget/TwonlyWidget.swift +++ b/ios/TwonlyWidget/TwonlyWidget.swift @@ -4,22 +4,6 @@ import OSLog import SwiftUI import WidgetKit -struct AdvanceWidgetIntent: AppIntent { - static let title: LocalizedStringResource = "Next image" - static let openAppWhenRun = false - - @Parameter(title: "Contact groups") var groupIds: [String] - - init() { groupIds = [] } - init(groupIds: [Int64]) { self.groupIds = groupIds.map(String.init) } - - func perform() async throws -> some IntentResult { - WidgetStorage.advance(groupIds.compactMap(Int64.init)) - WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) - return .result() - } -} - /// Why the widget has nothing to show, so the placeholder can say it. enum EmptyReason: Equatable { case notConfigured @@ -31,8 +15,8 @@ enum EmptyReason: Equatable { case .notConfigured: return "Hold to choose contact groups" case .noMatchingImages(let total): - // The count separates "the app never delivered anything" from "images - // arrived but carry other groups than the ones this widget selects". + // The count separates "the app never delivered anything" from "an image + // is being held for other groups than the ones this widget selects". return total == 0 ? "No images yet" : "\(total) shared, none in your groups" case .fault(let fault): return fault.summary @@ -43,18 +27,18 @@ enum EmptyReason: Equatable { struct TwonlyEntry: TimelineEntry { let date: Date let image: ManifestImage? - let groupIds: [Int64] let emptyReason: EmptyReason? /// Longest edge this entry will ever be drawn at, in pixels. Decoding to the /// size actually shown is what keeps the extension inside its memory budget. let maxPixelSize: CGFloat } -/// WidgetKit renders every timeline entry up front, so a timeline is a -/// multiplier on whatever one entry costs. Eight half-hour steps keep the -/// rotation going for four hours on a fraction of the memory a full day took. -private let timelineEntryCount = 8 -private let timelineStepMinutes = 30 +/// A widget shows the image that arrived last and nothing else, so its +/// timeline is a single entry. The app pushes a reload whenever the manifest +/// changes, which is what actually puts a new image on the home screen; this +/// interval only keeps the widget rebuilding while nothing arrives, because a +/// rebuild is the sole evidence that the widget is still placed. +private let timelineRefreshHours = 4 struct TwonlyProvider: AppIntentTimelineProvider { /// The widget is never drawn larger than its container, so this is the most @@ -68,7 +52,6 @@ struct TwonlyProvider: AppIntentTimelineProvider { TwonlyEntry( date: .now, image: nil, - groupIds: [], emptyReason: .noMatchingImages(total: 0), maxPixelSize: maxPixelSize(in: context) ) @@ -79,7 +62,6 @@ struct TwonlyProvider: AppIntentTimelineProvider { return entry( images: WidgetStorage.images(), ids: ids, - index: WidgetStorage.index(for: ids), date: .now, maxPixelSize: maxPixelSize(in: context) ) @@ -88,48 +70,24 @@ struct TwonlyProvider: AppIntentTimelineProvider { func timeline(for configuration: TwonlyWidgetIntent, in context: Context) async -> Timeline { let ids = (configuration.groups ?? []).compactMap { Int64($0.id) } WidgetStorage.persistSelection(ids) - // Read once: an extension that re-reads the manifest for every entry spends - // its whole budget on JSON. - let available = WidgetStorage.images() - // The rotation starts at whatever just arrived, so an image shared into - // this widget is on the home screen as soon as the timeline is rebuilt - // rather than whenever the rotation next comes back around to it. - let start = WidgetStorage.startIndex( - for: ids, - newestMediaId: newestMatching(in: available, ids: ids)?.mediaId + let entry = entry( + images: WidgetStorage.images(), + ids: ids, + date: .now, + maxPixelSize: maxPixelSize(in: context) ) - let pixels = maxPixelSize(in: context) - let entries = (0.., - ids: [Int64] - ) -> ManifestImage? { - guard !ids.isEmpty, let available = try? images.get() else { return nil } - let selected = Set(ids) - let now = Int64(Date().timeIntervalSince1970) - return available.first { $0.expiresAt > now && !selected.isDisjoint(with: $0.groupIds) } + let next = + Calendar.current.date(byAdding: .hour, value: timelineRefreshHours, to: .now) ?? .now + widgetLog.debug("built 1 entry at \(entry.maxPixelSize, privacy: .public)px") + return Timeline(entries: [entry], policy: .after(next)) } + /// The one image this widget shows: the most recent one published for any of + /// its contact groups. The manifest is written newest first, so that is + /// simply the first match. private func entry( images: Result<[ManifestImage], WidgetStorage.ManifestFault>, ids: [Int64], - index: Int, date: Date, maxPixelSize: CGFloat ) -> TwonlyEntry { @@ -139,7 +97,7 @@ struct TwonlyProvider: AppIntentTimelineProvider { available = value case .failure(let fault): return TwonlyEntry( - date: date, image: nil, groupIds: ids, + date: date, image: nil, emptyReason: .fault(fault), maxPixelSize: maxPixelSize) } @@ -148,36 +106,31 @@ struct TwonlyProvider: AppIntentTimelineProvider { guard !ids.isEmpty else { widgetLog.notice("no contact group configured; nothing can match") return TwonlyEntry( - date: date, image: nil, groupIds: ids, + date: date, image: nil, emptyReason: .notConfigured, maxPixelSize: maxPixelSize) } let selected = Set(ids) - let now = Int64(date.timeIntervalSince1970) - let unexpired = available.filter { $0.expiresAt > now } - let matching = unexpired.filter { !selected.isDisjoint(with: $0.groupIds) } - guard !matching.isEmpty else { - // The two counts separate "everything aged out" from "the sender is in - // groups this widget did not select", which look identical on screen. + guard let newest = available.first(where: { !selected.isDisjoint(with: $0.groupIds) }) else { + // The offered groups are what separates "nobody has sent anything" from + // "the sender is in groups this widget did not select", which look + // identical on screen. widgetLog.notice( """ no match: selected \(ids, privacy: .public), \ \(available.count, privacy: .public) images, \ - \(unexpired.count, privacy: .public) unexpired, \ offered groups \(Set(available.flatMap(\.groupIds)).sorted(), privacy: .public) """) return TwonlyEntry( date: date, image: nil, - groupIds: ids, emptyReason: .noMatchingImages(total: available.count), maxPixelSize: maxPixelSize ) } return TwonlyEntry( date: date, - image: matching[index % matching.count], - groupIds: ids, + image: newest, emptyReason: nil, maxPixelSize: maxPixelSize ) @@ -224,13 +177,10 @@ struct TwonlyWidgetView: View { var body: some View { content .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay { - Button(intent: AdvanceWidgetIntent(groupIds: entry.groupIds)) { - Color.clear - } - .buttonStyle(.plain) - .accessibilityLabel("Show next image") - } + // Opens the containing app. The scheme is not registered anywhere and + // does not need to be: WidgetKit hands the URL to twonly itself, and the + // app ignores links it does not recognise. + .widgetURL(URL(string: "twonly://widget")) .containerBackground(for: .widget) { brandBackground } } diff --git a/lib/core/bridge/api.dart b/lib/core/bridge/api.dart index eb104bc9..4676f9f7 100644 --- a/lib/core/bridge/api.dart +++ b/lib/core/bridge/api.dart @@ -475,10 +475,12 @@ class RustApi { required String groupId, required String messageType, required List additionalData, + required bool hidden, }) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendAdditionalData( groupId: groupId, messageType: messageType, additionalData: additionalData, + hidden: hidden, ); static Future insertAndSendAskAboutUser({ @@ -501,10 +503,12 @@ class RustApi { required String groupId, required String text, String? quoteMessageId, + Uint8List? additionalMessageData, }) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendText( groupId: groupId, text: text, quoteMessageId: quoteMessageId, + additionalMessageData: additionalMessageData, ); static Future ipaPurchase({ diff --git a/lib/core/bridge/webxdc.dart b/lib/core/bridge/webxdc.dart new file mode 100644 index 00000000..6ca06dd5 --- /dev/null +++ b/lib/core/bridge/webxdc.dart @@ -0,0 +1,260 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Refreshes the store listing. Metadata only; no bundle is fetched. +Future refreshCatalog() => + RustLib.instance.api.crateBridgeWebxdcRefreshCatalog(); + +/// What the in-app store offers, newest version of each app only. +/// +/// `languages` is what the UI prefers, most preferred first. Descriptions are +/// cached in every language the catalog carries and picked here, so which +/// language a user reads is never sent anywhere. +Future> catalog({required List languages}) => + RustLib.instance.api.crateBridgeWebxdcCatalog(languages: languages); + +/// Places an app into a chat and returns the id of the message that carries it. +Future createInstance({ + required String groupId, + required String appId, + required PlatformInt64 version, +}) => RustLib.instance.api.crateBridgeWebxdcCreateInstance( + groupId: groupId, + appId: appId, + version: version, +); + +Future instance({required String instanceId}) => + RustLib.instance.api.crateBridgeWebxdcInstance(instanceId: instanceId); + +/// Downloads and verifies the bundle if it is not already on disk, and returns +/// where it landed. Called when the user starts an app, never on message +/// arrival: a message must not be able to make a device fetch anything. +Future prepareBundle({required String instanceId}) => + RustLib.instance.api.crateBridgeWebxdcPrepareBundle(instanceId: instanceId); + +/// Answers one request the webview made for a file inside the bundle. +/// +/// The platform shim has already established that the request came from this +/// instance's own origin; everything after that is decided here. +Future serve({ + required String instanceId, + required String requestPath, +}) => RustLib.instance.api.crateBridgeWebxdcServe( + instanceId: instanceId, + requestPath: requestPath, +); + +Future> updatesAfter({ + required String instanceId, + required PlatformInt64 serial, +}) => RustLib.instance.api.crateBridgeWebxdcUpdatesAfter( + instanceId: instanceId, + serial: serial, +); + +Future sendUpdate({ + required String instanceId, + required String payload, + String? info, + String? href, + String? summary, + String? document, +}) => RustLib.instance.api.crateBridgeWebxdcSendUpdate( + instanceId: instanceId, + payload: payload, + info: info, + href: href, + summary: summary, + document: document, +); + +/// The address the running app sees for a participant. Stable inside one +/// instance and unrelated to the same user's address in any other. +Future addressFor({ + required String instanceId, + required PlatformInt64 userId, +}) => RustLib.instance.api.crateBridgeWebxdcAddressFor( + instanceId: instanceId, + userId: userId, +); + +/// Drops an instance and its whole update log, returning the origin whose web +/// storage the platform layer still has to clear. The log is only half the +/// state: an app is free to keep everything in `localStorage`, which no +/// database delete reaches. +Future deleteInstance({required String instanceId}) => RustLib + .instance + .api + .crateBridgeWebxdcDeleteInstance(instanceId: instanceId); + +class WebxdcInstanceInfo { + final String instanceId; + final String groupId; + final String appId; + final PlatformInt64 version; + + /// The webview host this instance is served from. Unique per instance, so + /// the browser's own origin model keeps one app's stored data out of reach + /// of every other app. + final String originToken; + final String? summary; + final String? document; + + const WebxdcInstanceInfo({ + required this.instanceId, + required this.groupId, + required this.appId, + required this.version, + required this.originToken, + this.summary, + this.document, + }); + + @override + int get hashCode => + instanceId.hashCode ^ + groupId.hashCode ^ + appId.hashCode ^ + version.hashCode ^ + originToken.hashCode ^ + summary.hashCode ^ + document.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WebxdcInstanceInfo && + runtimeType == other.runtimeType && + instanceId == other.instanceId && + groupId == other.groupId && + appId == other.appId && + version == other.version && + originToken == other.originToken && + summary == other.summary && + document == other.document; +} + +class WebxdcResponse { + final int status; + final String mime; + final List headerNames; + final List headerValues; + final Uint8List body; + + const WebxdcResponse({ + required this.status, + required this.mime, + required this.headerNames, + required this.headerValues, + required this.body, + }); + + @override + int get hashCode => + status.hashCode ^ + mime.hashCode ^ + headerNames.hashCode ^ + headerValues.hashCode ^ + body.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WebxdcResponse && + runtimeType == other.runtimeType && + status == other.status && + mime == other.mime && + headerNames == other.headerNames && + headerValues == other.headerValues && + body == other.body; +} + +class WebxdcStoreApp { + final String appId; + final PlatformInt64 version; + + /// Already in the reader's language, like the description. + final String name; + + /// One line about the app, already in the reader's language. + final String? description; + final String? sourceCodeUrl; + final Uint8List? icon; + final PlatformInt64 bundleBytes; + + const WebxdcStoreApp({ + required this.appId, + required this.version, + required this.name, + this.description, + this.sourceCodeUrl, + this.icon, + required this.bundleBytes, + }); + + @override + int get hashCode => + appId.hashCode ^ + version.hashCode ^ + name.hashCode ^ + description.hashCode ^ + sourceCodeUrl.hashCode ^ + icon.hashCode ^ + bundleBytes.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WebxdcStoreApp && + runtimeType == other.runtimeType && + appId == other.appId && + version == other.version && + name == other.name && + description == other.description && + sourceCodeUrl == other.sourceCodeUrl && + icon == other.icon && + bundleBytes == other.bundleBytes; +} + +class WebxdcUpdateEntry { + final PlatformInt64 serial; + final String payload; + final String? info; + final String? href; + + /// `None` when this device sent it. + final PlatformInt64? senderId; + + const WebxdcUpdateEntry({ + required this.serial, + required this.payload, + this.info, + this.href, + this.senderId, + }); + + @override + int get hashCode => + serial.hashCode ^ + payload.hashCode ^ + info.hashCode ^ + href.hashCode ^ + senderId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WebxdcUpdateEntry && + runtimeType == other.runtimeType && + serial == other.serial && + payload == other.payload && + info == other.info && + href == other.href && + senderId == other.senderId; +} diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index a8175f98..fbfb801c 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -10,6 +10,7 @@ import 'bridge/callbacks.dart'; import 'bridge/groups.dart'; import 'bridge/logging.dart'; import 'bridge/user_config.dart'; +import 'bridge/webxdc.dart'; import 'bridge/wrapper.dart'; import 'bridge/wrapper/app_database.dart'; import 'bridge/wrapper/backup.dart'; @@ -86,7 +87,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1545547125; + int get rustContentHash => 1362207365; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -107,15 +108,32 @@ abstract class RustLibApi extends BaseApi { required Int64List memberIds, }); + Future crateBridgeWebxdcAddressFor({ + required String instanceId, + required PlatformInt64 userId, + }); + + Future> crateBridgeWebxdcCatalog({ + required List languages, + }); + Future crateBridgeLoggingCleanLogFile(); Future crateBridgeLoggingClearLogFile(); + Future crateBridgeWebxdcCreateInstance({ + required String groupId, + required String appId, + required PlatformInt64 version, + }); + Future crateBridgeGroupsCreateNewGroup({ required String groupName, required Int64List memberIds, }); + Future crateBridgeWebxdcDeleteInstance({required String instanceId}); + Future crateBridgeGroupsFetchGroupState({required String groupId}); Future crateBridgeGroupsFetchGroupStatesForUnjoinedGroups(); @@ -156,6 +174,10 @@ abstract class RustLibApi extends BaseApi { required InitConfig config, }); + Future crateBridgeWebxdcInstance({ + required String instanceId, + }); + Future crateBridgeGroupsLeaveGroup({required String groupId}); Future crateBridgeLoggingLoadLogFile(); @@ -166,8 +188,12 @@ abstract class RustLibApi extends BaseApi { required bool remove, }); + Future crateBridgeWebxdcPrepareBundle({required String instanceId}); + Future crateBridgeLoggingReadLastLogLines({required int lineCount}); + Future crateBridgeWebxdcRefreshCatalog(); + Future crateBridgeGroupsRemoveMemberFromGroup({ required String groupId, required PlatformInt64 contactId, @@ -301,6 +327,7 @@ abstract class RustLibApi extends BaseApi { required String groupId, required String messageType, required List additionalData, + required bool hidden, }); Future crateBridgeApiRustApiInsertAndSendAskAboutUser({ @@ -317,6 +344,7 @@ abstract class RustLibApi extends BaseApi { required String groupId, required String text, String? quoteMessageId, + Uint8List? additionalMessageData, }); Future crateBridgeApiRustApiIpaPurchase({ @@ -664,6 +692,20 @@ abstract class RustLibApi extends BaseApi { required int threshold, }); + Future crateBridgeWebxdcSendUpdate({ + required String instanceId, + required String payload, + String? info, + String? href, + String? summary, + String? document, + }); + + Future crateBridgeWebxdcServe({ + required String instanceId, + required String requestPath, + }); + Future crateBridgeGroupsUpdateChatDeletionTime({ required String groupId, required PlatformInt64 deleteMessagesAfterMilliseconds, @@ -674,6 +716,11 @@ abstract class RustLibApi extends BaseApi { required String groupName, }); + Future> crateBridgeWebxdcUpdatesAfter({ + required String instanceId, + required PlatformInt64 serial, + }); + UserConfig crateBridgeUserConfigUserConfigApiClone({ required UserConfig config, }); @@ -785,6 +832,73 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["groupId", "memberIds"], ); + @override + Future crateBridgeWebxdcAddressFor({ + required String instanceId, + required PlatformInt64 userId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(instanceId, serializer); + sse_encode_i_64(userId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 3, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateBridgeWebxdcAddressForConstMeta, + argValues: [instanceId, userId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcAddressForConstMeta => + const TaskConstMeta( + debugName: "address_for", + argNames: ["instanceId", "userId"], + ); + + @override + Future> crateBridgeWebxdcCatalog({ + required List languages, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_String(languages, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 4, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_webxdc_store_app, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcCatalogConstMeta, + argValues: [languages], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcCatalogConstMeta => const TaskConstMeta( + debugName: "catalog", + argNames: ["languages"], + ); + @override Future crateBridgeLoggingCleanLogFile() { return handler.executeNormal( @@ -794,7 +908,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 3, + funcId: 5, port: port_, ); }, @@ -824,7 +938,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 4, + funcId: 6, port: port_, ); }, @@ -845,6 +959,43 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateBridgeWebxdcCreateInstance({ + required String groupId, + required String appId, + required PlatformInt64 version, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(groupId, serializer); + sse_encode_String(appId, serializer); + sse_encode_i_64(version, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 7, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcCreateInstanceConstMeta, + argValues: [groupId, appId, version], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcCreateInstanceConstMeta => + const TaskConstMeta( + debugName: "create_instance", + argNames: ["groupId", "appId", "version"], + ); + @override Future crateBridgeGroupsCreateNewGroup({ required String groupName, @@ -859,7 +1010,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 5, + funcId: 8, port: port_, ); }, @@ -880,6 +1031,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["groupName", "memberIds"], ); + @override + Future crateBridgeWebxdcDeleteInstance({ + required String instanceId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(instanceId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 9, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcDeleteInstanceConstMeta, + argValues: [instanceId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcDeleteInstanceConstMeta => + const TaskConstMeta( + debugName: "delete_instance", + argNames: ["instanceId"], + ); + @override Future crateBridgeGroupsFetchGroupState({required String groupId}) { return handler.executeNormal( @@ -890,7 +1074,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 6, + funcId: 10, port: port_, ); }, @@ -920,7 +1104,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 7, + funcId: 11, port: port_, ); }, @@ -956,7 +1140,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 8, + funcId: 12, port: port_, ); }, @@ -994,7 +1178,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 9, + funcId: 13, port: port_, ); }, @@ -1030,7 +1214,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 14, port: port_, ); }, @@ -1073,7 +1257,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 11, + funcId: 15, port: port_, ); }, @@ -1118,7 +1302,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 16, port: port_, ); }, @@ -1155,7 +1339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 13, + funcId: 17, port: port_, ); }, @@ -1188,7 +1372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 14, + funcId: 18, port: port_, ); }, @@ -1209,6 +1393,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["config"], ); + @override + Future crateBridgeWebxdcInstance({ + required String instanceId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(instanceId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 19, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_opt_box_autoadd_webxdc_instance_info, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcInstanceConstMeta, + argValues: [instanceId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcInstanceConstMeta => const TaskConstMeta( + debugName: "instance", + argNames: ["instanceId"], + ); + @override Future crateBridgeGroupsLeaveGroup({required String groupId}) { return handler.executeNormal( @@ -1219,7 +1435,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 15, + funcId: 20, port: port_, ); }, @@ -1249,7 +1465,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 16, + funcId: 21, port: port_, ); }, @@ -1286,7 +1502,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 17, + funcId: 22, port: port_, ); }, @@ -1307,6 +1523,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["groupId", "contactId", "remove"], ); + @override + Future crateBridgeWebxdcPrepareBundle({required String instanceId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(instanceId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 23, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcPrepareBundleConstMeta, + argValues: [instanceId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcPrepareBundleConstMeta => + const TaskConstMeta( + debugName: "prepare_bundle", + argNames: ["instanceId"], + ); + @override Future crateBridgeLoggingReadLastLogLines({required int lineCount}) { return handler.executeNormal( @@ -1317,7 +1564,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 18, + funcId: 24, port: port_, ); }, @@ -1338,6 +1585,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["lineCount"], ); + @override + Future crateBridgeWebxdcRefreshCatalog() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 25, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcRefreshCatalogConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcRefreshCatalogConstMeta => + const TaskConstMeta( + debugName: "refresh_catalog", + argNames: [], + ); + @override Future crateBridgeGroupsRemoveMemberFromGroup({ required String groupId, @@ -1352,7 +1629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 19, + funcId: 26, port: port_, ); }, @@ -1383,7 +1660,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 20, + funcId: 27, port: port_, ); }, @@ -1416,7 +1693,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 28, port: port_, ); }, @@ -1446,7 +1723,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 29, port: port_, ); }, @@ -1474,7 +1751,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(protocol, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1502,7 +1779,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 31, port: port_, ); }, @@ -1534,7 +1811,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_i_64(contactId, serializer); sse_encode_i_64(profileCounter, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 25)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 32)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1563,7 +1840,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 33, port: port_, ); }, @@ -1593,7 +1870,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 34, port: port_, ); }, @@ -1634,7 +1911,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 35, port: port_, ); }, @@ -1675,7 +1952,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 36, port: port_, ); }, @@ -1710,7 +1987,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 30, + funcId: 37, port: port_, ); }, @@ -1742,7 +2019,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 31, + funcId: 38, port: port_, ); }, @@ -1774,7 +2051,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 32, + funcId: 39, port: port_, ); }, @@ -1804,7 +2081,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 33, + funcId: 40, port: port_, ); }, @@ -1834,7 +2111,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 34, + funcId: 41, port: port_, ); }, @@ -1867,7 +2144,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 35, + funcId: 42, port: port_, ); }, @@ -1898,7 +2175,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 36, + funcId: 43, port: port_, ); }, @@ -1928,7 +2205,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_list_prim_u_8_loose(avatarSvgCompressed, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 44)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1956,7 +2233,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 45, port: port_, ); }, @@ -1987,7 +2264,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 46, port: port_, ); }, @@ -2020,7 +2297,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 47, port: port_, ); }, @@ -2050,7 +2327,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 48, port: port_, ); }, @@ -2081,7 +2358,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 49, port: port_, ); }, @@ -2112,7 +2389,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 50, port: port_, ); }, @@ -2142,7 +2419,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 51, port: port_, ); }, @@ -2175,7 +2452,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 52, port: port_, ); }, @@ -2210,7 +2487,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 53, port: port_, ); }, @@ -2243,7 +2520,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 54, port: port_, ); }, @@ -2275,7 +2552,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 55, port: port_, ); }, @@ -2305,7 +2582,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 56, port: port_, ); }, @@ -2340,7 +2617,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 57, port: port_, ); }, @@ -2370,7 +2647,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 58, port: port_, ); }, @@ -2400,7 +2677,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 59, port: port_, ); }, @@ -2430,7 +2707,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 60, port: port_, ); }, @@ -2471,7 +2748,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 61, port: port_, ); }, @@ -2518,7 +2795,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 62, port: port_, ); }, @@ -2551,7 +2828,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 63, port: port_, ); }, @@ -2584,7 +2861,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 64, port: port_, ); }, @@ -2614,7 +2891,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 65, port: port_, ); }, @@ -2654,7 +2931,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 59, + funcId: 66, port: port_, ); }, @@ -2680,6 +2957,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required String groupId, required String messageType, required List additionalData, + required bool hidden, }) { return handler.executeNormal( NormalTask( @@ -2688,10 +2966,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(groupId, serializer); sse_encode_String(messageType, serializer); sse_encode_list_prim_u_8_loose(additionalData, serializer); + sse_encode_bool(hidden, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 67, port: port_, ); }, @@ -2700,7 +2979,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_AnyhowException, ), constMeta: kCrateBridgeApiRustApiInsertAndSendAdditionalDataConstMeta, - argValues: [groupId, messageType, additionalData], + argValues: [groupId, messageType, additionalData, hidden], apiImpl: this, ), ); @@ -2710,7 +2989,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { get kCrateBridgeApiRustApiInsertAndSendAdditionalDataConstMeta => const TaskConstMeta( debugName: "rust_api_insert_and_send_additional_data", - argNames: ["groupId", "messageType", "additionalData"], + argNames: ["groupId", "messageType", "additionalData", "hidden"], ); @override @@ -2727,7 +3006,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 61, + funcId: 68, port: port_, ); }, @@ -2762,7 +3041,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 69, port: port_, ); }, @@ -2788,6 +3067,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required String groupId, required String text, String? quoteMessageId, + Uint8List? additionalMessageData, }) { return handler.executeNormal( NormalTask( @@ -2796,10 +3076,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_String(groupId, serializer); sse_encode_String(text, serializer); sse_encode_opt_String(quoteMessageId, serializer); + sse_encode_opt_list_prim_u_8_strict( + additionalMessageData, + serializer, + ); pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 63, + funcId: 70, port: port_, ); }, @@ -2808,7 +3092,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_AnyhowException, ), constMeta: kCrateBridgeApiRustApiInsertAndSendTextConstMeta, - argValues: [groupId, text, quoteMessageId], + argValues: [groupId, text, quoteMessageId, additionalMessageData], apiImpl: this, ), ); @@ -2817,7 +3101,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateBridgeApiRustApiInsertAndSendTextConstMeta => const TaskConstMeta( debugName: "rust_api_insert_and_send_text", - argNames: ["groupId", "text", "quoteMessageId"], + argNames: [ + "groupId", + "text", + "quoteMessageId", + "additionalMessageData", + ], ); @override @@ -2836,7 +3125,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 64, + funcId: 71, port: port_, ); }, @@ -2866,7 +3155,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 65, + funcId: 72, port: port_, ); }, @@ -2899,7 +3188,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 66, + funcId: 73, port: port_, ); }, @@ -2934,7 +3223,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 74, port: port_, ); }, @@ -2964,7 +3253,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 68, + funcId: 75, port: port_, ); }, @@ -2999,7 +3288,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 69, + funcId: 76, port: port_, ); }, @@ -3029,7 +3318,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 70, + funcId: 77, port: port_, ); }, @@ -3062,7 +3351,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 71, + funcId: 78, port: port_, ); }, @@ -3092,7 +3381,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 72, + funcId: 79, port: port_, ); }, @@ -3122,7 +3411,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 73, + funcId: 80, port: port_, ); }, @@ -3152,7 +3441,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 74, + funcId: 81, port: port_, ); }, @@ -3191,7 +3480,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 75, + funcId: 82, port: port_, ); }, @@ -3226,7 +3515,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 76, + funcId: 83, port: port_, ); }, @@ -3265,7 +3554,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 77, + funcId: 84, port: port_, ); }, @@ -3307,7 +3596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 78, + funcId: 85, port: port_, ); }, @@ -3338,7 +3627,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 79, + funcId: 86, port: port_, ); }, @@ -3371,7 +3660,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 80, + funcId: 87, port: port_, ); }, @@ -3404,7 +3693,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 81, + funcId: 88, port: port_, ); }, @@ -3439,7 +3728,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 82, + funcId: 89, port: port_, ); }, @@ -3472,7 +3761,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 83, + funcId: 90, port: port_, ); }, @@ -3505,7 +3794,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 84, + funcId: 91, port: port_, ); }, @@ -3538,7 +3827,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 85, + funcId: 92, port: port_, ); }, @@ -3575,7 +3864,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 86, + funcId: 93, port: port_, ); }, @@ -3605,7 +3894,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 87, + funcId: 94, port: port_, ); }, @@ -3635,7 +3924,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 88, + funcId: 95, port: port_, ); }, @@ -3665,7 +3954,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 89, + funcId: 96, port: port_, ); }, @@ -3698,7 +3987,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 90, + funcId: 97, port: port_, ); }, @@ -3729,7 +4018,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 91, + funcId: 98, port: port_, ); }, @@ -3762,7 +4051,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 92, + funcId: 99, port: port_, ); }, @@ -3808,7 +4097,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 93, + funcId: 100, port: port_, ); }, @@ -3864,7 +4153,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 94, + funcId: 101, port: port_, ); }, @@ -3912,7 +4201,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 95, + funcId: 102, port: port_, ); }, @@ -3950,7 +4239,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 96, + funcId: 103, port: port_, ); }, @@ -3985,7 +4274,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 97, + funcId: 104, port: port_, ); }, @@ -4020,7 +4309,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 98, + funcId: 105, port: port_, ); }, @@ -4053,7 +4342,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 99, + funcId: 106, port: port_, ); }, @@ -4090,7 +4379,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 100, + funcId: 107, port: port_, ); }, @@ -4121,7 +4410,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 101, + funcId: 108, port: port_, ); }, @@ -4159,7 +4448,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 102, + funcId: 109, port: port_, ); }, @@ -4194,7 +4483,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 103, + funcId: 110, port: port_, ); }, @@ -4233,7 +4522,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 104, + funcId: 111, port: port_, ); }, @@ -4266,7 +4555,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 105, + funcId: 112, port: port_, ); }, @@ -4297,7 +4586,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 106, + funcId: 113, port: port_, ); }, @@ -4332,7 +4621,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 107, + funcId: 114, port: port_, ); }, @@ -4362,7 +4651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 108, + funcId: 115, port: port_, ); }, @@ -4395,7 +4684,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 109, + funcId: 116, port: port_, ); }, @@ -4430,7 +4719,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 110, + funcId: 117, port: port_, ); }, @@ -4463,7 +4752,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 111, + funcId: 118, port: port_, ); }, @@ -4494,7 +4783,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 112, + funcId: 119, port: port_, ); }, @@ -4531,7 +4820,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 113, + funcId: 120, port: port_, ); }, @@ -4583,7 +4872,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 114, + funcId: 121, port: port_, ); }, @@ -4636,7 +4925,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 115, + funcId: 122, port: port_, ); }, @@ -4676,7 +4965,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 116, + funcId: 123, port: port_, ); }, @@ -4709,7 +4998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 117, + funcId: 124, port: port_, ); }, @@ -4742,7 +5031,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 118, + funcId: 125, port: port_, ); }, @@ -4779,7 +5068,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 119, + funcId: 126, port: port_, ); }, @@ -4811,7 +5100,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 120, + funcId: 127, port: port_, ); }, @@ -4844,7 +5133,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 121, + funcId: 128, port: port_, ); }, @@ -4879,7 +5168,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 122, + funcId: 129, port: port_, ); }, @@ -4911,7 +5200,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 123, + funcId: 130, port: port_, ); }, @@ -4949,7 +5238,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 124, + funcId: 131, port: port_, ); }, @@ -4982,7 +5271,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 125, + funcId: 132, port: port_, ); }, @@ -5020,7 +5309,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 126, + funcId: 133, port: port_, ); }, @@ -5057,7 +5346,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 127, + funcId: 134, port: port_, ); }, @@ -5094,7 +5383,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 128, + funcId: 135, port: port_, ); }, @@ -5132,7 +5421,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 129, + funcId: 136, port: port_, ); }, @@ -5170,7 +5459,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 130, + funcId: 137, port: port_, ); }, @@ -5203,7 +5492,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 131, + funcId: 138, port: port_, ); }, @@ -5235,7 +5524,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 132, + funcId: 139, port: port_, ); }, @@ -5270,7 +5559,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 133, + funcId: 140, port: port_, ); }, @@ -5307,7 +5596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 134, + funcId: 141, port: port_, ); }, @@ -5340,7 +5629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 135, + funcId: 142, port: port_, ); }, @@ -5372,7 +5661,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 136, + funcId: 143, port: port_, ); }, @@ -5407,7 +5696,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 137, + funcId: 144, port: port_, ); }, @@ -5446,7 +5735,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 138, + funcId: 145, port: port_, ); }, @@ -5483,7 +5772,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 139, + funcId: 146, port: port_, ); }, @@ -5513,7 +5802,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 140, + funcId: 147, port: port_, ); }, @@ -5545,7 +5834,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 141, + funcId: 148, port: port_, ); }, @@ -5580,7 +5869,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 142, + funcId: 149, port: port_, ); }, @@ -5612,7 +5901,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 143, + funcId: 150, port: port_, ); }, @@ -5650,7 +5939,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 144, + funcId: 151, port: port_, ); }, @@ -5689,7 +5978,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 145, + funcId: 152, port: port_, ); }, @@ -5724,7 +6013,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 146, + funcId: 153, port: port_, ); }, @@ -5745,6 +6034,90 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["shares", "threshold"], ); + @override + Future crateBridgeWebxdcSendUpdate({ + required String instanceId, + required String payload, + String? info, + String? href, + String? summary, + String? document, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(instanceId, serializer); + sse_encode_String(payload, serializer); + sse_encode_opt_String(info, serializer); + sse_encode_opt_String(href, serializer); + sse_encode_opt_String(summary, serializer); + sse_encode_opt_String(document, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 154, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcSendUpdateConstMeta, + argValues: [instanceId, payload, info, href, summary, document], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcSendUpdateConstMeta => + const TaskConstMeta( + debugName: "send_update", + argNames: [ + "instanceId", + "payload", + "info", + "href", + "summary", + "document", + ], + ); + + @override + Future crateBridgeWebxdcServe({ + required String instanceId, + required String requestPath, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(instanceId, serializer); + sse_encode_String(requestPath, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 155, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_webxdc_response, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcServeConstMeta, + argValues: [instanceId, requestPath], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcServeConstMeta => const TaskConstMeta( + debugName: "serve", + argNames: ["instanceId", "requestPath"], + ); + @override Future crateBridgeGroupsUpdateChatDeletionTime({ required String groupId, @@ -5759,7 +6132,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 147, + funcId: 156, port: port_, ); }, @@ -5794,7 +6167,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 148, + funcId: 157, port: port_, ); }, @@ -5815,6 +6188,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["groupId", "groupName"], ); + @override + Future> crateBridgeWebxdcUpdatesAfter({ + required String instanceId, + required PlatformInt64 serial, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(instanceId, serializer); + sse_encode_i_64(serial, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 158, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_webxdc_update_entry, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeWebxdcUpdatesAfterConstMeta, + argValues: [instanceId, serial], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeWebxdcUpdatesAfterConstMeta => + const TaskConstMeta( + debugName: "updates_after", + argNames: ["instanceId", "serial"], + ); + @override UserConfig crateBridgeUserConfigUserConfigApiClone({ required UserConfig config, @@ -5827,7 +6235,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 149, + funcId: 159, )!; }, codec: SseCodec( @@ -5867,7 +6275,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 150, + funcId: 160, port: port_, ); }, @@ -5912,7 +6320,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 151, + funcId: 161, port: port_, ); }, @@ -5942,7 +6350,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 152, + funcId: 162, port: port_, ); }, @@ -5975,7 +6383,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 153, + funcId: 163, port: port_, ); }, @@ -6010,7 +6418,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 154, + funcId: 164, port: port_, ); }, @@ -6049,7 +6457,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 155, + funcId: 165, )!; }, codec: SseCodec( @@ -6336,6 +6744,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_user_config(raw); } + @protected + WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_webxdc_instance_info(raw); + } + @protected double dco_decode_f_64(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6653,6 +7067,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (raw as List).map(dco_decode_sql_value).toList(); } + @protected + List dco_decode_list_webxdc_store_app(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_webxdc_store_app).toList(); + } + + @protected + List dco_decode_list_webxdc_update_entry(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_webxdc_update_entry).toList(); + } + @protected LogLevel dco_decode_log_level(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6752,6 +7178,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw == null ? null : dco_decode_box_autoadd_user_config(raw); } + @protected + WebxdcInstanceInfo? dco_decode_opt_box_autoadd_webxdc_instance_info( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null + ? null + : dco_decode_box_autoadd_webxdc_instance_info(raw); + } + @protected List? dco_decode_opt_list_String(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -6979,6 +7415,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + int dco_decode_u_16(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + @protected int dco_decode_u_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -7100,6 +7542,70 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dcoDecodeU64(raw); } + @protected + WebxdcInstanceInfo dco_decode_webxdc_instance_info(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 7) + throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + return WebxdcInstanceInfo( + instanceId: dco_decode_String(arr[0]), + groupId: dco_decode_String(arr[1]), + appId: dco_decode_String(arr[2]), + version: dco_decode_i_64(arr[3]), + originToken: dco_decode_String(arr[4]), + summary: dco_decode_opt_String(arr[5]), + document: dco_decode_opt_String(arr[6]), + ); + } + + @protected + WebxdcResponse dco_decode_webxdc_response(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 5) + throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); + return WebxdcResponse( + status: dco_decode_u_16(arr[0]), + mime: dco_decode_String(arr[1]), + headerNames: dco_decode_list_String(arr[2]), + headerValues: dco_decode_list_String(arr[3]), + body: dco_decode_list_prim_u_8_strict(arr[4]), + ); + } + + @protected + WebxdcStoreApp dco_decode_webxdc_store_app(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 7) + throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + return WebxdcStoreApp( + appId: dco_decode_String(arr[0]), + version: dco_decode_i_64(arr[1]), + name: dco_decode_String(arr[2]), + description: dco_decode_opt_String(arr[3]), + sourceCodeUrl: dco_decode_opt_String(arr[4]), + icon: dco_decode_opt_list_prim_u_8_strict(arr[5]), + bundleBytes: dco_decode_i_64(arr[6]), + ); + } + + @protected + WebxdcUpdateEntry dco_decode_webxdc_update_entry(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 5) + throw Exception('unexpected arr length: expect 5 but see ${arr.length}'); + return WebxdcUpdateEntry( + serial: dco_decode_i_64(arr[0]), + payload: dco_decode_String(arr[1]), + info: dco_decode_opt_String(arr[2]), + href: dco_decode_opt_String(arr[3]), + senderId: dco_decode_opt_box_autoadd_i_64(arr[4]), + ); + } + @protected AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -7299,6 +7805,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_user_config(deserializer)); } + @protected + WebxdcInstanceInfo sse_decode_box_autoadd_webxdc_instance_info( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_webxdc_instance_info(deserializer)); + } + @protected double sse_decode_f_64(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -7705,6 +8219,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return ans_; } + @protected + List sse_decode_list_webxdc_store_app( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_webxdc_store_app(deserializer)); + } + return ans_; + } + + @protected + List sse_decode_list_webxdc_update_entry( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_webxdc_update_entry(deserializer)); + } + return ans_; + } + @protected LogLevel sse_decode_log_level(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -7872,6 +8414,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + WebxdcInstanceInfo? sse_decode_opt_box_autoadd_webxdc_instance_info( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_webxdc_instance_info(deserializer)); + } else { + return null; + } + } + @protected List? sse_decode_opt_list_String(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -8100,6 +8655,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + int sse_decode_u_16(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint16(); + } + @protected int sse_decode_u_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -8285,6 +8846,86 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getBigUint64(); } + @protected + WebxdcInstanceInfo sse_decode_webxdc_instance_info( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_instanceId = sse_decode_String(deserializer); + var var_groupId = sse_decode_String(deserializer); + var var_appId = sse_decode_String(deserializer); + var var_version = sse_decode_i_64(deserializer); + var var_originToken = sse_decode_String(deserializer); + var var_summary = sse_decode_opt_String(deserializer); + var var_document = sse_decode_opt_String(deserializer); + return WebxdcInstanceInfo( + instanceId: var_instanceId, + groupId: var_groupId, + appId: var_appId, + version: var_version, + originToken: var_originToken, + summary: var_summary, + document: var_document, + ); + } + + @protected + WebxdcResponse sse_decode_webxdc_response(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_status = sse_decode_u_16(deserializer); + var var_mime = sse_decode_String(deserializer); + var var_headerNames = sse_decode_list_String(deserializer); + var var_headerValues = sse_decode_list_String(deserializer); + var var_body = sse_decode_list_prim_u_8_strict(deserializer); + return WebxdcResponse( + status: var_status, + mime: var_mime, + headerNames: var_headerNames, + headerValues: var_headerValues, + body: var_body, + ); + } + + @protected + WebxdcStoreApp sse_decode_webxdc_store_app(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_appId = sse_decode_String(deserializer); + var var_version = sse_decode_i_64(deserializer); + var var_name = sse_decode_String(deserializer); + var var_description = sse_decode_opt_String(deserializer); + var var_sourceCodeUrl = sse_decode_opt_String(deserializer); + var var_icon = sse_decode_opt_list_prim_u_8_strict(deserializer); + var var_bundleBytes = sse_decode_i_64(deserializer); + return WebxdcStoreApp( + appId: var_appId, + version: var_version, + name: var_name, + description: var_description, + sourceCodeUrl: var_sourceCodeUrl, + icon: var_icon, + bundleBytes: var_bundleBytes, + ); + } + + @protected + WebxdcUpdateEntry sse_decode_webxdc_update_entry( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_serial = sse_decode_i_64(deserializer); + var var_payload = sse_decode_String(deserializer); + var var_info = sse_decode_opt_String(deserializer); + var var_href = sse_decode_opt_String(deserializer); + var var_senderId = sse_decode_opt_box_autoadd_i_64(deserializer); + return WebxdcUpdateEntry( + serial: var_serial, + payload: var_payload, + info: var_info, + href: var_href, + senderId: var_senderId, + ); + } + @protected void sse_encode_AnyhowException( AnyhowException self, @@ -8553,6 +9194,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_user_config(self, serializer); } + @protected + void sse_encode_box_autoadd_webxdc_instance_info( + WebxdcInstanceInfo self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_webxdc_instance_info(self, serializer); + } + @protected void sse_encode_f_64(double self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -8899,6 +9549,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_list_webxdc_store_app( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_webxdc_store_app(item, serializer); + } + } + + @protected + void sse_encode_list_webxdc_update_entry( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_webxdc_update_entry(item, serializer); + } + } + @protected void sse_encode_log_level(LogLevel self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -9059,6 +9733,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_opt_box_autoadd_webxdc_instance_info( + WebxdcInstanceInfo? self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_webxdc_instance_info(self, serializer); + } + } + @protected void sse_encode_opt_list_String( List? self, @@ -9251,6 +9938,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(self.encryptionKey, serializer); } + @protected + void sse_encode_u_16(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint16(self); + } + @protected void sse_encode_u_32(int self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -9374,4 +10067,60 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { // Codec=Sse (Serialization based), see doc to use other codecs serializer.buffer.putBigUint64(self); } + + @protected + void sse_encode_webxdc_instance_info( + WebxdcInstanceInfo self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.instanceId, serializer); + sse_encode_String(self.groupId, serializer); + sse_encode_String(self.appId, serializer); + sse_encode_i_64(self.version, serializer); + sse_encode_String(self.originToken, serializer); + sse_encode_opt_String(self.summary, serializer); + sse_encode_opt_String(self.document, serializer); + } + + @protected + void sse_encode_webxdc_response( + WebxdcResponse self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_16(self.status, serializer); + sse_encode_String(self.mime, serializer); + sse_encode_list_String(self.headerNames, serializer); + sse_encode_list_String(self.headerValues, serializer); + sse_encode_list_prim_u_8_strict(self.body, serializer); + } + + @protected + void sse_encode_webxdc_store_app( + WebxdcStoreApp self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.appId, serializer); + sse_encode_i_64(self.version, serializer); + sse_encode_String(self.name, serializer); + sse_encode_opt_String(self.description, serializer); + sse_encode_opt_String(self.sourceCodeUrl, serializer); + sse_encode_opt_list_prim_u_8_strict(self.icon, serializer); + sse_encode_i_64(self.bundleBytes, serializer); + } + + @protected + void sse_encode_webxdc_update_entry( + WebxdcUpdateEntry self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_64(self.serial, serializer); + sse_encode_String(self.payload, serializer); + sse_encode_opt_String(self.info, serializer); + sse_encode_opt_String(self.href, serializer); + sse_encode_opt_box_autoadd_i_64(self.senderId, serializer); + } } diff --git a/lib/core/frb_generated.io.dart b/lib/core/frb_generated.io.dart index 1d72cf5d..7df08c07 100644 --- a/lib/core/frb_generated.io.dart +++ b/lib/core/frb_generated.io.dart @@ -10,6 +10,7 @@ import 'bridge/callbacks.dart'; import 'bridge/groups.dart'; import 'bridge/logging.dart'; import 'bridge/user_config.dart'; +import 'bridge/webxdc.dart'; import 'bridge/wrapper.dart'; import 'bridge/wrapper/app_database.dart'; import 'bridge/wrapper/backup.dart'; @@ -125,6 +126,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected UserConfig dco_decode_box_autoadd_user_config(dynamic raw); + @protected + WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw); + @protected double dco_decode_f_64(dynamic raw); @@ -234,6 +238,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_sql_value(dynamic raw); + @protected + List dco_decode_list_webxdc_store_app(dynamic raw); + + @protected + List dco_decode_list_webxdc_update_entry(dynamic raw); + @protected LogLevel dco_decode_log_level(dynamic raw); @@ -281,6 +291,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw); + @protected + WebxdcInstanceInfo? dco_decode_opt_box_autoadd_webxdc_instance_info( + dynamic raw, + ); + @protected List? dco_decode_opt_list_String(dynamic raw); @@ -348,6 +363,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw); + @protected + int dco_decode_u_16(dynamic raw); + @protected int dco_decode_u_32(dynamic raw); @@ -372,6 +390,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BigInt dco_decode_usize(dynamic raw); + @protected + WebxdcInstanceInfo dco_decode_webxdc_instance_info(dynamic raw); + + @protected + WebxdcResponse dco_decode_webxdc_response(dynamic raw); + + @protected + WebxdcStoreApp dco_decode_webxdc_store_app(dynamic raw); + + @protected + WebxdcUpdateEntry dco_decode_webxdc_update_entry(dynamic raw); + @protected AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); @@ -475,6 +505,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer); + @protected + WebxdcInstanceInfo sse_decode_box_autoadd_webxdc_instance_info( + SseDeserializer deserializer, + ); + @protected double sse_decode_f_64(SseDeserializer deserializer); @@ -606,6 +641,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List sse_decode_list_sql_value(SseDeserializer deserializer); + @protected + List sse_decode_list_webxdc_store_app( + SseDeserializer deserializer, + ); + + @protected + List sse_decode_list_webxdc_update_entry( + SseDeserializer deserializer, + ); + @protected LogLevel sse_decode_log_level(SseDeserializer deserializer); @@ -661,6 +706,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + WebxdcInstanceInfo? sse_decode_opt_box_autoadd_webxdc_instance_info( + SseDeserializer deserializer, + ); + @protected List? sse_decode_opt_list_String(SseDeserializer deserializer); @@ -738,6 +788,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer); + @protected + int sse_decode_u_16(SseDeserializer deserializer); + @protected int sse_decode_u_32(SseDeserializer deserializer); @@ -762,6 +815,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BigInt sse_decode_usize(SseDeserializer deserializer); + @protected + WebxdcInstanceInfo sse_decode_webxdc_instance_info( + SseDeserializer deserializer, + ); + + @protected + WebxdcResponse sse_decode_webxdc_response(SseDeserializer deserializer); + + @protected + WebxdcStoreApp sse_decode_webxdc_store_app(SseDeserializer deserializer); + + @protected + WebxdcUpdateEntry sse_decode_webxdc_update_entry( + SseDeserializer deserializer, + ); + @protected void sse_encode_AnyhowException( AnyhowException self, @@ -903,6 +972,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_webxdc_instance_info( + WebxdcInstanceInfo self, + SseSerializer serializer, + ); + @protected void sse_encode_f_64(double self, SseSerializer serializer); @@ -1068,6 +1143,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_list_sql_value(List self, SseSerializer serializer); + @protected + void sse_encode_list_webxdc_store_app( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_webxdc_update_entry( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_log_level(LogLevel self, SseSerializer serializer); @@ -1137,6 +1224,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_webxdc_instance_info( + WebxdcInstanceInfo? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_list_String(List? self, SseSerializer serializer); @@ -1236,6 +1329,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_u_16(int self, SseSerializer serializer); + @protected void sse_encode_u_32(int self, SseSerializer serializer); @@ -1259,6 +1355,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_usize(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_webxdc_instance_info( + WebxdcInstanceInfo self, + SseSerializer serializer, + ); + + @protected + void sse_encode_webxdc_response( + WebxdcResponse self, + SseSerializer serializer, + ); + + @protected + void sse_encode_webxdc_store_app( + WebxdcStoreApp self, + SseSerializer serializer, + ); + + @protected + void sse_encode_webxdc_update_entry( + WebxdcUpdateEntry self, + SseSerializer serializer, + ); } // Section: wire_class diff --git a/lib/core/frb_generated.web.dart b/lib/core/frb_generated.web.dart index 1f9d6bcb..6ba83798 100644 --- a/lib/core/frb_generated.web.dart +++ b/lib/core/frb_generated.web.dart @@ -13,6 +13,7 @@ import 'bridge/callbacks.dart'; import 'bridge/groups.dart'; import 'bridge/logging.dart'; import 'bridge/user_config.dart'; +import 'bridge/webxdc.dart'; import 'bridge/wrapper.dart'; import 'bridge/wrapper/app_database.dart'; import 'bridge/wrapper/backup.dart'; @@ -127,6 +128,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected UserConfig dco_decode_box_autoadd_user_config(dynamic raw); + @protected + WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw); + @protected double dco_decode_f_64(dynamic raw); @@ -236,6 +240,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_sql_value(dynamic raw); + @protected + List dco_decode_list_webxdc_store_app(dynamic raw); + + @protected + List dco_decode_list_webxdc_update_entry(dynamic raw); + @protected LogLevel dco_decode_log_level(dynamic raw); @@ -283,6 +293,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw); + @protected + WebxdcInstanceInfo? dco_decode_opt_box_autoadd_webxdc_instance_info( + dynamic raw, + ); + @protected List? dco_decode_opt_list_String(dynamic raw); @@ -350,6 +365,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw); + @protected + int dco_decode_u_16(dynamic raw); + @protected int dco_decode_u_32(dynamic raw); @@ -374,6 +392,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BigInt dco_decode_usize(dynamic raw); + @protected + WebxdcInstanceInfo dco_decode_webxdc_instance_info(dynamic raw); + + @protected + WebxdcResponse dco_decode_webxdc_response(dynamic raw); + + @protected + WebxdcStoreApp dco_decode_webxdc_store_app(dynamic raw); + + @protected + WebxdcUpdateEntry dco_decode_webxdc_update_entry(dynamic raw); + @protected AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); @@ -477,6 +507,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer); + @protected + WebxdcInstanceInfo sse_decode_box_autoadd_webxdc_instance_info( + SseDeserializer deserializer, + ); + @protected double sse_decode_f_64(SseDeserializer deserializer); @@ -608,6 +643,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List sse_decode_list_sql_value(SseDeserializer deserializer); + @protected + List sse_decode_list_webxdc_store_app( + SseDeserializer deserializer, + ); + + @protected + List sse_decode_list_webxdc_update_entry( + SseDeserializer deserializer, + ); + @protected LogLevel sse_decode_log_level(SseDeserializer deserializer); @@ -663,6 +708,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + WebxdcInstanceInfo? sse_decode_opt_box_autoadd_webxdc_instance_info( + SseDeserializer deserializer, + ); + @protected List? sse_decode_opt_list_String(SseDeserializer deserializer); @@ -740,6 +790,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer); + @protected + int sse_decode_u_16(SseDeserializer deserializer); + @protected int sse_decode_u_32(SseDeserializer deserializer); @@ -764,6 +817,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BigInt sse_decode_usize(SseDeserializer deserializer); + @protected + WebxdcInstanceInfo sse_decode_webxdc_instance_info( + SseDeserializer deserializer, + ); + + @protected + WebxdcResponse sse_decode_webxdc_response(SseDeserializer deserializer); + + @protected + WebxdcStoreApp sse_decode_webxdc_store_app(SseDeserializer deserializer); + + @protected + WebxdcUpdateEntry sse_decode_webxdc_update_entry( + SseDeserializer deserializer, + ); + @protected void sse_encode_AnyhowException( AnyhowException self, @@ -905,6 +974,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_webxdc_instance_info( + WebxdcInstanceInfo self, + SseSerializer serializer, + ); + @protected void sse_encode_f_64(double self, SseSerializer serializer); @@ -1070,6 +1145,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_list_sql_value(List self, SseSerializer serializer); + @protected + void sse_encode_list_webxdc_store_app( + List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_webxdc_update_entry( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_log_level(LogLevel self, SseSerializer serializer); @@ -1139,6 +1226,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_webxdc_instance_info( + WebxdcInstanceInfo? self, + SseSerializer serializer, + ); + @protected void sse_encode_opt_list_String(List? self, SseSerializer serializer); @@ -1238,6 +1331,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_u_16(int self, SseSerializer serializer); + @protected void sse_encode_u_32(int self, SseSerializer serializer); @@ -1261,6 +1357,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_usize(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_webxdc_instance_info( + WebxdcInstanceInfo self, + SseSerializer serializer, + ); + + @protected + void sse_encode_webxdc_response( + WebxdcResponse self, + SseSerializer serializer, + ); + + @protected + void sse_encode_webxdc_store_app( + WebxdcStoreApp self, + SseSerializer serializer, + ); + + @protected + void sse_encode_webxdc_update_entry( + WebxdcUpdateEntry self, + SseSerializer serializer, + ); } // Section: wire_class diff --git a/lib/main.dart b/lib/main.dart index 6580ef72..1e8dede2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,7 @@ import 'package:twonly/src/services/migrations.service.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/services/notifications/setup.notifications.dart'; +import 'package:twonly/src/services/webxdc/webxdc_host.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/startup_guard.dart'; import 'package:twonly/src/visual/themes/light.dart'; @@ -190,6 +191,9 @@ Future _startup(SettingsChangeProvider settings) async { await notificationSetup; NativeNotificationService.init(); + // Claims the channel the native webview talks to. Nothing runs in it until + // a user starts an app from a chat. + WebxdcHost.initialize(); // The theme was read before the user was known, so re-read it now that it // is. Repainting the splash costs nothing; the app UI is built after this. @@ -289,7 +293,7 @@ Future postStartupTasks() async { await twonlyDB.messagesDao.purgeMessageTable(); unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts()); unawaited(MediaFileService.purgeTempFolder()); - unawaited(HomeWidgetService.purgeExpiredMedia()); + unawaited(HomeWidgetService.pruneSupersededMedia()); unawaited(HomeWidgetService.syncPermissions()); // 2. Service initializations diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart index c9035292..3113d38a 100644 --- a/lib/src/constants/routes.keys.dart +++ b/lib/src/constants/routes.keys.dart @@ -37,7 +37,6 @@ class Routes { static const String settingsPrivacyUserDiscovery = '/settings/privacy/user_discovery'; static const String settingsNotification = '/settings/notification'; - static const String settingsWidgets = '/settings/widgets'; static const String settingsStorage = '/settings/storage_data'; static const String settingsStorageManage = '/settings/storage_data/manage'; static const String settingsStorageImport = '/settings/storage_data/import'; diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index 3d71806d..72e63240 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -140,7 +140,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { // so ensuring that this message is not shown in the messages anymore (messages.openedAt.isBiggerThanValue(deletionTime) | messages.openedAt.isNull() | - messages.mediaStored.equals(true)) & + messages.mediaStored.equals(true) | + messages.type.equals(MessageType.webxdcApp.name)) & (mediaFiles.downloadState .equals(DownloadState.reuploadRequested.name) .not() | @@ -208,7 +209,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { messages.groupId.equals(groupId) & (messages.openedAt.isBiggerThanValue(deletionTime) | messages.openedAt.isNull() | - messages.mediaStored.equals(true)) & + messages.mediaStored.equals(true) | + messages.type.equals(MessageType.webxdcApp.name)) & (messages.isDeletedFromSender.equals(true) | (messages.type.equals(MessageType.text.name).not() & messages.type.equals(MessageType.media.name).not()) | @@ -256,7 +258,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { ))) & (messages.openedAt.isBiggerThanValue(deletionTime) | messages.openedAt.isNull() | - messages.mediaStored.equals(true)) & + messages.mediaStored.equals(true) | + messages.type.equals(MessageType.webxdcApp.name)) & (messages.isDeletedFromSender.equals(true) | (messages.type.equals(MessageType.text.name).not() & messages.type.equals(MessageType.media.name).not()) | @@ -310,6 +313,12 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { await (delete(messages)..where( (m) => m.groupId.isIn(groupIds) & + // An app card owns a webxdc instance, and the instance owns + // its update log by foreign key. Sweeping the card away on + // the chat's timer would take an app's whole state with it + // without ever clearing the storage its origin holds, so + // apps leave only when somebody deletes them. + m.type.equals(MessageType.webxdcApp.name).not() & ((m.mediaStored.equals(true) & m.isDeletedFromSender.equals(true)) | m.mediaStored.equals(false)) & diff --git a/lib/src/database/tables/messages.table.dart b/lib/src/database/tables/messages.table.dart index f1ed06bd..00a2e675 100644 --- a/lib/src/database/tables/messages.table.dart +++ b/lib/src/database/tables/messages.table.dart @@ -3,7 +3,14 @@ import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; -enum MessageType { media, text, contacts, restoreFlameCounter, askAboutUser } +enum MessageType { + media, + text, + contacts, + restoreFlameCounter, + askAboutUser, + webxdcApp, +} @DataClassName('Message') @TableIndex( diff --git a/lib/src/database/tables/webxdc.table.dart b/lib/src/database/tables/webxdc.table.dart new file mode 100644 index 00000000..4a904a9a --- /dev/null +++ b/lib/src/database/tables/webxdc.table.dart @@ -0,0 +1,82 @@ +import 'package:drift/drift.dart'; +import 'package:twonly/src/database/tables/groups.table.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; + +/// Store metadata mirrored from the API. +/// +/// A cache, but a refresh keeps the rows an instance on this device runs even +/// once the store stops offering them: an unpublished app keeps working where +/// it was already downloaded. `published` says whether the last catalog still +/// carried the version, which is what the store list goes by. +@DataClassName('WebxdcApp') +class WebxdcApps extends Table { + TextColumn get appId => text()(); + IntColumn get version => integer()(); + + /// What the app is called where no translation fits. Every row has one, + /// which is what the store orders by. + TextColumn get name => text()(); + + /// The name per language, as a JSON object keyed by language tag, in the same + /// shape as [description]. The chat card picks from it; the store list is + /// built in Rust, which picks there. + TextColumn get nameTranslations => text().withDefault(const Constant('{}'))(); + + /// One short line per language, as a JSON object keyed by language tag. Rust + /// picks the one to show; nothing in Dart reads inside it. + TextColumn get description => text().withDefault(const Constant('{}'))(); + TextColumn get sourceCodeUrl => text().nullable()(); + BlobColumn get icon => blob().nullable()(); + TextColumn get bundleSha256 => text()(); + IntColumn get bundleBytes => integer()(); + BoolColumn get published => boolean().withDefault(const Constant(true))(); + IntColumn get cachedAt => integer()(); + + @override + Set get primaryKey => {appId, version}; +} + +/// One app placed into a chat. The row's id is the id of the message that +/// carries it, so the chat card and the instance are the same object. +@DataClassName('WebxdcInstance') +class WebxdcInstances extends Table { + TextColumn get instanceId => text().references( + Messages, + #messageId, + onDelete: KeyAction.cascade, + )(); + TextColumn get groupId => + text().references(Groups, #groupId, onDelete: KeyAction.cascade)(); + TextColumn get appId => text()(); + IntColumn get version => integer()(); + TextColumn get bundleSha256 => text().nullable()(); + TextColumn get originToken => text()(); + TextColumn get summary => text().nullable()(); + TextColumn get document => text().nullable()(); + IntColumn get createdAt => integer()(); + IntColumn get lastUpdateAt => integer()(); + + @override + Set get primaryKey => {instanceId}; +} + +/// The update log. Read-only from Dart: serials are assigned in Rust, and the +/// chat's deletion timer deliberately does not reach this table. +@DataClassName('WebxdcUpdate') +class WebxdcUpdates extends Table { + TextColumn get instanceId => text().references( + WebxdcInstances, + #instanceId, + onDelete: KeyAction.cascade, + )(); + IntColumn get serial => integer()(); + TextColumn get messageId => text()(); + IntColumn get senderId => integer().nullable()(); + TextColumn get payload => text()(); + TextColumn get info => text().nullable()(); + TextColumn get href => text().nullable()(); + IntColumn get receivedAt => integer()(); + + @override + Set get primaryKey => {instanceId, serial}; +} diff --git a/lib/src/database/twonly.db.dart b/lib/src/database/twonly.db.dart index 1369c5e7..12784df7 100644 --- a/lib/src/database/twonly.db.dart +++ b/lib/src/database/twonly.db.dart @@ -20,6 +20,7 @@ import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/tables/reactions.table.dart'; import 'package:twonly/src/database/tables/receipts.table.dart'; import 'package:twonly/src/database/tables/user_discovery.table.dart'; +import 'package:twonly/src/database/tables/webxdc.table.dart'; import 'package:twonly/src/database/twonly.db.steps.dart'; import 'package:twonly/src/utils/log.dart'; @@ -48,6 +49,9 @@ part 'twonly.db.g.dart'; UserDiscoveryShares, ContactGroups, ContactGroupMembers, + WebxdcApps, + WebxdcInstances, + WebxdcUpdates, ], daos: [ MessagesDao, diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart index a8f21b27..8f554af8 100644 --- a/lib/src/database/twonly.db.g.dart +++ b/lib/src/database/twonly.db.g.dart @@ -12536,6 +12536,1849 @@ class ContactGroupMembersCompanion extends UpdateCompanion { } } +class $WebxdcAppsTable extends WebxdcApps + with TableInfo<$WebxdcAppsTable, WebxdcApp> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $WebxdcAppsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _appIdMeta = const VerificationMeta('appId'); + @override + late final GeneratedColumn appId = GeneratedColumn( + 'app_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _versionMeta = const VerificationMeta( + 'version', + ); + @override + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _nameTranslationsMeta = const VerificationMeta( + 'nameTranslations', + ); + @override + late final GeneratedColumn nameTranslations = GeneratedColumn( + 'name_translations', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('{}'), + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta( + 'description', + ); + @override + late final GeneratedColumn description = GeneratedColumn( + 'description', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('{}'), + ); + static const VerificationMeta _sourceCodeUrlMeta = const VerificationMeta( + 'sourceCodeUrl', + ); + @override + late final GeneratedColumn sourceCodeUrl = GeneratedColumn( + 'source_code_url', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _iconMeta = const VerificationMeta('icon'); + @override + late final GeneratedColumn icon = GeneratedColumn( + 'icon', + aliasedName, + true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + ); + static const VerificationMeta _bundleSha256Meta = const VerificationMeta( + 'bundleSha256', + ); + @override + late final GeneratedColumn bundleSha256 = GeneratedColumn( + 'bundle_sha256', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bundleBytesMeta = const VerificationMeta( + 'bundleBytes', + ); + @override + late final GeneratedColumn bundleBytes = GeneratedColumn( + 'bundle_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _publishedMeta = const VerificationMeta( + 'published', + ); + @override + late final GeneratedColumn published = GeneratedColumn( + 'published', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("published" IN (0, 1))', + ), + defaultValue: const Constant(true), + ); + static const VerificationMeta _cachedAtMeta = const VerificationMeta( + 'cachedAt', + ); + @override + late final GeneratedColumn cachedAt = GeneratedColumn( + 'cached_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + appId, + version, + name, + nameTranslations, + description, + sourceCodeUrl, + icon, + bundleSha256, + bundleBytes, + published, + cachedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'webxdc_apps'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('app_id')) { + context.handle( + _appIdMeta, + appId.isAcceptableOrUnknown(data['app_id']!, _appIdMeta), + ); + } else if (isInserting) { + context.missing(_appIdMeta); + } + if (data.containsKey('version')) { + context.handle( + _versionMeta, + version.isAcceptableOrUnknown(data['version']!, _versionMeta), + ); + } else if (isInserting) { + context.missing(_versionMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('name_translations')) { + context.handle( + _nameTranslationsMeta, + nameTranslations.isAcceptableOrUnknown( + data['name_translations']!, + _nameTranslationsMeta, + ), + ); + } + if (data.containsKey('description')) { + context.handle( + _descriptionMeta, + description.isAcceptableOrUnknown( + data['description']!, + _descriptionMeta, + ), + ); + } + if (data.containsKey('source_code_url')) { + context.handle( + _sourceCodeUrlMeta, + sourceCodeUrl.isAcceptableOrUnknown( + data['source_code_url']!, + _sourceCodeUrlMeta, + ), + ); + } + if (data.containsKey('icon')) { + context.handle( + _iconMeta, + icon.isAcceptableOrUnknown(data['icon']!, _iconMeta), + ); + } + if (data.containsKey('bundle_sha256')) { + context.handle( + _bundleSha256Meta, + bundleSha256.isAcceptableOrUnknown( + data['bundle_sha256']!, + _bundleSha256Meta, + ), + ); + } else if (isInserting) { + context.missing(_bundleSha256Meta); + } + if (data.containsKey('bundle_bytes')) { + context.handle( + _bundleBytesMeta, + bundleBytes.isAcceptableOrUnknown( + data['bundle_bytes']!, + _bundleBytesMeta, + ), + ); + } else if (isInserting) { + context.missing(_bundleBytesMeta); + } + if (data.containsKey('published')) { + context.handle( + _publishedMeta, + published.isAcceptableOrUnknown(data['published']!, _publishedMeta), + ); + } + if (data.containsKey('cached_at')) { + context.handle( + _cachedAtMeta, + cachedAt.isAcceptableOrUnknown(data['cached_at']!, _cachedAtMeta), + ); + } else if (isInserting) { + context.missing(_cachedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {appId, version}; + @override + WebxdcApp map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return WebxdcApp( + appId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}app_id'], + )!, + version: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}version'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + nameTranslations: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name_translations'], + )!, + description: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}description'], + )!, + sourceCodeUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_code_url'], + ), + icon: attachedDatabase.typeMapping.read( + DriftSqlType.blob, + data['${effectivePrefix}icon'], + ), + bundleSha256: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}bundle_sha256'], + )!, + bundleBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bundle_bytes'], + )!, + published: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}published'], + )!, + cachedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}cached_at'], + )!, + ); + } + + @override + $WebxdcAppsTable createAlias(String alias) { + return $WebxdcAppsTable(attachedDatabase, alias); + } +} + +class WebxdcApp extends DataClass implements Insertable { + final String appId; + final int version; + + /// What the app is called where no translation fits. Every row has one, + /// which is what the store orders by. + final String name; + + /// The name per language, as a JSON object keyed by language tag, in the same + /// shape as [description]. The chat card picks from it; the store list is + /// built in Rust, which picks there. + final String nameTranslations; + + /// One short line per language, as a JSON object keyed by language tag. Rust + /// picks the one to show; nothing in Dart reads inside it. + final String description; + final String? sourceCodeUrl; + final Uint8List? icon; + final String bundleSha256; + final int bundleBytes; + final bool published; + final int cachedAt; + const WebxdcApp({ + required this.appId, + required this.version, + required this.name, + required this.nameTranslations, + required this.description, + this.sourceCodeUrl, + this.icon, + required this.bundleSha256, + required this.bundleBytes, + required this.published, + required this.cachedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['app_id'] = Variable(appId); + map['version'] = Variable(version); + map['name'] = Variable(name); + map['name_translations'] = Variable(nameTranslations); + map['description'] = Variable(description); + if (!nullToAbsent || sourceCodeUrl != null) { + map['source_code_url'] = Variable(sourceCodeUrl); + } + if (!nullToAbsent || icon != null) { + map['icon'] = Variable(icon); + } + map['bundle_sha256'] = Variable(bundleSha256); + map['bundle_bytes'] = Variable(bundleBytes); + map['published'] = Variable(published); + map['cached_at'] = Variable(cachedAt); + return map; + } + + WebxdcAppsCompanion toCompanion(bool nullToAbsent) { + return WebxdcAppsCompanion( + appId: Value(appId), + version: Value(version), + name: Value(name), + nameTranslations: Value(nameTranslations), + description: Value(description), + sourceCodeUrl: sourceCodeUrl == null && nullToAbsent + ? const Value.absent() + : Value(sourceCodeUrl), + icon: icon == null && nullToAbsent ? const Value.absent() : Value(icon), + bundleSha256: Value(bundleSha256), + bundleBytes: Value(bundleBytes), + published: Value(published), + cachedAt: Value(cachedAt), + ); + } + + factory WebxdcApp.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return WebxdcApp( + appId: serializer.fromJson(json['appId']), + version: serializer.fromJson(json['version']), + name: serializer.fromJson(json['name']), + nameTranslations: serializer.fromJson(json['nameTranslations']), + description: serializer.fromJson(json['description']), + sourceCodeUrl: serializer.fromJson(json['sourceCodeUrl']), + icon: serializer.fromJson(json['icon']), + bundleSha256: serializer.fromJson(json['bundleSha256']), + bundleBytes: serializer.fromJson(json['bundleBytes']), + published: serializer.fromJson(json['published']), + cachedAt: serializer.fromJson(json['cachedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'appId': serializer.toJson(appId), + 'version': serializer.toJson(version), + 'name': serializer.toJson(name), + 'nameTranslations': serializer.toJson(nameTranslations), + 'description': serializer.toJson(description), + 'sourceCodeUrl': serializer.toJson(sourceCodeUrl), + 'icon': serializer.toJson(icon), + 'bundleSha256': serializer.toJson(bundleSha256), + 'bundleBytes': serializer.toJson(bundleBytes), + 'published': serializer.toJson(published), + 'cachedAt': serializer.toJson(cachedAt), + }; + } + + WebxdcApp copyWith({ + String? appId, + int? version, + String? name, + String? nameTranslations, + String? description, + Value sourceCodeUrl = const Value.absent(), + Value icon = const Value.absent(), + String? bundleSha256, + int? bundleBytes, + bool? published, + int? cachedAt, + }) => WebxdcApp( + appId: appId ?? this.appId, + version: version ?? this.version, + name: name ?? this.name, + nameTranslations: nameTranslations ?? this.nameTranslations, + description: description ?? this.description, + sourceCodeUrl: sourceCodeUrl.present + ? sourceCodeUrl.value + : this.sourceCodeUrl, + icon: icon.present ? icon.value : this.icon, + bundleSha256: bundleSha256 ?? this.bundleSha256, + bundleBytes: bundleBytes ?? this.bundleBytes, + published: published ?? this.published, + cachedAt: cachedAt ?? this.cachedAt, + ); + WebxdcApp copyWithCompanion(WebxdcAppsCompanion data) { + return WebxdcApp( + appId: data.appId.present ? data.appId.value : this.appId, + version: data.version.present ? data.version.value : this.version, + name: data.name.present ? data.name.value : this.name, + nameTranslations: data.nameTranslations.present + ? data.nameTranslations.value + : this.nameTranslations, + description: data.description.present + ? data.description.value + : this.description, + sourceCodeUrl: data.sourceCodeUrl.present + ? data.sourceCodeUrl.value + : this.sourceCodeUrl, + icon: data.icon.present ? data.icon.value : this.icon, + bundleSha256: data.bundleSha256.present + ? data.bundleSha256.value + : this.bundleSha256, + bundleBytes: data.bundleBytes.present + ? data.bundleBytes.value + : this.bundleBytes, + published: data.published.present ? data.published.value : this.published, + cachedAt: data.cachedAt.present ? data.cachedAt.value : this.cachedAt, + ); + } + + @override + String toString() { + return (StringBuffer('WebxdcApp(') + ..write('appId: $appId, ') + ..write('version: $version, ') + ..write('name: $name, ') + ..write('nameTranslations: $nameTranslations, ') + ..write('description: $description, ') + ..write('sourceCodeUrl: $sourceCodeUrl, ') + ..write('icon: $icon, ') + ..write('bundleSha256: $bundleSha256, ') + ..write('bundleBytes: $bundleBytes, ') + ..write('published: $published, ') + ..write('cachedAt: $cachedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + appId, + version, + name, + nameTranslations, + description, + sourceCodeUrl, + $driftBlobEquality.hash(icon), + bundleSha256, + bundleBytes, + published, + cachedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is WebxdcApp && + other.appId == this.appId && + other.version == this.version && + other.name == this.name && + other.nameTranslations == this.nameTranslations && + other.description == this.description && + other.sourceCodeUrl == this.sourceCodeUrl && + $driftBlobEquality.equals(other.icon, this.icon) && + other.bundleSha256 == this.bundleSha256 && + other.bundleBytes == this.bundleBytes && + other.published == this.published && + other.cachedAt == this.cachedAt); +} + +class WebxdcAppsCompanion extends UpdateCompanion { + final Value appId; + final Value version; + final Value name; + final Value nameTranslations; + final Value description; + final Value sourceCodeUrl; + final Value icon; + final Value bundleSha256; + final Value bundleBytes; + final Value published; + final Value cachedAt; + final Value rowid; + const WebxdcAppsCompanion({ + this.appId = const Value.absent(), + this.version = const Value.absent(), + this.name = const Value.absent(), + this.nameTranslations = const Value.absent(), + this.description = const Value.absent(), + this.sourceCodeUrl = const Value.absent(), + this.icon = const Value.absent(), + this.bundleSha256 = const Value.absent(), + this.bundleBytes = const Value.absent(), + this.published = const Value.absent(), + this.cachedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + WebxdcAppsCompanion.insert({ + required String appId, + required int version, + required String name, + this.nameTranslations = const Value.absent(), + this.description = const Value.absent(), + this.sourceCodeUrl = const Value.absent(), + this.icon = const Value.absent(), + required String bundleSha256, + required int bundleBytes, + this.published = const Value.absent(), + required int cachedAt, + this.rowid = const Value.absent(), + }) : appId = Value(appId), + version = Value(version), + name = Value(name), + bundleSha256 = Value(bundleSha256), + bundleBytes = Value(bundleBytes), + cachedAt = Value(cachedAt); + static Insertable custom({ + Expression? appId, + Expression? version, + Expression? name, + Expression? nameTranslations, + Expression? description, + Expression? sourceCodeUrl, + Expression? icon, + Expression? bundleSha256, + Expression? bundleBytes, + Expression? published, + Expression? cachedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (appId != null) 'app_id': appId, + if (version != null) 'version': version, + if (name != null) 'name': name, + if (nameTranslations != null) 'name_translations': nameTranslations, + if (description != null) 'description': description, + if (sourceCodeUrl != null) 'source_code_url': sourceCodeUrl, + if (icon != null) 'icon': icon, + if (bundleSha256 != null) 'bundle_sha256': bundleSha256, + if (bundleBytes != null) 'bundle_bytes': bundleBytes, + if (published != null) 'published': published, + if (cachedAt != null) 'cached_at': cachedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + WebxdcAppsCompanion copyWith({ + Value? appId, + Value? version, + Value? name, + Value? nameTranslations, + Value? description, + Value? sourceCodeUrl, + Value? icon, + Value? bundleSha256, + Value? bundleBytes, + Value? published, + Value? cachedAt, + Value? rowid, + }) { + return WebxdcAppsCompanion( + appId: appId ?? this.appId, + version: version ?? this.version, + name: name ?? this.name, + nameTranslations: nameTranslations ?? this.nameTranslations, + description: description ?? this.description, + sourceCodeUrl: sourceCodeUrl ?? this.sourceCodeUrl, + icon: icon ?? this.icon, + bundleSha256: bundleSha256 ?? this.bundleSha256, + bundleBytes: bundleBytes ?? this.bundleBytes, + published: published ?? this.published, + cachedAt: cachedAt ?? this.cachedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (appId.present) { + map['app_id'] = Variable(appId.value); + } + if (version.present) { + map['version'] = Variable(version.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (nameTranslations.present) { + map['name_translations'] = Variable(nameTranslations.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (sourceCodeUrl.present) { + map['source_code_url'] = Variable(sourceCodeUrl.value); + } + if (icon.present) { + map['icon'] = Variable(icon.value); + } + if (bundleSha256.present) { + map['bundle_sha256'] = Variable(bundleSha256.value); + } + if (bundleBytes.present) { + map['bundle_bytes'] = Variable(bundleBytes.value); + } + if (published.present) { + map['published'] = Variable(published.value); + } + if (cachedAt.present) { + map['cached_at'] = Variable(cachedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WebxdcAppsCompanion(') + ..write('appId: $appId, ') + ..write('version: $version, ') + ..write('name: $name, ') + ..write('nameTranslations: $nameTranslations, ') + ..write('description: $description, ') + ..write('sourceCodeUrl: $sourceCodeUrl, ') + ..write('icon: $icon, ') + ..write('bundleSha256: $bundleSha256, ') + ..write('bundleBytes: $bundleBytes, ') + ..write('published: $published, ') + ..write('cachedAt: $cachedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $WebxdcInstancesTable extends WebxdcInstances + with TableInfo<$WebxdcInstancesTable, WebxdcInstance> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $WebxdcInstancesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _instanceIdMeta = const VerificationMeta( + 'instanceId', + ); + @override + late final GeneratedColumn instanceId = GeneratedColumn( + 'instance_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES messages (message_id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _groupIdMeta = const VerificationMeta( + 'groupId', + ); + @override + late final GeneratedColumn groupId = GeneratedColumn( + 'group_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES "groups" (group_id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _appIdMeta = const VerificationMeta('appId'); + @override + late final GeneratedColumn appId = GeneratedColumn( + 'app_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _versionMeta = const VerificationMeta( + 'version', + ); + @override + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _bundleSha256Meta = const VerificationMeta( + 'bundleSha256', + ); + @override + late final GeneratedColumn bundleSha256 = GeneratedColumn( + 'bundle_sha256', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _originTokenMeta = const VerificationMeta( + 'originToken', + ); + @override + late final GeneratedColumn originToken = GeneratedColumn( + 'origin_token', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _summaryMeta = const VerificationMeta( + 'summary', + ); + @override + late final GeneratedColumn summary = GeneratedColumn( + 'summary', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _documentMeta = const VerificationMeta( + 'document', + ); + @override + late final GeneratedColumn document = GeneratedColumn( + 'document', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _lastUpdateAtMeta = const VerificationMeta( + 'lastUpdateAt', + ); + @override + late final GeneratedColumn lastUpdateAt = GeneratedColumn( + 'last_update_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + instanceId, + groupId, + appId, + version, + bundleSha256, + originToken, + summary, + document, + createdAt, + lastUpdateAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'webxdc_instances'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('instance_id')) { + context.handle( + _instanceIdMeta, + instanceId.isAcceptableOrUnknown(data['instance_id']!, _instanceIdMeta), + ); + } else if (isInserting) { + context.missing(_instanceIdMeta); + } + if (data.containsKey('group_id')) { + context.handle( + _groupIdMeta, + groupId.isAcceptableOrUnknown(data['group_id']!, _groupIdMeta), + ); + } else if (isInserting) { + context.missing(_groupIdMeta); + } + if (data.containsKey('app_id')) { + context.handle( + _appIdMeta, + appId.isAcceptableOrUnknown(data['app_id']!, _appIdMeta), + ); + } else if (isInserting) { + context.missing(_appIdMeta); + } + if (data.containsKey('version')) { + context.handle( + _versionMeta, + version.isAcceptableOrUnknown(data['version']!, _versionMeta), + ); + } else if (isInserting) { + context.missing(_versionMeta); + } + if (data.containsKey('bundle_sha256')) { + context.handle( + _bundleSha256Meta, + bundleSha256.isAcceptableOrUnknown( + data['bundle_sha256']!, + _bundleSha256Meta, + ), + ); + } + if (data.containsKey('origin_token')) { + context.handle( + _originTokenMeta, + originToken.isAcceptableOrUnknown( + data['origin_token']!, + _originTokenMeta, + ), + ); + } else if (isInserting) { + context.missing(_originTokenMeta); + } + if (data.containsKey('summary')) { + context.handle( + _summaryMeta, + summary.isAcceptableOrUnknown(data['summary']!, _summaryMeta), + ); + } + if (data.containsKey('document')) { + context.handle( + _documentMeta, + document.isAcceptableOrUnknown(data['document']!, _documentMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('last_update_at')) { + context.handle( + _lastUpdateAtMeta, + lastUpdateAt.isAcceptableOrUnknown( + data['last_update_at']!, + _lastUpdateAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_lastUpdateAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {instanceId}; + @override + WebxdcInstance map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return WebxdcInstance( + instanceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}instance_id'], + )!, + groupId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}group_id'], + )!, + appId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}app_id'], + )!, + version: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}version'], + )!, + bundleSha256: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}bundle_sha256'], + ), + originToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}origin_token'], + )!, + summary: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}summary'], + ), + document: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}document'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}created_at'], + )!, + lastUpdateAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}last_update_at'], + )!, + ); + } + + @override + $WebxdcInstancesTable createAlias(String alias) { + return $WebxdcInstancesTable(attachedDatabase, alias); + } +} + +class WebxdcInstance extends DataClass implements Insertable { + final String instanceId; + final String groupId; + final String appId; + final int version; + final String? bundleSha256; + final String originToken; + final String? summary; + final String? document; + final int createdAt; + final int lastUpdateAt; + const WebxdcInstance({ + required this.instanceId, + required this.groupId, + required this.appId, + required this.version, + this.bundleSha256, + required this.originToken, + this.summary, + this.document, + required this.createdAt, + required this.lastUpdateAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['instance_id'] = Variable(instanceId); + map['group_id'] = Variable(groupId); + map['app_id'] = Variable(appId); + map['version'] = Variable(version); + if (!nullToAbsent || bundleSha256 != null) { + map['bundle_sha256'] = Variable(bundleSha256); + } + map['origin_token'] = Variable(originToken); + if (!nullToAbsent || summary != null) { + map['summary'] = Variable(summary); + } + if (!nullToAbsent || document != null) { + map['document'] = Variable(document); + } + map['created_at'] = Variable(createdAt); + map['last_update_at'] = Variable(lastUpdateAt); + return map; + } + + WebxdcInstancesCompanion toCompanion(bool nullToAbsent) { + return WebxdcInstancesCompanion( + instanceId: Value(instanceId), + groupId: Value(groupId), + appId: Value(appId), + version: Value(version), + bundleSha256: bundleSha256 == null && nullToAbsent + ? const Value.absent() + : Value(bundleSha256), + originToken: Value(originToken), + summary: summary == null && nullToAbsent + ? const Value.absent() + : Value(summary), + document: document == null && nullToAbsent + ? const Value.absent() + : Value(document), + createdAt: Value(createdAt), + lastUpdateAt: Value(lastUpdateAt), + ); + } + + factory WebxdcInstance.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return WebxdcInstance( + instanceId: serializer.fromJson(json['instanceId']), + groupId: serializer.fromJson(json['groupId']), + appId: serializer.fromJson(json['appId']), + version: serializer.fromJson(json['version']), + bundleSha256: serializer.fromJson(json['bundleSha256']), + originToken: serializer.fromJson(json['originToken']), + summary: serializer.fromJson(json['summary']), + document: serializer.fromJson(json['document']), + createdAt: serializer.fromJson(json['createdAt']), + lastUpdateAt: serializer.fromJson(json['lastUpdateAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'instanceId': serializer.toJson(instanceId), + 'groupId': serializer.toJson(groupId), + 'appId': serializer.toJson(appId), + 'version': serializer.toJson(version), + 'bundleSha256': serializer.toJson(bundleSha256), + 'originToken': serializer.toJson(originToken), + 'summary': serializer.toJson(summary), + 'document': serializer.toJson(document), + 'createdAt': serializer.toJson(createdAt), + 'lastUpdateAt': serializer.toJson(lastUpdateAt), + }; + } + + WebxdcInstance copyWith({ + String? instanceId, + String? groupId, + String? appId, + int? version, + Value bundleSha256 = const Value.absent(), + String? originToken, + Value summary = const Value.absent(), + Value document = const Value.absent(), + int? createdAt, + int? lastUpdateAt, + }) => WebxdcInstance( + instanceId: instanceId ?? this.instanceId, + groupId: groupId ?? this.groupId, + appId: appId ?? this.appId, + version: version ?? this.version, + bundleSha256: bundleSha256.present ? bundleSha256.value : this.bundleSha256, + originToken: originToken ?? this.originToken, + summary: summary.present ? summary.value : this.summary, + document: document.present ? document.value : this.document, + createdAt: createdAt ?? this.createdAt, + lastUpdateAt: lastUpdateAt ?? this.lastUpdateAt, + ); + WebxdcInstance copyWithCompanion(WebxdcInstancesCompanion data) { + return WebxdcInstance( + instanceId: data.instanceId.present + ? data.instanceId.value + : this.instanceId, + groupId: data.groupId.present ? data.groupId.value : this.groupId, + appId: data.appId.present ? data.appId.value : this.appId, + version: data.version.present ? data.version.value : this.version, + bundleSha256: data.bundleSha256.present + ? data.bundleSha256.value + : this.bundleSha256, + originToken: data.originToken.present + ? data.originToken.value + : this.originToken, + summary: data.summary.present ? data.summary.value : this.summary, + document: data.document.present ? data.document.value : this.document, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + lastUpdateAt: data.lastUpdateAt.present + ? data.lastUpdateAt.value + : this.lastUpdateAt, + ); + } + + @override + String toString() { + return (StringBuffer('WebxdcInstance(') + ..write('instanceId: $instanceId, ') + ..write('groupId: $groupId, ') + ..write('appId: $appId, ') + ..write('version: $version, ') + ..write('bundleSha256: $bundleSha256, ') + ..write('originToken: $originToken, ') + ..write('summary: $summary, ') + ..write('document: $document, ') + ..write('createdAt: $createdAt, ') + ..write('lastUpdateAt: $lastUpdateAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + instanceId, + groupId, + appId, + version, + bundleSha256, + originToken, + summary, + document, + createdAt, + lastUpdateAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is WebxdcInstance && + other.instanceId == this.instanceId && + other.groupId == this.groupId && + other.appId == this.appId && + other.version == this.version && + other.bundleSha256 == this.bundleSha256 && + other.originToken == this.originToken && + other.summary == this.summary && + other.document == this.document && + other.createdAt == this.createdAt && + other.lastUpdateAt == this.lastUpdateAt); +} + +class WebxdcInstancesCompanion extends UpdateCompanion { + final Value instanceId; + final Value groupId; + final Value appId; + final Value version; + final Value bundleSha256; + final Value originToken; + final Value summary; + final Value document; + final Value createdAt; + final Value lastUpdateAt; + final Value rowid; + const WebxdcInstancesCompanion({ + this.instanceId = const Value.absent(), + this.groupId = const Value.absent(), + this.appId = const Value.absent(), + this.version = const Value.absent(), + this.bundleSha256 = const Value.absent(), + this.originToken = const Value.absent(), + this.summary = const Value.absent(), + this.document = const Value.absent(), + this.createdAt = const Value.absent(), + this.lastUpdateAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + WebxdcInstancesCompanion.insert({ + required String instanceId, + required String groupId, + required String appId, + required int version, + this.bundleSha256 = const Value.absent(), + required String originToken, + this.summary = const Value.absent(), + this.document = const Value.absent(), + required int createdAt, + required int lastUpdateAt, + this.rowid = const Value.absent(), + }) : instanceId = Value(instanceId), + groupId = Value(groupId), + appId = Value(appId), + version = Value(version), + originToken = Value(originToken), + createdAt = Value(createdAt), + lastUpdateAt = Value(lastUpdateAt); + static Insertable custom({ + Expression? instanceId, + Expression? groupId, + Expression? appId, + Expression? version, + Expression? bundleSha256, + Expression? originToken, + Expression? summary, + Expression? document, + Expression? createdAt, + Expression? lastUpdateAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (instanceId != null) 'instance_id': instanceId, + if (groupId != null) 'group_id': groupId, + if (appId != null) 'app_id': appId, + if (version != null) 'version': version, + if (bundleSha256 != null) 'bundle_sha256': bundleSha256, + if (originToken != null) 'origin_token': originToken, + if (summary != null) 'summary': summary, + if (document != null) 'document': document, + if (createdAt != null) 'created_at': createdAt, + if (lastUpdateAt != null) 'last_update_at': lastUpdateAt, + if (rowid != null) 'rowid': rowid, + }); + } + + WebxdcInstancesCompanion copyWith({ + Value? instanceId, + Value? groupId, + Value? appId, + Value? version, + Value? bundleSha256, + Value? originToken, + Value? summary, + Value? document, + Value? createdAt, + Value? lastUpdateAt, + Value? rowid, + }) { + return WebxdcInstancesCompanion( + instanceId: instanceId ?? this.instanceId, + groupId: groupId ?? this.groupId, + appId: appId ?? this.appId, + version: version ?? this.version, + bundleSha256: bundleSha256 ?? this.bundleSha256, + originToken: originToken ?? this.originToken, + summary: summary ?? this.summary, + document: document ?? this.document, + createdAt: createdAt ?? this.createdAt, + lastUpdateAt: lastUpdateAt ?? this.lastUpdateAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (instanceId.present) { + map['instance_id'] = Variable(instanceId.value); + } + if (groupId.present) { + map['group_id'] = Variable(groupId.value); + } + if (appId.present) { + map['app_id'] = Variable(appId.value); + } + if (version.present) { + map['version'] = Variable(version.value); + } + if (bundleSha256.present) { + map['bundle_sha256'] = Variable(bundleSha256.value); + } + if (originToken.present) { + map['origin_token'] = Variable(originToken.value); + } + if (summary.present) { + map['summary'] = Variable(summary.value); + } + if (document.present) { + map['document'] = Variable(document.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (lastUpdateAt.present) { + map['last_update_at'] = Variable(lastUpdateAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WebxdcInstancesCompanion(') + ..write('instanceId: $instanceId, ') + ..write('groupId: $groupId, ') + ..write('appId: $appId, ') + ..write('version: $version, ') + ..write('bundleSha256: $bundleSha256, ') + ..write('originToken: $originToken, ') + ..write('summary: $summary, ') + ..write('document: $document, ') + ..write('createdAt: $createdAt, ') + ..write('lastUpdateAt: $lastUpdateAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $WebxdcUpdatesTable extends WebxdcUpdates + with TableInfo<$WebxdcUpdatesTable, WebxdcUpdate> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $WebxdcUpdatesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _instanceIdMeta = const VerificationMeta( + 'instanceId', + ); + @override + late final GeneratedColumn instanceId = GeneratedColumn( + 'instance_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES webxdc_instances (instance_id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _serialMeta = const VerificationMeta('serial'); + @override + late final GeneratedColumn serial = GeneratedColumn( + 'serial', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _messageIdMeta = const VerificationMeta( + 'messageId', + ); + @override + late final GeneratedColumn messageId = GeneratedColumn( + 'message_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _senderIdMeta = const VerificationMeta( + 'senderId', + ); + @override + late final GeneratedColumn senderId = GeneratedColumn( + 'sender_id', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _payloadMeta = const VerificationMeta( + 'payload', + ); + @override + late final GeneratedColumn payload = GeneratedColumn( + 'payload', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _infoMeta = const VerificationMeta('info'); + @override + late final GeneratedColumn info = GeneratedColumn( + 'info', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _hrefMeta = const VerificationMeta('href'); + @override + late final GeneratedColumn href = GeneratedColumn( + 'href', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _receivedAtMeta = const VerificationMeta( + 'receivedAt', + ); + @override + late final GeneratedColumn receivedAt = GeneratedColumn( + 'received_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + instanceId, + serial, + messageId, + senderId, + payload, + info, + href, + receivedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'webxdc_updates'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('instance_id')) { + context.handle( + _instanceIdMeta, + instanceId.isAcceptableOrUnknown(data['instance_id']!, _instanceIdMeta), + ); + } else if (isInserting) { + context.missing(_instanceIdMeta); + } + if (data.containsKey('serial')) { + context.handle( + _serialMeta, + serial.isAcceptableOrUnknown(data['serial']!, _serialMeta), + ); + } else if (isInserting) { + context.missing(_serialMeta); + } + if (data.containsKey('message_id')) { + context.handle( + _messageIdMeta, + messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta), + ); + } else if (isInserting) { + context.missing(_messageIdMeta); + } + if (data.containsKey('sender_id')) { + context.handle( + _senderIdMeta, + senderId.isAcceptableOrUnknown(data['sender_id']!, _senderIdMeta), + ); + } + if (data.containsKey('payload')) { + context.handle( + _payloadMeta, + payload.isAcceptableOrUnknown(data['payload']!, _payloadMeta), + ); + } else if (isInserting) { + context.missing(_payloadMeta); + } + if (data.containsKey('info')) { + context.handle( + _infoMeta, + info.isAcceptableOrUnknown(data['info']!, _infoMeta), + ); + } + if (data.containsKey('href')) { + context.handle( + _hrefMeta, + href.isAcceptableOrUnknown(data['href']!, _hrefMeta), + ); + } + if (data.containsKey('received_at')) { + context.handle( + _receivedAtMeta, + receivedAt.isAcceptableOrUnknown(data['received_at']!, _receivedAtMeta), + ); + } else if (isInserting) { + context.missing(_receivedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {instanceId, serial}; + @override + WebxdcUpdate map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return WebxdcUpdate( + instanceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}instance_id'], + )!, + serial: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}serial'], + )!, + messageId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}message_id'], + )!, + senderId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}sender_id'], + ), + payload: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}payload'], + )!, + info: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}info'], + ), + href: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}href'], + ), + receivedAt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}received_at'], + )!, + ); + } + + @override + $WebxdcUpdatesTable createAlias(String alias) { + return $WebxdcUpdatesTable(attachedDatabase, alias); + } +} + +class WebxdcUpdate extends DataClass implements Insertable { + final String instanceId; + final int serial; + final String messageId; + final int? senderId; + final String payload; + final String? info; + final String? href; + final int receivedAt; + const WebxdcUpdate({ + required this.instanceId, + required this.serial, + required this.messageId, + this.senderId, + required this.payload, + this.info, + this.href, + required this.receivedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['instance_id'] = Variable(instanceId); + map['serial'] = Variable(serial); + map['message_id'] = Variable(messageId); + if (!nullToAbsent || senderId != null) { + map['sender_id'] = Variable(senderId); + } + map['payload'] = Variable(payload); + if (!nullToAbsent || info != null) { + map['info'] = Variable(info); + } + if (!nullToAbsent || href != null) { + map['href'] = Variable(href); + } + map['received_at'] = Variable(receivedAt); + return map; + } + + WebxdcUpdatesCompanion toCompanion(bool nullToAbsent) { + return WebxdcUpdatesCompanion( + instanceId: Value(instanceId), + serial: Value(serial), + messageId: Value(messageId), + senderId: senderId == null && nullToAbsent + ? const Value.absent() + : Value(senderId), + payload: Value(payload), + info: info == null && nullToAbsent ? const Value.absent() : Value(info), + href: href == null && nullToAbsent ? const Value.absent() : Value(href), + receivedAt: Value(receivedAt), + ); + } + + factory WebxdcUpdate.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return WebxdcUpdate( + instanceId: serializer.fromJson(json['instanceId']), + serial: serializer.fromJson(json['serial']), + messageId: serializer.fromJson(json['messageId']), + senderId: serializer.fromJson(json['senderId']), + payload: serializer.fromJson(json['payload']), + info: serializer.fromJson(json['info']), + href: serializer.fromJson(json['href']), + receivedAt: serializer.fromJson(json['receivedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'instanceId': serializer.toJson(instanceId), + 'serial': serializer.toJson(serial), + 'messageId': serializer.toJson(messageId), + 'senderId': serializer.toJson(senderId), + 'payload': serializer.toJson(payload), + 'info': serializer.toJson(info), + 'href': serializer.toJson(href), + 'receivedAt': serializer.toJson(receivedAt), + }; + } + + WebxdcUpdate copyWith({ + String? instanceId, + int? serial, + String? messageId, + Value senderId = const Value.absent(), + String? payload, + Value info = const Value.absent(), + Value href = const Value.absent(), + int? receivedAt, + }) => WebxdcUpdate( + instanceId: instanceId ?? this.instanceId, + serial: serial ?? this.serial, + messageId: messageId ?? this.messageId, + senderId: senderId.present ? senderId.value : this.senderId, + payload: payload ?? this.payload, + info: info.present ? info.value : this.info, + href: href.present ? href.value : this.href, + receivedAt: receivedAt ?? this.receivedAt, + ); + WebxdcUpdate copyWithCompanion(WebxdcUpdatesCompanion data) { + return WebxdcUpdate( + instanceId: data.instanceId.present + ? data.instanceId.value + : this.instanceId, + serial: data.serial.present ? data.serial.value : this.serial, + messageId: data.messageId.present ? data.messageId.value : this.messageId, + senderId: data.senderId.present ? data.senderId.value : this.senderId, + payload: data.payload.present ? data.payload.value : this.payload, + info: data.info.present ? data.info.value : this.info, + href: data.href.present ? data.href.value : this.href, + receivedAt: data.receivedAt.present + ? data.receivedAt.value + : this.receivedAt, + ); + } + + @override + String toString() { + return (StringBuffer('WebxdcUpdate(') + ..write('instanceId: $instanceId, ') + ..write('serial: $serial, ') + ..write('messageId: $messageId, ') + ..write('senderId: $senderId, ') + ..write('payload: $payload, ') + ..write('info: $info, ') + ..write('href: $href, ') + ..write('receivedAt: $receivedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + instanceId, + serial, + messageId, + senderId, + payload, + info, + href, + receivedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is WebxdcUpdate && + other.instanceId == this.instanceId && + other.serial == this.serial && + other.messageId == this.messageId && + other.senderId == this.senderId && + other.payload == this.payload && + other.info == this.info && + other.href == this.href && + other.receivedAt == this.receivedAt); +} + +class WebxdcUpdatesCompanion extends UpdateCompanion { + final Value instanceId; + final Value serial; + final Value messageId; + final Value senderId; + final Value payload; + final Value info; + final Value href; + final Value receivedAt; + final Value rowid; + const WebxdcUpdatesCompanion({ + this.instanceId = const Value.absent(), + this.serial = const Value.absent(), + this.messageId = const Value.absent(), + this.senderId = const Value.absent(), + this.payload = const Value.absent(), + this.info = const Value.absent(), + this.href = const Value.absent(), + this.receivedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + WebxdcUpdatesCompanion.insert({ + required String instanceId, + required int serial, + required String messageId, + this.senderId = const Value.absent(), + required String payload, + this.info = const Value.absent(), + this.href = const Value.absent(), + required int receivedAt, + this.rowid = const Value.absent(), + }) : instanceId = Value(instanceId), + serial = Value(serial), + messageId = Value(messageId), + payload = Value(payload), + receivedAt = Value(receivedAt); + static Insertable custom({ + Expression? instanceId, + Expression? serial, + Expression? messageId, + Expression? senderId, + Expression? payload, + Expression? info, + Expression? href, + Expression? receivedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (instanceId != null) 'instance_id': instanceId, + if (serial != null) 'serial': serial, + if (messageId != null) 'message_id': messageId, + if (senderId != null) 'sender_id': senderId, + if (payload != null) 'payload': payload, + if (info != null) 'info': info, + if (href != null) 'href': href, + if (receivedAt != null) 'received_at': receivedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + WebxdcUpdatesCompanion copyWith({ + Value? instanceId, + Value? serial, + Value? messageId, + Value? senderId, + Value? payload, + Value? info, + Value? href, + Value? receivedAt, + Value? rowid, + }) { + return WebxdcUpdatesCompanion( + instanceId: instanceId ?? this.instanceId, + serial: serial ?? this.serial, + messageId: messageId ?? this.messageId, + senderId: senderId ?? this.senderId, + payload: payload ?? this.payload, + info: info ?? this.info, + href: href ?? this.href, + receivedAt: receivedAt ?? this.receivedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (instanceId.present) { + map['instance_id'] = Variable(instanceId.value); + } + if (serial.present) { + map['serial'] = Variable(serial.value); + } + if (messageId.present) { + map['message_id'] = Variable(messageId.value); + } + if (senderId.present) { + map['sender_id'] = Variable(senderId.value); + } + if (payload.present) { + map['payload'] = Variable(payload.value); + } + if (info.present) { + map['info'] = Variable(info.value); + } + if (href.present) { + map['href'] = Variable(href.value); + } + if (receivedAt.present) { + map['received_at'] = Variable(receivedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('WebxdcUpdatesCompanion(') + ..write('instanceId: $instanceId, ') + ..write('serial: $serial, ') + ..write('messageId: $messageId, ') + ..write('senderId: $senderId, ') + ..write('payload: $payload, ') + ..write('info: $info, ') + ..write('href: $href, ') + ..write('receivedAt: $receivedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$TwonlyDB extends GeneratedDatabase { _$TwonlyDB(QueryExecutor e) : super(e); $TwonlyDBManager get managers => $TwonlyDBManager(this); @@ -12572,6 +14415,11 @@ abstract class _$TwonlyDB extends GeneratedDatabase { late final $ContactGroupsTable contactGroups = $ContactGroupsTable(this); late final $ContactGroupMembersTable contactGroupMembers = $ContactGroupMembersTable(this); + late final $WebxdcAppsTable webxdcApps = $WebxdcAppsTable(this); + late final $WebxdcInstancesTable webxdcInstances = $WebxdcInstancesTable( + this, + ); + late final $WebxdcUpdatesTable webxdcUpdates = $WebxdcUpdatesTable(this); late final Index idxMessagesGroupIdCreatedAt = Index( 'idx_messages_group_id_created_at', 'CREATE INDEX idx_messages_group_id_created_at ON messages (group_id, created_at)', @@ -12620,6 +14468,9 @@ abstract class _$TwonlyDB extends GeneratedDatabase { userDiscoveryShares, contactGroups, contactGroupMembers, + webxdcApps, + webxdcInstances, + webxdcUpdates, idxMessagesGroupIdCreatedAt, idxReceiptsMessageId, ]; @@ -12787,6 +14638,27 @@ abstract class _$TwonlyDB extends GeneratedDatabase { ), result: [TableUpdate('contact_group_members', kind: UpdateKind.delete)], ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'messages', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('webxdc_instances', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'groups', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('webxdc_instances', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'webxdc_instances', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('webxdc_updates', kind: UpdateKind.delete)], + ), ]); } @@ -14827,6 +16699,26 @@ final class $$GroupsTableReferences manager.$state.copyWith(prefetchedData: cache), ); } + + static MultiTypedResultKey<$WebxdcInstancesTable, List> + _webxdcInstancesRefsTable(_$TwonlyDB db) => MultiTypedResultKey.fromTable( + db.webxdcInstances, + aliasName: 'groups__group_id__webxdc_instances__group_id', + ); + + $$WebxdcInstancesTableProcessedTableManager get webxdcInstancesRefs { + final manager = + $$WebxdcInstancesTableTableManager($_db, $_db.webxdcInstances).filter( + (f) => f.groupId.groupId.sqlEquals($_itemColumn('group_id')!), + ); + + final cache = $_typedResult.readTableOrNull( + _webxdcInstancesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } } class $$GroupsTableFilterComposer extends Composer<_$TwonlyDB, $GroupsTable> { @@ -15061,6 +16953,31 @@ class $$GroupsTableFilterComposer extends Composer<_$TwonlyDB, $GroupsTable> { ); return f(composer); } + + Expression webxdcInstancesRefs( + Expression Function($$WebxdcInstancesTableFilterComposer f) f, + ) { + final $$WebxdcInstancesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.groupId, + referencedTable: $db.webxdcInstances, + getReferencedColumn: (t) => t.groupId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebxdcInstancesTableFilterComposer( + $db: $db, + $table: $db.webxdcInstances, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$GroupsTableOrderingComposer extends Composer<_$TwonlyDB, $GroupsTable> { @@ -15421,6 +17338,31 @@ class $$GroupsTableAnnotationComposer ); return f(composer); } + + Expression webxdcInstancesRefs( + Expression Function($$WebxdcInstancesTableAnnotationComposer a) f, + ) { + final $$WebxdcInstancesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.groupId, + referencedTable: $db.webxdcInstances, + getReferencedColumn: (t) => t.groupId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebxdcInstancesTableAnnotationComposer( + $db: $db, + $table: $db.webxdcInstances, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$GroupsTableTableManager @@ -15441,6 +17383,7 @@ class $$GroupsTableTableManager bool groupMembersRefs, bool groupHistoriesRefs, bool contactGroupMembersRefs, + bool webxdcInstancesRefs, }) > { $$GroupsTableTableManager(_$TwonlyDB db, $GroupsTable table) @@ -15582,6 +17525,7 @@ class $$GroupsTableTableManager groupMembersRefs = false, groupHistoriesRefs = false, contactGroupMembersRefs = false, + webxdcInstancesRefs = false, }) { return PrefetchHooks( db: db, @@ -15590,6 +17534,7 @@ class $$GroupsTableTableManager if (groupMembersRefs) db.groupMembers, if (groupHistoriesRefs) db.groupHistories, if (contactGroupMembersRefs) db.contactGroupMembers, + if (webxdcInstancesRefs) db.webxdcInstances, ], addJoins: null, getPrefetchedDataCallback: (items) async { @@ -15674,6 +17619,27 @@ class $$GroupsTableTableManager ), typedResults: items, ), + if (webxdcInstancesRefs) + await $_getPrefetchedData< + Group, + $GroupsTable, + WebxdcInstance + >( + currentTable: table, + referencedTable: $$GroupsTableReferences + ._webxdcInstancesRefsTable(db), + managerFromTypedResult: (p0) => + $$GroupsTableReferences( + db, + table, + p0, + ).webxdcInstancesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.groupId == item.groupId, + ), + typedResults: items, + ), ]; }, ); @@ -15699,6 +17665,7 @@ typedef $$GroupsTableProcessedTableManager = bool groupMembersRefs, bool groupHistoriesRefs, bool contactGroupMembersRefs, + bool webxdcInstancesRefs, }) >; typedef $$MediaFilesTableCreateCompanionBuilder = @@ -16653,6 +18620,28 @@ final class $$MessagesTableReferences manager.$state.copyWith(prefetchedData: cache), ); } + + static MultiTypedResultKey<$WebxdcInstancesTable, List> + _webxdcInstancesRefsTable(_$TwonlyDB db) => MultiTypedResultKey.fromTable( + db.webxdcInstances, + aliasName: 'messages__message_id__webxdc_instances__instance_id', + ); + + $$WebxdcInstancesTableProcessedTableManager get webxdcInstancesRefs { + final manager = + $$WebxdcInstancesTableTableManager($_db, $_db.webxdcInstances).filter( + (f) => f.instanceId.messageId.sqlEquals( + $_itemColumn('message_id')!, + ), + ); + + final cache = $_typedResult.readTableOrNull( + _webxdcInstancesRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } } class $$MessagesTableFilterComposer @@ -16912,6 +18901,31 @@ class $$MessagesTableFilterComposer ); return f(composer); } + + Expression webxdcInstancesRefs( + Expression Function($$WebxdcInstancesTableFilterComposer f) f, + ) { + final $$WebxdcInstancesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.webxdcInstances, + getReferencedColumn: (t) => t.instanceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebxdcInstancesTableFilterComposer( + $db: $db, + $table: $db.webxdcInstances, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$MessagesTableOrderingComposer @@ -17318,6 +19332,31 @@ class $$MessagesTableAnnotationComposer ); return f(composer); } + + Expression webxdcInstancesRefs( + Expression Function($$WebxdcInstancesTableAnnotationComposer a) f, + ) { + final $$WebxdcInstancesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.webxdcInstances, + getReferencedColumn: (t) => t.instanceId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$WebxdcInstancesTableAnnotationComposer( + $db: $db, + $table: $db.webxdcInstances, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$MessagesTableTableManager @@ -17341,6 +19380,7 @@ class $$MessagesTableTableManager bool reactionsRefs, bool receiptsRefs, bool messageActionsRefs, + bool webxdcInstancesRefs, }) > { $$MessagesTableTableManager(_$TwonlyDB db, $MessagesTable table) @@ -17459,6 +19499,7 @@ class $$MessagesTableTableManager reactionsRefs = false, receiptsRefs = false, messageActionsRefs = false, + webxdcInstancesRefs = false, }) { return PrefetchHooks( db: db, @@ -17467,6 +19508,7 @@ class $$MessagesTableTableManager if (reactionsRefs) db.reactions, if (receiptsRefs) db.receipts, if (messageActionsRefs) db.messageActions, + if (webxdcInstancesRefs) db.webxdcInstances, ], addJoins: < @@ -17612,6 +19654,27 @@ class $$MessagesTableTableManager ), typedResults: items, ), + if (webxdcInstancesRefs) + await $_getPrefetchedData< + Message, + $MessagesTable, + WebxdcInstance + >( + currentTable: table, + referencedTable: $$MessagesTableReferences + ._webxdcInstancesRefsTable(db), + managerFromTypedResult: (p0) => + $$MessagesTableReferences( + db, + table, + p0, + ).webxdcInstancesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.instanceId == item.messageId, + ), + typedResults: items, + ), ]; }, ); @@ -17640,6 +19703,7 @@ typedef $$MessagesTableProcessedTableManager = bool reactionsRefs, bool receiptsRefs, bool messageActionsRefs, + bool webxdcInstancesRefs, }) >; typedef $$MessageHistoriesTableCreateCompanionBuilder = @@ -23726,6 +25790,1082 @@ typedef $$ContactGroupMembersTableProcessedTableManager = ContactGroupMember, PrefetchHooks Function({bool contactGroupId, bool userId, bool groupId}) >; +typedef $$WebxdcAppsTableCreateCompanionBuilder = + WebxdcAppsCompanion Function({ + required String appId, + required int version, + required String name, + Value nameTranslations, + Value description, + Value sourceCodeUrl, + Value icon, + required String bundleSha256, + required int bundleBytes, + Value published, + required int cachedAt, + Value rowid, + }); +typedef $$WebxdcAppsTableUpdateCompanionBuilder = + WebxdcAppsCompanion Function({ + Value appId, + Value version, + Value name, + Value nameTranslations, + Value description, + Value sourceCodeUrl, + Value icon, + Value bundleSha256, + Value bundleBytes, + Value published, + Value cachedAt, + Value rowid, + }); + +class $$WebxdcAppsTableFilterComposer + extends Composer<_$TwonlyDB, $WebxdcAppsTable> { + $$WebxdcAppsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get appId => $composableBuilder( + column: $table.appId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get nameTranslations => $composableBuilder( + column: $table.nameTranslations, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sourceCodeUrl => $composableBuilder( + column: $table.sourceCodeUrl, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get icon => $composableBuilder( + column: $table.icon, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get bundleSha256 => $composableBuilder( + column: $table.bundleSha256, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get bundleBytes => $composableBuilder( + column: $table.bundleBytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get published => $composableBuilder( + column: $table.published, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$WebxdcAppsTableOrderingComposer + extends Composer<_$TwonlyDB, $WebxdcAppsTable> { + $$WebxdcAppsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get appId => $composableBuilder( + column: $table.appId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get nameTranslations => $composableBuilder( + column: $table.nameTranslations, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get description => $composableBuilder( + column: $table.description, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sourceCodeUrl => $composableBuilder( + column: $table.sourceCodeUrl, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get icon => $composableBuilder( + column: $table.icon, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get bundleSha256 => $composableBuilder( + column: $table.bundleSha256, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get bundleBytes => $composableBuilder( + column: $table.bundleBytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get published => $composableBuilder( + column: $table.published, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cachedAt => $composableBuilder( + column: $table.cachedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$WebxdcAppsTableAnnotationComposer + extends Composer<_$TwonlyDB, $WebxdcAppsTable> { + $$WebxdcAppsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get appId => + $composableBuilder(column: $table.appId, builder: (column) => column); + + GeneratedColumn get version => + $composableBuilder(column: $table.version, builder: (column) => column); + + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get nameTranslations => $composableBuilder( + column: $table.nameTranslations, + builder: (column) => column, + ); + + GeneratedColumn get description => $composableBuilder( + column: $table.description, + builder: (column) => column, + ); + + GeneratedColumn get sourceCodeUrl => $composableBuilder( + column: $table.sourceCodeUrl, + builder: (column) => column, + ); + + GeneratedColumn get icon => + $composableBuilder(column: $table.icon, builder: (column) => column); + + GeneratedColumn get bundleSha256 => $composableBuilder( + column: $table.bundleSha256, + builder: (column) => column, + ); + + GeneratedColumn get bundleBytes => $composableBuilder( + column: $table.bundleBytes, + builder: (column) => column, + ); + + GeneratedColumn get published => + $composableBuilder(column: $table.published, builder: (column) => column); + + GeneratedColumn get cachedAt => + $composableBuilder(column: $table.cachedAt, builder: (column) => column); +} + +class $$WebxdcAppsTableTableManager + extends + RootTableManager< + _$TwonlyDB, + $WebxdcAppsTable, + WebxdcApp, + $$WebxdcAppsTableFilterComposer, + $$WebxdcAppsTableOrderingComposer, + $$WebxdcAppsTableAnnotationComposer, + $$WebxdcAppsTableCreateCompanionBuilder, + $$WebxdcAppsTableUpdateCompanionBuilder, + (WebxdcApp, BaseReferences<_$TwonlyDB, $WebxdcAppsTable, WebxdcApp>), + WebxdcApp, + PrefetchHooks Function() + > { + $$WebxdcAppsTableTableManager(_$TwonlyDB db, $WebxdcAppsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$WebxdcAppsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$WebxdcAppsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$WebxdcAppsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value appId = const Value.absent(), + Value version = const Value.absent(), + Value name = const Value.absent(), + Value nameTranslations = const Value.absent(), + Value description = const Value.absent(), + Value sourceCodeUrl = const Value.absent(), + Value icon = const Value.absent(), + Value bundleSha256 = const Value.absent(), + Value bundleBytes = const Value.absent(), + Value published = const Value.absent(), + Value cachedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => WebxdcAppsCompanion( + appId: appId, + version: version, + name: name, + nameTranslations: nameTranslations, + description: description, + sourceCodeUrl: sourceCodeUrl, + icon: icon, + bundleSha256: bundleSha256, + bundleBytes: bundleBytes, + published: published, + cachedAt: cachedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String appId, + required int version, + required String name, + Value nameTranslations = const Value.absent(), + Value description = const Value.absent(), + Value sourceCodeUrl = const Value.absent(), + Value icon = const Value.absent(), + required String bundleSha256, + required int bundleBytes, + Value published = const Value.absent(), + required int cachedAt, + Value rowid = const Value.absent(), + }) => WebxdcAppsCompanion.insert( + appId: appId, + version: version, + name: name, + nameTranslations: nameTranslations, + description: description, + sourceCodeUrl: sourceCodeUrl, + icon: icon, + bundleSha256: bundleSha256, + bundleBytes: bundleBytes, + published: published, + cachedAt: cachedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$WebxdcAppsTableProcessedTableManager = + ProcessedTableManager< + _$TwonlyDB, + $WebxdcAppsTable, + WebxdcApp, + $$WebxdcAppsTableFilterComposer, + $$WebxdcAppsTableOrderingComposer, + $$WebxdcAppsTableAnnotationComposer, + $$WebxdcAppsTableCreateCompanionBuilder, + $$WebxdcAppsTableUpdateCompanionBuilder, + (WebxdcApp, BaseReferences<_$TwonlyDB, $WebxdcAppsTable, WebxdcApp>), + WebxdcApp, + PrefetchHooks Function() + >; +typedef $$WebxdcInstancesTableCreateCompanionBuilder = + WebxdcInstancesCompanion Function({ + required String instanceId, + required String groupId, + required String appId, + required int version, + Value bundleSha256, + required String originToken, + Value summary, + Value document, + required int createdAt, + required int lastUpdateAt, + Value rowid, + }); +typedef $$WebxdcInstancesTableUpdateCompanionBuilder = + WebxdcInstancesCompanion Function({ + Value instanceId, + Value groupId, + Value appId, + Value version, + Value bundleSha256, + Value originToken, + Value summary, + Value document, + Value createdAt, + Value lastUpdateAt, + Value rowid, + }); + +final class $$WebxdcInstancesTableReferences + extends BaseReferences<_$TwonlyDB, $WebxdcInstancesTable, WebxdcInstance> { + $$WebxdcInstancesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $MessagesTable _instanceIdTable(_$TwonlyDB db) => db.messages + .createAlias('webxdc_instances__instance_id__messages__message_id'); + + $$MessagesTableProcessedTableManager get instanceId { + final $_column = $_itemColumn('instance_id')!; + + final manager = $$MessagesTableTableManager( + $_db, + $_db.messages, + ).filter((f) => f.messageId.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_instanceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $GroupsTable _groupIdTable(_$TwonlyDB db) => + db.groups.createAlias('webxdc_instances__group_id__groups__group_id'); + + $$GroupsTableProcessedTableManager get groupId { + final $_column = $_itemColumn('group_id')!; + + final manager = $$GroupsTableTableManager( + $_db, + $_db.groups, + ).filter((f) => f.groupId.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_groupIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$WebxdcInstancesTableFilterComposer + extends Composer<_$TwonlyDB, $WebxdcInstancesTable> { + $$WebxdcInstancesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get appId => $composableBuilder( + column: $table.appId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get bundleSha256 => $composableBuilder( + column: $table.bundleSha256, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get originToken => $composableBuilder( + column: $table.originToken, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get document => $composableBuilder( + column: $table.document, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastUpdateAt => $composableBuilder( + column: $table.lastUpdateAt, + builder: (column) => ColumnFilters(column), + ); + + $$MessagesTableFilterComposer get instanceId { + final $$MessagesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.instanceId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.messageId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MessagesTableFilterComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$GroupsTableFilterComposer get groupId { + final $$GroupsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.groupId, + referencedTable: $db.groups, + getReferencedColumn: (t) => t.groupId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$GroupsTableFilterComposer( + $db: $db, + $table: $db.groups, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$WebxdcInstancesTableOrderingComposer + extends Composer<_$TwonlyDB, $WebxdcInstancesTable> { + $$WebxdcInstancesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get appId => $composableBuilder( + column: $table.appId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get bundleSha256 => $composableBuilder( + column: $table.bundleSha256, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get originToken => $composableBuilder( + column: $table.originToken, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get summary => $composableBuilder( + column: $table.summary, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get document => $composableBuilder( + column: $table.document, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastUpdateAt => $composableBuilder( + column: $table.lastUpdateAt, + builder: (column) => ColumnOrderings(column), + ); + + $$MessagesTableOrderingComposer get instanceId { + final $$MessagesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.instanceId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.messageId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MessagesTableOrderingComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$GroupsTableOrderingComposer get groupId { + final $$GroupsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.groupId, + referencedTable: $db.groups, + getReferencedColumn: (t) => t.groupId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$GroupsTableOrderingComposer( + $db: $db, + $table: $db.groups, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$WebxdcInstancesTableAnnotationComposer + extends Composer<_$TwonlyDB, $WebxdcInstancesTable> { + $$WebxdcInstancesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get appId => + $composableBuilder(column: $table.appId, builder: (column) => column); + + GeneratedColumn get version => + $composableBuilder(column: $table.version, builder: (column) => column); + + GeneratedColumn get bundleSha256 => $composableBuilder( + column: $table.bundleSha256, + builder: (column) => column, + ); + + GeneratedColumn get originToken => $composableBuilder( + column: $table.originToken, + builder: (column) => column, + ); + + GeneratedColumn get summary => + $composableBuilder(column: $table.summary, builder: (column) => column); + + GeneratedColumn get document => + $composableBuilder(column: $table.document, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get lastUpdateAt => $composableBuilder( + column: $table.lastUpdateAt, + builder: (column) => column, + ); + + $$MessagesTableAnnotationComposer get instanceId { + final $$MessagesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.instanceId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.messageId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$MessagesTableAnnotationComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$GroupsTableAnnotationComposer get groupId { + final $$GroupsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.groupId, + referencedTable: $db.groups, + getReferencedColumn: (t) => t.groupId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$GroupsTableAnnotationComposer( + $db: $db, + $table: $db.groups, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$WebxdcInstancesTableTableManager + extends + RootTableManager< + _$TwonlyDB, + $WebxdcInstancesTable, + WebxdcInstance, + $$WebxdcInstancesTableFilterComposer, + $$WebxdcInstancesTableOrderingComposer, + $$WebxdcInstancesTableAnnotationComposer, + $$WebxdcInstancesTableCreateCompanionBuilder, + $$WebxdcInstancesTableUpdateCompanionBuilder, + (WebxdcInstance, $$WebxdcInstancesTableReferences), + WebxdcInstance, + PrefetchHooks Function({bool instanceId, bool groupId}) + > { + $$WebxdcInstancesTableTableManager(_$TwonlyDB db, $WebxdcInstancesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$WebxdcInstancesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$WebxdcInstancesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$WebxdcInstancesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value instanceId = const Value.absent(), + Value groupId = const Value.absent(), + Value appId = const Value.absent(), + Value version = const Value.absent(), + Value bundleSha256 = const Value.absent(), + Value originToken = const Value.absent(), + Value summary = const Value.absent(), + Value document = const Value.absent(), + Value createdAt = const Value.absent(), + Value lastUpdateAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => WebxdcInstancesCompanion( + instanceId: instanceId, + groupId: groupId, + appId: appId, + version: version, + bundleSha256: bundleSha256, + originToken: originToken, + summary: summary, + document: document, + createdAt: createdAt, + lastUpdateAt: lastUpdateAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String instanceId, + required String groupId, + required String appId, + required int version, + Value bundleSha256 = const Value.absent(), + required String originToken, + Value summary = const Value.absent(), + Value document = const Value.absent(), + required int createdAt, + required int lastUpdateAt, + Value rowid = const Value.absent(), + }) => WebxdcInstancesCompanion.insert( + instanceId: instanceId, + groupId: groupId, + appId: appId, + version: version, + bundleSha256: bundleSha256, + originToken: originToken, + summary: summary, + document: document, + createdAt: createdAt, + lastUpdateAt: lastUpdateAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$WebxdcInstancesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({instanceId = false, groupId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (instanceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.instanceId, + referencedTable: + $$WebxdcInstancesTableReferences + ._instanceIdTable(db), + referencedColumn: + $$WebxdcInstancesTableReferences + ._instanceIdTable(db) + .messageId, + ) + as T; + } + if (groupId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.groupId, + referencedTable: + $$WebxdcInstancesTableReferences + ._groupIdTable(db), + referencedColumn: + $$WebxdcInstancesTableReferences + ._groupIdTable(db) + .groupId, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$WebxdcInstancesTableProcessedTableManager = + ProcessedTableManager< + _$TwonlyDB, + $WebxdcInstancesTable, + WebxdcInstance, + $$WebxdcInstancesTableFilterComposer, + $$WebxdcInstancesTableOrderingComposer, + $$WebxdcInstancesTableAnnotationComposer, + $$WebxdcInstancesTableCreateCompanionBuilder, + $$WebxdcInstancesTableUpdateCompanionBuilder, + (WebxdcInstance, $$WebxdcInstancesTableReferences), + WebxdcInstance, + PrefetchHooks Function({bool instanceId, bool groupId}) + >; +typedef $$WebxdcUpdatesTableCreateCompanionBuilder = + WebxdcUpdatesCompanion Function({ + required String instanceId, + required int serial, + required String messageId, + Value senderId, + required String payload, + Value info, + Value href, + required int receivedAt, + Value rowid, + }); +typedef $$WebxdcUpdatesTableUpdateCompanionBuilder = + WebxdcUpdatesCompanion Function({ + Value instanceId, + Value serial, + Value messageId, + Value senderId, + Value payload, + Value info, + Value href, + Value receivedAt, + Value rowid, + }); + +class $$WebxdcUpdatesTableFilterComposer + extends Composer<_$TwonlyDB, $WebxdcUpdatesTable> { + $$WebxdcUpdatesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get serial => $composableBuilder( + column: $table.serial, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get messageId => $composableBuilder( + column: $table.messageId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get senderId => $composableBuilder( + column: $table.senderId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get info => $composableBuilder( + column: $table.info, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get href => $composableBuilder( + column: $table.href, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get receivedAt => $composableBuilder( + column: $table.receivedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$WebxdcUpdatesTableOrderingComposer + extends Composer<_$TwonlyDB, $WebxdcUpdatesTable> { + $$WebxdcUpdatesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get serial => $composableBuilder( + column: $table.serial, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get messageId => $composableBuilder( + column: $table.messageId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get senderId => $composableBuilder( + column: $table.senderId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get payload => $composableBuilder( + column: $table.payload, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get info => $composableBuilder( + column: $table.info, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get href => $composableBuilder( + column: $table.href, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get receivedAt => $composableBuilder( + column: $table.receivedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$WebxdcUpdatesTableAnnotationComposer + extends Composer<_$TwonlyDB, $WebxdcUpdatesTable> { + $$WebxdcUpdatesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get serial => + $composableBuilder(column: $table.serial, builder: (column) => column); + + GeneratedColumn get messageId => + $composableBuilder(column: $table.messageId, builder: (column) => column); + + GeneratedColumn get senderId => + $composableBuilder(column: $table.senderId, builder: (column) => column); + + GeneratedColumn get payload => + $composableBuilder(column: $table.payload, builder: (column) => column); + + GeneratedColumn get info => + $composableBuilder(column: $table.info, builder: (column) => column); + + GeneratedColumn get href => + $composableBuilder(column: $table.href, builder: (column) => column); + + GeneratedColumn get receivedAt => $composableBuilder( + column: $table.receivedAt, + builder: (column) => column, + ); +} + +class $$WebxdcUpdatesTableTableManager + extends + RootTableManager< + _$TwonlyDB, + $WebxdcUpdatesTable, + WebxdcUpdate, + $$WebxdcUpdatesTableFilterComposer, + $$WebxdcUpdatesTableOrderingComposer, + $$WebxdcUpdatesTableAnnotationComposer, + $$WebxdcUpdatesTableCreateCompanionBuilder, + $$WebxdcUpdatesTableUpdateCompanionBuilder, + ( + WebxdcUpdate, + BaseReferences<_$TwonlyDB, $WebxdcUpdatesTable, WebxdcUpdate>, + ), + WebxdcUpdate, + PrefetchHooks Function() + > { + $$WebxdcUpdatesTableTableManager(_$TwonlyDB db, $WebxdcUpdatesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$WebxdcUpdatesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$WebxdcUpdatesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$WebxdcUpdatesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value instanceId = const Value.absent(), + Value serial = const Value.absent(), + Value messageId = const Value.absent(), + Value senderId = const Value.absent(), + Value payload = const Value.absent(), + Value info = const Value.absent(), + Value href = const Value.absent(), + Value receivedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => WebxdcUpdatesCompanion( + instanceId: instanceId, + serial: serial, + messageId: messageId, + senderId: senderId, + payload: payload, + info: info, + href: href, + receivedAt: receivedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String instanceId, + required int serial, + required String messageId, + Value senderId = const Value.absent(), + required String payload, + Value info = const Value.absent(), + Value href = const Value.absent(), + required int receivedAt, + Value rowid = const Value.absent(), + }) => WebxdcUpdatesCompanion.insert( + instanceId: instanceId, + serial: serial, + messageId: messageId, + senderId: senderId, + payload: payload, + info: info, + href: href, + receivedAt: receivedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$WebxdcUpdatesTableProcessedTableManager = + ProcessedTableManager< + _$TwonlyDB, + $WebxdcUpdatesTable, + WebxdcUpdate, + $$WebxdcUpdatesTableFilterComposer, + $$WebxdcUpdatesTableOrderingComposer, + $$WebxdcUpdatesTableAnnotationComposer, + $$WebxdcUpdatesTableCreateCompanionBuilder, + $$WebxdcUpdatesTableUpdateCompanionBuilder, + ( + WebxdcUpdate, + BaseReferences<_$TwonlyDB, $WebxdcUpdatesTable, WebxdcUpdate>, + ), + WebxdcUpdate, + PrefetchHooks Function() + >; class $TwonlyDBManager { final _$TwonlyDB _db; @@ -23786,4 +26926,10 @@ class $TwonlyDBManager { $$ContactGroupsTableTableManager(_db, _db.contactGroups); $$ContactGroupMembersTableTableManager get contactGroupMembers => $$ContactGroupMembersTableTableManager(_db, _db.contactGroupMembers); + $$WebxdcAppsTableTableManager get webxdcApps => + $$WebxdcAppsTableTableManager(_db, _db.webxdcApps); + $$WebxdcInstancesTableTableManager get webxdcInstances => + $$WebxdcInstancesTableTableManager(_db, _db.webxdcInstances); + $$WebxdcUpdatesTableTableManager get webxdcUpdates => + $$WebxdcUpdatesTableTableManager(_db, _db.webxdcUpdates); } diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index e17592d7..9e408735 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -4676,144 +4676,6 @@ abstract class AppLocalizations { /// **'Upgrade plan'** String get fileLimitReachedUpgrade; - /// No description provided for @settingsWidgets. - /// - /// In en, this message translates to: - /// **'Widgets'** - String get settingsWidgets; - - /// No description provided for @widgetsTitle. - /// - /// In en, this message translates to: - /// **'Widgets'** - String get widgetsTitle; - - /// No description provided for @widgetsIntroTitle. - /// - /// In en, this message translates to: - /// **'Photos on your home screen'** - String get widgetsIntroTitle; - - /// No description provided for @widgetsIntroBody. - /// - /// In en, this message translates to: - /// **'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.'** - String get widgetsIntroBody; - - /// No description provided for @widgetsSetupIos. - /// - /// In en, this message translates to: - /// **'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.'** - String get widgetsSetupIos; - - /// No description provided for @widgetsSetupAndroid. - /// - /// In en, this message translates to: - /// **'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.'** - String get widgetsSetupAndroid; - - /// No description provided for @widgetsNoneTitle. - /// - /// In en, this message translates to: - /// **'No widget added yet'** - String get widgetsNoneTitle; - - /// No description provided for @widgetsPlacedTitle. - /// - /// In en, this message translates to: - /// **'On your home screen'** - String get widgetsPlacedTitle; - - /// No description provided for @widgetsAddAnother. - /// - /// In en, this message translates to: - /// **'Add another widget'** - String get widgetsAddAnother; - - /// No description provided for @widgetsNoGroups. - /// - /// In en, this message translates to: - /// **'No contact group selected'** - String get widgetsNoGroups; - - /// No description provided for @widgetsNoGroupsHint. - /// - /// In en, this message translates to: - /// **'Nobody can send to this widget until you choose at least one contact group.'** - String get widgetsNoGroupsHint; - - /// No description provided for @widgetsEditGroup. - /// - /// In en, this message translates to: - /// **'Edit contact group'** - String get widgetsEditGroup; - - /// No description provided for @widgetsChangeGroupsIos. - /// - /// In en, this message translates to: - /// **'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.'** - String get widgetsChangeGroupsIos; - - /// No description provided for @widgetsChangeGroupsAndroid. - /// - /// In en, this message translates to: - /// **'To change the contact groups, remove the widget and add it again.'** - String get widgetsChangeGroupsAndroid; - - /// No description provided for @widgetsCurrentImages. - /// - /// In en, this message translates to: - /// **'Currently showing'** - String get widgetsCurrentImages; - - /// No description provided for @widgetsNoImages. - /// - /// In en, this message translates to: - /// **'No photos right now'** - String get widgetsNoImages; - - /// No description provided for @widgetsNoImagesHint. - /// - /// In en, this message translates to: - /// **'Photos your friends send to this widget will appear here.'** - String get widgetsNoImagesHint; - - /// No description provided for @widgetsFrom. - /// - /// In en, this message translates to: - /// **'From {sender}'** - String widgetsFrom(String sender); - - /// No description provided for @widgetsExpiresIn. - /// - /// In en, this message translates to: - /// **'Shown for {duration} more'** - String widgetsExpiresIn(String duration); - - /// No description provided for @widgetsDeleteImage. - /// - /// In en, this message translates to: - /// **'Delete photo'** - String get widgetsDeleteImage; - - /// No description provided for @widgetsDeleteImageConfirm. - /// - /// In en, this message translates to: - /// **'This removes the photo from every widget showing it. It cannot be undone.'** - String get widgetsDeleteImageConfirm; - - /// No description provided for @widgetsDurationHours. - /// - /// In en, this message translates to: - /// **'{hours} h'** - String widgetsDurationHours(int hours); - - /// No description provided for @widgetsDurationMinutes. - /// - /// In en, this message translates to: - /// **'{minutes} min'** - String widgetsDurationMinutes(int minutes); - /// No description provided for @contactGroupUsedByWidget. /// /// In en, this message translates to: @@ -4832,35 +4694,59 @@ abstract class AppLocalizations { /// **'{count, plural, =1{This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.} other{This contact group is used by {count} widgets on your home screen. Remove those widgets first, then you can delete the group.}}'** String contactGroupDeleteBlockedByWidget(int count); - /// No description provided for @widgetsQueryFailed. + /// No description provided for @webxdcStoreMenu. /// /// In en, this message translates to: - /// **'Could not read the widgets on your home screen, so this list may be out of date.'** - String get widgetsQueryFailed; + /// **'App'** + String get webxdcStoreMenu; - /// No description provided for @widgetsSizeSmall. + /// No description provided for @webxdcStoreTitle. /// /// In en, this message translates to: - /// **'Small widget'** - String get widgetsSizeSmall; + /// **'Add an app'** + String get webxdcStoreTitle; - /// No description provided for @widgetsSizeMedium. + /// No description provided for @webxdcStoreEmpty. /// /// In en, this message translates to: - /// **'Medium widget'** - String get widgetsSizeMedium; + /// **'No apps are available yet.'** + String get webxdcStoreEmpty; - /// No description provided for @widgetsSizeLarge. + /// No description provided for @webxdcStoreOffline. /// /// In en, this message translates to: - /// **'Large widget'** - String get widgetsSizeLarge; + /// **'The app list could not be loaded. Check your connection and try again.'** + String get webxdcStoreOffline; - /// No description provided for @widgetsSizeUnknown. + /// No description provided for @webxdcStoreFailed. /// /// In en, this message translates to: - /// **'Widget'** - String get widgetsSizeUnknown; + /// **'The app could not be added to this chat.'** + String get webxdcStoreFailed; + + /// No description provided for @webxdcUnavailable. + /// + /// In en, this message translates to: + /// **'This app could not be loaded.'** + String get webxdcUnavailable; + + /// No description provided for @webxdcDeleteConfirm. + /// + /// In en, this message translates to: + /// **'Everything this app saved on this device is deleted. Other members keep their own copy.'** + String get webxdcDeleteConfirm; + + /// No description provided for @webxdcExternalLinkTitle. + /// + /// In en, this message translates to: + /// **'Leave twonly?'** + String get webxdcExternalLinkTitle; + + /// No description provided for @webxdcExternalLinkBody. + /// + /// In en, this message translates to: + /// **'This link opens outside twonly, in your browser:'** + String get webxdcExternalLinkBody; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 7a731f0b..8f829e83 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2700,91 +2700,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get fileLimitReachedUpgrade => 'Tarif wechseln'; - @override - String get settingsWidgets => 'Widgets'; - - @override - String get widgetsTitle => 'Widgets'; - - @override - String get widgetsIntroTitle => 'Fotos auf deinem Homebildschirm'; - - @override - String get widgetsIntroBody => - 'Freunde aus den von dir gewΓ€hlten Kontaktgruppen kΓΆnnen ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.'; - - @override - String get widgetsSetupIos => - 'Halte eine freie Stelle auf dem Homebildschirm gedrΓΌckt, tippe auf Bearbeiten, dann auf Widget hinzufΓΌgen und wΓ€hle twonly. Halte anschließend das neue Widget gedrΓΌckt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwΓ€hlen.'; - - @override - String get widgetsSetupAndroid => - 'Halte eine freie Stelle auf dem Homebildschirm gedrΓΌckt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dΓΌrfen.'; - - @override - String get widgetsNoneTitle => 'Noch kein Widget hinzugefΓΌgt'; - - @override - String get widgetsPlacedTitle => 'Auf deinem Homebildschirm'; - - @override - String get widgetsAddAnother => 'Weiteres Widget hinzufΓΌgen'; - - @override - String get widgetsNoGroups => 'Keine Kontaktgruppe ausgewΓ€hlt'; - - @override - String get widgetsNoGroupsHint => - 'Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswΓ€hlst.'; - - @override - String get widgetsEditGroup => 'Kontaktgruppe bearbeiten'; - - @override - String get widgetsChangeGroupsIos => - 'Um die Kontaktgruppen zu Γ€ndern, halte das Widget auf dem Homebildschirm gedrΓΌckt und tippe auf Widget bearbeiten.'; - - @override - String get widgetsChangeGroupsAndroid => - 'Um die Kontaktgruppen zu Γ€ndern, entferne das Widget und fΓΌge es erneut hinzu.'; - - @override - String get widgetsCurrentImages => 'Wird aktuell angezeigt'; - - @override - String get widgetsNoImages => 'Aktuell keine Fotos'; - - @override - String get widgetsNoImagesHint => - 'Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.'; - - @override - String widgetsFrom(String sender) { - return 'Von $sender'; - } - - @override - String widgetsExpiresIn(String duration) { - return 'Noch $duration sichtbar'; - } - - @override - String get widgetsDeleteImage => 'Foto lΓΆschen'; - - @override - String get widgetsDeleteImageConfirm => - 'Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rΓΌckgΓ€ngig gemacht werden.'; - - @override - String widgetsDurationHours(int hours) { - return '$hours Std.'; - } - - @override - String widgetsDurationMinutes(int minutes) { - return '$minutes Min.'; - } - @override String get contactGroupUsedByWidget => 'Homebildschirm-Widget'; @@ -2815,18 +2730,33 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get widgetsQueryFailed => - 'Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist mΓΆglicherweise veraltet.'; + String get webxdcStoreMenu => 'App'; @override - String get widgetsSizeSmall => 'Kleines Widget'; + String get webxdcStoreTitle => 'App hinzufΓΌgen'; @override - String get widgetsSizeMedium => 'Mittleres Widget'; + String get webxdcStoreEmpty => 'Es sind noch keine Apps verfΓΌgbar.'; @override - String get widgetsSizeLarge => 'Großes Widget'; + String get webxdcStoreOffline => + 'Die App-Liste konnte nicht geladen werden. PrΓΌfe deine Verbindung und versuche es erneut.'; @override - String get widgetsSizeUnknown => 'Widget'; + String get webxdcStoreFailed => + 'Die App konnte diesem Chat nicht hinzugefΓΌgt werden.'; + + @override + String get webxdcUnavailable => 'Diese App konnte nicht geladen werden.'; + + @override + String get webxdcDeleteConfirm => + 'Alles, was diese App auf diesem GerΓ€t gespeichert hat, wird gelΓΆscht. Andere Mitglieder behalten ihre eigene Kopie.'; + + @override + String get webxdcExternalLinkTitle => 'twonly verlassen?'; + + @override + String get webxdcExternalLinkBody => + 'Dieser Link ΓΆffnet sich außerhalb von twonly in deinem Browser:'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 5b17fbd3..c85c9a20 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2675,91 +2675,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fileLimitReachedUpgrade => 'Upgrade plan'; - @override - String get settingsWidgets => 'Widgets'; - - @override - String get widgetsTitle => 'Widgets'; - - @override - String get widgetsIntroTitle => 'Photos on your home screen'; - - @override - String get widgetsIntroBody => - 'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.'; - - @override - String get widgetsSetupIos => - 'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.'; - - @override - String get widgetsSetupAndroid => - 'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.'; - - @override - String get widgetsNoneTitle => 'No widget added yet'; - - @override - String get widgetsPlacedTitle => 'On your home screen'; - - @override - String get widgetsAddAnother => 'Add another widget'; - - @override - String get widgetsNoGroups => 'No contact group selected'; - - @override - String get widgetsNoGroupsHint => - 'Nobody can send to this widget until you choose at least one contact group.'; - - @override - String get widgetsEditGroup => 'Edit contact group'; - - @override - String get widgetsChangeGroupsIos => - 'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.'; - - @override - String get widgetsChangeGroupsAndroid => - 'To change the contact groups, remove the widget and add it again.'; - - @override - String get widgetsCurrentImages => 'Currently showing'; - - @override - String get widgetsNoImages => 'No photos right now'; - - @override - String get widgetsNoImagesHint => - 'Photos your friends send to this widget will appear here.'; - - @override - String widgetsFrom(String sender) { - return 'From $sender'; - } - - @override - String widgetsExpiresIn(String duration) { - return 'Shown for $duration more'; - } - - @override - String get widgetsDeleteImage => 'Delete photo'; - - @override - String get widgetsDeleteImageConfirm => - 'This removes the photo from every widget showing it. It cannot be undone.'; - - @override - String widgetsDurationHours(int hours) { - return '$hours h'; - } - - @override - String widgetsDurationMinutes(int minutes) { - return '$minutes min'; - } - @override String get contactGroupUsedByWidget => 'Home screen widget'; @@ -2788,18 +2703,32 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get widgetsQueryFailed => - 'Could not read the widgets on your home screen, so this list may be out of date.'; + String get webxdcStoreMenu => 'App'; @override - String get widgetsSizeSmall => 'Small widget'; + String get webxdcStoreTitle => 'Add an app'; @override - String get widgetsSizeMedium => 'Medium widget'; + String get webxdcStoreEmpty => 'No apps are available yet.'; @override - String get widgetsSizeLarge => 'Large widget'; + String get webxdcStoreOffline => + 'The app list could not be loaded. Check your connection and try again.'; @override - String get widgetsSizeUnknown => 'Widget'; + String get webxdcStoreFailed => 'The app could not be added to this chat.'; + + @override + String get webxdcUnavailable => 'This app could not be loaded.'; + + @override + String get webxdcDeleteConfirm => + 'Everything this app saved on this device is deleted. Other members keep their own copy.'; + + @override + String get webxdcExternalLinkTitle => 'Leave twonly?'; + + @override + String get webxdcExternalLinkBody => + 'This link opens outside twonly, in your browser:'; } diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index 36b78e22..04c7cff0 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -1013,57 +1013,6 @@ "fileLimitReachedHint": "Nimm ein kΓΌrzeres Video auf und sende es erneut.", "fileLimitReachedHintFree": "Nimm ein kΓΌrzeres Video auf oder wechsle den Tarif, um grâßere Dateien zu senden.", "fileLimitReachedUpgrade": "Tarif wechseln", - "settingsWidgets": "Widgets", - "widgetsTitle": "Widgets", - "widgetsIntroTitle": "Fotos auf deinem Homebildschirm", - "widgetsIntroBody": "Freunde aus den von dir gewΓ€hlten Kontaktgruppen kΓΆnnen ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.", - "widgetsSetupIos": "Halte eine freie Stelle auf dem Homebildschirm gedrΓΌckt, tippe auf Bearbeiten, dann auf Widget hinzufΓΌgen und wΓ€hle twonly. Halte anschließend das neue Widget gedrΓΌckt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwΓ€hlen.", - "widgetsSetupAndroid": "Halte eine freie Stelle auf dem Homebildschirm gedrΓΌckt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dΓΌrfen.", - "widgetsNoneTitle": "Noch kein Widget hinzugefΓΌgt", - "widgetsPlacedTitle": "Auf deinem Homebildschirm", - "widgetsAddAnother": "Weiteres Widget hinzufΓΌgen", - "widgetsNoGroups": "Keine Kontaktgruppe ausgewΓ€hlt", - "widgetsNoGroupsHint": "Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswΓ€hlst.", - "widgetsEditGroup": "Kontaktgruppe bearbeiten", - "widgetsChangeGroupsIos": "Um die Kontaktgruppen zu Γ€ndern, halte das Widget auf dem Homebildschirm gedrΓΌckt und tippe auf Widget bearbeiten.", - "widgetsChangeGroupsAndroid": "Um die Kontaktgruppen zu Γ€ndern, entferne das Widget und fΓΌge es erneut hinzu.", - "widgetsCurrentImages": "Wird aktuell angezeigt", - "widgetsNoImages": "Aktuell keine Fotos", - "widgetsNoImagesHint": "Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.", - "widgetsFrom": "Von {sender}", - "@widgetsFrom": { - "placeholders": { - "sender": { - "type": "String" - } - } - }, - "widgetsExpiresIn": "Noch {duration} sichtbar", - "@widgetsExpiresIn": { - "placeholders": { - "duration": { - "type": "String" - } - } - }, - "widgetsDeleteImage": "Foto lΓΆschen", - "widgetsDeleteImageConfirm": "Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rΓΌckgΓ€ngig gemacht werden.", - "widgetsDurationHours": "{hours} Std.", - "@widgetsDurationHours": { - "placeholders": { - "hours": { - "type": "int" - } - } - }, - "widgetsDurationMinutes": "{minutes} Min.", - "@widgetsDurationMinutes": { - "placeholders": { - "minutes": { - "type": "int" - } - } - }, "contactGroupUsedByWidget": "Homebildschirm-Widget", "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{Ein Widget auf deinem Homebildschirm zeigt Fotos aus dieser Gruppe.} other{{count} Widgets auf deinem Homebildschirm zeigen Fotos aus dieser Gruppe.}}", "@contactGroupUsedByWidgetSubtitle": { @@ -1081,9 +1030,13 @@ } } }, - "widgetsQueryFailed": "Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist mΓΆglicherweise veraltet.", - "widgetsSizeSmall": "Kleines Widget", - "widgetsSizeMedium": "Mittleres Widget", - "widgetsSizeLarge": "Großes Widget", - "widgetsSizeUnknown": "Widget" + "webxdcStoreMenu": "App", + "webxdcStoreTitle": "App hinzufΓΌgen", + "webxdcStoreEmpty": "Es sind noch keine Apps verfΓΌgbar.", + "webxdcStoreOffline": "Die App-Liste konnte nicht geladen werden. PrΓΌfe deine Verbindung und versuche es erneut.", + "webxdcStoreFailed": "Die App konnte diesem Chat nicht hinzugefΓΌgt werden.", + "webxdcUnavailable": "Diese App konnte nicht geladen werden.", + "webxdcDeleteConfirm": "Alles, was diese App auf diesem GerΓ€t gespeichert hat, wird gelΓΆscht. Andere Mitglieder behalten ihre eigene Kopie.", + "webxdcExternalLinkTitle": "twonly verlassen?", + "webxdcExternalLinkBody": "Dieser Link ΓΆffnet sich außerhalb von twonly in deinem Browser:" } diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index 1eb55c8e..d64da670 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -1023,57 +1023,6 @@ "fileLimitReachedHint": "Record a shorter video and send it again.", "fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.", "fileLimitReachedUpgrade": "Upgrade plan", - "settingsWidgets": "Widgets", - "widgetsTitle": "Widgets", - "widgetsIntroTitle": "Photos on your home screen", - "widgetsIntroBody": "Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.", - "widgetsSetupIos": "Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.", - "widgetsSetupAndroid": "Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.", - "widgetsNoneTitle": "No widget added yet", - "widgetsPlacedTitle": "On your home screen", - "widgetsAddAnother": "Add another widget", - "widgetsNoGroups": "No contact group selected", - "widgetsNoGroupsHint": "Nobody can send to this widget until you choose at least one contact group.", - "widgetsEditGroup": "Edit contact group", - "widgetsChangeGroupsIos": "To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.", - "widgetsChangeGroupsAndroid": "To change the contact groups, remove the widget and add it again.", - "widgetsCurrentImages": "Currently showing", - "widgetsNoImages": "No photos right now", - "widgetsNoImagesHint": "Photos your friends send to this widget will appear here.", - "widgetsFrom": "From {sender}", - "@widgetsFrom": { - "placeholders": { - "sender": { - "type": "String" - } - } - }, - "widgetsExpiresIn": "Shown for {duration} more", - "@widgetsExpiresIn": { - "placeholders": { - "duration": { - "type": "String" - } - } - }, - "widgetsDeleteImage": "Delete photo", - "widgetsDeleteImageConfirm": "This removes the photo from every widget showing it. It cannot be undone.", - "widgetsDurationHours": "{hours} h", - "@widgetsDurationHours": { - "placeholders": { - "hours": { - "type": "int" - } - } - }, - "widgetsDurationMinutes": "{minutes} min", - "@widgetsDurationMinutes": { - "placeholders": { - "minutes": { - "type": "int" - } - } - }, "contactGroupUsedByWidget": "Home screen widget", "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{A widget on your home screen shows photos from this group.} other{{count} widgets on your home screen show photos from this group.}}", "@contactGroupUsedByWidgetSubtitle": { @@ -1091,9 +1040,13 @@ } } }, - "widgetsQueryFailed": "Could not read the widgets on your home screen, so this list may be out of date.", - "widgetsSizeSmall": "Small widget", - "widgetsSizeMedium": "Medium widget", - "widgetsSizeLarge": "Large widget", - "widgetsSizeUnknown": "Widget" + "webxdcStoreMenu": "App", + "webxdcStoreTitle": "Add an app", + "webxdcStoreEmpty": "No apps are available yet.", + "webxdcStoreOffline": "The app list could not be loaded. Check your connection and try again.", + "webxdcStoreFailed": "The app could not be added to this chat.", + "webxdcUnavailable": "This app could not be loaded.", + "webxdcDeleteConfirm": "Everything this app saved on this device is deleted. Other members keep their own copy.", + "webxdcExternalLinkTitle": "Leave twonly?", + "webxdcExternalLinkBody": "This link opens outside twonly, in your browser:" } diff --git a/lib/src/model/protobuf/client/generated/data.pb.dart b/lib/src/model/protobuf/client/generated/data.pb.dart index 368474b1..7d41a9b2 100644 --- a/lib/src/model/protobuf/client/generated/data.pb.dart +++ b/lib/src/model/protobuf/client/generated/data.pb.dart @@ -106,6 +106,9 @@ class AdditionalMessageData extends $pb.GeneratedMessage { $core.Iterable? contacts, $fixnum.Int64? restoredFlameCounter, $fixnum.Int64? askAboutUserId, + WebxdcApp? webxdcApp, + WebxdcUpdate? webxdcUpdate, + WebxdcOrigin? webxdcOrigin, }) { final result = create(); if (type != null) result.type = type; @@ -114,6 +117,9 @@ class AdditionalMessageData extends $pb.GeneratedMessage { if (restoredFlameCounter != null) result.restoredFlameCounter = restoredFlameCounter; if (askAboutUserId != null) result.askAboutUserId = askAboutUserId; + if (webxdcApp != null) result.webxdcApp = webxdcApp; + if (webxdcUpdate != null) result.webxdcUpdate = webxdcUpdate; + if (webxdcOrigin != null) result.webxdcOrigin = webxdcOrigin; return result; } @@ -136,6 +142,12 @@ class AdditionalMessageData extends $pb.GeneratedMessage { subBuilder: SharedContact.create) ..aInt64(4, _omitFieldNames ? '' : 'restoredFlameCounter') ..aInt64(5, _omitFieldNames ? '' : 'askAboutUserId') + ..aOM(6, _omitFieldNames ? '' : 'webxdcApp', + subBuilder: WebxdcApp.create) + ..aOM(7, _omitFieldNames ? '' : 'webxdcUpdate', + subBuilder: WebxdcUpdate.create) + ..aOM(8, _omitFieldNames ? '' : 'webxdcOrigin', + subBuilder: WebxdcOrigin.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -196,6 +208,305 @@ class AdditionalMessageData extends $pb.GeneratedMessage { $core.bool hasAskAboutUserId() => $_has(4); @$pb.TagNumber(5) void clearAskAboutUserId() => $_clearField(5); + + @$pb.TagNumber(6) + WebxdcApp get webxdcApp => $_getN(5); + @$pb.TagNumber(6) + set webxdcApp(WebxdcApp value) => $_setField(6, value); + @$pb.TagNumber(6) + $core.bool hasWebxdcApp() => $_has(5); + @$pb.TagNumber(6) + void clearWebxdcApp() => $_clearField(6); + @$pb.TagNumber(6) + WebxdcApp ensureWebxdcApp() => $_ensure(5); + + @$pb.TagNumber(7) + WebxdcUpdate get webxdcUpdate => $_getN(6); + @$pb.TagNumber(7) + set webxdcUpdate(WebxdcUpdate value) => $_setField(7, value); + @$pb.TagNumber(7) + $core.bool hasWebxdcUpdate() => $_has(6); + @$pb.TagNumber(7) + void clearWebxdcUpdate() => $_clearField(7); + @$pb.TagNumber(7) + WebxdcUpdate ensureWebxdcUpdate() => $_ensure(6); + + @$pb.TagNumber(8) + WebxdcOrigin get webxdcOrigin => $_getN(7); + @$pb.TagNumber(8) + set webxdcOrigin(WebxdcOrigin value) => $_setField(8, value); + @$pb.TagNumber(8) + $core.bool hasWebxdcOrigin() => $_has(7); + @$pb.TagNumber(8) + void clearWebxdcOrigin() => $_clearField(8); + @$pb.TagNumber(8) + WebxdcOrigin ensureWebxdcOrigin() => $_ensure(7); +} + +/// Attached to a message a webxdc app asked the user to send, so the chat can +/// say which app it came from. +/// +/// `instance_id` is the app card in the chat the app runs in, which is not +/// necessarily the chat this message was sent to: the user picks the recipient. +/// `app_id` and `version` are carried as well so the receiver can name and +/// picture the app even when it has no instance of its own. +class WebxdcOrigin extends $pb.GeneratedMessage { + factory WebxdcOrigin({ + $core.String? instanceId, + $core.String? appId, + $fixnum.Int64? version, + }) { + final result = create(); + if (instanceId != null) result.instanceId = instanceId; + if (appId != null) result.appId = appId; + if (version != null) result.version = version; + return result; + } + + WebxdcOrigin._(); + + factory WebxdcOrigin.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory WebxdcOrigin.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'WebxdcOrigin', + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'instanceId') + ..aOS(2, _omitFieldNames ? '' : 'appId') + ..aInt64(3, _omitFieldNames ? '' : 'version') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WebxdcOrigin clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WebxdcOrigin copyWith(void Function(WebxdcOrigin) updates) => + super.copyWith((message) => updates(message as WebxdcOrigin)) + as WebxdcOrigin; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WebxdcOrigin create() => WebxdcOrigin._(); + @$core.override + WebxdcOrigin createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static WebxdcOrigin getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static WebxdcOrigin? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get instanceId => $_getSZ(0); + @$pb.TagNumber(1) + set instanceId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasInstanceId() => $_has(0); + @$pb.TagNumber(1) + void clearInstanceId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get appId => $_getSZ(1); + @$pb.TagNumber(2) + set appId($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasAppId() => $_has(1); + @$pb.TagNumber(2) + void clearAppId() => $_clearField(2); + + @$pb.TagNumber(3) + $fixnum.Int64 get version => $_getI64(2); + @$pb.TagNumber(3) + set version($fixnum.Int64 value) => $_setInt64(2, value); + @$pb.TagNumber(3) + $core.bool hasVersion() => $_has(2); + @$pb.TagNumber(3) + void clearVersion() => $_clearField(3); +} + +/// The app itself is never sent. Peers resolve the id and version against the +/// twonly store and download the bundle from the API server, so a sender can +/// only point at code that has already been published. +class WebxdcApp extends $pb.GeneratedMessage { + factory WebxdcApp({ + $core.String? appId, + $fixnum.Int64? version, + }) { + final result = create(); + if (appId != null) result.appId = appId; + if (version != null) result.version = version; + return result; + } + + WebxdcApp._(); + + factory WebxdcApp.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory WebxdcApp.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'WebxdcApp', + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'appId') + ..aInt64(2, _omitFieldNames ? '' : 'version') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WebxdcApp clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WebxdcApp copyWith(void Function(WebxdcApp) updates) => + super.copyWith((message) => updates(message as WebxdcApp)) as WebxdcApp; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WebxdcApp create() => WebxdcApp._(); + @$core.override + WebxdcApp createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static WebxdcApp getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static WebxdcApp? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get appId => $_getSZ(0); + @$pb.TagNumber(1) + set appId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasAppId() => $_has(0); + @$pb.TagNumber(1) + void clearAppId() => $_clearField(1); + + @$pb.TagNumber(2) + $fixnum.Int64 get version => $_getI64(1); + @$pb.TagNumber(2) + set version($fixnum.Int64 value) => $_setInt64(1, value); + @$pb.TagNumber(2) + $core.bool hasVersion() => $_has(1); + @$pb.TagNumber(2) + void clearVersion() => $_clearField(2); +} + +class WebxdcUpdate extends $pb.GeneratedMessage { + factory WebxdcUpdate({ + $core.String? instanceId, + $core.String? payload, + $core.String? info, + $core.String? href, + $core.String? summary, + $core.String? document, + }) { + final result = create(); + if (instanceId != null) result.instanceId = instanceId; + if (payload != null) result.payload = payload; + if (info != null) result.info = info; + if (href != null) result.href = href; + if (summary != null) result.summary = summary; + if (document != null) result.document = document; + return result; + } + + WebxdcUpdate._(); + + factory WebxdcUpdate.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory WebxdcUpdate.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'WebxdcUpdate', + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'instanceId') + ..aOS(2, _omitFieldNames ? '' : 'payload') + ..aOS(3, _omitFieldNames ? '' : 'info') + ..aOS(4, _omitFieldNames ? '' : 'href') + ..aOS(5, _omitFieldNames ? '' : 'summary') + ..aOS(6, _omitFieldNames ? '' : 'document') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WebxdcUpdate clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + WebxdcUpdate copyWith(void Function(WebxdcUpdate) updates) => + super.copyWith((message) => updates(message as WebxdcUpdate)) + as WebxdcUpdate; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static WebxdcUpdate create() => WebxdcUpdate._(); + @$core.override + WebxdcUpdate createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static WebxdcUpdate getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static WebxdcUpdate? _defaultInstance; + + /// The message id of the app card this update belongs to. + @$pb.TagNumber(1) + $core.String get instanceId => $_getSZ(0); + @$pb.TagNumber(1) + set instanceId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasInstanceId() => $_has(0); + @$pb.TagNumber(1) + void clearInstanceId() => $_clearField(1); + + /// JSON, as the app produced it. Never parsed by twonly. + @$pb.TagNumber(2) + $core.String get payload => $_getSZ(1); + @$pb.TagNumber(2) + set payload($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasPayload() => $_has(1); + @$pb.TagNumber(2) + void clearPayload() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get info => $_getSZ(2); + @$pb.TagNumber(3) + set info($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasInfo() => $_has(2); + @$pb.TagNumber(3) + void clearInfo() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get href => $_getSZ(3); + @$pb.TagNumber(4) + set href($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasHref() => $_has(3); + @$pb.TagNumber(4) + void clearHref() => $_clearField(4); + + @$pb.TagNumber(5) + $core.String get summary => $_getSZ(4); + @$pb.TagNumber(5) + set summary($core.String value) => $_setString(4, value); + @$pb.TagNumber(5) + $core.bool hasSummary() => $_has(4); + @$pb.TagNumber(5) + void clearSummary() => $_clearField(5); + + @$pb.TagNumber(6) + $core.String get document => $_getSZ(5); + @$pb.TagNumber(6) + set document($core.String value) => $_setString(5, value); + @$pb.TagNumber(6) + $core.bool hasDocument() => $_has(5); + @$pb.TagNumber(6) + void clearDocument() => $_clearField(6); } const $core.bool _omitFieldNames = diff --git a/lib/src/model/protobuf/client/generated/data.pbenum.dart b/lib/src/model/protobuf/client/generated/data.pbenum.dart index 5e2fef55..999e4075 100644 --- a/lib/src/model/protobuf/client/generated/data.pbenum.dart +++ b/lib/src/model/protobuf/client/generated/data.pbenum.dart @@ -24,6 +24,12 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum { 2, _omitEnumNames ? '' : 'RESTORED_FLAME_COUNTER'); static const AdditionalMessageData_Type ASK_ABOUT_USER = AdditionalMessageData_Type._(3, _omitEnumNames ? '' : 'ASK_ABOUT_USER'); + static const AdditionalMessageData_Type WEBXDC_APP = + AdditionalMessageData_Type._(4, _omitEnumNames ? '' : 'WEBXDC_APP'); + static const AdditionalMessageData_Type WEBXDC_UPDATE = + AdditionalMessageData_Type._(5, _omitEnumNames ? '' : 'WEBXDC_UPDATE'); + static const AdditionalMessageData_Type WEBXDC_SENT = + AdditionalMessageData_Type._(6, _omitEnumNames ? '' : 'WEBXDC_SENT'); static const $core.List values = [ @@ -31,10 +37,13 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum { CONTACTS, RESTORED_FLAME_COUNTER, ASK_ABOUT_USER, + WEBXDC_APP, + WEBXDC_UPDATE, + WEBXDC_SENT, ]; static final $core.List _byValue = - $pb.ProtobufEnum.$_initByValueList(values, 3); + $pb.ProtobufEnum.$_initByValueList(values, 6); static AdditionalMessageData_Type? valueOf($core.int value) => value < 0 || value >= _byValue.length ? null : _byValue[value]; diff --git a/lib/src/model/protobuf/client/generated/data.pbjson.dart b/lib/src/model/protobuf/client/generated/data.pbjson.dart index 0c61dc76..c393bc68 100644 --- a/lib/src/model/protobuf/client/generated/data.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/data.pbjson.dart @@ -76,12 +76,45 @@ const AdditionalMessageData$json = { '10': 'askAboutUserId', '17': true }, + { + '1': 'webxdc_app', + '3': 6, + '4': 1, + '5': 11, + '6': '.WebxdcApp', + '9': 3, + '10': 'webxdcApp', + '17': true + }, + { + '1': 'webxdc_update', + '3': 7, + '4': 1, + '5': 11, + '6': '.WebxdcUpdate', + '9': 4, + '10': 'webxdcUpdate', + '17': true + }, + { + '1': 'webxdc_origin', + '3': 8, + '4': 1, + '5': 11, + '6': '.WebxdcOrigin', + '9': 5, + '10': 'webxdcOrigin', + '17': true + }, ], '4': [AdditionalMessageData_Type$json], '8': [ {'1': '_link'}, {'1': '_restored_flame_counter'}, {'1': '_ask_about_user_id'}, + {'1': '_webxdc_app'}, + {'1': '_webxdc_update'}, + {'1': '_webxdc_origin'}, ], }; @@ -93,6 +126,9 @@ const AdditionalMessageData_Type$json = { {'1': 'CONTACTS', '2': 1}, {'1': 'RESTORED_FLAME_COUNTER', '2': 2}, {'1': 'ASK_ABOUT_USER', '2': 3}, + {'1': 'WEBXDC_APP', '2': 4}, + {'1': 'WEBXDC_UPDATE', '2': 5}, + {'1': 'WEBXDC_SENT', '2': 6}, ], }; @@ -102,6 +138,83 @@ final $typed_data.Uint8List additionalMessageDataDescriptor = $convert.base64Dec 'dlRGF0YS5UeXBlUgR0eXBlEhcKBGxpbmsYAiABKAlIAFIEbGlua4gBARIqCghjb250YWN0cxgD' 'IAMoCzIOLlNoYXJlZENvbnRhY3RSCGNvbnRhY3RzEjkKFnJlc3RvcmVkX2ZsYW1lX2NvdW50ZX' 'IYBCABKANIAVIUcmVzdG9yZWRGbGFtZUNvdW50ZXKIAQESLgoRYXNrX2Fib3V0X3VzZXJfaWQY' - 'BSABKANIAlIOYXNrQWJvdXRVc2VySWSIAQEiTgoEVHlwZRIICgRMSU5LEAASDAoIQ09OVEFDVF' - 'MQARIaChZSRVNUT1JFRF9GTEFNRV9DT1VOVEVSEAISEgoOQVNLX0FCT1VUX1VTRVIQA0IHCgVf' - 'bGlua0IZChdfcmVzdG9yZWRfZmxhbWVfY291bnRlckIUChJfYXNrX2Fib3V0X3VzZXJfaWQ='); + 'BSABKANIAlIOYXNrQWJvdXRVc2VySWSIAQESLgoKd2VieGRjX2FwcBgGIAEoCzIKLldlYnhkY0' + 'FwcEgDUgl3ZWJ4ZGNBcHCIAQESNwoNd2VieGRjX3VwZGF0ZRgHIAEoCzINLldlYnhkY1VwZGF0' + 'ZUgEUgx3ZWJ4ZGNVcGRhdGWIAQESNwoNd2VieGRjX29yaWdpbhgIIAEoCzINLldlYnhkY09yaW' + 'dpbkgFUgx3ZWJ4ZGNPcmlnaW6IAQEiggEKBFR5cGUSCAoETElOSxAAEgwKCENPTlRBQ1RTEAES' + 'GgoWUkVTVE9SRURfRkxBTUVfQ09VTlRFUhACEhIKDkFTS19BQk9VVF9VU0VSEAMSDgoKV0VCWE' + 'RDX0FQUBAEEhEKDVdFQlhEQ19VUERBVEUQBRIPCgtXRUJYRENfU0VOVBAGQgcKBV9saW5rQhkK' + 'F19yZXN0b3JlZF9mbGFtZV9jb3VudGVyQhQKEl9hc2tfYWJvdXRfdXNlcl9pZEINCgtfd2VieG' + 'RjX2FwcEIQCg5fd2VieGRjX3VwZGF0ZUIQCg5fd2VieGRjX29yaWdpbg=='); + +@$core.Deprecated('Use webxdcOriginDescriptor instead') +const WebxdcOrigin$json = { + '1': 'WebxdcOrigin', + '2': [ + {'1': 'instance_id', '3': 1, '4': 1, '5': 9, '10': 'instanceId'}, + {'1': 'app_id', '3': 2, '4': 1, '5': 9, '10': 'appId'}, + {'1': 'version', '3': 3, '4': 1, '5': 3, '10': 'version'}, + ], +}; + +/// Descriptor for `WebxdcOrigin`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List webxdcOriginDescriptor = $convert.base64Decode( + 'CgxXZWJ4ZGNPcmlnaW4SHwoLaW5zdGFuY2VfaWQYASABKAlSCmluc3RhbmNlSWQSFQoGYXBwX2' + 'lkGAIgASgJUgVhcHBJZBIYCgd2ZXJzaW9uGAMgASgDUgd2ZXJzaW9u'); + +@$core.Deprecated('Use webxdcAppDescriptor instead') +const WebxdcApp$json = { + '1': 'WebxdcApp', + '2': [ + {'1': 'app_id', '3': 1, '4': 1, '5': 9, '10': 'appId'}, + {'1': 'version', '3': 2, '4': 1, '5': 3, '10': 'version'}, + ], +}; + +/// Descriptor for `WebxdcApp`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List webxdcAppDescriptor = $convert.base64Decode( + 'CglXZWJ4ZGNBcHASFQoGYXBwX2lkGAEgASgJUgVhcHBJZBIYCgd2ZXJzaW9uGAIgASgDUgd2ZX' + 'JzaW9u'); + +@$core.Deprecated('Use webxdcUpdateDescriptor instead') +const WebxdcUpdate$json = { + '1': 'WebxdcUpdate', + '2': [ + {'1': 'instance_id', '3': 1, '4': 1, '5': 9, '10': 'instanceId'}, + {'1': 'payload', '3': 2, '4': 1, '5': 9, '10': 'payload'}, + {'1': 'info', '3': 3, '4': 1, '5': 9, '9': 0, '10': 'info', '17': true}, + {'1': 'href', '3': 4, '4': 1, '5': 9, '9': 1, '10': 'href', '17': true}, + { + '1': 'summary', + '3': 5, + '4': 1, + '5': 9, + '9': 2, + '10': 'summary', + '17': true + }, + { + '1': 'document', + '3': 6, + '4': 1, + '5': 9, + '9': 3, + '10': 'document', + '17': true + }, + ], + '8': [ + {'1': '_info'}, + {'1': '_href'}, + {'1': '_summary'}, + {'1': '_document'}, + ], +}; + +/// Descriptor for `WebxdcUpdate`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List webxdcUpdateDescriptor = $convert.base64Decode( + 'CgxXZWJ4ZGNVcGRhdGUSHwoLaW5zdGFuY2VfaWQYASABKAlSCmluc3RhbmNlSWQSGAoHcGF5bG' + '9hZBgCIAEoCVIHcGF5bG9hZBIXCgRpbmZvGAMgASgJSABSBGluZm+IAQESFwoEaHJlZhgEIAEo' + 'CUgBUgRocmVmiAEBEh0KB3N1bW1hcnkYBSABKAlIAlIHc3VtbWFyeYgBARIfCghkb2N1bWVudB' + 'gGIAEoCUgDUghkb2N1bWVudIgBAUIHCgVfaW5mb0IHCgVfaHJlZkIKCghfc3VtbWFyeUILCglf' + 'ZG9jdW1lbnQ='); diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index ea0b5b3b..5958a28e 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -47,7 +47,6 @@ import 'package:twonly/src/visual/views/settings/profile/profile.view.dart'; import 'package:twonly/src/visual/views/settings/settings_main.view.dart'; import 'package:twonly/src/visual/views/settings/share_with_friends.view.dart'; import 'package:twonly/src/visual/views/settings/subscription/subscription.view.dart'; -import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart'; final GlobalKey rootNavigatorKey = GlobalKey(); @@ -229,10 +228,6 @@ final routerProvider = GoRouter( path: 'notification', builder: (context, state) => const NotificationView(), ), - GoRoute( - path: 'widgets', - builder: (context, state) => const WidgetsSettingsView(), - ), GoRoute( path: 'storage_data', builder: (context, state) => const DataAndStorageView(), diff --git a/lib/src/services/home_widget.service.dart b/lib/src/services/home_widget.service.dart index 332faa15..2c543b06 100644 --- a/lib/src/services/home_widget.service.dart +++ b/lib/src/services/home_widget.service.dart @@ -13,41 +13,11 @@ class PlacedWidget { required this.id, required this.platform, required this.contactGroupIds, - this.family, }); final String id; final String platform; final List contactGroupIds; - - /// The widget's size, as WidgetKit names it (`systemSmall` and so on). Null - /// on Android, which does not report one. - final String? family; - - /// A widget with no contact group can never show anything: nobody is allowed - /// to share with it, so nothing is ever delivered. - bool get isConfigured => contactGroupIds.isNotEmpty; -} - -/// An image a widget is currently rotating through. -class WidgetImage { - const WidgetImage({ - required this.mediaId, - required this.path, - required this.sender, - required this.contactGroupIds, - required this.expiresAt, - }); - - final String mediaId; - final String path; - final String sender; - final List contactGroupIds; - final DateTime expiresAt; - - Duration get remaining => expiresAt.difference(DateTime.now()); - bool get isExpired => remaining.isNegative; - File get file => File(path); } /// Keeps native widget placement and Rust's derived sharing permissions in @@ -131,7 +101,10 @@ class HomeWidgetService { /// Drops the cached reconcile so the next read asks the system again. static void invalidate() => _cachedReport = null; - static Future purgeExpiredMedia() async { + /// Settles the widget media that arrived while nothing was running: an image + /// is deleted by the next one for its contact groups, during the refresh that + /// publishes that successor. + static Future pruneSupersededMedia() async { await RustApi.purgeWidgetMedia(); await refresh(); } @@ -145,12 +118,6 @@ class HomeWidgetService { await refresh(); } - /// Removes one image from every widget showing it, and from disk. - static Future deleteImage(String mediaId) async { - await RustApi.deleteWidgetMedia(mediaId: mediaId); - await refresh(); - } - /// Asks the placed widgets to redraw from the manifest Rust just wrote. /// /// Neither platform notices the rewrite on its own: WidgetKit keeps the @@ -181,45 +148,29 @@ class HomeWidgetService { /// the file whenever a widget is added or removed, so there the file is the /// authority. /// - /// The returned `error` is set when iOS could not be asked; the widgets are - /// then whatever the file last recorded, which may name widgets that are - /// already gone. - static Future<({List widgets, String? error})> - placedWidgetsResult() async { - if (!Platform.isIOS) { - return (widgets: await _widgetsFromFile(), error: null); - } + /// When iOS cannot be asked, this falls back to whatever the file last + /// recorded, which may name widgets that are already gone. + static Future> placedWidgets() async { + if (!Platform.isIOS) return _widgetsFromFile(); final report = await reconcileReport(); - if (report == null || report['error'] != null) { - return ( - widgets: await _widgetsFromFile(), - error: '${report?['error'] ?? 'unknown'}', - ); - } + if (report == null || report['error'] != null) return _widgetsFromFile(); final reported = ((report['widgets'] as List?) ?? const []) .cast>() .where((entry) => entry['mine'] == true && entry['live'] == true); final seen = {}; - return ( - widgets: [ - for (final entry in reported) - if (seen.add('${entry['id']}')) - PlacedWidget( - id: '${entry['id']}', - platform: 'ios', - family: entry['family'] as String?, - contactGroupIds: ((entry['group_ids'] as List?) ?? const []) - .map((id) => (id as num).toInt()) - .toList(), - ), - ], - error: null, - ); + return [ + for (final entry in reported) + if (seen.add('${entry['id']}')) + PlacedWidget( + id: '${entry['id']}', + platform: 'ios', + contactGroupIds: ((entry['group_ids'] as List?) ?? const []) + .map((id) => (id as num).toInt()) + .toList(), + ), + ]; } - static Future> placedWidgets() async => - (await placedWidgetsResult()).widgets; - static Future> _widgetsFromFile() async { final file = File('${_root.path}/native-config.json'); if (!file.existsSync()) return const []; @@ -252,44 +203,4 @@ class HomeWidgetService { lastSeen.toInt() * 1000, ).isAfter(oldestLive); } - - /// Every unexpired image Rust has published to the widgets, newest first. - static Future> images() async { - final file = File('${_root.path}/manifest.json'); - if (!file.existsSync()) return const []; - try { - final decoded = - jsonDecode(await file.readAsString()) as Map; - final images = ((decoded['images'] as List?) ?? const []) - .cast>(); - final parsed = [ - for (final image in images) - WidgetImage( - mediaId: '${image['mediaId'] ?? image['media_id']}', - path: '${image['path']}', - sender: '${image['sender']}', - contactGroupIds: ((image['group_ids'] as List?) ?? const []) - .map((id) => (id as num).toInt()) - .toList(), - expiresAt: DateTime.fromMillisecondsSinceEpoch( - ((image['expires_at'] as num?)?.toInt() ?? 0) * 1000, - ), - ), - ]..sort((a, b) => b.expiresAt.compareTo(a.expiresAt)); - return parsed.where((image) => !image.isExpired).toList(); - } catch (error) { - Log.error('Could not read the widget manifest: $error'); - return const []; - } - } - - /// The images a single widget rotates through: only senders whose contact - /// groups overlap the ones that widget selected. - static Future> imagesFor(PlacedWidget widget) async { - final selected = widget.contactGroupIds.toSet(); - final all = await images(); - return all - .where((image) => image.contactGroupIds.any(selected.contains)) - .toList(); - } } diff --git a/lib/src/services/notifications/native.notifications.dart b/lib/src/services/notifications/native.notifications.dart index f9d4368e..3d959d6b 100644 --- a/lib/src/services/notifications/native.notifications.dart +++ b/lib/src/services/notifications/native.notifications.dart @@ -37,10 +37,11 @@ class NativeNotificationService { /// Emits metadata for every native notification tapped while the app runs. static Stream get taps => _taps.stream; - /// Text messages (including quoted replies) open their conversation. Media - /// and every other notification kind stay on the chat overview. + /// Text messages (including quoted replies) and the announcements an app + /// writes into a chat open their conversation. Media and every other + /// notification kind stay on the chat overview. static bool opensConversation(String? kind) => - kind == 'text' || kind == 'response'; + kind == 'text' || kind == 'response' || kind == 'webxdc'; static void init() { _startBadgeSync(); diff --git a/lib/src/services/webxdc/webxdc.service.dart b/lib/src/services/webxdc/webxdc.service.dart new file mode 100644 index 00000000..0c28a3b6 --- /dev/null +++ b/lib/src/services/webxdc/webxdc.service.dart @@ -0,0 +1,259 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart' show Expression, TableUpdateQuery; +import 'package:twonly/core/bridge/webxdc.dart' as rust_webxdc; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/log.dart'; + +/// The Flutter side of the webxdc runtime. +/// +/// Everything that decides anything -- which bundle an instance runs, what an +/// app may send, what its text may contain -- lives in Rust. This class moves +/// values across and reads the mirrored tables for the chat UI; it deliberately +/// makes no judgements of its own, because the calls it forwards originate in +/// a webview running third-party code. +class WebxdcService { + /// The last row seen for an instance and for a store entry, held for the + /// life of the process. + /// + /// A chat recycles a card's state whenever it scrolls out of view, and both + /// lookups behind a card are asynchronous, so a rebuilt card would paint + /// nothing for a frame or two and then pop back in with a freshly decoded + /// icon. Reading the last known values synchronously means it comes back + /// looking the way it left, while the queries behind it still run and + /// correct anything that changed. + static final Map _instanceCache = {}; + static final Map _appCache = {}; + + static String _appKey(String appId, int version) => '$appId:$version'; + + /// Refreshes the store listing from the API. Metadata only, no bundles. + static Future refreshCatalog() async { + try { + await rust_webxdc.refreshCatalog(); + return true; + } catch (error) { + Log.warn('refreshing the webxdc catalog failed: $error'); + return false; + } + } + + /// What the store currently offers: newest published version of each app. + /// + /// `languages` is what the UI prefers, most preferred first. Every app is + /// cached with all its translations, so choosing one costs nothing and is + /// never sent anywhere. + static Future> catalog( + List languages, + ) async { + try { + return await rust_webxdc.catalog(languages: languages); + } catch (error) { + Log.warn('reading the webxdc catalog failed: $error'); + return []; + } + } + + /// Places an app into a chat. Returns the id of the message carrying it, + /// which is also the instance id. + static Future createInstance( + String groupId, + String appId, + int version, + ) async { + try { + return await rust_webxdc.createInstance( + groupId: groupId, + appId: appId, + version: version, + ); + } catch (error) { + Log.error('placing a webxdc app into $groupId failed: $error'); + return null; + } + } + + /// Gets the bundle an instance runs onto disk, verified. + /// + /// Rust checks the store for a newer version first and moves the instance to + /// it if there is one, so an app updates between two runs and never during + /// one. Called when the user starts an app and never on message arrival: a + /// message must not be able to make a device fetch anything. + static Future prepareBundle(String instanceId) async { + try { + return await rust_webxdc.prepareBundle(instanceId: instanceId); + } catch (error) { + Log.warn('preparing the bundle for $instanceId failed: $error'); + return null; + } + } + + static Future instance( + String instanceId, + ) async { + try { + return await rust_webxdc.instance(instanceId: instanceId); + } catch (error) { + Log.warn('reading webxdc instance $instanceId failed: $error'); + return null; + } + } + + static Future> updatesAfter( + String instanceId, + int serial, + ) async { + try { + return await rust_webxdc.updatesAfter( + instanceId: instanceId, + serial: serial, + ); + } catch (error) { + Log.warn('reading updates for $instanceId failed: $error'); + return []; + } + } + + /// Forwards an update the running app produced. Rust applies the size, rate + /// and text limits and throws when one is exceeded, which the caller passes + /// back to the app as a rejected promise. + static Future sendUpdate({ + required String instanceId, + required String payload, + String? info, + String? href, + String? summary, + String? document, + }) => rust_webxdc.sendUpdate( + instanceId: instanceId, + payload: payload, + info: info, + href: href, + summary: summary, + document: document, + ); + + /// Removes an instance, its whole update log, and the web storage its origin + /// accumulated. The log is only half the state: an app is free to keep + /// everything in `localStorage`, which no database delete reaches. + static Future deleteInstance(String instanceId) async { + _instanceCache.remove(instanceId); + final origin = await rust_webxdc.deleteInstance(instanceId: instanceId); + if (origin != null) { + await WebxdcWebviewStorage.clearOrigin(origin); + } + } + + /// The store entry an instance runs, for the chat card. Null while the + /// catalog has not been fetched on this device yet. + static Future appFor(WebxdcInstance instance) => + appNamed(instance.appId, instance.version); + + static Future appNamed(String appId, int version) async { + final app = + await (twonlyDB.select(twonlyDB.webxdcApps)..where( + (app) => Expression.and([ + app.appId.equals(appId), + app.version.equals(version), + ]), + )) + .getSingleOrNull(); + if (app != null) _appCache[_appKey(appId, version)] = app; + return app; + } + + /// What to call an app on the card in a chat, in the language the reader + /// asked for. + /// + /// The same rules Rust applies to the store list, because the two are read + /// side by side: an exact tag wins, then one sharing its primary language + /// (`de-at` for a reader of `de`), then English, then whatever the app is + /// translated into at all. `name` is what an app translated into none of the + /// reader's languages is called. + static String localizedName(WebxdcApp app, List languages) { + Map byLanguage; + try { + final decoded = jsonDecode(app.nameTranslations); + if (decoded is! Map) return app.name; + byLanguage = decoded; + } catch (_) { + return app.name; + } + + String? named(bool Function(String tag) matches) { + for (final entry in byLanguage.entries) { + final value = entry.value; + if (value is String && value.isNotEmpty && matches(entry.key)) { + return value; + } + } + return null; + } + + for (final language in languages.map((tag) => tag.toLowerCase())) { + final exact = named((tag) => tag == language); + if (exact != null) return exact; + final primary = language.split('-').first; + final related = named((tag) => tag.split('-').first == primary); + if (related != null) return related; + } + return named((tag) => tag == 'en') ?? named((_) => true) ?? app.name; + } + + /// The store entry as it was last read, without touching the database, for + /// the first frame of a card whose state was just recreated. Null until some + /// card has looked the version up; the caller still runs [appNamed]. + static WebxdcApp? cachedApp(String appId, int version) => + _appCache[_appKey(appId, version)]; + + /// The instance as it was last read, for the same reason as [cachedApp]. + static WebxdcInstance? cachedInstance(String instanceId) => + _instanceCache[instanceId]; + + /// The instance row, if this device has the app card the message points at. + /// A `sendToChat` message can land in a chat that has no instance of its own. + static Future instanceRow(String instanceId) async { + final row = await (twonlyDB.select( + twonlyDB.webxdcInstances, + )..where((row) => row.instanceId.equals(instanceId))).getSingleOrNull(); + if (row != null) _instanceCache[instanceId] = row; + return row; + } + + static Stream watchInstance(String instanceId) { + return (twonlyDB.select(twonlyDB.webxdcInstances) + ..where((row) => row.instanceId.equals(instanceId))) + .watchSingleOrNull() + .map((row) { + if (row != null) _instanceCache[instanceId] = row; + return row; + }); + } + + /// Fires whenever any update lands. The caller filters by serial, so a tick + /// for another instance costs one query and nothing else; only one app is + /// ever open at a time. + static Stream watchUpdates() { + return twonlyDB + .tableUpdates(TableUpdateQuery.onTable(twonlyDB.webxdcUpdates)) + .map((_) {}); + } +} + +/// Clearing a webview origin is the platform's job; the channel is declared +/// here so the service can finish a deletion without reaching into the view +/// layer. +abstract final class WebxdcWebviewStorage { + static Future Function(String origin)? clear; + + static Future clearOrigin(String origin) async { + final clear = WebxdcWebviewStorage.clear; + if (clear == null) { + Log.warn('no webview storage cleaner registered, $origin kept its data'); + return; + } + await clear(origin); + } +} diff --git a/lib/src/services/webxdc/webxdc_host.dart b/lib/src/services/webxdc/webxdc_host.dart new file mode 100644 index 00000000..b139076a --- /dev/null +++ b/lib/src/services/webxdc/webxdc_host.dart @@ -0,0 +1,495 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/core/bridge/webxdc.dart' as rust_webxdc; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; +import 'package:twonly/src/providers/routing.provider.dart'; +import 'package:twonly/src/services/webxdc/webxdc.service.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/views/shared/select_contacts.view.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// The Flutter half of the webxdc runtime. +/// +/// The webview itself is native on both platforms: one screen, one origin, one +/// bridge, and no navigation of its own. Everything it asks for comes back +/// through this channel, which is the only path from a running app into the +/// rest of twonly. +/// +/// Nothing arriving from the page is trusted. The instance an app may act on is +/// the one this side opened, never the one a message claims; every limit is +/// applied in Rust; and the only values handed to the page are the ones +/// prepared here. +class WebxdcHost { + static const MethodChannel _channel = MethodChannel('eu.twonly/webxdc'); + + /// The instance currently on screen. A page may only ever act on this one: + /// the id in a bridge message is checked against it rather than obeyed. + static String? _openInstanceId; + static StreamSubscription? _updateSubscription; + static int _deliveredSerial = 0; + static VoidCallback? _closeView; + + /// Whether this device composites the webview with Hybrid Composition++. + /// + /// HCPP hands the webview to the system compositor through `SurfaceControl` + /// rather than merging the raster and platform threads, which is what keeps a + /// route transition smooth while an app is on screen -- the predictive back + /// gesture above all, since it transforms the whole outgoing route on every + /// frame of the drag. It needs the engine opt-in in the manifest and Vulkan + /// on API 34 or newer, so the answer belongs to the device rather than to the + /// app being opened and is asked once, here. Everything else falls back to + /// the texture path, which composites the webview like any other layer. + static bool hybridComposition = false; + + static void initialize() { + _channel.setMethodCallHandler(_handleNativeCall); + WebxdcWebviewStorage.clear = _clearOrigin; + unawaited(_probeHybridComposition()); + } + + static Future _probeHybridComposition() async { + if (defaultTargetPlatform != TargetPlatform.android) return; + try { + hybridComposition = await HybridAndroidViewController.checkIfSupported(); + } catch (error) { + // An engine built without the opt-in does not answer this at all. + hybridComposition = false; + } + } + + /// Stops the running app while its screen animates away, and starts it again + /// if the screen turns out to be staying. + /// + /// An app keeps running for as long as it is loaded, and a game redraws every + /// frame it is given: on the way out that work lands on exactly the frames the + /// closing animation needs. This is the webview's own pause, so the page keeps + /// its state and nothing is reloaded. Android only: WebKit offers no + /// equivalent, and the transition there does not transform the platform view + /// the way the predictive back gesture does. + static Future setPaused({required bool paused}) async { + if (defaultTargetPlatform != TargetPlatform.android) return; + final instanceId = _openInstanceId; + if (instanceId == null) return; + try { + await _channel.invokeMethod('setPaused', { + 'instanceId': instanceId, + 'paused': paused, + }); + } on PlatformException catch (error) { + Log.warn('pausing the webxdc app failed: ${error.message}'); + } + } + + /// Gets an app ready to run: makes sure the bundle is on disk and verified, + /// and returns what the view needs to show it. + /// + /// Rust checks the store for a newer version as part of this, so an app + /// updates here and nowhere else. Returns a failure instead when the bundle + /// cannot be had at all -- a first start with no network, or an app that was + /// taken out of the store before this device ever downloaded it. + /// `languages` is what the reader prefers, most preferred first: the title + /// bar names the app the way the chat card does. + static Future prepare( + String instanceId, + List languages, + ) async { + final instance = await WebxdcService.instance(instanceId); + if (instance == null) { + return const WebxdcLaunch.failed('unavailable'); + } + // The only place a bundle is ever fetched. A message arriving in a chat + // never reaches this. + final bundlePath = await WebxdcService.prepareBundle(instanceId); + if (bundlePath == null) { + return const WebxdcLaunch.failed('unavailable'); + } + + // Read after the bundle: an update moves the instance to another version, + // and the name shown is the one belonging to the version that will run. + final started = await WebxdcService.instance(instanceId) ?? instance; + final app = await WebxdcService.appNamed(started.appId, started.version); + return WebxdcLaunch( + instanceId: instanceId, + // The webview host. Unique per instance, so the browser's own origin + // model keeps this app's stored data to itself. + origin: instance.originToken, + // The store's name for the app, never one the running app chose. + title: app == null + ? instance.appId + : WebxdcService.localizedName(app, languages), + ); + } + + /// Binds the runtime to the screen showing an app. + /// + /// Until this is called nothing may act on the instance, and once `detach` + /// runs nothing may again: a bridge message naming any other instance is + /// refused rather than obeyed. + static void attach(String instanceId, {required VoidCallback close}) { + _openInstanceId = instanceId; + _closeView = close; + _deliveredSerial = 0; + unawaited(_updateSubscription?.cancel()); + _updateSubscription = WebxdcService.watchUpdates().listen( + (_) => unawaited(_pushPendingUpdates()), + ); + } + + static void detach(String instanceId) { + if (_openInstanceId != instanceId) return; + unawaited(_updateSubscription?.cancel()); + _updateSubscription = null; + _openInstanceId = null; + _closeView = null; + } + + static Future _clearOrigin(String origin) async { + try { + await _channel.invokeMethod('clearOrigin', {'origin': origin}); + } on PlatformException catch (error) { + Log.warn('clearing webxdc origin failed: ${error.message}'); + } + } + + static Future _handleNativeCall(MethodCall call) async { + switch (call.method) { + case 'serve': + return _serve(call.arguments as Map); + case 'bridge': + return _bridge(call.arguments as Map); + case 'openLink': + // Every link out of an app comes through here. The page is never + // allowed to follow one itself. + await _confirmExternalLink(call.arguments as Map); + return null; + default: + throw MissingPluginException('unknown webxdc call ${call.method}'); + } + } + + /// Answers one request the page made. The bundle is read in Rust, straight + /// out of the zip, and comes back with the headers that keep the page boxed + /// in. + static Future> _serve( + Map arguments, + ) async { + final instanceId = _openInstanceId; + if (instanceId == null || instanceId != arguments['instanceId']) { + // A request from an origin that is not the open instance's. There is no + // legitimate way for one to arrive. + return {'status': 403, 'mime': 'text/plain', 'body': Uint8List(0)}; + } + final response = await rust_webxdc.serve( + instanceId: instanceId, + requestPath: arguments['path']! as String, + ); + return { + 'status': response.status, + 'mime': response.mime, + 'headerNames': response.headerNames, + 'headerValues': response.headerValues, + 'body': response.body, + }; + } + + /// One call from `webxdc.js`. + /// + /// The reply shape mirrors what the shim expects: `result` on success, + /// `error` on refusal. A refusal is a rejected promise inside the app, never + /// a crash out here. + static Future _bridge(Map arguments) async { + final instanceId = _openInstanceId; + final raw = arguments['message']! as String; + + Map message; + try { + message = jsonDecode(raw) as Map; + } catch (_) { + return jsonEncode({'error': 'malformed call'}); + } + final id = message['id']; + if (instanceId == null || instanceId != arguments['instanceId']) { + return jsonEncode({'id': id, 'error': 'this app is not open'}); + } + + final params = (message['params'] as Map?) ?? {}; + try { + switch (message['method']) { + case 'sendUpdate': + // `payload` is re-encoded rather than passed through, so what is + // stored and sent is JSON this side produced. + await WebxdcService.sendUpdate( + instanceId: instanceId, + payload: jsonEncode(params['payload']), + info: params['info'] as String?, + href: params['href'] as String?, + summary: params['summary'] as String?, + document: params['document'] as String?, + ); + return jsonEncode({'id': id, 'result': null}); + + case 'catchUp': + final serial = (params['serial'] as num?)?.toInt() ?? 0; + _deliveredSerial = serial; + await _pushPendingUpdates(); + return jsonEncode({'id': id, 'result': null}); + + case 'sendToChat': + // Validated before the promise is settled, so an app learns that a + // file twonly cannot send was refused and can fall back. Everything + // after that -- the picker, the editor, the sending -- is the user's, + // and the spec asks for none of it to be reported back. + final handover = _validateHandover(params); + if (handover.error != null) { + return jsonEncode({'id': id, 'error': handover.error}); + } + unawaited(_sendToChat(instanceId, handover)); + return jsonEncode({'id': id, 'result': null}); + + case 'importFiles': + // Answered by the platform, which owns the picker and the screen it + // has to appear over. Reaching this means the page found a bridge + // that does not implement it. + return jsonEncode({'id': id, 'error': 'not available in twonly'}); + + default: + return jsonEncode({'id': id, 'error': 'unknown method'}); + } + } catch (error) { + // Rust refuses an update that is too large, too frequent, or belongs to + // an instance that has gone away. The app sees a rejected promise. + return jsonEncode({'id': id, 'error': '$error'}); + } + } + + /// Text an app may hand to a chat. The user still sees it before it is sent, + /// but an app must not be able to fill a message with megabytes of anything. + static const int _maxChatTextChars = 4096; + + /// Shows the whole URL and says plainly that it leaves twonly, before + /// anything opens. A page cannot reach the browser any other way. + static Future _confirmExternalLink( + Map arguments, + ) async { + final raw = arguments['url'] as String? ?? ''; + final url = Uri.tryParse(raw); + if (url == null || raw.isEmpty) return; + + final context = rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.lang.webxdcExternalLinkTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.lang.webxdcExternalLinkBody), + const SizedBox(height: 12), + SelectableText(raw, style: const TextStyle(fontSize: 13)), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.lang.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.lang.open), + ), + ], + ), + ); + if (confirmed ?? false) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + } + + /// What an app asked to hand to a chat, once it has been checked. + /// + /// `error` set means the call is refused and the page is told so; the app can + /// then fall back to something twonly can carry. + static _Handover _validateHandover(Map params) { + // Files are refused outright. twonly has no message type for one, and no + // app in the store sends them, so accepting one could only end in it being + // dropped later without the app ever learning why. + if (params['file'] != null) { + return const _Handover(error: 'twonly can only send text'); + } + final text = _plainText(params['text'] as String?); + if (text == null) { + return const _Handover(error: 'nothing to send'); + } + return _Handover(text: text); + } + + /// Hands what an app produced back to twonly, where the user picks the chat. + /// + /// Nothing is sent from here. The user chooses who receives it and may + /// abandon the whole thing -- neither of which is reported back, which is + /// what the spec asks for. + static Future _sendToChat(String instanceId, _Handover handover) async { + try { + await _handOverToChat(instanceId, handover); + } catch (error) { + // The page has already been told the call was accepted, so a failure + // here can only be logged, not reported back to the app. + Log.error('handing webxdc text to a chat failed: $error'); + } + } + + static Future _handOverToChat( + String instanceId, + _Handover handover, + ) async { + final instance = await WebxdcService.instance(instanceId); + if (instance == null) return; + + // The app's screen goes first: the picker belongs to twonly, and leaving + // the app behind it would blur exactly the boundary this hands over. + _closeView?.call(); + + final context = rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return; + + // Recorded alongside the message so both devices can say which app the text + // came from. The instance id names the app card, which may well be in a + // different chat than the one the user is about to pick, so the app id and + // version travel too. + final origin = AdditionalMessageData( + type: AdditionalMessageData_Type.WEBXDC_SENT, + webxdcOrigin: WebxdcOrigin( + instanceId: instanceId, + appId: instance.appId, + version: Int64(instance.version), + ), + ).writeToBuffer(); + + await _shareText(context, handover.text!, origin); + } + + static Future _shareText( + BuildContext context, + String text, + Uint8List origin, + ) async { + final selected = + await context.navPush( + SelectContactsView( + text: SelectedContactView( + title: context.lang.shareContactsTitle, + submitButton: (_, _) => context.lang.shareContactsSubmit, + submitIcon: FontAwesomeIcons.shareNodes, + ), + ), + ) + as List?; + if (selected == null || selected.isEmpty) return; + + for (final contactId in selected) { + final group = await twonlyDB.groupsDao.getDirectChat(contactId); + if (group == null) continue; + await RustApi.insertAndSendText( + groupId: group.groupId, + text: text, + additionalMessageData: origin, + ); + } + } + + /// Bounds and flattens text an app produced. Direction overrides and control + /// characters are stripped for the same reason they are in an update: this + /// ends up in a chat, next to real messages. + static final RegExp _strippedFromText = RegExp( + r'[\p{C}\u200e\u200f\u202a-\u202e\u2066-\u2069]', + unicode: true, + ); + + static String? _plainText(String? value) { + if (value == null) return null; + final cleaned = value + .replaceAll(_strippedFromText, '') + .characters + .take(_maxChatTextChars) + .join() + .trim(); + return cleaned.isEmpty ? null : cleaned; + } + + /// Hands the page everything it has not seen yet, in serial order. + static Future _pushPendingUpdates() async { + final instanceId = _openInstanceId; + if (instanceId == null) return; + + final pending = await WebxdcService.updatesAfter( + instanceId, + _deliveredSerial, + ); + if (pending.isEmpty) return; + + final maxSerial = pending.last.serial; + final updates = pending + .map( + (update) => { + 'payload': jsonDecode(update.payload), + 'serial': update.serial, + 'max_serial': maxSerial, + if (update.info != null) 'info': update.info, + if (update.href != null) 'href': update.href, + }, + ) + .toList(); + + try { + await _channel.invokeMethod('deliver', { + 'instanceId': instanceId, + 'message': jsonEncode({ + 'method': 'update', + 'params': {'updates': updates}, + }), + }); + _deliveredSerial = maxSerial; + } on PlatformException catch (error) { + Log.warn('delivering webxdc updates failed: ${error.message}'); + } + } +} + +/// One `sendToChat` call, after checking. +class _Handover { + const _Handover({this.text, this.error}); + + final String? text; + final String? error; +} + +/// A prepared app, or the reason it cannot run. +class WebxdcLaunch { + const WebxdcLaunch({ + required this.instanceId, + required this.origin, + required this.title, + }) : failure = null; + + const WebxdcLaunch.failed(this.failure) + : instanceId = '', + origin = '', + title = ''; + + final String instanceId; + final String origin; + final String title; + + /// `'unavailable'`; null when the app is ready to run. + final String? failure; +} diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index b2b6fccf..6913aba0 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -386,6 +386,19 @@ class _ChatMessagesViewState extends State bool reportOpened = false, }) async { final newestKnownAt = _animationState.newestKnownAt; + + // Only a message the user just sent pulls the list back down. Deciding + // this from the newest known message alone would scroll on every unrelated + // update as well: reactions, media state, group actions and every page of + // older messages run through here too, and each of them would yank the + // reader back to the bottom whenever their own message ends the chat. + final lastMessage = newMessages.lastOrNull; + final wasSentByMe = + _animationState.hasReceivedFirstBatch && + lastMessage != null && + lastMessage.senderId == null && + !_animationState.knownMessageIds.contains(lastMessage.messageId); + for (final msg in newMessages) { // Only messages appended after the newest one already loaded are new to // the user. Messages fetched by scrolling up are older and must not @@ -475,11 +488,6 @@ class _ChatMessagesViewState extends State } } - final wasSentByMe = - _animationState.hasReceivedFirstBatch && - newMessages.isNotEmpty && - newMessages.last.senderId == null; - if (!mounted) return; _data.chatItems = chatItems.reversed.toList(); _notifyMessageDataChanged(); diff --git a/lib/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart b/lib/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart index 02b719ff..918d507e 100644 --- a/lib/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart +++ b/lib/src/visual/views/chats/chat_messages_components/bottom_sheets/share_additional.bottom_sheet.dart @@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/webxdc_store.bottom_sheet.dart'; import 'package:twonly/src/visual/views/shared/select_contacts.view.dart'; class ShareAdditionalView extends StatefulWidget { @@ -62,6 +63,16 @@ class _ShareAdditionalViewState extends State { } } + Future openAppStore() async { + Navigator.pop(context); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => WebxdcStoreView(group: widget.group), + ); + } + @override Widget build(BuildContext context) { return SingleChildScrollView( @@ -95,27 +106,17 @@ class _ShareAdditionalViewState extends State { Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.center, + spacing: 32, children: [ - GestureDetector( + _entry( + icon: FontAwesomeIcons.circleUser, + label: context.lang.shareContactsMenu, onTap: openShareContactView, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: context.color.surfaceContainer, - borderRadius: BorderRadius.circular(12), - ), - child: const FaIcon(FontAwesomeIcons.circleUser), - ), - const SizedBox(height: 8), - Text( - context.lang.shareContactsMenu, - textAlign: TextAlign.center, - ), - ], - ), + ), + _entry( + icon: Icons.apps_rounded, + label: context.lang.webxdcStoreMenu, + onTap: openAppStore, ), ], ), @@ -125,4 +126,33 @@ class _ShareAdditionalViewState extends State { ), ); } + + /// [icon] is an `IconData` or `FaIconData`, matching the rest of the app's + /// icon handling. + Widget _entry({ + required dynamic icon, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: context.color.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + child: icon is IconData + ? Icon(icon) + : FaIcon(icon as FaIconData?), + ), + const SizedBox(height: 8), + Text(label, textAlign: TextAlign.center), + ], + ), + ); + } } diff --git a/lib/src/visual/views/chats/chat_messages_components/bottom_sheets/webxdc_store.bottom_sheet.dart b/lib/src/visual/views/chats/chat_messages_components/bottom_sheets/webxdc_store.bottom_sheet.dart new file mode 100644 index 00000000..7c55c5e3 --- /dev/null +++ b/lib/src/visual/views/chats/chat_messages_components/bottom_sheets/webxdc_store.bottom_sheet.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/core/bridge/webxdc.dart' as rust_webxdc; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/webxdc/webxdc.service.dart'; +import 'package:twonly/src/utils/misc.dart'; + +/// The in-app store. +/// +/// Only twonly publishes apps, so there is nothing here for a user to judge: +/// the list is the complete set of code that can run in a chat. Picking one +/// places it into the chat; the bundle is downloaded when somebody presses +/// Start, not now. +class WebxdcStoreView extends StatefulWidget { + const WebxdcStoreView({required this.group, super.key}); + + final Group group; + + @override + State createState() => _WebxdcStoreViewState(); +} + +class _WebxdcStoreViewState extends State { + List? _apps; + bool _offline = false; + String? _placing; + bool _loading = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Loaded from here rather than `initState`: the descriptions are fetched in + // the reader's language, and the resolved locale is only available once the + // sheet has its dependencies. + if (_loading) return; + _loading = true; + unawaited(_load(Localizations.localeOf(context).toLanguageTag())); + } + + Future _load(String language) async { + // The cached listing is shown first so the sheet is never empty while the + // refresh is in flight. + final languages = [language]; + final cached = await WebxdcService.catalog(languages); + if (mounted) setState(() => _apps = cached); + + final refreshed = await WebxdcService.refreshCatalog(); + final apps = refreshed ? await WebxdcService.catalog(languages) : cached; + if (!mounted) return; + setState(() { + _apps = apps; + _offline = !refreshed && cached.isEmpty; + }); + } + + Future _place(rust_webxdc.WebxdcStoreApp app) async { + setState(() => _placing = app.appId); + final instanceId = await WebxdcService.createInstance( + widget.group.groupId, + app.appId, + app.version, + ); + if (!mounted) return; + if (instanceId == null) { + setState(() => _placing = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.lang.webxdcStoreFailed)), + ); + return; + } + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + // Never the whole screen: the chat has to stay visible behind the sheet, + // because what picking an app does is put it into that chat. + maxChildSize: 0.7, + expand: false, + builder: (context, controller) => Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + color: context.color.surface, + ), + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + color: Colors.grey, + ), + height: 3, + width: 60, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text( + context.lang.webxdcStoreTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + const SizedBox(height: 8), + Expanded(child: _body(controller)), + ], + ), + ), + ); + } + + Widget _body(ScrollController controller) { + final apps = _apps; + if (apps == null) { + return const Center(child: CircularProgressIndicator()); + } + if (apps.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + _offline + ? context.lang.webxdcStoreOffline + : context.lang.webxdcStoreEmpty, + textAlign: TextAlign.center, + ), + ), + ); + } + + return ListView.builder( + controller: controller, + itemCount: apps.length, + itemBuilder: (context, index) { + final app = apps[index]; + final icon = app.icon; + // What the app says about itself, in the reader's language, as the + // store published it. Plain text, bounded and stripped of control + // characters before it was ever accepted for publishing. + final subtitle = userService.currentUser.isDeveloper + ? ['v${app.version}', ?app.description].join(' Β· ') + : app.description; + + return ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: icon == null + ? const FaIcon(FontAwesomeIcons.puzzlePiece) + : ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory(icon, fit: BoxFit.cover), + ), + ), + title: Text(app.name), + subtitle: subtitle == null + ? null + : Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), + trailing: _placing == app.appId + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + onTap: _placing == null ? () => _place(app) : null, + ); + }, + ); + } +} diff --git a/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart index ccf4d66b..a9871f9e 100644 --- a/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/chat_list_entry.dart @@ -21,6 +21,8 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_webxdc.entry.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_webxdc_message.entry.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_context_menu.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_reply_drag.dart'; @@ -144,6 +146,16 @@ class _ChatListEntryState extends State { BubbleInfo info, ) { if (widget.message.type == MessageType.text.name) { + // Only a message a webxdc app produced -- text handed to a chat, or an + // announcement an app made in one -- carries data alongside its text, so + // this is enough to route it without decoding anything here. + if (widget.message.additionalMessageData != null) { + return ChatWebxdcMessageEntry( + message: widget.message, + borderRadius: borderRadius, + info: info, + ); + } return ChatTextEntry( message: widget.message, borderRadius: borderRadius, @@ -198,6 +210,14 @@ class _ChatListEntryState extends State { ); } + if (widget.message.type == MessageType.webxdcApp.name) { + return ChatWebxdcEntry( + message: widget.message, + borderRadius: borderRadius, + info: info, + ); + } + return const ChatUnknownEntry(); } diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc.entry.dart new file mode 100644 index 00000000..8e812ab8 --- /dev/null +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc.entry.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/webxdc/webxdc.service.dart'; +import 'package:twonly/src/services/webxdc/webxdc_host.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart'; +import 'package:twonly/src/visual/views/webxdc/webxdc_app.view.dart'; + +/// The card an app shows in a chat: icon, name, whatever the app last put in +/// its summary or document, and when it arrived. Tapping it starts the app. +/// +/// Every string here except the app's own name comes from `webxdc_instances`, +/// where Rust already stripped control characters and bounded the length. This +/// widget renders them as plain text and does nothing else with them: they are +/// written by third-party code and sit next to real messages. +class ChatWebxdcEntry extends StatefulWidget { + const ChatWebxdcEntry({ + required this.message, + required this.borderRadius, + required this.info, + super.key, + }); + + final Message message; + final BorderRadiusGeometry borderRadius; + final BubbleInfo info; + + @override + State createState() => _ChatWebxdcEntryState(); +} + +class _ChatWebxdcEntryState extends State { + /// Held across rebuilds rather than created in `build`. + /// + /// A stream or future built during `build` is a new one every time the chat + /// rebuilds -- which any incoming message causes -- and each new one starts + /// out empty, so the card would blank out and the icon would visibly reload + /// every time anything else in the chat changed. + StreamSubscription? _subscription; + WebxdcInstance? _instance; + WebxdcApp? _app; + + @override + void initState() { + super.initState(); + _seed(); + _subscribe(); + } + + @override + void didUpdateWidget(ChatWebxdcEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.messageId != widget.message.messageId) { + _seed(); + _subscribe(); + } + } + + /// What the card looked like the last time this message was on screen. + /// + /// Scrolling a card out of view destroys its state, and the query behind it + /// only answers a frame or two after the card is built again; starting from + /// nothing would collapse the card and then pop it back in. The stream still + /// corrects whatever changed in the meantime. + void _seed() { + final instance = WebxdcService.cachedInstance(widget.message.messageId); + _instance = instance; + _app = instance == null + ? null + : WebxdcService.cachedApp(instance.appId, instance.version); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + super.dispose(); + } + + void _subscribe() { + unawaited(_subscription?.cancel()); + _subscription = WebxdcService.watchInstance( + widget.message.messageId, + ).listen(_onInstance); + } + + Future _onInstance(WebxdcInstance? instance) async { + if (!mounted) return; + setState(() => _instance = instance); + if (instance == null) return; + + // The store entry only changes when the instance moves to another version, + // which happens once, when the app is started after an update was + // published; looking it up again on every update to the app's state would + // throw the decoded icon away for nothing. + final cached = _app; + if (cached != null && + cached.appId == instance.appId && + cached.version == instance.version) { + return; + } + final app = await WebxdcService.appNamed(instance.appId, instance.version); + if (!mounted) return; + setState(() => _app = app); + } + + @override + Widget build(BuildContext context) { + final instance = _instance; + if (instance == null) { + return const SizedBox.shrink(); + } + return _card(context, instance, _app); + } + + /// Downloading and verifying the bundle happens here and nowhere else: a + /// message arriving in a chat never causes a fetch, only this tap does. + Future _start(BuildContext context, WebxdcInstance instance) async { + final messenger = ScaffoldMessenger.of(context); + final unavailable = context.lang.webxdcUnavailable; + final languages = readerLanguages(context); + + final launch = await WebxdcHost.prepare(instance.instanceId, languages); + if (launch.failure != null) { + messenger.showSnackBar(SnackBar(content: Text(unavailable))); + return; + } + if (!context.mounted) return; + await context.navPush(WebxdcAppView(launch: launch)); + } + + Widget _card( + BuildContext context, + WebxdcInstance instance, + WebxdcApp? app, + ) { + final subtitle = instance.document ?? instance.summary; + // The card sits on a message bubble, whose color is the same in either + // theme, so what it is drawn in follows the bubble and not the surface. + final foreground = widget.info.textColor; + final secondary = foreground.withAlpha(180); + + // The whole card starts the app; the chevron is what says so. + return GestureDetector( + onTap: () => _start(context, instance), + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.8, + ), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: widget.info.color, + borderRadius: widget.borderRadius, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + WebxdcAppIcon( + app: app, + size: 40, + radius: 8, + color: foreground, + ), + const SizedBox(width: 10), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + // The store name in the reader's language, not anything + // the running app chose. + app == null + ? instance.appId + : WebxdcService.localizedName( + app, + readerLanguages(context), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: foreground, + ), + ), + if (subtitle != null && subtitle.isNotEmpty) + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: secondary, + ), + ), + ], + ), + ), + const SizedBox(width: 4), + FaIcon( + FontAwesomeIcons.angleRight, + size: 16, + color: secondary, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc_message.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc_message.entry.dart new file mode 100644 index 00000000..61e50f3f --- /dev/null +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_webxdc_message.entry.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart' + show AdditionalMessageData, WebxdcOrigin; +import 'package:twonly/src/services/webxdc/webxdc.service.dart'; +import 'package:twonly/src/services/webxdc/webxdc_host.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/better_text.element.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart'; +import 'package:twonly/src/visual/views/webxdc/webxdc_app.view.dart'; + +/// A message that came out of a webxdc app, shown with the app it came from. +/// +/// Two things end up here: text the user handed to a chat from inside an app, +/// and an announcement an app made in the chat it runs in ("a new game +/// started", "it is your turn"). Either way the words were written by +/// third-party code rather than by the person the bubble belongs to, which is +/// what the header says. +/// +/// The app may live in a different chat than the one this was sent to, so the +/// header is only tappable when this device actually has that instance. +class ChatWebxdcMessageEntry extends StatefulWidget { + const ChatWebxdcMessageEntry({ + required this.message, + required this.borderRadius, + required this.info, + super.key, + }); + + final Message message; + final BorderRadius borderRadius; + final BubbleInfo info; + + @override + State createState() => _ChatWebxdcMessageEntryState(); +} + +class _ChatWebxdcMessageEntryState extends State { + /// Resolved once and held, so a rebuild -- which any new message in the chat + /// causes -- does not blank the header out and decode the icon again. + WebxdcOrigin? _origin; + WebxdcApp? _app; + WebxdcInstance? _instance; + bool _resolved = false; + + @override + void initState() { + super.initState(); + unawaited(_resolve()); + } + + @override + void didUpdateWidget(ChatWebxdcMessageEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.messageId != widget.message.messageId) { + _origin = null; + _app = null; + _instance = null; + _resolved = false; + unawaited(_resolve()); + } + } + + /// The header as it was the last time this message was on screen, so a card + /// scrolled back into view does not blank out and decode the icon again while + /// [_resolve] runs. + void _seed(WebxdcOrigin origin) { + _app = WebxdcService.cachedApp(origin.appId, origin.version.toInt()); + _instance = WebxdcService.cachedInstance(origin.instanceId); + } + + Future _resolve() async { + final data = widget.message.additionalMessageData; + WebxdcOrigin? origin; + if (data != null) { + try { + final decoded = AdditionalMessageData.fromBuffer(data); + if (decoded.hasWebxdcOrigin()) origin = decoded.webxdcOrigin; + } catch (_) { + origin = null; + } + } + if (origin == null) { + if (mounted) setState(() => _resolved = true); + return; + } + // Assigned rather than set: both callers are followed by a build, and this + // runs synchronously inside `initState`, where `setState` is not allowed. + _origin = origin; + _seed(origin); + + final app = await WebxdcService.appNamed( + origin.appId, + origin.version.toInt(), + ); + final instance = await WebxdcService.instanceRow(origin.instanceId); + if (!mounted) return; + setState(() { + _origin = origin; + _app = app; + _instance = instance; + _resolved = true; + }); + } + + Future _openApp() async { + final instance = _instance; + if (instance == null) return; + final launch = await WebxdcHost.prepare( + instance.instanceId, + readerLanguages(context), + ); + if (launch.failure != null || !mounted) return; + await context.navPush(WebxdcAppView(launch: launch)); + } + + @override + Widget build(BuildContext context) { + final origin = _origin; + if (origin == null) { + // Either the marker is missing or it did not decode. Nothing is lost by + // showing the message the way any other text message is shown. + if (!_resolved) return const SizedBox.shrink(); + return ChatTextEntry( + message: widget.message, + borderRadius: widget.borderRadius, + info: widget.info, + ); + } + + final app = _app; + final name = app == null + ? origin.appId + : WebxdcService.localizedName(app, readerLanguages(context)); + + return IntrinsicWidth( + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.8, + minWidth: widget.info.minWidth, + ), + padding: widget.info.padding, + decoration: BoxDecoration( + color: widget.info.color, + borderRadius: widget.borderRadius, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _instance == null ? null : _openApp, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + WebxdcAppIcon( + app: _app, + size: 16, + radius: 4, + color: widget.info.textColor, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: widget.info.textColor, + ), + ), + ), + if (_instance != null) ...[ + const SizedBox(width: 4), + FaIcon( + FontAwesomeIcons.angleRight, + size: 10, + color: widget.info.textColor, + ), + ], + ], + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: BetterText( + text: widget.info.text, + textColor: widget.info.textColor, + ), + ), + FriendlyMessageTime(message: widget.message), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/common.dart b/lib/src/visual/views/chats/chat_messages_components/entries/common.dart index 099cc548..0055a4a1 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/common.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/common.dart @@ -81,6 +81,14 @@ BubbleInfo getBubbleInfo( return info; } +/// The languages the reader prefers, most preferred first, for the parts of a +/// message that arrive translated -- a webxdc app's name, say. What the reader +/// asked for is decided from the resolved locale and never sent anywhere: every +/// translation is already on the device. +List readerLanguages(BuildContext context) => [ + Localizations.localeOf(context).toLanguageTag(), +]; + /// Laying text out is expensive and `getBubbleInfo` runs for every visible /// bubble on every rebuild, while the same message content is measured over and /// over. Keep the last few hundred results around, in insertion order, so the diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart b/lib/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart new file mode 100644 index 00000000..be98c29d --- /dev/null +++ b/lib/src/visual/views/chats/chat_messages_components/entries/webxdc_app_icon.comp.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/database/twonly.db.dart'; + +/// One `MemoryImage` per app version, kept for the life of the process. +/// +/// `MemoryImage` compares its bytes by identity, so the image cache treats the +/// `Uint8List` a fresh database read hands back as a different image and decodes +/// the same PNG again -- visibly, since the card is already on screen by then. +/// An app version's icon never changes, so holding the provider means every +/// card showing that app resolves out of the image cache instead. +final Map _providers = {}; + +/// The app's icon, or the placeholder shown while the store entry is unknown +/// and for an app that published none. +class WebxdcAppIcon extends StatelessWidget { + const WebxdcAppIcon({ + required this.app, + required this.size, + required this.radius, + required this.color, + super.key, + }); + + final WebxdcApp? app; + final double size; + final double radius; + + /// Both the placeholder's color and, being drawn on a message bubble rather + /// than on a surface, what the icon has to stay legible against. + final Color color; + + @override + Widget build(BuildContext context) { + final provider = _providerFor(app); + return SizedBox( + width: size, + height: size, + child: provider == null + ? FaIcon( + FontAwesomeIcons.puzzlePiece, + size: size * 0.6, + color: color, + ) + : ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Image(image: provider, fit: BoxFit.cover), + ), + ); + } +} + +MemoryImage? _providerFor(WebxdcApp? app) { + if (app == null) return null; + final icon = app.icon; + if (icon == null || icon.isEmpty) return null; + return _providers.putIfAbsent( + '${app.appId}:${app.version}', + () => MemoryImage(icon), + ); +} diff --git a/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart index 3f925248..4af04851 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_context_menu.dart @@ -13,6 +13,7 @@ import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart' as pb; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/services/webxdc/webxdc.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/emoji_picker.bottom.dart'; import 'package:twonly/src/visual/context_menu/context_menu.helper.dart'; @@ -185,6 +186,14 @@ class MessageContextMenu extends StatelessWidget { group, galleryItems, ); + if (action == null) return; + if (message.type == MessageType.webxdcApp.name) { + // Removing the rows is only half of it. An app is free to keep + // its whole state in localStorage or IndexedDB, which no database + // delete reaches, so the instance goes first and takes its origin + // with it. + await WebxdcService.deleteInstance(message.messageId); + } if (action == 'delete_for_all') { await twonlyDB.messagesDao.handleMessageDeletion( null, @@ -285,6 +294,18 @@ Future showDeleteMessageOptions( const SizedBox(height: 24), const Divider(), const SizedBox(height: 24), + // Deleting an app is not the same as deleting a message: the whole + // update log and everything the app saved on this device go with + // it, so the sheet says so rather than letting the usual wording + // stand in for it. + if (message.type == MessageType.webxdcApp.name) ...[ + Text( + context.lang.webxdcDeleteConfirm, + textAlign: TextAlign.center, + style: TextStyle(color: context.color.onSurfaceVariant), + ), + const SizedBox(height: 24), + ], if (isForAll) ...[ Center( child: MyButton( diff --git a/lib/src/visual/views/settings/developer/home_widget_developer.view.dart b/lib/src/visual/views/settings/developer/home_widget_developer.view.dart index d5f97dff..f1b8daf0 100644 --- a/lib/src/visual/views/settings/developer/home_widget_developer.view.dart +++ b/lib/src/visual/views/settings/developer/home_widget_developer.view.dart @@ -133,7 +133,10 @@ class _HomeWidgetDeveloperViewState extends State { final groups = (decoded['groups'] as List?) ?? []; final images = (decoded['images'] as List?) ?? []; - lines.add('${groups.length} contact groups, ${images.length} images'); + lines.add( + '${groups.length} contact groups, ${images.length} images ' + '(at most one per contact group)', + ); // The group IDs are the whole matching rule: the widget shows an image only // when its sender's groups intersect the groups the widget was configured @@ -141,14 +144,17 @@ class _HomeWidgetDeveloperViewState extends State { for (final group in groups.cast>()) { lines.add(' group ${group['id']}: ${group['name']}'); } - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; for (final image in images.cast>()) { - final expiresAt = (image['expires_at'] as num?)?.toInt() ?? 0; + final receivedAt = DateTime.fromMillisecondsSinceEpoch( + ((image['received_at'] as num?)?.toInt() ?? 0) * 1000, + ); final exists = File('${image['path']}').existsSync(); lines.add( ' ${image['media_id']} from ${image['sender']} ' + // One image per contact group, so these are the groups it is the + // current image for β€” not the sender's full membership. 'groups=${image['group_ids']} ' - '${expiresAt > now ? 'valid' : 'EXPIRED'} ' + 'received ${receivedAt.toLocal()} ' '${exists ? '' : 'FILE MISSING'}', ); } diff --git a/lib/src/visual/views/settings/settings_main.view.dart b/lib/src/visual/views/settings/settings_main.view.dart index 53f7ca77..4ea798e3 100644 --- a/lib/src/visual/views/settings/settings_main.view.dart +++ b/lib/src/visual/views/settings/settings_main.view.dart @@ -110,11 +110,6 @@ class SettingsMainView extends StatelessWidget { text: context.lang.settingsNotification, onTap: () => context.push(Routes.settingsNotification), ), - BetterListTile( - icon: Icons.widgets_rounded, - text: context.lang.settingsWidgets, - onTap: () => context.push(Routes.settingsWidgets), - ), BetterListTile( icon: FontAwesomeIcons.chartPie, iconSize: 15, diff --git a/lib/src/visual/views/settings/widgets/widget_detail.view.dart b/lib/src/visual/views/settings/widgets/widget_detail.view.dart deleted file mode 100644 index 58d0d2b7..00000000 --- a/lib/src/visual/views/settings/widgets/widget_detail.view.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/home_widget.service.dart'; -import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/components/alert.dialog.dart'; -import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart'; -import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart'; - -/// One placed widget: who may send to it, and what it is showing right now. -class WidgetDetailView extends StatefulWidget { - const WidgetDetailView({required this.widget, super.key}); - - final PlacedWidget widget; - - @override - State createState() => _WidgetDetailViewState(); -} - -class _WidgetDetailViewState extends State { - List? _images; - List _groups = const []; - Timer? _ticker; - - @override - void initState() { - super.initState(); - unawaited(_load()); - // Every entry shows how much of its 24 hours is left, so the screen has to - // keep counting down while it is open. - _ticker = Timer.periodic(const Duration(seconds: 30), (_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _ticker?.cancel(); - super.dispose(); - } - - Future _load() async { - final images = await HomeWidgetService.imagesFor(widget.widget); - final all = await twonlyDB.contactGroupsDao.watchAllContactGroups().first; - final selected = widget.widget.contactGroupIds.toSet(); - if (!mounted) return; - setState(() { - _images = images; - _groups = all.where((group) => selected.contains(group.id)).toList(); - }); - } - - Future _delete(WidgetImage image) async { - final confirmed = await showAlertDialog( - context, - context.lang.widgetsDeleteImage, - context.lang.widgetsDeleteImageConfirm, - customOk: context.lang.widgetsDeleteImage, - ); - if (!confirmed) return; - await HomeWidgetService.deleteImage(image.mediaId); - await _load(); - } - - /// Coarse on purpose: the exact second an image disappears is noise, and the - /// user only needs to know whether it is here for a while or nearly gone. - String _remaining(WidgetImage image) { - final remaining = image.remaining; - if (remaining.inHours >= 1) { - return context.lang.widgetsDurationHours(remaining.inHours); - } - return context.lang.widgetsDurationMinutes( - remaining.inMinutes.clamp(1, 59), - ); - } - - @override - Widget build(BuildContext context) { - final images = _images; - return Scaffold( - appBar: AppBar(title: Text(context.lang.widgetsTitle)), - body: ListView( - padding: const EdgeInsets.only(top: 8, bottom: 32), - children: [ - if (_groups.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - context.lang.widgetsNoGroupsHint, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ) - else - for (final group in _groups) - ListTile( - leading: group.emoji == null - ? const Icon(Icons.group_outlined) - : Text( - group.emoji!, - style: const TextStyle(fontSize: 20), - ), - title: Text(group.name), - subtitle: Text(context.lang.widgetsEditGroup), - trailing: const Icon(Icons.chevron_right), - onTap: () async { - await context.navPush( - ContactGroupSettingsView(contactGroup: group), - ); - await _load(); - }, - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), - child: Text( - changeGroupsHint(context), - style: Theme.of(context).textTheme.bodySmall, - ), - ), - const Divider(height: 32), - _sectionTitle(context.lang.widgetsCurrentImages), - if (images == null) - const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: CircularProgressIndicator(), - ), - ) - else if (images.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - context.lang.widgetsNoImagesHint, - style: Theme.of(context).textTheme.bodyMedium, - ), - ) - else - for (final image in images) _imageTile(image), - ], - ), - ); - } - - Widget _sectionTitle(String text) => Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), - child: Text( - text, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ); - - Widget _imageTile(WidgetImage image) { - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - image.file, - width: 56, - height: 56, - fit: BoxFit.cover, - // The file is deleted the moment the user confirms, and Flutter keeps - // decoded frames in a cache keyed by path, so a stale entry would - // outlive the file it came from. - cacheWidth: 168, - errorBuilder: (_, _, _) => const SizedBox( - width: 56, - height: 56, - child: Icon(Icons.broken_image_outlined), - ), - ), - ), - title: Text(context.lang.widgetsFrom(image.sender)), - subtitle: Text(context.lang.widgetsExpiresIn(_remaining(image))), - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: context.lang.widgetsDeleteImage, - onPressed: () => _delete(image), - ), - ); - } -} diff --git a/lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart b/lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart deleted file mode 100644 index 4ebe59ad..00000000 --- a/lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:twonly/src/utils/misc.dart'; - -/// Explains how to put a twonly widget on the home screen. -/// -/// A widget can only be placed by the user, from the home screen itself β€” -/// neither iOS nor Android exposes an API for an app to add or configure one on -/// the user's behalf β€” so instructions are the most the app can offer. -class WidgetSetupGuide extends StatelessWidget { - const WidgetSetupGuide({required this.showIntro, super.key}); - - /// Whether to lead with what the feature is. Shown when the user has no - /// widget yet; skipped when this sits under a list they can already see. - final bool showIntro; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showIntro) ...[ - Center( - child: Icon( - Icons.widgets_outlined, - size: 56, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(height: 16), - Text( - context.lang.widgetsIntroTitle, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - context.lang.widgetsIntroBody, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 24), - ], - Text( - showIntro - ? context.lang.widgetsNoneTitle - : context.lang.widgetsAddAnother, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Text( - Platform.isIOS - ? context.lang.widgetsSetupIos - : context.lang.widgetsSetupAndroid, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodySmall?.color, - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/visual/views/settings/widgets/widgets.view.dart b/lib/src/visual/views/settings/widgets/widgets.view.dart deleted file mode 100644 index 47e25dd6..00000000 --- a/lib/src/visual/views/settings/widgets/widgets.view.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:twonly/locator.dart'; -import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/home_widget.service.dart'; -import 'package:twonly/src/utils/misc.dart'; -import 'package:twonly/src/visual/elements/my_card.element.dart'; -import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; -import 'package:twonly/src/visual/views/settings/widgets/widget_detail.view.dart'; -import 'package:twonly/src/visual/views/settings/widgets/widget_setup_guide.comp.dart'; - -/// Lists the widgets on the home screen and who may send to each of them. -/// -/// Neither platform lets an app place or reconfigure a widget on the user's -/// behalf, so this screen explains the placement and then reflects the result. -/// What it *can* offer directly is the part that lives in the app: which -/// contacts are in each group. -class WidgetsSettingsView extends StatefulWidget { - const WidgetsSettingsView({super.key}); - - @override - State createState() => _WidgetsSettingsViewState(); -} - -class _WidgetsSettingsViewState extends State { - List? _widgets; - String? _queryError; - Map _groups = const {}; - Map _imageCounts = const {}; - - @override - void initState() { - super.initState(); - unawaited(_load()); - } - - Future _load() async { - // The manifest and the placement file are rewritten by Rust and by the - // native widgets, so this screen always re-reads rather than caching. - HomeWidgetService.invalidate(); - await HomeWidgetService.syncPermissions(); - final result = await HomeWidgetService.placedWidgetsResult(); - final widgets = result.widgets; - final groups = await twonlyDB.contactGroupsDao - .watchAllContactGroups() - .first; - final counts = {}; - for (final widget in widgets) { - counts[widget.id] = (await HomeWidgetService.imagesFor(widget)).length; - } - if (!mounted) return; - setState(() { - _widgets = widgets; - _queryError = result.error; - _groups = {for (final group in groups) group.id: group}; - _imageCounts = counts; - }); - } - - @override - Widget build(BuildContext context) { - final widgets = _widgets; - return Scaffold( - appBar: AppBar(title: Text(context.lang.widgetsTitle)), - body: widgets == null - ? const Center(child: ThreeRotatingDots(size: 40)) - : RefreshIndicator( - onRefresh: _load, - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - // Without this the list would quietly present a stale file as - // the state of the home screen. - if (_queryError != null) - Padding( - padding: const EdgeInsets.fromLTRB(24, 8, 24, 8), - child: Text( - context.lang.widgetsQueryFailed, - style: TextStyle(color: context.color.error), - ), - ), - if (widgets.isEmpty) - const WidgetSetupGuide(showIntro: true) - else ...[ - for (final widget in widgets) _tile(widget), - const Divider(height: 32), - const WidgetSetupGuide(showIntro: false), - ], - ], - ), - ), - ); - } - - /// Several widgets can share one contact group, so the size is what tells - /// two cards apart. - String _size(PlacedWidget widget) => switch (widget.family) { - 'systemSmall' => context.lang.widgetsSizeSmall, - 'systemMedium' => context.lang.widgetsSizeMedium, - 'systemLarge' => context.lang.widgetsSizeLarge, - _ => context.lang.widgetsSizeUnknown, - }; - - Widget _tile(PlacedWidget widget) { - final selected = [ - for (final id in widget.contactGroupIds) ?_groups[id], - ]; - final count = _imageCounts[widget.id] ?? 0; - final unconfigured = selected.isEmpty; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: MyCard( - icon: FontAwesomeIcons.image, - accentColor: unconfigured ? context.color.error : null, - titleColor: unconfigured ? context.color.error : null, - title: unconfigured - ? context.lang.widgetsNoGroups - : selected - .map( - (g) => '${g.emoji == null ? '' : '${g.emoji} '}${g.name}', - ) - .join(', '), - subtitle: unconfigured - ? context.lang.widgetsNoGroupsHint - : '${_size(widget)} Β· ' - '${count == 0 ? context.lang.widgetsNoImages : context.lang.widgetsCurrentImages}', - trailing: count == 0 - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Not a warning, just a count: the badge's default error red - // reads as something being wrong. - Badge( - backgroundColor: context.color.primary, - textColor: context.color.onPrimary, - label: Text('$count'), - ), - const SizedBox(width: 8), - Icon( - Icons.chevron_right_rounded, - color: context.color.onSurfaceVariant, - ), - ], - ), - onTap: () async { - await context.navPush(WidgetDetailView(widget: widget)); - await _load(); - }, - ), - ); - } -} - -/// Platform wording for how the contact groups of a placed widget are changed. -String changeGroupsHint(BuildContext context) => Platform.isIOS - ? context.lang.widgetsChangeGroupsIos - : context.lang.widgetsChangeGroupsAndroid; diff --git a/lib/src/visual/views/webxdc/webxdc_app.view.dart b/lib/src/visual/views/webxdc/webxdc_app.view.dart new file mode 100644 index 00000000..e1816b4d --- /dev/null +++ b/lib/src/visual/views/webxdc/webxdc_app.view.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:twonly/src/services/webxdc/webxdc_host.dart'; + +/// A running webxdc app, inside twonly. +/// +/// The app is a native webview embedded in an ordinary route, so it keeps the +/// app bar, the back gesture and the theme around it: from the user's side it +/// is a screen in twonly rather than something that took the phone over. +class WebxdcAppView extends StatefulWidget { + const WebxdcAppView({required this.launch, super.key}); + + final WebxdcLaunch launch; + + @override + State createState() => _WebxdcAppViewState(); +} + +class _WebxdcAppViewState extends State { + Animation? _routeAnimation; + + @override + void initState() { + super.initState(); + // From here until dispose, this is the one instance a page may act on. + WebxdcHost.attach( + widget.launch.instanceId, + close: () { + if (mounted) Navigator.of(context).pop(); + }, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final animation = ModalRoute.of(context)?.animation; + if (identical(animation, _routeAnimation)) return; + _routeAnimation?.removeStatusListener(_handleRouteAnimation); + _routeAnimation = animation; + animation?.addStatusListener(_handleRouteAnimation); + } + + @override + void dispose() { + _routeAnimation?.removeStatusListener(_handleRouteAnimation); + WebxdcHost.detach(widget.launch.instanceId); + super.dispose(); + } + + /// Pauses the app for as long as this screen is animating away. + /// + /// Every way out reverses the route's animation -- the app bar's back button, + /// the system back gesture and `window.close` from the page itself -- so this + /// is the one place that sees all of them, and it sees them before the first + /// animated frame is built. `dispose` is too late: it only runs once the + /// animation is over. + void _handleRouteAnimation(AnimationStatus status) { + if (status == AnimationStatus.reverse) { + unawaited(WebxdcHost.setPaused(paused: true)); + } else if (status == AnimationStatus.forward) { + // Opening, or a pop that was called off part way through. + unawaited(WebxdcHost.setPaused(paused: false)); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.launch.title)), + body: SafeArea( + child: _WebxdcSurface( + instanceId: widget.launch.instanceId, + origin: widget.launch.origin, + ), + ), + ); + } +} + +/// The embedded webview. +/// +/// Both platforms hand back a native view rather than a screen of their own, so +/// what the app draws is composited into this route like any other widget. +class _WebxdcSurface extends StatelessWidget { + const _WebxdcSurface({required this.instanceId, required this.origin}); + + static const String _viewType = 'eu.twonly/webxdc_webview'; + + final String instanceId; + final String origin; + + @override + Widget build(BuildContext context) { + final parameters = { + 'instanceId': instanceId, + 'origin': origin, + }; + + if (defaultTargetPlatform == TargetPlatform.iOS) { + return UiKitView( + viewType: _viewType, + layoutDirection: TextDirection.ltr, + creationParams: parameters, + creationParamsCodec: const StandardMessageCodec(), + ); + } + + return PlatformViewLink( + viewType: _viewType, + surfaceFactory: (context, controller) => AndroidViewSurface( + controller: controller as AndroidViewController, + gestureRecognizers: const >{}, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + ), + onCreatePlatformView: (params) { + // Hybrid Composition++ wherever the device runs it: the webview is + // handed to the system compositor, so an animation over it no longer + // drags the platform thread along. Everywhere else the texture path, + // which composites the webview like any other layer and drops to plain + // hybrid composition on its own when a page needs a real surface. + // Plain hybrid composition is not asked for here: it merges the raster + // and platform threads for as long as the app is on screen, and every + // frame of the closing animation then pays for it. + final controller = WebxdcHost.hybridComposition + ? PlatformViewsService.initHybridAndroidView( + id: params.id, + viewType: _viewType, + layoutDirection: TextDirection.ltr, + creationParams: parameters, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ) + : PlatformViewsService.initSurfaceAndroidView( + id: params.id, + viewType: _viewType, + layoutDirection: TextDirection.ltr, + creationParams: parameters, + creationParamsCodec: const StandardMessageCodec(), + onFocus: () => params.onFocusChanged(true), + ); + return controller + ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated) + ..create(); + }, + ); + } +} diff --git a/rust/.sqlx/query-035624a9d81984e031f6d69d0b0a73fd0f0424de549a69739d6d0ff16b57710a.json b/rust/.sqlx/query-035624a9d81984e031f6d69d0b0a73fd0f0424de549a69739d6d0ff16b57710a.json new file mode 100644 index 00000000..d776a8a5 --- /dev/null +++ b/rust/.sqlx/query-035624a9d81984e031f6d69d0b0a73fd0f0424de549a69739d6d0ff16b57710a.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT bundle_sha256 AS \"bundle_sha256!: String\"\n FROM webxdc_apps WHERE app_id = ? AND version = ?", + "describe": { + "columns": [ + { + "name": "bundle_sha256!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "bundle_sha256" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "035624a9d81984e031f6d69d0b0a73fd0f0424de549a69739d6d0ff16b57710a" +} diff --git a/rust/.sqlx/query-03b9944c6234e269f00c123d5d0e7ffe617df1295c20b4f0fa457bfda6fa7785.json b/rust/.sqlx/query-03b9944c6234e269f00c123d5d0e7ffe617df1295c20b4f0fa457bfda6fa7785.json new file mode 100644 index 00000000..ccf72f1d --- /dev/null +++ b/rust/.sqlx/query-03b9944c6234e269f00c123d5d0e7ffe617df1295c20b4f0fa457bfda6fa7785.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM receipts WHERE message_id = ? AND wake_receiver = 1", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "03b9944c6234e269f00c123d5d0e7ffe617df1295c20b4f0fa457bfda6fa7785" +} diff --git a/rust/.sqlx/query-1690c503c4d633e891b40671af249ef313f0d4c8e3dbddf38e6d67868be1f5a9.json b/rust/.sqlx/query-1690c503c4d633e891b40671af249ef313f0d4c8e3dbddf38e6d67868be1f5a9.json new file mode 100644 index 00000000..47cd713b --- /dev/null +++ b/rust/.sqlx/query-1690c503c4d633e891b40671af249ef313f0d4c8e3dbddf38e6d67868be1f5a9.json @@ -0,0 +1,28 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) AS \"count!: i64\",\n COALESCE(SUM(LENGTH(payload)), 0) AS \"bytes!: i64\"\n FROM webxdc_updates WHERE instance_id = ?", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "bytes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "1690c503c4d633e891b40671af249ef313f0d4c8e3dbddf38e6d67868be1f5a9" +} diff --git a/rust/.sqlx/query-19c1f52003002eee3c02a7c1bd3df878ad6deb92bd6eee6d23cdba4ea2eb108c.json b/rust/.sqlx/query-19c1f52003002eee3c02a7c1bd3df878ad6deb92bd6eee6d23cdba4ea2eb108c.json new file mode 100644 index 00000000..b144d049 --- /dev/null +++ b/rust/.sqlx/query-19c1f52003002eee3c02a7c1bd3df878ad6deb92bd6eee6d23cdba4ea2eb108c.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT group_id AS \"group_id!: String\", app_id AS \"app_id!: String\",\n version AS \"version!: i64\"\n FROM webxdc_instances WHERE instance_id = ?", + "describe": { + "columns": [ + { + "name": "group_id!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "group_id" + } + } + }, + { + "name": "app_id!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "app_id" + } + } + }, + { + "name": "version!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "version" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "19c1f52003002eee3c02a7c1bd3df878ad6deb92bd6eee6d23cdba4ea2eb108c" +} diff --git a/rust/.sqlx/query-2066610ac883966a5d38cb80c193fd46f3fbde9940e6a221fb54671f446ca596.json b/rust/.sqlx/query-2066610ac883966a5d38cb80c193fd46f3fbde9940e6a221fb54671f446ca596.json new file mode 100644 index 00000000..d77adbcb --- /dev/null +++ b/rust/.sqlx/query-2066610ac883966a5d38cb80c193fd46f3fbde9940e6a221fb54671f446ca596.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO webxdc_instances\n (instance_id, group_id, app_id, version, origin_token)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(instance_id) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "2066610ac883966a5d38cb80c193fd46f3fbde9940e6a221fb54671f446ca596" +} diff --git a/rust/.sqlx/query-287521b40159e737315505b74bb01248900b5a59eebf04ff5ede8f45dd5d9398.json b/rust/.sqlx/query-287521b40159e737315505b74bb01248900b5a59eebf04ff5ede8f45dd5d9398.json new file mode 100644 index 00000000..0fbd09a2 --- /dev/null +++ b/rust/.sqlx/query-287521b40159e737315505b74bb01248900b5a59eebf04ff5ede8f45dd5d9398.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT instance_id AS \"instance_id!: String\", group_id AS \"group_id!: String\",\n app_id AS \"app_id!: String\", version AS \"version!: i64\",\n bundle_sha256 AS \"bundle_sha256: String\",\n origin_token AS \"origin_token!: String\",\n summary AS \"summary: String\", document AS \"document: String\"\n FROM webxdc_instances WHERE instance_id = ?", + "describe": { + "columns": [ + { + "name": "instance_id!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "instance_id" + } + } + }, + { + "name": "group_id!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "group_id" + } + } + }, + { + "name": "app_id!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "app_id" + } + } + }, + { + "name": "version!: i64", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "version" + } + } + }, + { + "name": "bundle_sha256: String", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "bundle_sha256" + } + } + }, + { + "name": "origin_token!: String", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "origin_token" + } + } + }, + { + "name": "summary: String", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "summary" + } + } + }, + { + "name": "document: String", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_instances", + "name": "document" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "287521b40159e737315505b74bb01248900b5a59eebf04ff5ede8f45dd5d9398" +} diff --git a/rust/.sqlx/query-2f40db61376840cb99ba77cc908dc44463a335faef338601689ce148d48d1f5d.json b/rust/.sqlx/query-2f40db61376840cb99ba77cc908dc44463a335faef338601689ce148d48d1f5d.json new file mode 100644 index 00000000..e12bc15c --- /dev/null +++ b/rust/.sqlx/query-2f40db61376840cb99ba77cc908dc44463a335faef338601689ce148d48d1f5d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE receipts SET\n ack_by_server_at = CAST(strftime('%s', 'now') AS INTEGER),\n wake_receiver = 0,\n retry_count = retry_count + 1,\n last_retry = CAST(strftime('%s', 'now') AS INTEGER),\n mark_for_retry = NULL\n WHERE receipt_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2f40db61376840cb99ba77cc908dc44463a335faef338601689ce148d48d1f5d" +} diff --git a/rust/.sqlx/query-3c6a9504fe958ab1dfd2330078d761fe69934fb2118cf48ff845a3524c7ffead.json b/rust/.sqlx/query-3c6a9504fe958ab1dfd2330078d761fe69934fb2118cf48ff845a3524c7ffead.json new file mode 100644 index 00000000..9790c1c9 --- /dev/null +++ b/rust/.sqlx/query-3c6a9504fe958ab1dfd2330078d761fe69934fb2118cf48ff845a3524c7ffead.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE webxdc_apps SET published = 0", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "3c6a9504fe958ab1dfd2330078d761fe69934fb2118cf48ff845a3524c7ffead" +} diff --git a/rust/.sqlx/query-47006731399a7be81a9fff3982a6c56d77bbe74c67cba8ae457844de4c6237c4.json b/rust/.sqlx/query-47006731399a7be81a9fff3982a6c56d77bbe74c67cba8ae457844de4c6237c4.json new file mode 100644 index 00000000..535c35e5 --- /dev/null +++ b/rust/.sqlx/query-47006731399a7be81a9fff3982a6c56d77bbe74c67cba8ae457844de4c6237c4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE webxdc_instances SET version = ?, bundle_sha256 = ? WHERE instance_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "47006731399a7be81a9fff3982a6c56d77bbe74c67cba8ae457844de4c6237c4" +} diff --git a/rust/.sqlx/query-563a47296cffb2edfe6dd5c4b15be4ac1574be1610596b9f3fce0fee39761e87.json b/rust/.sqlx/query-563a47296cffb2edfe6dd5c4b15be4ac1574be1610596b9f3fce0fee39761e87.json new file mode 100644 index 00000000..feebf3b7 --- /dev/null +++ b/rust/.sqlx/query-563a47296cffb2edfe6dd5c4b15be4ac1574be1610596b9f3fce0fee39761e87.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) AS \"count!: i64\" FROM webxdc_updates\n WHERE instance_id = ? AND sender_id IS NULL AND received_at > ?", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "563a47296cffb2edfe6dd5c4b15be4ac1574be1610596b9f3fce0fee39761e87" +} diff --git a/rust/.sqlx/query-cf821c9b1530db447a1ba030c3be50c5eeeb78cb28fe74481d6493a6a0af4161.json b/rust/.sqlx/query-674895e43478d91c66caeb1cee349fd22ab22b783f8d985fe2ce92501503dc83.json similarity index 57% rename from rust/.sqlx/query-cf821c9b1530db447a1ba030c3be50c5eeeb78cb28fe74481d6493a6a0af4161.json rename to rust/.sqlx/query-674895e43478d91c66caeb1cee349fd22ab22b783f8d985fe2ce92501503dc83.json index 3251c7d7..634877f1 100644 --- a/rust/.sqlx/query-cf821c9b1530db447a1ba030c3be50c5eeeb78cb28fe74481d6493a6a0af4161.json +++ b/rust/.sqlx/query-674895e43478d91c66caeb1cee349fd22ab22b783f8d985fe2ce92501503dc83.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry\n FROM receipts WHERE contact_id = ? AND message_id = ?", + "query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry,\n MAX(ack_by_server_at) AS acknowledged\n FROM receipts WHERE contact_id = ? AND message_id = ?", "describe": { "columns": [ { @@ -14,6 +14,12 @@ "ordinal": 1, "type_info": "Integer", "origin": "Expression" + }, + { + "name": "acknowledged", + "ordinal": 2, + "type_info": "Integer", + "origin": "Expression" } ], "parameters": { @@ -21,8 +27,9 @@ }, "nullable": [ false, + true, true ] }, - "hash": "cf821c9b1530db447a1ba030c3be50c5eeeb78cb28fe74481d6493a6a0af4161" + "hash": "674895e43478d91c66caeb1cee349fd22ab22b783f8d985fe2ce92501503dc83" } diff --git a/rust/.sqlx/query-87e855a064b824324330f60b0fb64319eb8768e2c631f493a53f5b997a040761.json b/rust/.sqlx/query-87e855a064b824324330f60b0fb64319eb8768e2c631f493a53f5b997a040761.json new file mode 100644 index 00000000..719588b1 --- /dev/null +++ b/rust/.sqlx/query-87e855a064b824324330f60b0fb64319eb8768e2c631f493a53f5b997a040761.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT sender_id FROM messages WHERE message_id = ?", + "describe": { + "columns": [ + { + "name": "sender_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "messages", + "name": "sender_id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "87e855a064b824324330f60b0fb64319eb8768e2c631f493a53f5b997a040761" +} diff --git a/rust/.sqlx/query-8b5cf3984e457319657888e04e5fd3f287b1d8507c4d759c1fc97be01cb9872c.json b/rust/.sqlx/query-8b5cf3984e457319657888e04e5fd3f287b1d8507c4d759c1fc97be01cb9872c.json new file mode 100644 index 00000000..43ccbfce --- /dev/null +++ b/rust/.sqlx/query-8b5cf3984e457319657888e04e5fd3f287b1d8507c4d759c1fc97be01cb9872c.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT message_id AS \"message_id!: String\", content AS \"content: String\",\n additional_message_data AS \"data: Vec\", created_at AS \"created_at!: i64\"\n FROM messages WHERE type = 'text'", + "describe": { + "columns": [ + { + "name": "message_id!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "messages", + "name": "message_id" + } + } + }, + { + "name": "content: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "messages", + "name": "content" + } + } + }, + { + "name": "data: Vec", + "ordinal": 2, + "type_info": "Blob", + "origin": { + "Table": { + "table": "messages", + "name": "additional_message_data" + } + } + }, + { + "name": "created_at!: i64", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "messages", + "name": "created_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true, + true, + false + ] + }, + "hash": "8b5cf3984e457319657888e04e5fd3f287b1d8507c4d759c1fc97be01cb9872c" +} diff --git a/rust/.sqlx/query-8c0e68f56a7d625bfc87bb5991ac55a77dec6c9192bcd72c3f02858fa2b23657.json b/rust/.sqlx/query-8c0e68f56a7d625bfc87bb5991ac55a77dec6c9192bcd72c3f02858fa2b23657.json new file mode 100644 index 00000000..b5be75c6 --- /dev/null +++ b/rust/.sqlx/query-8c0e68f56a7d625bfc87bb5991ac55a77dec6c9192bcd72c3f02858fa2b23657.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM webxdc_updates WHERE instance_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "8c0e68f56a7d625bfc87bb5991ac55a77dec6c9192bcd72c3f02858fa2b23657" +} diff --git a/rust/.sqlx/query-8f5e08747a47ed6707dddc09aeb3f103dbe5ea938cc6454b64d1b3e67ab9f772.json b/rust/.sqlx/query-8f5e08747a47ed6707dddc09aeb3f103dbe5ea938cc6454b64d1b3e67ab9f772.json new file mode 100644 index 00000000..bac09cfd --- /dev/null +++ b/rust/.sqlx/query-8f5e08747a47ed6707dddc09aeb3f103dbe5ea938cc6454b64d1b3e67ab9f772.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO groups(group_id, group_name) VALUES ('group-1', 'Chat')", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "8f5e08747a47ed6707dddc09aeb3f103dbe5ea938cc6454b64d1b3e67ab9f772" +} diff --git a/rust/.sqlx/query-90ff8eee4b84514f67c14673e986b1f15d3b580adca9adfe27b507cd0f046164.json b/rust/.sqlx/query-90ff8eee4b84514f67c14673e986b1f15d3b580adca9adfe27b507cd0f046164.json new file mode 100644 index 00000000..e3754d4e --- /dev/null +++ b/rust/.sqlx/query-90ff8eee4b84514f67c14673e986b1f15d3b580adca9adfe27b507cd0f046164.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT version AS \"version!: i64\", bundle_sha256 AS \"bundle_sha256!: String\"\n FROM webxdc_apps\n WHERE app_id = ? AND published = 1\n ORDER BY version DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "version!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "version" + } + } + }, + { + "name": "bundle_sha256!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "bundle_sha256" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "90ff8eee4b84514f67c14673e986b1f15d3b580adca9adfe27b507cd0f046164" +} diff --git a/rust/.sqlx/query-9b2c09fdedde17a608aac9b80d6fc3eee919224d56a6d3232c44de29d51eb2cb.json b/rust/.sqlx/query-9b2c09fdedde17a608aac9b80d6fc3eee919224d56a6d3232c44de29d51eb2cb.json new file mode 100644 index 00000000..bdd740d8 --- /dev/null +++ b/rust/.sqlx/query-9b2c09fdedde17a608aac9b80d6fc3eee919224d56a6d3232c44de29d51eb2cb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO messages(group_id, message_id, type, created_at)\n VALUES ('group-1', 'card-1', 'webxdcApp', 0)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "9b2c09fdedde17a608aac9b80d6fc3eee919224d56a6d3232c44de29d51eb2cb" +} diff --git a/rust/.sqlx/query-a0236f0973f6b96b9546e53df8619e553fa4d20a474dc862c3f9c1d518ec2397.json b/rust/.sqlx/query-a0236f0973f6b96b9546e53df8619e553fa4d20a474dc862c3f9c1d518ec2397.json new file mode 100644 index 00000000..e2dbc33a --- /dev/null +++ b/rust/.sqlx/query-a0236f0973f6b96b9546e53df8619e553fa4d20a474dc862c3f9c1d518ec2397.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COALESCE(MAX(serial), 0) + 1 AS \"next!: i64\"\n FROM webxdc_updates WHERE instance_id = ?", + "describe": { + "columns": [ + { + "name": "next!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "a0236f0973f6b96b9546e53df8619e553fa4d20a474dc862c3f9c1d518ec2397" +} diff --git a/rust/.sqlx/query-a2b3853b9a075ef533e61c0630b868d0ac30145736156003e223d3ab54830fa6.json b/rust/.sqlx/query-a2b3853b9a075ef533e61c0630b868d0ac30145736156003e223d3ab54830fa6.json new file mode 100644 index 00000000..ab82e95f --- /dev/null +++ b/rust/.sqlx/query-a2b3853b9a075ef533e61c0630b868d0ac30145736156003e223d3ab54830fa6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM webxdc_apps\n WHERE NOT EXISTS (SELECT 1 FROM webxdc_instances\n WHERE webxdc_instances.app_id = webxdc_apps.app_id\n AND webxdc_instances.version = webxdc_apps.version)", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "a2b3853b9a075ef533e61c0630b868d0ac30145736156003e223d3ab54830fa6" +} diff --git a/rust/.sqlx/query-a2e8523eb0be989585370d3d8b1d9a5586a1f75b84a609c61fd2f18faa67832f.json b/rust/.sqlx/query-a2e8523eb0be989585370d3d8b1d9a5586a1f75b84a609c61fd2f18faa67832f.json deleted file mode 100644 index 89ec1a50..00000000 --- a/rust/.sqlx/query-a2e8523eb0be989585370d3d8b1d9a5586a1f75b84a609c61fd2f18faa67832f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE receipts SET\n ack_by_server_at = CAST(strftime('%s', 'now') AS INTEGER),\n retry_count = retry_count + 1,\n last_retry = CAST(strftime('%s', 'now') AS INTEGER),\n mark_for_retry = NULL\n WHERE receipt_id = ?\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "a2e8523eb0be989585370d3d8b1d9a5586a1f75b84a609c61fd2f18faa67832f" -} diff --git a/rust/.sqlx/query-b71a58e0499f8b69debddba47cf1ec86c4b99309441a4506e998beedfcead834.json b/rust/.sqlx/query-b71a58e0499f8b69debddba47cf1ec86c4b99309441a4506e998beedfcead834.json new file mode 100644 index 00000000..b18b769a --- /dev/null +++ b/rust/.sqlx/query-b71a58e0499f8b69debddba47cf1ec86c4b99309441a4506e998beedfcead834.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "SELECT app_id AS \"app_id!: String\", version AS \"version!: i64\",\n name AS \"name!: String\",\n name_translations AS \"name_translations!: String\",\n source_code_url AS \"source_code_url: String\",\n description AS \"description!: String\",\n icon AS \"icon: Vec\", bundle_bytes AS \"bundle_bytes!: i64\"\n FROM webxdc_apps\n WHERE published = 1\n AND version = (SELECT MAX(version) FROM webxdc_apps AS newer\n WHERE newer.app_id = webxdc_apps.app_id AND newer.published = 1)\n ORDER BY name ASC", + "describe": { + "columns": [ + { + "name": "app_id!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "app_id" + } + } + }, + { + "name": "version!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "version" + } + } + }, + { + "name": "name!: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "name" + } + } + }, + { + "name": "name_translations!: String", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "name_translations" + } + } + }, + { + "name": "source_code_url: String", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "source_code_url" + } + } + }, + { + "name": "description!: String", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "description" + } + } + }, + { + "name": "icon: Vec", + "ordinal": 6, + "type_info": "Blob", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "icon" + } + } + }, + { + "name": "bundle_bytes!: i64", + "ordinal": 7, + "type_info": "Integer", + "origin": { + "Table": { + "table": "webxdc_apps", + "name": "bundle_bytes" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + false + ] + }, + "hash": "b71a58e0499f8b69debddba47cf1ec86c4b99309441a4506e998beedfcead834" +} diff --git a/rust/.sqlx/query-c6818f3b395db59a7fbe80435c4ef89416ff0cfb32139abff226eec319053a68.json b/rust/.sqlx/query-c6818f3b395db59a7fbe80435c4ef89416ff0cfb32139abff226eec319053a68.json deleted file mode 100644 index 7f8c2fe2..00000000 --- a/rust/.sqlx/query-c6818f3b395db59a7fbe80435c4ef89416ff0cfb32139abff226eec319053a68.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO messages(group_id, message_id, type, content, quotes_message_id, created_at)\n VALUES (?, ?, 'text', ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "c6818f3b395db59a7fbe80435c4ef89416ff0cfb32139abff226eec319053a68" -} diff --git a/rust/.sqlx/query-cc6625aa285f9e6dcdc1a73d7bfb6ad23cff6c8932f61b48645c431dd7adaf48.json b/rust/.sqlx/query-cc6625aa285f9e6dcdc1a73d7bfb6ad23cff6c8932f61b48645c431dd7adaf48.json new file mode 100644 index 00000000..8ae27d71 --- /dev/null +++ b/rust/.sqlx/query-cc6625aa285f9e6dcdc1a73d7bfb6ad23cff6c8932f61b48645c431dd7adaf48.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO webxdc_apps\n (app_id, version, name, name_translations, source_code_url,\n description, icon, bundle_sha256, bundle_bytes, published,\n cached_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)\n ON CONFLICT(app_id, version) DO UPDATE SET\n name = excluded.name,\n name_translations = excluded.name_translations,\n source_code_url = excluded.source_code_url,\n description = excluded.description,\n icon = excluded.icon,\n bundle_sha256 = excluded.bundle_sha256,\n bundle_bytes = excluded.bundle_bytes,\n published = 1,\n cached_at = excluded.cached_at", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "cc6625aa285f9e6dcdc1a73d7bfb6ad23cff6c8932f61b48645c431dd7adaf48" +} diff --git a/rust/.sqlx/query-cfa0b29edf334cdec34ade89ff33e9ad70f36f2616333a71d69bc5cc7a2781e3.json b/rust/.sqlx/query-cfa0b29edf334cdec34ade89ff33e9ad70f36f2616333a71d69bc5cc7a2781e3.json new file mode 100644 index 00000000..0cce3cd6 --- /dev/null +++ b/rust/.sqlx/query-cfa0b29edf334cdec34ade89ff33e9ad70f36f2616333a71d69bc5cc7a2781e3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM webxdc_instances WHERE instance_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "cfa0b29edf334cdec34ade89ff33e9ad70f36f2616333a71d69bc5cc7a2781e3" +} diff --git a/rust/.sqlx/query-fedeeeaee2ad31906726430055edac23359bb61ff80d53632c95b40837d504f3.json b/rust/.sqlx/query-cffb25fc9ca6a574dbafd9a4f661b64e9e55d162dd5b8b3b3791c82150e4a73f.json similarity index 51% rename from rust/.sqlx/query-fedeeeaee2ad31906726430055edac23359bb61ff80d53632c95b40837d504f3.json rename to rust/.sqlx/query-cffb25fc9ca6a574dbafd9a4f661b64e9e55d162dd5b8b3b3791c82150e4a73f.json index e4a07001..d96a8e93 100644 --- a/rust/.sqlx/query-fedeeeaee2ad31906726430055edac23359bb61ff80d53632c95b40837d504f3.json +++ b/rust/.sqlx/query-cffb25fc9ca6a574dbafd9a4f661b64e9e55d162dd5b8b3b3791c82150e4a73f.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ? WHERE receipt_id = ?", + "query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ?,\n wake_receiver = CASE WHEN ? THEN 0 ELSE wake_receiver END\n WHERE receipt_id = ?", "describe": { "columns": [], "parameters": { - "Right": 5 + "Right": 6 }, "nullable": [] }, - "hash": "fedeeeaee2ad31906726430055edac23359bb61ff80d53632c95b40837d504f3" + "hash": "cffb25fc9ca6a574dbafd9a4f661b64e9e55d162dd5b8b3b3791c82150e4a73f" } diff --git a/rust/.sqlx/query-d2977db7648dfba60704ffa01ecf94add71edc00fe4625dc143948b7cb76daf2.json b/rust/.sqlx/query-d2977db7648dfba60704ffa01ecf94add71edc00fe4625dc143948b7cb76daf2.json new file mode 100644 index 00000000..486e4cc1 --- /dev/null +++ b/rust/.sqlx/query-d2977db7648dfba60704ffa01ecf94add71edc00fe4625dc143948b7cb76daf2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO webxdc_updates\n (instance_id, serial, message_id, sender_id, payload, info, href, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(message_id) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "d2977db7648dfba60704ffa01ecf94add71edc00fe4625dc143948b7cb76daf2" +} diff --git a/rust/.sqlx/query-d90e5fe15c88c30f9f4ffc90fd87ff27ca739f11202c2ea91e429bb8598ce928.json b/rust/.sqlx/query-d90e5fe15c88c30f9f4ffc90fd87ff27ca739f11202c2ea91e429bb8598ce928.json new file mode 100644 index 00000000..7faa77c3 --- /dev/null +++ b/rust/.sqlx/query-d90e5fe15c88c30f9f4ffc90fd87ff27ca739f11202c2ea91e429bb8598ce928.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE webxdc_instances\n SET summary = COALESCE(?, summary),\n document = COALESCE(?, document),\n last_update_at = ?\n WHERE instance_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "d90e5fe15c88c30f9f4ffc90fd87ff27ca739f11202c2ea91e429bb8598ce928" +} diff --git a/rust/.sqlx/query-732496b1ad12c49c3aca6925d45abc4c801838e606d9bfd3f728f4e1dd91dd3a.json b/rust/.sqlx/query-e1154392dd25c96e3ac0e5dd3c5a2805fd8ebaaf5400b87b73b4439e43080c19.json similarity index 54% rename from rust/.sqlx/query-732496b1ad12c49c3aca6925d45abc4c801838e606d9bfd3f728f4e1dd91dd3a.json rename to rust/.sqlx/query-e1154392dd25c96e3ac0e5dd3c5a2805fd8ebaaf5400b87b73b4439e43080c19.json index 8155b131..f17442b1 100644 --- a/rust/.sqlx/query-732496b1ad12c49c3aca6925d45abc4c801838e606d9bfd3f728f4e1dd91dd3a.json +++ b/rust/.sqlx/query-e1154392dd25c96e3ac0e5dd3c5a2805fd8ebaaf5400b87b73b4439e43080c19.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ? WHERE receipt_id = ?", + "query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ?,\n wake_receiver = CASE WHEN ? THEN 0 ELSE wake_receiver END\n WHERE receipt_id = ?", "describe": { "columns": [], "parameters": { - "Right": 5 + "Right": 6 }, "nullable": [] }, - "hash": "732496b1ad12c49c3aca6925d45abc4c801838e606d9bfd3f728f4e1dd91dd3a" + "hash": "e1154392dd25c96e3ac0e5dd3c5a2805fd8ebaaf5400b87b73b4439e43080c19" } diff --git a/rust/.sqlx/query-7ba6ad25a53db6d9ee725ad8dd06a58c119e4f445edf966b97016fb6d598f09a.json b/rust/.sqlx/query-e6a42d65c955aa13817fd01b4be533c53e52f20e6deca7054fd1161277a9e27e.json similarity index 55% rename from rust/.sqlx/query-7ba6ad25a53db6d9ee725ad8dd06a58c119e4f445edf966b97016fb6d598f09a.json rename to rust/.sqlx/query-e6a42d65c955aa13817fd01b4be533c53e52f20e6deca7054fd1161277a9e27e.json index 0703e471..6b17b212 100644 --- a/rust/.sqlx/query-7ba6ad25a53db6d9ee725ad8dd06a58c119e4f445edf966b97016fb6d598f09a.json +++ b/rust/.sqlx/query-e6a42d65c955aa13817fd01b4be533c53e52f20e6deca7054fd1161277a9e27e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO messages(group_id, message_id, type, additional_message_data, created_at)\n VALUES (?, ?, ?, ?, ?)", + "query": "INSERT INTO messages(group_id, message_id, type, additional_message_data, created_at)\n VALUES (?, ?, ?, ?, ?)", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "7ba6ad25a53db6d9ee725ad8dd06a58c119e4f445edf966b97016fb6d598f09a" + "hash": "e6a42d65c955aa13817fd01b4be533c53e52f20e6deca7054fd1161277a9e27e" } diff --git a/rust/.sqlx/query-4ad8ea916d6113c45fef05150f2e619e06ac351ac0b7d0a9b340835ac7109fdf.json b/rust/.sqlx/query-eac6f09e21a49428007bb59568259c6163d5ba4daeced9f699aa6a9d9b15b1d3.json similarity index 55% rename from rust/.sqlx/query-4ad8ea916d6113c45fef05150f2e619e06ac351ac0b7d0a9b340835ac7109fdf.json rename to rust/.sqlx/query-eac6f09e21a49428007bb59568259c6163d5ba4daeced9f699aa6a9d9b15b1d3.json index c41836fe..9f372731 100644 --- a/rust/.sqlx/query-4ad8ea916d6113c45fef05150f2e619e06ac351ac0b7d0a9b340835ac7109fdf.json +++ b/rust/.sqlx/query-eac6f09e21a49428007bb59568259c6163d5ba4daeced9f699aa6a9d9b15b1d3.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry\n FROM receipts WHERE contact_id = ? AND message_id = ?", + "query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry,\n MAX(ack_by_server_at) AS acknowledged\n FROM receipts WHERE contact_id = ? AND message_id = ?", "describe": { "columns": [ { @@ -14,6 +14,12 @@ "ordinal": 1, "type_info": "Integer", "origin": "Expression" + }, + { + "name": "acknowledged", + "ordinal": 2, + "type_info": "Integer", + "origin": "Expression" } ], "parameters": { @@ -21,8 +27,9 @@ }, "nullable": [ false, + true, true ] }, - "hash": "4ad8ea916d6113c45fef05150f2e619e06ac351ac0b7d0a9b340835ac7109fdf" + "hash": "eac6f09e21a49428007bb59568259c6163d5ba4daeced9f699aa6a9d9b15b1d3" } diff --git a/rust/.sqlx/query-f436f2290486b44d631dd516159c8a0e8a8b054c1aac8ad2526694a3148fac04.json b/rust/.sqlx/query-f436f2290486b44d631dd516159c8a0e8a8b054c1aac8ad2526694a3148fac04.json new file mode 100644 index 00000000..61bff26a --- /dev/null +++ b/rust/.sqlx/query-f436f2290486b44d631dd516159c8a0e8a8b054c1aac8ad2526694a3148fac04.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT serial AS \"serial!: i64\", payload AS \"payload!: String\",\n info AS \"info: String\", href AS \"href: String\",\n sender_id AS \"sender_id: i64\"\n FROM webxdc_updates\n WHERE instance_id = ? AND serial > ?\n ORDER BY serial ASC", + "describe": { + "columns": [ + { + "name": "serial!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "webxdc_updates", + "name": "serial" + } + } + }, + { + "name": "payload!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_updates", + "name": "payload" + } + } + }, + { + "name": "info: String", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_updates", + "name": "info" + } + } + }, + { + "name": "href: String", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "webxdc_updates", + "name": "href" + } + } + }, + { + "name": "sender_id: i64", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "webxdc_updates", + "name": "sender_id" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + true, + true, + true + ] + }, + "hash": "f436f2290486b44d631dd516159c8a0e8a8b054c1aac8ad2526694a3148fac04" +} diff --git a/rust/.sqlx/query-f8f16df05f05636feb28a5c783ad930a8e009746f50f69ac68bd2ff7d24858ef.json b/rust/.sqlx/query-f8f16df05f05636feb28a5c783ad930a8e009746f50f69ac68bd2ff7d24858ef.json new file mode 100644 index 00000000..62b72827 --- /dev/null +++ b/rust/.sqlx/query-f8f16df05f05636feb28a5c783ad930a8e009746f50f69ac68bd2ff7d24858ef.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO messages(group_id, message_id, type, content, quotes_message_id,\n additional_message_data, created_at)\n VALUES (?, ?, 'text', ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "f8f16df05f05636feb28a5c783ad930a8e009746f50f69ac68bd2ff7d24858ef" +} diff --git a/rust/.sqlx/query-f98e1d298509f8febb8e8bd00344d2dee95adc3d9a9f3d023f7d7673d31ba039.json b/rust/.sqlx/query-f98e1d298509f8febb8e8bd00344d2dee95adc3d9a9f3d023f7d7673d31ba039.json new file mode 100644 index 00000000..5a1df186 --- /dev/null +++ b/rust/.sqlx/query-f98e1d298509f8febb8e8bd00344d2dee95adc3d9a9f3d023f7d7673d31ba039.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) AS \"count!: i64\" FROM webxdc_instances WHERE bundle_sha256 = ?", + "describe": { + "columns": [ + { + "name": "count!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "f98e1d298509f8febb8e8bd00344d2dee95adc3d9a9f3d023f7d7673d31ba039" +} diff --git a/rust/src/api/groups.rs b/rust/src/api/groups.rs index 7f01ae69..c75a335f 100644 --- a/rust/src/api/groups.rs +++ b/rust/src/api/groups.rs @@ -145,6 +145,11 @@ fn client() -> Result { async fn success(response: reqwest::Response) -> Result<()> { if response.status().is_success() { Ok(()) + } else if response.status() == reqwest::StatusCode::CONFLICT { + // The one status a caller can act on: the state moved while the + // request was being prepared, and re-applying the change to the new + // version is all that is needed. + Err(TwonlyError::GroupStateConflict) } else { Err(TwonlyError::Generic(format!( "group server returned {}", diff --git a/rust/src/api/messages/incoming/additional_data.rs b/rust/src/api/messages/incoming/additional_data.rs index 650e8f18..46a4590d 100644 --- a/rust/src/api/messages/incoming/additional_data.rs +++ b/rust/src/api/messages/incoming/additional_data.rs @@ -11,6 +11,7 @@ use crate::database::app::tables::Message; use crate::database::app::tables::NewKeyVerification; use crate::database::app::tables::{MessageType, NewMessage}; use crate::error::Result; +use crate::services::webxdc::WebxdcService; use crate::utils::milliseconds_to_seconds; use prost::Message as ProstMessage; use sqlx::{Sqlite, Transaction}; @@ -25,14 +26,46 @@ pub(crate) async fn handle_additional_data_message( Message::check_message_owner(tr, &message.sender_message_id, from_user_id).await?; let timestamp = milliseconds_to_seconds(message.timestamp); - if let Some(data) = message.additional_message_data.as_deref() { - // Additional metadata is optional. Keep accepting the containing - // message if it is malformed or contact verification cannot complete. - if let Err(error) = verify_shared_contacts(ctx, tr, from_user_id, data).await { + // Additional metadata is optional. Keep accepting the containing message if + // it is malformed or a handler cannot complete. + let data = match message.additional_message_data.as_deref() { + Some(bytes) => match proto::AdditionalMessageData::decode(bytes) { + Ok(data) => Some(data), + Err(error) => { + tracing::warn!("failed to decode additional message data: {error}"); + None + } + }, + None => None, + }; + + if let Some(data) = data.as_ref() { + if let Some(update) = data.webxdc_update.as_ref() { + // App state belongs in the instance's own log, which is ordered, + // gap free, and outside the reach of the chat's deletion timer. + WebxdcService::handle_incoming_update( + tr, + &message.sender_message_id, + group_id, + from_user_id, + update, + timestamp, + ) + .await?; + } else if let Err(error) = verify_shared_contacts(ctx, tr, from_user_id, data).await { tracing::warn!("failed to handle additional message data: {error}"); } } + // A hidden message carries state a feature exchanges rather than something + // a person sent, so it leaves no trace in the chat: no row means it cannot + // be rendered, quoted, deleted, or swept up by the deletion timer, and the + // chat's last exchange is left alone so a running app cannot keep a streak + // alive on its own. + if message.hidden { + return Ok(()); + } + NewMessage::builder() .group_id(group_id) .message_id(&message.sender_message_id) @@ -45,6 +78,12 @@ pub(crate) async fn handle_additional_data_message( .insert(tr) .await?; + if let Some(app) = data.as_ref().and_then(|data| data.webxdc_app.as_ref()) { + // Recorded only once the card exists: the instance is keyed by that + // message and cascades from it. + WebxdcService::handle_incoming_app(tr, &message.sender_message_id, group_id, app).await?; + } + Group::increase_last_message_exchange(tr, group_id, timestamp).await?; Ok(()) @@ -54,17 +93,15 @@ async fn verify_shared_contacts( ctx: &Context, tr: &mut Transaction<'_, Sqlite>, from_user_id: i64, - bytes: &[u8], + data: &proto::AdditionalMessageData, ) -> Result<()> { - let data = proto::AdditionalMessageData::decode(bytes)?; - if data.r#type != proto::additional_message_data::Type::Contacts as i32 { return Ok(()); } let signal_database = ctx.rust_db.read().await.clone(); - for contact in data.contacts { + for contact in &data.contacts { if contact.public_identity_key.is_empty() { tracing::info!("shared contact carries no public key, skipping verification"); continue; diff --git a/rust/src/api/messages/incoming/messages.rs b/rust/src/api/messages/incoming/messages.rs index f4e2c196..dfea773d 100644 --- a/rust/src/api/messages/incoming/messages.rs +++ b/rust/src/api/messages/incoming/messages.rs @@ -15,27 +15,31 @@ use crate::utils::new_uuid_v4; use prost::Message as ProstMessage; use proto::encrypted_content::error_messages::Type; use sqlx::{Sqlite, Transaction}; -use std::sync::{Arc, LazyLock, Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError}; #[cfg(not(debug_assertions))] use std::collections::HashMap; +#[cfg(not(debug_assertions))] +use std::sync::LazyLock; #[cfg(not(debug_assertions))] static ALREADY_QUEUED_RECEIPTS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); -/// Coalesces overlapping flushes of the receipt queue. +/// Coalesces overlapping flushes of one account's receipt queue. /// /// Every committed inbound message asks for a flush, and a flush scans the /// whole queue, so one mailbox page would otherwise start as many identical /// scans as it carries messages β€” all of them competing for the single /// app-database connection. A request that arrives while a flush runs only /// marks it dirty, so the running flush queries once more before it returns. -static QUEUED_RECEIPT_FLUSH: LazyLock> = - LazyLock::new(|| Mutex::new(FlushClaim::default())); - +/// +/// It lives on the [`Context`] rather than in a static because a flush is +/// bound to the database it scans: a process running two accounts would +/// otherwise let one account's flush turn the other's into a no-op, and the +/// dirty flag would buy the wrong queue a second pass. #[derive(Default)] -struct FlushClaim { +pub(crate) struct FlushClaim { running: bool, dirty: bool, } @@ -43,24 +47,27 @@ struct FlushClaim { /// Held for as long as a flush owns the claim. Dropping it without releasing /// it β€” an error or a panic on the way out β€” frees the claim for the next /// caller instead of blocking every later flush. -struct FlushGuard { +struct FlushGuard<'a> { + claim: &'a Mutex, released: bool, } -impl FlushGuard { +impl<'a> FlushGuard<'a> { /// Registers a flush for this caller. Returns `None` when one is already /// running, in which case it is marked dirty and will query once more. - fn claim() -> Option { - let mut claim = QUEUED_RECEIPT_FLUSH - .lock() - .unwrap_or_else(PoisonError::into_inner); + fn claim(state: &'a Mutex) -> Option { + let mut claim = state.lock().unwrap_or_else(PoisonError::into_inner); if claim.running { claim.dirty = true; return None; } claim.running = true; claim.dirty = false; - Some(Self { released: false }) + drop(claim); + Some(Self { + claim: state, + released: false, + }) } /// Ends the flush when nothing asked for another one while it ran, @@ -68,9 +75,7 @@ impl FlushGuard { /// Both happen under one lock so a request cannot be dropped between the /// last query and the release. fn release_or_take_dirty(&mut self) -> bool { - let mut claim = QUEUED_RECEIPT_FLUSH - .lock() - .unwrap_or_else(PoisonError::into_inner); + let mut claim = self.claim.lock().unwrap_or_else(PoisonError::into_inner); if claim.dirty { claim.dirty = false; return true; @@ -81,12 +86,12 @@ impl FlushGuard { } } -impl Drop for FlushGuard { +impl Drop for FlushGuard<'_> { fn drop(&mut self) { if self.released { return; } - QUEUED_RECEIPT_FLUSH + self.claim .lock() .unwrap_or_else(PoisonError::into_inner) .running = false; @@ -101,7 +106,8 @@ pub(crate) async fn queue_encrypted_content( ) -> Result { Contact::ensure_exists(t, target_user_id).await?; - let wake_receiver = crate::services::notifications::should_wake_receiver(&content); + let wake_receiver = + crate::services::notifications::should_wake_receiver(t, target_user_id, &content).await?; let message = proto::Message { r#type: proto::message::Type::CiphertextV2 as i32, @@ -584,10 +590,20 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc, receipt_id: &str) -> if receipt.contact_will_sends_receipt == 0 { Receipt::delete(&mut *t, receipt_id).await?; } else { + // The wake is spent the moment the server accepts the envelope, so it + // is cleared here alongside the acknowledgement that records it. + // + // This receipt outlives its delivery -- it is kept until the peer's own + // receipt comes back -- and every inbound message from that peer marks + // it for retry again. Without this, one message would push the peer + // once per retransmission: a chat busy enough to keep retrying, a game + // exchanging updates for instance, would alert them over and over for a + // message they were told about the first time and already have. sqlx::query!( r#" UPDATE receipts SET ack_by_server_at = CAST(strftime('%s', 'now') AS INTEGER), + wake_receiver = 0, retry_count = retry_count + 1, last_retry = CAST(strftime('%s', 'now') AS INTEGER), mark_for_retry = NULL @@ -643,7 +659,7 @@ pub(crate) async fn release_deferred_receipts( } pub async fn retransmit_queued_receipts(ctx: &Arc) -> Result<()> { - let Some(mut guard) = FlushGuard::claim() else { + let Some(mut guard) = FlushGuard::claim(&ctx.queued_receipt_flush) else { return Ok(()); }; @@ -789,8 +805,9 @@ mod tests { #[test] fn a_second_flush_request_marks_the_running_one_dirty() { - let mut guard = FlushGuard::claim().expect("nothing else holds the claim"); - assert!(FlushGuard::claim().is_none()); + let state = Mutex::new(FlushClaim::default()); + let mut guard = FlushGuard::claim(&state).expect("nothing else holds the claim"); + assert!(FlushGuard::claim(&state).is_none()); // The running flush sees the dirty flag and keeps its claim. assert!(guard.release_or_take_dirty()); @@ -799,8 +816,19 @@ mod tests { // Dropping a guard that never released frees the claim too, so an // error on the way out cannot block every later flush. - let guard = FlushGuard::claim().expect("the claim was released"); + let guard = FlushGuard::claim(&state).expect("the claim was released"); drop(guard); - assert!(FlushGuard::claim().is_some()); + assert!(FlushGuard::claim(&state).is_some()); + } + + /// Two accounts in one process each flush their own queue. A claim held + /// for one must not turn the other's flush into a no-op. + #[test] + fn each_account_holds_its_own_flush_claim() { + let first = Mutex::new(FlushClaim::default()); + let second = Mutex::new(FlushClaim::default()); + + let _held = FlushGuard::claim(&first).expect("nothing else holds the claim"); + assert!(FlushGuard::claim(&second).is_some()); } } diff --git a/rust/src/api/messages/incoming/text_message.rs b/rust/src/api/messages/incoming/text_message.rs index b4e0b808..c50f91e6 100644 --- a/rust/src/api/messages/incoming/text_message.rs +++ b/rust/src/api/messages/incoming/text_message.rs @@ -30,6 +30,7 @@ pub(crate) async fn handle_text_message( .sender_id(from_user_id) .content(&message.text) .maybe_quotes_message_id(message.quote_message_id.as_deref()) + .maybe_additional_message_data(message.additional_message_data.as_deref()) .ack_by_server(current_time().timestamp()) .build() .insert(t) diff --git a/rust/src/api/messages/outgoing.rs b/rust/src/api/messages/outgoing.rs index dc42ae25..467220fe 100644 --- a/rust/src/api/messages/outgoing.rs +++ b/rust/src/api/messages/outgoing.rs @@ -10,10 +10,43 @@ use crate::context::Context; use crate::error::Result; use crate::user_config::UserConfig; use prost::Message as _; +use sqlx::{Sqlite, Transaction}; use std::sync::Arc; +/// Brings user discovery up before an envelope asks it for a version. +/// +/// Initializing it builds the share set, which writes through the app database +/// on a connection of its own. The pool has exactly one, so this has to happen +/// before the caller opens the transaction [`decorate_content`] then runs on. +pub(crate) async fn prepare_user_discovery( + ctx: &Context, + is_persisted_message: bool, +) -> Result<()> { + if !is_persisted_message { + return Ok(()); + } + let Some(config) = UserConfig::load_from(ctx)? else { + return Ok(()); + }; + if config.is_user_discovery_enabled { + ctx.initialize_user_discovery_from_config().await?; + } + Ok(()) +} + +/// Fills in the metadata every outgoing envelope carries beside its payload. +/// +/// The reads run on the caller's transaction rather than on the pool: the app +/// database has a single connection, so asking the pool for one here while the +/// caller holds that connection open waits for the caller to finish and +/// deadlocks until the acquire times out thirty seconds later -- with every +/// other database user in the process blocked behind it. +/// +/// A caller that passes `is_persisted_message` must have called +/// [`prepare_user_discovery`] before opening `t`. pub(crate) async fn decorate_content( ctx: &Context, + t: &mut Transaction<'_, Sqlite>, contact_id: i64, content: &mut proto::EncryptedContent, is_persisted_message: bool, @@ -23,20 +56,19 @@ pub(crate) async fn decorate_content( }; content.sender_profile_counter = Some(config.avatar_counter); - let database = ctx.app_db.read().await.clone(); content.widget_sharing_allowed = Some( sqlx::query_scalar!( "SELECT widget_sharing_granted FROM contacts WHERE user_id = ?", contact_id, ) - .fetch_optional(&database.pool) + .fetch_optional(&mut **t) .await? .unwrap_or(0) != 0, ); if config.ask_for_friend_promotions { let accepted = sqlx::query_scalar!("SELECT COUNT(*) FROM contacts WHERE accepted = 1") - .fetch_one(&database.pool) + .fetch_one(&mut **t) .await?; if accepted <= 5 { content.ask_for_friend_promotions = Some(true); @@ -44,8 +76,6 @@ pub(crate) async fn decorate_content( } if config.is_user_discovery_enabled & is_persisted_message { - ctx.initialize_user_discovery_from_config().await?; - let database = ctx.app_db.read().await.clone(); let allowed = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM contacts WHERE user_id = ? AND accepted = 1 AND blocked = 0 AND media_send_counter >= ? AND user_discovery_excluded = 0 @@ -54,7 +84,7 @@ pub(crate) async fn decorate_content( config.required_send_images, config.user_discovery_requires_manual_approval, ) - .fetch_one(&database.pool) + .fetch_one(&mut **t) .await?; if allowed != 0 { content.sender_user_discovery_version = @@ -76,11 +106,13 @@ pub async fn send_c2c_message_to_contact( ) -> Result>> { let mut content = proto::EncryptedContent::decode(encrypted_content.as_slice())?; - decorate_content(ctx, contact_id, &mut content, message_id.is_some()).await?; + prepare_user_discovery(ctx, message_id.is_some()).await?; let db_app = ctx.app_db.read().await.clone(); let mut t = db_app.pool.begin().await?; + decorate_content(ctx, &mut t, contact_id, &mut content, message_id.is_some()).await?; + if only_send_if_no_receipts_are_open { let count = sqlx::query_scalar!( "SELECT COUNT(*) FROM receipts WHERE contact_id = ?", @@ -95,9 +127,14 @@ pub async fn send_c2c_message_to_contact( let mut retry_count = 0_i64; let mut last_retry = None; + // A resend of a message the server already took has already pushed the + // recipient once. The replacement receipt must not ask for a second alert + // about the same message. + let mut already_woken = false; if let Some(message_id) = &message_id { let previous = sqlx::query!( - r#"SELECT COUNT(*) AS "count!: i64", MAX(last_retry) AS last_retry + r#"SELECT COUNT(*) AS "count!: i64", MAX(last_retry) AS last_retry, + MAX(ack_by_server_at) AS acknowledged FROM receipts WHERE contact_id = ? AND message_id = ?"#, contact_id, message_id, @@ -106,6 +143,7 @@ pub async fn send_c2c_message_to_contact( .await?; retry_count = previous.count; last_retry = previous.last_retry; + already_woken = previous.acknowledged.is_some(); sqlx::query!( "DELETE FROM receipts WHERE contact_id = ? AND message_id = ?", contact_id, @@ -128,11 +166,14 @@ pub async fn send_c2c_message_to_contact( sqlx::query!( r#"UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?, - retry_count = ?, last_retry = ? WHERE receipt_id = ?"#, + retry_count = ?, last_retry = ?, + wake_receiver = CASE WHEN ? THEN 0 ELSE wake_receiver END + WHERE receipt_id = ?"#, message_id, only_return_encrypted_data, retry_count, last_retry, + already_woken, receipt_id ) .execute(&mut *t) diff --git a/rust/src/api/proto/api/http/http_requests.proto b/rust/src/api/proto/api/http/http_requests.proto index 2dd00075..34f78208 100644 --- a/rust/src/api/proto/api/http/http_requests.proto +++ b/rust/src/api/proto/api/http/http_requests.proto @@ -111,3 +111,30 @@ message GroupState { bytes encrypted_group_state = 2; repeated AppendGroupState appended_group_states = 3; } + +// One published webxdc app, as the store advertises it. `bundle_sha256` is the +// hash of the .xdc the blob endpoint serves; the client verifies it before the +// zip is opened, and pins it per instance until the app is next started. +// +// An app that is unpublished simply stops appearing here. Clients keep the +// bundles they already downloaded, so an absence is not a signal to stop. +message WebxdcCatalogEntry { + string app_id = 1; + int64 version = 2; + string name = 3; + optional string source_code_url = 4; + string bundle_sha256 = 5; + int64 bundle_bytes = 6; + bytes icon = 8; + // One short line per language, keyed by language tag (`en`, `de`, `pt-br`). + // Every client gets every translation and picks its own, so asking for the + // catalog says nothing about what language a user reads. + map description = 9; + // The name in every language the bundle carries it in, keyed the same way. + // `name` is what a client shows when none of them is a language it reads. + map name_translations = 10; +} + +message WebxdcCatalog { + repeated WebxdcCatalogEntry entries = 1; +} diff --git a/rust/src/api/proto/client/data.proto b/rust/src/api/proto/client/data.proto index 56cd65a7..97a57576 100644 --- a/rust/src/api/proto/client/data.proto +++ b/rust/src/api/proto/client/data.proto @@ -12,6 +12,9 @@ message AdditionalMessageData { CONTACTS = 1; RESTORED_FLAME_COUNTER = 2; ASK_ABOUT_USER = 3; + WEBXDC_APP = 4; + WEBXDC_UPDATE = 5; + WEBXDC_SENT = 6; } Type type = 1; @@ -19,4 +22,39 @@ message AdditionalMessageData { repeated SharedContact contacts = 3; optional int64 restored_flame_counter = 4; optional int64 ask_about_user_id = 5; + optional WebxdcApp webxdc_app = 6; + optional WebxdcUpdate webxdc_update = 7; + optional WebxdcOrigin webxdc_origin = 8; +} + +// Attached to a message a webxdc app asked the user to send, so the chat can +// say which app it came from. +// +// `instance_id` is the app card in the chat the app runs in, which is not +// necessarily the chat this message was sent to: the user picks the recipient. +// `app_id` and `version` are carried as well so the receiver can name and +// picture the app even when it has no instance of its own. +message WebxdcOrigin { + string instance_id = 1; + string app_id = 2; + int64 version = 3; +} + +// The app itself is never sent. Peers resolve the id and version against the +// twonly store and download the bundle from the API server, so a sender can +// only point at code that has already been published. +message WebxdcApp { + string app_id = 1; + int64 version = 2; +} + +message WebxdcUpdate { + // The message id of the app card this update belongs to. + string instance_id = 1; + // JSON, as the app produced it. Never parsed by twonly. + string payload = 2; + optional string info = 3; + optional string href = 4; + optional string summary = 5; + optional string document = 6; } \ No newline at end of file diff --git a/rust/src/api/proto/client/messages.proto b/rust/src/api/proto/client/messages.proto index 0cb43858..ce655b5a 100644 --- a/rust/src/api/proto/client/messages.proto +++ b/rust/src/api/proto/client/messages.proto @@ -66,10 +66,13 @@ message EncryptedContent { } message TextMessage { - string sender_message_id = 1; - string text = 2; - int64 timestamp = 3; - optional string quote_message_id = 4; + string sender_message_id = 1; + string text = 2; + int64 timestamp = 3; + optional string quote_message_id = 4; + // Carries where the text came from when it was not typed by the sender -- + // today, the webxdc app that produced it. + optional bytes additional_message_data = 5; } message AdditionalDataMessage { @@ -77,6 +80,14 @@ message EncryptedContent { int64 timestamp = 2; string type = 3; optional bytes additional_message_data = 4; + // State a feature exchanges rather than something a person sent. A hidden + // message leaves no row in `messages`, so it never appears in a chat, and + // it neither raises a notification nor wakes the receiver. + // + // The one exception is a webxdc update carrying an `info`: the state stays + // hidden, but the announcement in it is written to be read and becomes a + // chat row of its own on arrival, which is worth waking for. + bool hidden = 5; } message Reaction { diff --git a/rust/src/bridge/api.rs b/rust/src/bridge/api.rs index a33a6a0d..d8af538c 100644 --- a/rust/src/bridge/api.rs +++ b/rust/src/bridge/api.rs @@ -916,10 +916,11 @@ impl RustApi { group_id: String, text: String, quote_message_id: Option, + additional_message_data: Option>, ) -> Result { let ctx = Context::get_static()?; crate::services::messages::MessageService::new(ctx) - .insert_and_send_text(group_id, text, quote_message_id) + .insert_and_send_text(group_id, text, quote_message_id, additional_message_data) .await } @@ -927,10 +928,11 @@ impl RustApi { group_id: String, message_type: String, additional_data: Vec, + hidden: bool, ) -> Result { let ctx = Context::get_static()?; crate::services::messages::MessageService::new(ctx) - .insert_and_send_additional_data(group_id, message_type, additional_data) + .insert_and_send_additional_data(group_id, message_type, additional_data, hidden) .await } diff --git a/rust/src/bridge/mod.rs b/rust/src/bridge/mod.rs index 252d42b4..3044c591 100644 --- a/rust/src/bridge/mod.rs +++ b/rust/src/bridge/mod.rs @@ -9,6 +9,7 @@ pub mod callbacks; pub mod groups; pub mod logging; pub mod user_config; +pub mod webxdc; pub mod wrapper; use crate::context::Context; diff --git a/rust/src/bridge/webxdc.rs b/rust/src/bridge/webxdc.rs new file mode 100644 index 00000000..2538f4e0 --- /dev/null +++ b/rust/src/bridge/webxdc.rs @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026, Tobias MΓΌller git@tsmr.eu + * + */ + +//! What the Flutter layer may ask of the webxdc runtime. +//! +//! Everything the webview says arrives here. None of it is trusted: the +//! instance an update belongs to, the limits it has to fit in and the text it +//! may put in front of the user are all decided on this side, because +//! `webxdc.js` is code the app can replace. + +use crate::error::Result; +use crate::services::webxdc::{bundle, store::WebxdcStore, WebxdcService}; + +pub struct WebxdcStoreApp { + pub app_id: String, + pub version: i64, + /// Already in the reader's language, like the description. + pub name: String, + /// One line about the app, already in the reader's language. + pub description: Option, + pub source_code_url: Option, + pub icon: Option>, + pub bundle_bytes: i64, +} + +pub struct WebxdcInstanceInfo { + pub instance_id: String, + pub group_id: String, + pub app_id: String, + pub version: i64, + /// The webview host this instance is served from. Unique per instance, so + /// the browser's own origin model keeps one app's stored data out of reach + /// of every other app. + pub origin_token: String, + pub summary: Option, + pub document: Option, +} + +pub struct WebxdcUpdateEntry { + pub serial: i64, + pub payload: String, + pub info: Option, + pub href: Option, + /// `None` when this device sent it. + pub sender_id: Option, +} + +pub struct WebxdcResponse { + pub status: u16, + pub mime: String, + pub header_names: Vec, + pub header_values: Vec, + pub body: Vec, +} + +/// Refreshes the store listing. Metadata only; no bundle is fetched. +pub async fn refresh_catalog() -> Result<()> { + let ctx = crate::context::Context::get_static()?; + WebxdcStore::new(ctx).refresh_catalog().await +} + +/// What the in-app store offers, newest version of each app only. +/// +/// `languages` is what the UI prefers, most preferred first. Names and +/// descriptions are cached in every language the catalog carries and picked +/// here, so which language a user reads is never sent anywhere. +pub async fn catalog(languages: Vec) -> Result> { + let ctx = crate::context::Context::get_static()?; + let database = ctx.app_db.read().await.clone(); + let rows = sqlx::query!( + r#"SELECT app_id AS "app_id!: String", version AS "version!: i64", + name AS "name!: String", + name_translations AS "name_translations!: String", + source_code_url AS "source_code_url: String", + description AS "description!: String", + icon AS "icon: Vec", bundle_bytes AS "bundle_bytes!: i64" + FROM webxdc_apps + WHERE published = 1 + AND version = (SELECT MAX(version) FROM webxdc_apps AS newer + WHERE newer.app_id = webxdc_apps.app_id AND newer.published = 1) + ORDER BY name ASC"# + ) + .fetch_all(&database.pool) + .await?; + let mut apps: Vec = rows + .into_iter() + .map(|row| WebxdcStoreApp { + app_id: row.app_id, + version: row.version, + // The untranslated name is what an app translated into no language + // the reader has is still called. + name: crate::services::webxdc::store::pick_localized( + &row.name_translations, + &languages, + ) + .unwrap_or(row.name), + description: crate::services::webxdc::store::pick_localized( + &row.description, + &languages, + ), + source_code_url: row.source_code_url, + icon: row.icon, + bundle_bytes: row.bundle_bytes, + }) + .collect(); + // The query orders by the untranslated name, which is not the order the + // list is read in once the names are the reader's. + apps.sort_by_key(|app| app.name.to_lowercase()); + Ok(apps) +} + +/// Places an app into a chat and returns the id of the message that carries it. +pub async fn create_instance(group_id: String, app_id: String, version: i64) -> Result { + let ctx = crate::context::Context::get_static()?; + WebxdcService::new(ctx) + .create_instance(group_id, app_id, version) + .await +} + +pub async fn instance(instance_id: String) -> Result> { + let ctx = crate::context::Context::get_static()?; + Ok(WebxdcService::new(ctx) + .instance(&instance_id) + .await? + .map(|instance| WebxdcInstanceInfo { + instance_id: instance.instance_id, + group_id: instance.group_id, + app_id: instance.app_id, + version: instance.version, + origin_token: instance.origin_token, + summary: instance.summary, + document: instance.document, + })) +} + +/// Downloads and verifies the bundle if it is not already on disk, and returns +/// where it landed. Called when the user starts an app, never on message +/// arrival: a message must not be able to make a device fetch anything. +pub async fn prepare_bundle(instance_id: String) -> Result { + let ctx = crate::context::Context::get_static()?; + let path = WebxdcService::new(ctx).prepare_bundle(&instance_id).await?; + Ok(path.display().to_string()) +} + +/// Answers one request the webview made for a file inside the bundle. +/// +/// The platform shim has already established that the request came from this +/// instance's own origin; everything after that is decided here. +pub async fn serve(instance_id: String, request_path: String) -> Result { + let ctx = crate::context::Context::get_static()?; + let service = WebxdcService::new(ctx); + // The bundle the instance is pinned to, not whatever the store offers now: + // every file of one run comes out of the same `.xdc`, and the store was + // already consulted when the app started. + let path = service.pinned_bundle(&instance_id).await?; + let init = service.init_script_values(&instance_id).await?; + let response = bundle::serve(&path, &request_path, &init); + let (header_names, header_values) = response.headers.into_iter().unzip(); + Ok(WebxdcResponse { + status: response.status, + mime: response.mime, + header_names, + header_values, + body: response.body, + }) +} + +pub async fn updates_after(instance_id: String, serial: i64) -> Result> { + let ctx = crate::context::Context::get_static()?; + Ok(WebxdcService::new(ctx) + .updates_after(&instance_id, serial) + .await? + .into_iter() + .map(|update| WebxdcUpdateEntry { + serial: update.serial, + payload: update.payload, + info: update.info, + href: update.href, + sender_id: update.sender_id, + }) + .collect()) +} + +pub async fn send_update( + instance_id: String, + payload: String, + info: Option, + href: Option, + summary: Option, + document: Option, +) -> Result<()> { + let ctx = crate::context::Context::get_static()?; + WebxdcService::new(ctx) + .send_update(instance_id, payload, info, href, summary, document) + .await +} + +/// The address the running app sees for a participant. Stable inside one +/// instance and unrelated to the same user's address in any other. +pub fn address_for(instance_id: String, user_id: i64) -> String { + WebxdcService::address_for(&instance_id, user_id) +} + +/// Drops an instance and its whole update log, returning the origin whose web +/// storage the platform layer still has to clear. The log is only half the +/// state: an app is free to keep everything in `localStorage`, which no +/// database delete reaches. +pub async fn delete_instance(instance_id: String) -> Result> { + let ctx = crate::context::Context::get_static()?; + WebxdcService::new(ctx).delete_instance(&instance_id).await +} diff --git a/rust/src/context.rs b/rust/src/context.rs index bc1d4f01..59a2c6df 100644 --- a/rust/src/context.rs +++ b/rust/src/context.rs @@ -59,6 +59,19 @@ pub struct Context { /// bundle on the server (or has just published one). Per context rather /// than process-wide so two accounts in one process check independently. pub(crate) pqc_bundle_verified: AtomicBool, + /// Coalesces overlapping flushes of this account's receipt queue. Per + /// context for the same reason: a flush scans one app database, so a claim + /// held for one account must not silence another account's flush. + pub(crate) queued_receipt_flush: + std::sync::Mutex, + /// Serializes this account's upload preparation, which reserves slots and + /// writes request files that a second concurrent pass would duplicate. + /// Held across a reconciliation round trip, so one account waiting on the + /// network must not stall another's uploads. + pub(crate) media_preprocessing: Mutex<()>, + /// Serializes this account's media retransmission sweep, for the same + /// reason. + pub(crate) media_retransmission: Mutex<()>, } impl Context { @@ -138,6 +151,9 @@ impl Context { incoming_generation: AtomicU64::new(0), incoming_committed: Notify::new(), pqc_bundle_verified: AtomicBool::new(false), + queued_receipt_flush: std::sync::Mutex::default(), + media_preprocessing: Mutex::new(()), + media_retransmission: Mutex::new(()), }); ApiRuntime::initialize(&ctx).await?; ApiRuntime::connect(&ctx).await?; @@ -321,7 +337,10 @@ impl Context { mailbox_drained: Notify::new(), incoming_generation: AtomicU64::new(0), incoming_committed: Notify::new(), - pqc_bundle_verified: AtomicBool::new(false), + pqc_bundle_verified: AtomicBool::new(false), + queued_receipt_flush: std::sync::Mutex::default(), + media_preprocessing: Mutex::new(()), + media_retransmission: Mutex::new(()), }); if let Err(error) = ctx.initialize_user_discovery_from_config().await { tracing::warn!("failed to initialize user discovery: {error}"); @@ -364,7 +383,10 @@ impl Context { mailbox_drained: Notify::new(), incoming_generation: AtomicU64::new(0), incoming_committed: Notify::new(), - pqc_bundle_verified: AtomicBool::new(false), + pqc_bundle_verified: AtomicBool::new(false), + queued_receipt_flush: std::sync::Mutex::default(), + media_preprocessing: Mutex::new(()), + media_retransmission: Mutex::new(()), }); if let Err(error) = ctx.initialize_user_discovery_from_config().await { tracing::warn!("failed to initialize user discovery: {error}"); diff --git a/rust/src/database/app/migrations/0014_webxdc.sql b/rust/src/database/app/migrations/0014_webxdc.sql new file mode 100644 index 00000000..723b82a7 --- /dev/null +++ b/rust/src/database/app/migrations/0014_webxdc.sql @@ -0,0 +1,71 @@ +-- Webxdc apps are distributed through the twonly store rather than sent +-- between clients: a message carries only the app id and the version its +-- sender pinned, and the bundle itself is fetched from the API server. + +-- Catalog metadata mirrored from the API. Purely a cache: rows may be replaced +-- wholesale on every refresh, so nothing outside this table may reference it. +CREATE TABLE webxdc_apps ( + app_id TEXT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + source_code_url TEXT, + icon BLOB, + bundle_sha256 TEXT NOT NULL, + bundle_bytes INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 CHECK (revoked IN (0, 1)), + cached_at INTEGER NOT NULL, + PRIMARY KEY (app_id, version) +); + +-- One row per app placed into a chat. +-- +-- `origin_token` is the host the bundle is served from inside the webview. The +-- browser's origin model is what isolates one instance's localStorage and +-- IndexedDB from every other instance, so the token is per instance rather +-- than per app, and is never derived from anything a peer controls. +-- +-- Addresses handed to the app are derived from the instance id, which every +-- participant knows and nobody outside the chat does. That keeps one peer's +-- address identical on every device without sending anything, while staying +-- uncorrelatable with the same user in another chat. +-- +-- `bundle_sha256` is pinned when the instance first starts. The catalog is +-- consulted to resolve it, but never again afterwards: a later catalog change +-- must not swap the code out from under a game that is already being played. +CREATE TABLE webxdc_instances ( + instance_id TEXT NOT NULL PRIMARY KEY REFERENCES messages(message_id) ON DELETE CASCADE, + group_id TEXT NOT NULL REFERENCES groups(group_id) ON DELETE CASCADE, + app_id TEXT NOT NULL, + version INTEGER NOT NULL, + bundle_sha256 TEXT, + origin_token TEXT NOT NULL UNIQUE, + summary TEXT, + document TEXT, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), + last_update_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)) +); + +CREATE INDEX idx_webxdc_instances_group_id ON webxdc_instances(group_id); + +-- The update log, deliberately kept out of `messages`. +-- +-- `purgeMessageTable` deletes message rows individually once the chat's +-- deletion timer has passed. An update log purged that way loses a prefix or +-- an arbitrary subset of its entries, and an app replaying serials with holes +-- in them rebuilds a state that is silently wrong rather than obviously empty. +-- Instances are therefore deleted whole, by the webxdc code, or not at all -- +-- the same exemption stored media has from that purge. +-- +-- `serial` is local to this device: assigned on arrival, gap free, and never +-- reused, which is what `setUpdateListener` promises the app. +CREATE TABLE webxdc_updates ( + instance_id TEXT NOT NULL REFERENCES webxdc_instances(instance_id) ON DELETE CASCADE, + serial INTEGER NOT NULL, + message_id TEXT NOT NULL UNIQUE, + sender_id INTEGER REFERENCES contacts(user_id), + payload TEXT NOT NULL, + info TEXT, + href TEXT, + received_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)), + PRIMARY KEY (instance_id, serial) +); diff --git a/rust/src/database/app/migrations/0015_webxdc_store.sql b/rust/src/database/app/migrations/0015_webxdc_store.sql new file mode 100644 index 00000000..3d873d59 --- /dev/null +++ b/rust/src/database/app/migrations/0015_webxdc_store.sql @@ -0,0 +1,39 @@ +-- The store drops revocation, gains per-language descriptions, and lets an app +-- move to a newer version when it is started. +-- +-- `webxdc_apps` is rebuilt rather than altered. It is a cache of the catalog, +-- so the next refresh fills it again, and rebuilding says what the table is +-- now in one piece instead of leaving the shape to be read as a diff. +DROP TABLE webxdc_apps; + +-- Catalog metadata mirrored from the API. +-- +-- A cache, but not a disposable one: a refresh keeps the rows an instance on +-- this device points at, even once the store stops offering them. Unpublishing +-- an app takes it out of the store and nothing more -- it keeps working where +-- it was already downloaded, and the card in the chat keeps its name and icon. +CREATE TABLE webxdc_apps ( + app_id TEXT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + source_code_url TEXT, + -- One short line per language, as a JSON object keyed by language tag: + -- `{"en": "...", "de": "..."}`. The catalog carries every translation, so + -- which one a device shows is decided here and never asked for. + description TEXT NOT NULL DEFAULT '{}', + icon BLOB, + bundle_sha256 TEXT NOT NULL, + bundle_bytes INTEGER NOT NULL, + -- Whether the last catalog we saw still offered this version. Rows kept for + -- an instance after the store dropped them are `0`: they may still be + -- started, but the store must not offer them for placing into a new chat. + published INTEGER NOT NULL DEFAULT 1 CHECK (published IN (0, 1)), + cached_at INTEGER NOT NULL, + PRIMARY KEY (app_id, version) +); + +-- `webxdc_instances.bundle_sha256` keeps its meaning but not its lifetime: it is +-- re-pinned when the app is started and the store has published a newer +-- version, and never in between. An update lands between two runs of an app, so +-- a catalog change still cannot swap the code out from under a game that is +-- being played. diff --git a/rust/src/database/app/migrations/0016_webxdc_app_names.sql b/rust/src/database/app/migrations/0016_webxdc_app_names.sql new file mode 100644 index 00000000..fb674c19 --- /dev/null +++ b/rust/src/database/app/migrations/0016_webxdc_app_names.sql @@ -0,0 +1,8 @@ +-- Names are translated, the way descriptions already are. +-- +-- `name` keeps its meaning: the one name every row has, which the store orders +-- by and which a reader falls back to when the app was not translated into +-- their language. The translations sit beside it in the same shape as +-- `description`, so choosing one stays a decision this device makes. +ALTER TABLE webxdc_apps + ADD COLUMN name_translations TEXT NOT NULL DEFAULT '{}'; diff --git a/rust/src/error.rs b/rust/src/error.rs index 426eb9cc..de36d912 100644 --- a/rust/src/error.rs +++ b/rust/src/error.rs @@ -78,6 +78,12 @@ pub enum TwonlyError { #[error("encrypted media is {bytes} bytes but the plan allows {limit}")] MediaTooLarge { bytes: i64, limit: i64 }, + /// The group server refused a state update because it was written against + /// a version it no longer holds. Somebody else changed the group first, so + /// the update has to be applied again to the version that won. + #[error("group state was updated by somebody else first")] + GroupStateConflict, + /// Content that will never be processable: it is malformed, or it uses a /// feature this client does not implement. Callers must not retry it β€” /// a reliable-mailbox row carrying such a message is acknowledged so the diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 6c1e5f87..9ae6c6d2 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1545547125; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1362207365; // Section: executor @@ -124,6 +124,79 @@ fn wire__crate__bridge__groups__add_new_group_members_impl( }, ) } +fn wire__crate__bridge__webxdc__address_for_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "address_for", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_instance_id = ::sse_decode(&mut deserializer); + let api_user_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::bridge::webxdc::address_for( + api_instance_id, + api_user_id, + ))?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__bridge__webxdc__catalog_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "catalog", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_languages = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::webxdc::catalog(api_languages).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__logging__clean_log_file_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -194,6 +267,49 @@ fn wire__crate__bridge__logging__clear_log_file_impl( }, ) } +fn wire__crate__bridge__webxdc__create_instance_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "create_instance", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_group_id = ::sse_decode(&mut deserializer); + let api_app_id = ::sse_decode(&mut deserializer); + let api_version = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::webxdc::create_instance( + api_group_id, + api_app_id, + api_version, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__groups__create_new_group_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -233,6 +349,43 @@ fn wire__crate__bridge__groups__create_new_group_impl( }, ) } +fn wire__crate__bridge__webxdc__delete_instance_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "delete_instance", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_instance_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::webxdc::delete_instance(api_instance_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__groups__fetch_group_state_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -517,6 +670,42 @@ fn wire__crate__bridge__initialize_twonly_standalone_impl( }, ) } +fn wire__crate__bridge__webxdc__instance_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "instance", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_instance_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::webxdc::instance(api_instance_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__groups__leave_group_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -631,6 +820,43 @@ fn wire__crate__bridge__groups__manage_admin_state_impl( }, ) } +fn wire__crate__bridge__webxdc__prepare_bundle_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "prepare_bundle", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_instance_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::webxdc::prepare_bundle(api_instance_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__logging__read_last_log_lines_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -668,6 +894,41 @@ fn wire__crate__bridge__logging__read_last_log_lines_impl( }, ) } +fn wire__crate__bridge__webxdc__refresh_catalog_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "refresh_catalog", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::webxdc::refresh_catalog().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__groups__remove_member_from_group_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2218,6 +2479,7 @@ fn wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl( let api_group_id = ::sse_decode(&mut deserializer); let api_message_type = ::sse_decode(&mut deserializer); let api_additional_data = >::sse_decode(&mut deserializer); + let api_hidden = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( @@ -2227,6 +2489,7 @@ fn wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl( api_group_id, api_message_type, api_additional_data, + api_hidden, ) .await?; Ok(output_ok) @@ -2345,6 +2608,7 @@ fn wire__crate__bridge__api__rust_api_insert_and_send_text_impl( let api_group_id = ::sse_decode(&mut deserializer); let api_text = ::sse_decode(&mut deserializer); let api_quote_message_id = >::sse_decode(&mut deserializer); + let api_additional_message_data = >>::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( @@ -2353,6 +2617,7 @@ fn wire__crate__bridge__api__rust_api_insert_and_send_text_impl( api_group_id, api_text, api_quote_message_id, + api_additional_message_data, ) .await?; Ok(output_ok) @@ -5299,6 +5564,93 @@ fn wire__crate__bridge__wrapper__rust_utils_recover_secret_impl( }, ) } +fn wire__crate__bridge__webxdc__send_update_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "send_update", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_instance_id = ::sse_decode(&mut deserializer); + let api_payload = ::sse_decode(&mut deserializer); + let api_info = >::sse_decode(&mut deserializer); + let api_href = >::sse_decode(&mut deserializer); + let api_summary = >::sse_decode(&mut deserializer); + let api_document = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::webxdc::send_update( + api_instance_id, + api_payload, + api_info, + api_href, + api_summary, + api_document, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__webxdc__serve_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "serve", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_instance_id = ::sse_decode(&mut deserializer); + let api_request_path = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::webxdc::serve(api_instance_id, api_request_path).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__groups__update_chat_deletion_time_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -5379,6 +5731,45 @@ fn wire__crate__bridge__groups__update_group_name_impl( }, ) } +fn wire__crate__bridge__webxdc__updates_after_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "updates_after", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_instance_id = ::sse_decode(&mut deserializer); + let api_serial = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::webxdc::updates_after(api_instance_id, api_serial) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__user_config__user_config_api_clone_impl( ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, rust_vec_len_: i32, @@ -6280,6 +6671,34 @@ impl SseDecode for Vec { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode( + deserializer, + )); + } + return ans_; + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode( + deserializer, + )); + } + return ans_; + } +} + impl SseDecode for crate::bridge::logging::LogLevel { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6449,6 +6868,19 @@ impl SseDecode for Option { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode( + deserializer, + )); + } else { + return None; + } + } +} + impl SseDecode for Option> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6684,6 +7116,13 @@ impl SseDecode for crate::user_config::TwonlySafeBackup { } } +impl SseDecode for u16 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u16::().unwrap() + } +} + impl SseDecode for u32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -6869,6 +7308,86 @@ impl SseDecode for usize { } } +impl SseDecode for crate::bridge::webxdc::WebxdcInstanceInfo { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_instanceId = ::sse_decode(deserializer); + let mut var_groupId = ::sse_decode(deserializer); + let mut var_appId = ::sse_decode(deserializer); + let mut var_version = ::sse_decode(deserializer); + let mut var_originToken = ::sse_decode(deserializer); + let mut var_summary = >::sse_decode(deserializer); + let mut var_document = >::sse_decode(deserializer); + return crate::bridge::webxdc::WebxdcInstanceInfo { + instance_id: var_instanceId, + group_id: var_groupId, + app_id: var_appId, + version: var_version, + origin_token: var_originToken, + summary: var_summary, + document: var_document, + }; + } +} + +impl SseDecode for crate::bridge::webxdc::WebxdcResponse { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_status = ::sse_decode(deserializer); + let mut var_mime = ::sse_decode(deserializer); + let mut var_headerNames = >::sse_decode(deserializer); + let mut var_headerValues = >::sse_decode(deserializer); + let mut var_body = >::sse_decode(deserializer); + return crate::bridge::webxdc::WebxdcResponse { + status: var_status, + mime: var_mime, + header_names: var_headerNames, + header_values: var_headerValues, + body: var_body, + }; + } +} + +impl SseDecode for crate::bridge::webxdc::WebxdcStoreApp { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_appId = ::sse_decode(deserializer); + let mut var_version = ::sse_decode(deserializer); + let mut var_name = ::sse_decode(deserializer); + let mut var_description = >::sse_decode(deserializer); + let mut var_sourceCodeUrl = >::sse_decode(deserializer); + let mut var_icon = >>::sse_decode(deserializer); + let mut var_bundleBytes = ::sse_decode(deserializer); + return crate::bridge::webxdc::WebxdcStoreApp { + app_id: var_appId, + version: var_version, + name: var_name, + description: var_description, + source_code_url: var_sourceCodeUrl, + icon: var_icon, + bundle_bytes: var_bundleBytes, + }; + } +} + +impl SseDecode for crate::bridge::webxdc::WebxdcUpdateEntry { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_serial = ::sse_decode(deserializer); + let mut var_payload = ::sse_decode(deserializer); + let mut var_info = >::sse_decode(deserializer); + let mut var_href = >::sse_decode(deserializer); + let mut var_senderId = >::sse_decode(deserializer); + return crate::bridge::webxdc::WebxdcUpdateEntry { + serial: var_serial, + payload: var_payload, + info: var_info, + href: var_href, + sender_id: var_senderId, + }; + } +} + fn pde_ffi_dispatcher_primary_impl( func_id: i32, port: flutter_rust_bridge::for_generated::MessagePort, @@ -6880,154 +7399,164 @@ fn pde_ffi_dispatcher_primary_impl( match func_id { 1 => wire__crate__bridge__groups__add_hidden_contact_impl(port, ptr, rust_vec_len, data_len), 2 => wire__crate__bridge__groups__add_new_group_members_impl(port, ptr, rust_vec_len, data_len), -3 => wire__crate__bridge__logging__clean_log_file_impl(port, ptr, rust_vec_len, data_len), -4 => wire__crate__bridge__logging__clear_log_file_impl(port, ptr, rust_vec_len, data_len), -5 => wire__crate__bridge__groups__create_new_group_impl(port, ptr, rust_vec_len, data_len), -6 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len), -7 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len), -8 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len), -9 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len), -10 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len), -11 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len), -12 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len), -13 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len), -14 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len), -15 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len), -16 => wire__crate__bridge__logging__load_log_file_impl(port, ptr, rust_vec_len, data_len), -17 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len), -18 => wire__crate__bridge__logging__read_last_log_lines_impl(port, ptr, rust_vec_len, data_len), -19 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len), -20 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len), -21 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len), -22 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len), -24 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len), -26 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len), -27 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len), -28 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -29 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len), -30 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len), -31 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), -32 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), -33 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), -34 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), -35 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len), -36 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len), -38 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), -39 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), -40 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len), -41 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), -42 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), -43 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), -44 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), -45 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len), -46 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), -47 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), -48 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len), -49 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), -50 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), -51 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), -52 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), -53 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), -54 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -55 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), -56 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), -57 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), -58 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len), -59 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len), -60 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), -61 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), -62 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), -63 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), -64 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), -65 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), -66 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len), -67 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len), -68 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), -69 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), -70 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), -71 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len), -72 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), -73 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len), -74 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len), -75 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), -76 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len), -77 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -78 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -79 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), -80 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), -81 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), -82 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), -83 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), -84 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), -85 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), -86 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), -87 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), -88 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), -89 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), -90 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), -91 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), -92 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), -93 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), -94 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), -95 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), -96 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), -97 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), -98 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), -99 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), -100 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len), -101 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), -102 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), -103 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), -104 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), -105 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), -106 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), -107 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), -108 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len), -109 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), -110 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), -111 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len), -112 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), -113 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), -114 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), -115 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), -116 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), -117 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), -118 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), -119 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), -120 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), -121 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), -122 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), -123 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), -124 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -125 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), -126 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -127 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), -128 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -129 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -130 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -131 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), -132 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), -133 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), -134 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -135 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), -136 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), -137 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), -138 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), -139 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), -140 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), -141 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), -142 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), -143 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), -144 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -145 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -146 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -147 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), -148 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), -150 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), -151 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), -152 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), -153 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), -154 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), +3 => wire__crate__bridge__webxdc__address_for_impl(port, ptr, rust_vec_len, data_len), +4 => wire__crate__bridge__webxdc__catalog_impl(port, ptr, rust_vec_len, data_len), +5 => wire__crate__bridge__logging__clean_log_file_impl(port, ptr, rust_vec_len, data_len), +6 => wire__crate__bridge__logging__clear_log_file_impl(port, ptr, rust_vec_len, data_len), +7 => wire__crate__bridge__webxdc__create_instance_impl(port, ptr, rust_vec_len, data_len), +8 => wire__crate__bridge__groups__create_new_group_impl(port, ptr, rust_vec_len, data_len), +9 => wire__crate__bridge__webxdc__delete_instance_impl(port, ptr, rust_vec_len, data_len), +10 => wire__crate__bridge__groups__fetch_group_state_impl(port, ptr, rust_vec_len, data_len), +11 => wire__crate__bridge__groups__fetch_group_states_for_unjoined_groups_impl(port, ptr, rust_vec_len, data_len), +12 => wire__crate__bridge__groups__fetch_missing_group_public_keys_impl(port, ptr, rust_vec_len, data_len), +13 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_change_exclusion_for_contact_impl(port, ptr, rust_vec_len, data_len), +14 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_get_current_version_impl(port, ptr, rust_vec_len, data_len), +15 => wire__crate__bridge__wrapper__user_discovery__flutter_user_discovery_update_verification_state_for_user_impl(port, ptr, rust_vec_len, data_len), +16 => wire__crate__bridge__callbacks__init_flutter_callbacks_impl(port, ptr, rust_vec_len, data_len), +17 => wire__crate__bridge__initialize_twonly_flutter_impl(port, ptr, rust_vec_len, data_len), +18 => wire__crate__bridge__initialize_twonly_standalone_impl(port, ptr, rust_vec_len, data_len), +19 => wire__crate__bridge__webxdc__instance_impl(port, ptr, rust_vec_len, data_len), +20 => wire__crate__bridge__groups__leave_group_impl(port, ptr, rust_vec_len, data_len), +21 => wire__crate__bridge__logging__load_log_file_impl(port, ptr, rust_vec_len, data_len), +22 => wire__crate__bridge__groups__manage_admin_state_impl(port, ptr, rust_vec_len, data_len), +23 => wire__crate__bridge__webxdc__prepare_bundle_impl(port, ptr, rust_vec_len, data_len), +24 => wire__crate__bridge__logging__read_last_log_lines_impl(port, ptr, rust_vec_len, data_len), +25 => wire__crate__bridge__webxdc__refresh_catalog_impl(port, ptr, rust_vec_len, data_len), +26 => wire__crate__bridge__groups__remove_member_from_group_impl(port, ptr, rust_vec_len, data_len), +27 => wire__crate__bridge__api__rust_api_abandon_media_impl(port, ptr, rust_vec_len, data_len), +28 => wire__crate__bridge__api__rust_api_add_additional_user_impl(port, ptr, rust_vec_len, data_len), +29 => wire__crate__bridge__api__rust_api_allocate_sequence_impl(port, ptr, rust_vec_len, data_len), +31 => wire__crate__bridge__api__rust_api_authentication_headers_impl(port, ptr, rust_vec_len, data_len), +33 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len), +34 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len), +35 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +36 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len), +37 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len), +38 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), +39 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), +40 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), +41 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), +42 => wire__crate__bridge__api__rust_api_crop_media_transparent_borders_impl(port, ptr, rust_vec_len, data_len), +43 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len), +45 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), +46 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), +47 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len), +48 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), +49 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), +50 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), +51 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), +52 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len), +53 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), +54 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), +55 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len), +56 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), +57 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), +58 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), +59 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), +60 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), +61 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +62 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), +63 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), +64 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), +65 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len), +66 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len), +67 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), +68 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), +69 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), +70 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), +71 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), +72 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), +73 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len), +74 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len), +75 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), +76 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), +77 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), +78 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len), +79 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), +80 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len), +81 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len), +82 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), +83 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len), +84 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +85 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +86 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), +87 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), +88 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), +89 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), +90 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), +91 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), +92 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), +93 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), +94 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), +95 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), +96 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), +97 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), +98 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), +99 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), +100 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), +101 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), +102 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), +103 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), +104 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), +105 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), +106 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), +107 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len), +108 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), +109 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), +110 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), +111 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), +112 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), +113 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), +114 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), +115 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len), +116 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), +117 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), +118 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len), +119 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), +120 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), +121 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), +122 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), +123 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), +124 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), +125 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), +126 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), +127 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), +128 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), +129 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), +130 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), +131 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +132 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), +133 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +134 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), +135 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +136 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +137 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +138 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), +139 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), +140 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), +141 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), +142 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), +143 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), +144 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), +145 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), +146 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), +147 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), +148 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), +149 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), +150 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), +151 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), +152 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +153 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +154 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len), +155 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len), +156 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), +157 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), +158 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len), +160 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), +161 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), +162 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), +163 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), +164 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -7040,17 +7569,17 @@ fn pde_ffi_dispatcher_sync_impl( ) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { - 23 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len), - 25 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len), - 37 => { + 30 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len), + 32 => wire__crate__bridge__api__rust_api_avatar_png_path_impl(ptr, rust_vec_len, data_len), + 44 => { wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len) } - 149 => wire__crate__bridge__user_config__user_config_api_clone_impl( + 159 => wire__crate__bridge__user_config__user_config_api_clone_impl( ptr, rust_vec_len, data_len, ), - 155 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), + 165 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -7972,6 +8501,106 @@ impl flutter_rust_bridge::IntoIntoDart flutter_rust_bridge::for_generated::DartAbi { + [ + self.instance_id.into_into_dart().into_dart(), + self.group_id.into_into_dart().into_dart(), + self.app_id.into_into_dart().into_dart(), + self.version.into_into_dart().into_dart(), + self.origin_token.into_into_dart().into_dart(), + self.summary.into_into_dart().into_dart(), + self.document.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::bridge::webxdc::WebxdcInstanceInfo +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::bridge::webxdc::WebxdcInstanceInfo +{ + fn into_into_dart(self) -> crate::bridge::webxdc::WebxdcInstanceInfo { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::bridge::webxdc::WebxdcResponse { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.status.into_into_dart().into_dart(), + self.mime.into_into_dart().into_dart(), + self.header_names.into_into_dart().into_dart(), + self.header_values.into_into_dart().into_dart(), + self.body.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::bridge::webxdc::WebxdcResponse +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::bridge::webxdc::WebxdcResponse +{ + fn into_into_dart(self) -> crate::bridge::webxdc::WebxdcResponse { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::bridge::webxdc::WebxdcStoreApp { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.app_id.into_into_dart().into_dart(), + self.version.into_into_dart().into_dart(), + self.name.into_into_dart().into_dart(), + self.description.into_into_dart().into_dart(), + self.source_code_url.into_into_dart().into_dart(), + self.icon.into_into_dart().into_dart(), + self.bundle_bytes.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::bridge::webxdc::WebxdcStoreApp +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::bridge::webxdc::WebxdcStoreApp +{ + fn into_into_dart(self) -> crate::bridge::webxdc::WebxdcStoreApp { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::bridge::webxdc::WebxdcUpdateEntry { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.serial.into_into_dart().into_dart(), + self.payload.into_into_dart().into_dart(), + self.info.into_into_dart().into_dart(), + self.href.into_into_dart().into_dart(), + self.sender_id.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::bridge::webxdc::WebxdcUpdateEntry +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::bridge::webxdc::WebxdcUpdateEntry +{ + fn into_into_dart(self) -> crate::bridge::webxdc::WebxdcUpdateEntry { + self + } +} impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { // Codec=Sse (Serialization based), see doc to use other codecs @@ -8418,6 +9047,26 @@ impl SseEncode for Vec { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for crate::bridge::logging::LogLevel { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8565,6 +9214,16 @@ impl SseEncode for Option { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for Option> { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8752,6 +9411,13 @@ impl SseEncode for crate::user_config::TwonlySafeBackup { } } +impl SseEncode for u16 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u16::(self).unwrap(); + } +} + impl SseEncode for u32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -8891,6 +9557,54 @@ impl SseEncode for usize { } } +impl SseEncode for crate::bridge::webxdc::WebxdcInstanceInfo { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.instance_id, serializer); + ::sse_encode(self.group_id, serializer); + ::sse_encode(self.app_id, serializer); + ::sse_encode(self.version, serializer); + ::sse_encode(self.origin_token, serializer); + >::sse_encode(self.summary, serializer); + >::sse_encode(self.document, serializer); + } +} + +impl SseEncode for crate::bridge::webxdc::WebxdcResponse { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.status, serializer); + ::sse_encode(self.mime, serializer); + >::sse_encode(self.header_names, serializer); + >::sse_encode(self.header_values, serializer); + >::sse_encode(self.body, serializer); + } +} + +impl SseEncode for crate::bridge::webxdc::WebxdcStoreApp { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.app_id, serializer); + ::sse_encode(self.version, serializer); + ::sse_encode(self.name, serializer); + >::sse_encode(self.description, serializer); + >::sse_encode(self.source_code_url, serializer); + >>::sse_encode(self.icon, serializer); + ::sse_encode(self.bundle_bytes, serializer); + } +} + +impl SseEncode for crate::bridge::webxdc::WebxdcUpdateEntry { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.serial, serializer); + ::sse_encode(self.payload, serializer); + >::sse_encode(self.info, serializer); + >::sse_encode(self.href, serializer); + >::sse_encode(self.sender_id, serializer); + } +} + #[cfg(not(target_family = "wasm"))] mod io { // This file is automatically generated, so please do not edit it. diff --git a/rust/src/services/direct_media_upload.rs b/rust/src/services/direct_media_upload.rs index 3b004cd2..2e3b6a77 100644 --- a/rust/src/services/direct_media_upload.rs +++ b/rust/src/services/direct_media_upload.rs @@ -1021,7 +1021,10 @@ impl DirectMediaUploadService { dispatches.push(AttachmentDispatch { recipient_user_id: recipient.contact_id, encrypted_body, - push_data: Some(vec![1]), + // Widget media arrives already opened and raises no + // notification, so a push for it only buys the recipient + // an alert with nothing behind it. + push_data: (message.is_widget_media == 0).then(|| vec![1]), download_token, }); } diff --git a/rust/src/services/groups/mod.rs b/rust/src/services/groups/mod.rs index e064cb91..2574c0cb 100644 --- a/rust/src/services/groups/mod.rs +++ b/rust/src/services/groups/mod.rs @@ -68,6 +68,28 @@ fn group_state_lock(group_id: &str) -> Arc> { locks.entry(group_id.to_owned()).or_default().clone() } +/// How many versions a group-state update chases before it gives up. +const STATE_UPDATE_ATTEMPTS: usize = 3; + +/// The admin rights a state update grants or revokes alongside the new state. +#[derive(Default)] +struct AdminKeys { + add: Option>, + remove: Option>, +} + +/// A folded-down group state that still has to reach the group server. +/// +/// Every admin that reads a member's leave-append removes them from its own +/// copy of the state; whoever pushes first wins, and the losers' rejections +/// are of no consequence. It is carried out of the inbound transaction because +/// pushing it is a network round trip and the app database has one connection. +struct PendingCompaction { + group: GroupRecord, + version_id: u64, + state: EncryptedGroupState, +} + /// When each member was last asked to resend its group public key. /// /// Process-local on purpose: the point is to keep one session from re-asking a @@ -310,11 +332,30 @@ impl GroupService { let database = self.ctx.app_db.read().await.clone(); let mut t = database.pool.begin().await?; - let updated = self + let (updated, compaction) = self .apply_fetched_group_state(&mut t, &group_id, server) .await?; t.commit().await?; + if let Some(compaction) = compaction { + // Every admin that reads the same leave-append folds it away, and + // the group server takes whichever gets there first. A rejection + // means another admin already did the work, so the state this + // refresh applied stands either way and the next refresh reads the + // folded version. + if let Err(error) = GroupApi::update_remote( + &compaction.group, + compaction.version_id, + &compaction.state, + None, + None, + ) + .await + { + tracing::warn!(group_id, %error, "group-state compaction was not accepted"); + } + } + // `apply_state` may have queued public-key requests. Nothing else // flushes them here -- this is not an inbound-message path -- and they // would otherwise wait for the next reconnect. @@ -328,15 +369,16 @@ impl GroupService { Ok(updated) } - /// Applies a group state that has already been fetched. Database work only, - /// apart from the admin-side `update_remote` repair below, which the - /// caller's group lock keeps free of races. + /// Applies a group state that has already been fetched. Database work + /// only: the admin-side compaction of leave-appends is handed back to the + /// caller instead of sent from here, because it is a network round trip + /// and this runs while the caller holds the app database's one connection. async fn apply_fetched_group_state( &self, t: &mut sqlx::Transaction<'_, sqlx::Sqlite>, group_id: &str, server: Option, - ) -> Result { + ) -> Result<(bool, Option)> { let group = GroupRecord::load_in_transaction(t, group_id).await?; let Some(server) = server else { UpdateGroup::builder() @@ -345,7 +387,7 @@ impl GroupService { .build() .execute(t) .await?; - return Ok(false); + return Ok((false, None)); }; let state_key = group.state_key()?; let raw = crypto::decrypt(state_key, &server.encrypted_group_state)?; @@ -412,19 +454,64 @@ impl GroupService { group_state.admin_ids.retain(|id| *id != leaving_id); appended_changes = true; } - if appended_changes & group_state.admin_ids.contains(&local_user_id) { - GroupApi::update_remote(&group, server.version_id, &group_state, None, None).await?; - } + let compaction = + (appended_changes & group_state.admin_ids.contains(&local_user_id)).then(|| { + PendingCompaction { + group: group.clone(), + version_id: server.version_id, + state: group_state.clone(), + } + }); self.apply_state(t, group_id, server.version_id as i64, &group_state) .await?; - Ok(true) + Ok((true, compaction)) + } + + /// Reads the group state, applies `change`, and writes it back, re-reading + /// and re-applying while the group server rejects the write as stale. + /// + /// The server accepts an update only against the version it currently + /// holds, so two admins acting at once -- or one racing the compaction a + /// member's leave-append triggers -- leave the loser holding a state the + /// server has already moved past. The loser's intent is still valid; only + /// the version it was expressed against is not. That is why `change` is a + /// function of the state rather than a finished state: it can simply be + /// applied again to the version that won. + /// + /// `change` returning `None` means there is nothing left to write. + async fn update_state_with_retry( + &self, + group: &GroupRecord, + mut change: impl FnMut(&mut EncryptedGroupState) -> Result>, + ) -> Result<()> { + let mut attempt = 1; + loop { + let (version, mut state) = GroupApi::load_state(group).await?; + let Some(keys) = change(&mut state)? else { + return Ok(()); + }; + match GroupApi::update_remote(group, version, &state, keys.add, keys.remove).await { + Ok(()) => return Ok(()), + Err(TwonlyError::GroupStateConflict) if attempt < STATE_UPDATE_ATTEMPTS => { + attempt += 1; + tracing::info!( + group_id = group.group_id, + attempt, + "group state moved on while updating it, applying the change again" + ); + } + Err(error) => return Err(error), + } + } } pub async fn update_group_name(&self, group_id: String, name: String) -> Result { let (db, g) = self.load_group(&group_id).await?; - let (v, mut s) = GroupApi::load_state(&g).await?; - s.group_name = name.clone(); - GroupApi::update_remote(&g, v, &s, None, None).await?; + self.update_state_with_retry(&g, |state| { + state.group_name = name.clone(); + Ok(Some(AdminKeys::default())) + }) + .await?; self.announce( &group_id, "updatedGroupName", @@ -449,9 +536,11 @@ impl GroupService { pub async fn update_chat_deletion_time(&self, group_id: String, ms: i64) -> Result { let (db, g) = self.load_group(&group_id).await?; - let (v, mut s) = GroupApi::load_state(&g).await?; - s.delete_messages_after_milliseconds = Some(ms); - GroupApi::update_remote(&g, v, &s, None, None).await?; + self.update_state_with_retry(&g, |state| { + state.delete_messages_after_milliseconds = Some(ms); + Ok(Some(AdminKeys::default())) + }) + .await?; self.announce(&group_id, "changeDisplayMaxTime", None, None, Some(ms)) .await?; let mut tr = db.pool.begin().await?; @@ -501,29 +590,27 @@ impl GroupService { remove: bool, ) -> Result { let (db, g) = self.load_group(&group_id).await?; - let (v, mut s) = GroupApi::load_state(&g).await?; let public_key = self .member_public_key(&db, &g, &group_id, contact_id) .await?; - if remove { - s.admin_ids.retain(|x| *x != contact_id) - } else if !s.admin_ids.contains(&contact_id) { - s.admin_ids.push(contact_id) - }; let kind = if remove { "demoteToMember" } else { "promoteToAdmin" }; - GroupApi::update_remote( - &g, - v, - &s, - (!remove).then_some(public_key.clone()), - remove.then_some(public_key), - ) + self.update_state_with_retry(&g, |state| { + if remove { + state.admin_ids.retain(|x| *x != contact_id) + } else if !state.admin_ids.contains(&contact_id) { + state.admin_ids.push(contact_id) + }; + Ok(Some(AdminKeys { + add: (!remove).then(|| public_key.clone()), + remove: remove.then(|| public_key.clone()), + })) + }) .await?; self.announce(&group_id, kind, Some(contact_id), None, None) .await?; @@ -535,13 +622,15 @@ impl GroupService { pub async fn add_members(&self, group_id: String, ids: Vec) -> Result { let (db, g) = self.load_group(&group_id).await?; - let (v, mut s) = GroupApi::load_state(&g).await?; - for id in &ids { - if !s.member_ids.contains(id) { - s.member_ids.push(*id) + self.update_state_with_retry(&g, |state| { + for id in &ids { + if !state.member_ids.contains(id) { + state.member_ids.push(*id) + } } - } - GroupApi::update_remote(&g, v, &s, None, None).await?; + Ok(Some(AdminKeys::default())) + }) + .await?; let identity = g.identity()?; for id in ids { self.announce(&group_id, "addMember", Some(id), None, None) @@ -578,22 +667,35 @@ impl GroupService { /// unremovable. pub async fn remove_member(&self, group_id: String, contact_id: i64) -> Result { let (db, g) = self.load_group(&group_id).await?; - let (v, mut s) = GroupApi::load_state(&g).await?; - if !s.member_ids.contains(&contact_id) { - return Ok(true); - } - s.member_ids.retain(|x| *x != contact_id); - let was_admin = s.admin_ids.contains(&contact_id); - s.admin_ids.retain(|x| *x != contact_id); - let revoked_key = if was_admin { - Some( - self.member_public_key(&db, &g, &group_id, contact_id) - .await?, - ) - } else { - None - }; - GroupApi::update_remote(&g, v, &s, None, revoked_key).await?; + // Looked up before the state is read so the change below stays a plain + // function of it. A member who is not an admin never has this key + // consulted, which is what keeps one that never arrived from making + // them unremovable. + let public_key = self + .member_public_key(&db, &g, &group_id, contact_id) + .await + .ok(); + self.update_state_with_retry(&g, |state| { + if !state.member_ids.contains(&contact_id) { + return Ok(None); + } + state.member_ids.retain(|x| *x != contact_id); + let was_admin = state.admin_ids.contains(&contact_id); + state.admin_ids.retain(|x| *x != contact_id); + if !was_admin { + return Ok(Some(AdminKeys::default())); + } + let revoked = public_key.clone().ok_or_else(|| { + TwonlyError::Generic(format!( + "group public key for contact {contact_id} not found" + )) + })?; + Ok(Some(AdminKeys { + add: None, + remove: Some(revoked), + })) + }) + .await?; self.announce(&group_id, "removedMember", Some(contact_id), None, None) .await?; let mut tr = db.pool.begin().await?; diff --git a/rust/src/services/home_widget.rs b/rust/src/services/home_widget.rs index 9d55a4ba..ef37fa51 100644 --- a/rust/src/services/home_widget.rs +++ b/rust/src/services/home_widget.rs @@ -6,6 +6,10 @@ //! //! WidgetKit and AppWidget never open the encrypted application database. They //! only read the JSON and PNG files produced here under the shared data root. +//! +//! Every contact group holds exactly one image β€” the last one a member of it +//! sent β€” and an arriving image deletes the one it replaces. Nothing is kept on +//! a clock, so the manifest is at most as long as the user has contact groups. use crate::api::messages::outgoing::send_c2c_message_to_contact; use crate::api::proto::client::EncryptedContent; @@ -19,7 +23,10 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::sync::Arc; -const WIDGET_LIFETIME_SECONDS: i64 = 24 * 60 * 60; +/// A widget image lives until the next one takes its place. Only images that +/// could not be published yet β€” a download still running, a file that could not +/// be read β€” are kept on a clock, because nothing else would ever remove them. +const ABANDONED_MEDIA_SECONDS: i64 = 24 * 60 * 60; /// Mirrors `staleWidgetSeconds` in the iOS widget: an entry that has not /// refreshed within this belongs to a widget that is no longer placed, and its @@ -52,8 +59,12 @@ struct ManifestImage { media_id: String, path: String, sender: String, + /// The contact groups this image is the current one for. Not the sender's + /// full membership: an older image still holds every group a newer one did + /// not claim, and publishing the full list would offer two images to the + /// same group. group_ids: Vec, - expires_at: i64, + received_at: i64, } #[derive(FromRow)] @@ -63,12 +74,7 @@ struct WidgetMediaRow { sender_id: i64, sender: String, created_at: i64, -} - -#[derive(FromRow)] -struct ExpiredMediaRow { - media_id: String, - media_type: String, + download_state: String, } fn widget_root(ctx: &Context) -> PathBuf { @@ -273,6 +279,14 @@ async fn contact_group_ids_for_sender(ctx: &Arc, sender_id: i64) -> Res .await?) } +/// Publishes the current widget image of every contact group, and deletes +/// everything that is no longer one. +/// +/// A group holds exactly one image: the most recent one a member of it sent. +/// The next arrival takes that place, and the image it replaced is gone β€” not +/// hidden, not aged out, deleted along with its files. Walking the rows newest +/// first and letting each claim only the groups still unclaimed is what makes +/// that true for every group at once, including a sender who is in several. pub async fn refresh_manifest(ctx: &Arc) -> Result<()> { let root = widget_root(ctx); let images_dir = root.join("images"); @@ -286,60 +300,66 @@ pub async fn refresh_manifest(ctx: &Arc) -> Result<()> { .await?; let rows = sqlx::query_as::<_, WidgetMediaRow>( r#"SELECT f.media_id, f.type AS media_type, m.sender_id, - COALESCE(c.display_name, c.username) AS sender, m.created_at + COALESCE(c.display_name, c.username) AS sender, m.created_at, + f.download_state FROM messages m JOIN media_files f ON f.media_id = m.media_id JOIN contacts c ON c.user_id = m.sender_id WHERE m.is_widget_media = 1 AND f.is_widget_media = 1 - AND f.download_state = 'ready' - AND m.created_at > CAST(strftime('%s','now') AS INTEGER) - 86400 ORDER BY m.created_at DESC"#, ) .fetch_all(&database.pool) .await?; drop(database); - let files = MediaFileService::new(ctx); - let mut images = Vec::with_capacity(rows.len()); + let abandoned_before = chrono::Utc::now().timestamp() - ABANDONED_MEDIA_SECONDS; + let mut claimed: BTreeSet = BTreeSet::new(); + let mut images = Vec::new(); + let mut superseded: Vec<(String, String)> = Vec::new(); + for row in rows { - let source = files.temp_path(&row.media_id, &row.media_type); - if !source.exists() { - continue; - } - let destination = images_dir.join(format!("{}.png", row.media_id)); - // One unreadable file must not abort the whole manifest: bailing out - // here would freeze every other widget image at its last version. - let decoded = match image::open(&source) { - Ok(decoded) => decoded, - Err(error) => { - tracing::warn!(media_id = %row.media_id, %error, "widget image decode failed"); + if row.download_state == "ready" { + let holds: Vec = contact_group_ids_for_sender(ctx, row.sender_id) + .await? + .into_iter() + .filter(|group_id| !claimed.contains(group_id)) + .collect(); + // Every group this sender reaches already has something newer, so + // nothing can ever show this again. + if holds.is_empty() { + superseded.push((row.media_id, row.media_type)); + continue; + } + // Claimed only once the file is really on disk in the form the + // widgets read: an image that claims a group it cannot then be + // rendered for would blank that group instead of leaving the + // previous image in place. + if let Some(path) = publish_image(ctx, &images_dir, &row) { + claimed.extend(holds.iter().copied()); + images.push(ManifestImage { + media_id: row.media_id, + path, + sender: row.sender, + group_ids: holds, + received_at: row.created_at, + }); continue; } - }; - let scaled = if decoded.width() > WIDGET_IMAGE_MAX_EDGE - || decoded.height() > WIDGET_IMAGE_MAX_EDGE - { - decoded.resize( - WIDGET_IMAGE_MAX_EDGE, - WIDGET_IMAGE_MAX_EDGE, - image::imageops::FilterType::Triangle, - ) - } else { - decoded - }; - if let Err(error) = scaled.save_with_format(&destination, image::ImageFormat::Png) { - tracing::warn!(media_id = %row.media_id, %error, "widget image encode failed"); - continue; } - images.push(ManifestImage { - media_id: row.media_id, - path: destination.display().to_string(), - sender: row.sender, - group_ids: contact_group_ids_for_sender(ctx, row.sender_id).await?, - expires_at: row.created_at + WIDGET_LIFETIME_SECONDS, - }); + + // Not publishable this round: a download still running, or a file that + // could not be read. Kept so the next refresh can try again β€” but + // something that never became showable would otherwise stay forever, + // since only a successor deletes an image now. + if row.created_at <= abandoned_before { + tracing::info!(media_id = %row.media_id, "dropping abandoned widget media"); + superseded.push((row.media_id, row.media_type)); + } } + remove_media(ctx, &superseded).await?; + remove_orphaned_images(&images_dir, &images); + let manifest = WidgetManifest { version: 1, updated_at: chrono::Utc::now().timestamp(), @@ -353,11 +373,104 @@ pub async fn refresh_manifest(ctx: &Arc) -> Result<()> { Ok(()) } +/// Writes the copy of one image the widgets read, and answers with its path. +/// +/// None means this row cannot be shown right now. One unreadable file must not +/// abort the whole manifest: bailing out here would freeze every other widget +/// image at its last version. +fn publish_image(ctx: &Arc, images_dir: &Path, row: &WidgetMediaRow) -> Option { + let source = MediaFileService::new(ctx).temp_path(&row.media_id, &row.media_type); + if !source.exists() { + return None; + } + let destination = images_dir.join(format!("{}.png", row.media_id)); + let decoded = match image::open(&source) { + Ok(decoded) => decoded, + Err(error) => { + tracing::warn!(media_id = %row.media_id, %error, "widget image decode failed"); + return None; + } + }; + let scaled = + if decoded.width() > WIDGET_IMAGE_MAX_EDGE || decoded.height() > WIDGET_IMAGE_MAX_EDGE { + decoded.resize( + WIDGET_IMAGE_MAX_EDGE, + WIDGET_IMAGE_MAX_EDGE, + image::imageops::FilterType::Triangle, + ) + } else { + decoded + }; + if let Err(error) = scaled.save_with_format(&destination, image::ImageFormat::Png) { + tracing::warn!(media_id = %row.media_id, %error, "widget image encode failed"); + return None; + } + Some(destination.display().to_string()) +} + +/// Drops rendered images no manifest entry names any more. +/// +/// Every path out of [`remove_media`] already deletes the one it owns; this +/// only catches what a crash between the two steps left behind, so that the +/// directory cannot grow by a file nobody will ever look at again. +fn remove_orphaned_images(images_dir: &Path, published: &[ManifestImage]) { + let live: BTreeSet<&str> = published.iter().map(|image| image.path.as_str()).collect(); + let Ok(entries) = std::fs::read_dir(images_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|extension| extension == "png") + && !live.contains(path.display().to_string().as_str()) + { + let _ = std::fs::remove_file(path); + } + } +} + +/// Deletes widget media outright: the message, the media file, and every copy +/// of the image on disk. +/// +/// Widget media exists for the home screen and nowhere else β€” it is hidden from +/// chats and never enters memories β€” so an image no group holds any more has no +/// second place to live on in. +async fn remove_media(ctx: &Arc, media: &[(String, String)]) -> Result<()> { + if media.is_empty() { + return Ok(()); + } + let database = ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + for (media_id, _) in media { + sqlx::query("DELETE FROM messages WHERE media_id = ?") + .bind(media_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM media_files WHERE media_id = ?") + .bind(media_id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + drop(database); + + let files = MediaFileService::new(ctx); + for (media_id, media_type) in media { + files.remove_files(media_id, media_type)?; + let _ = std::fs::remove_file( + widget_root(ctx) + .join("images") + .join(format!("{media_id}.png")), + ); + } + Ok(()) +} + /// Removes one image the user dismissed from a widget, everywhere it lives. /// /// Deliberately not a "hide": the sender shared it into a widget on the home /// screen, so taking it back has to remove the bytes rather than filter them -/// out of one view. +/// out of one view. Its groups then hold nothing until the next image arrives β€” +/// the one it replaced is long gone. pub async fn delete_media(ctx: &Arc, media_id: &str) -> Result<()> { let database = ctx.app_db.read().await.clone(); let media_type = sqlx::query_scalar::<_, String>( @@ -366,64 +479,22 @@ pub async fn delete_media(ctx: &Arc, media_id: &str) -> Result<()> { .bind(media_id) .fetch_optional(&database.pool) .await?; + drop(database); let Some(media_type) = media_type else { - // Already gone: the expiry sweep and the user can race each other. + // Already gone: a newer image and the user can race each other. return Ok(()); }; - let mut transaction = database.pool.begin().await?; - sqlx::query("DELETE FROM messages WHERE media_id = ?") - .bind(media_id) - .execute(&mut *transaction) - .await?; - sqlx::query("DELETE FROM media_files WHERE media_id = ?") - .bind(media_id) - .execute(&mut *transaction) - .await?; - transaction.commit().await?; - drop(database); - - MediaFileService::new(ctx).remove_files(media_id, &media_type)?; - let _ = std::fs::remove_file( - widget_root(ctx) - .join("images") - .join(format!("{media_id}.png")), - ); + remove_media(ctx, &[(media_id.to_string(), media_type)]).await?; refresh_manifest(ctx).await } +/// Republishes the manifest, which is what drops superseded media. +/// +/// Retention is no longer a clock the caller has to tick: an image is deleted +/// by its successor, during the refresh that publishes it. This stays as the +/// maintenance entry point so a wake-up or a launch settles the media that +/// arrived while nothing was running. pub async fn purge_widget_media(ctx: &Arc) -> Result<()> { - let database = ctx.app_db.read().await.clone(); - let expired = sqlx::query_as::<_, ExpiredMediaRow>( - r#"SELECT DISTINCT f.media_id, f.type AS media_type - FROM media_files f JOIN messages m ON m.media_id = f.media_id - WHERE f.is_widget_media = 1 AND m.created_at <= ?"#, - ) - .bind(chrono::Utc::now().timestamp() - WIDGET_LIFETIME_SECONDS) - .fetch_all(&database.pool) - .await?; - let mut transaction = database.pool.begin().await?; - for media in &expired { - sqlx::query("DELETE FROM messages WHERE media_id = ?") - .bind(&media.media_id) - .execute(&mut *transaction) - .await?; - sqlx::query("DELETE FROM media_files WHERE media_id = ?") - .bind(&media.media_id) - .execute(&mut *transaction) - .await?; - } - transaction.commit().await?; - drop(database); - - let files = MediaFileService::new(ctx); - for media in expired { - files.remove_files(&media.media_id, &media.media_type)?; - let _ = std::fs::remove_file( - widget_root(ctx) - .join("images") - .join(format!("{}.png", media.media_id)), - ); - } refresh_manifest(ctx).await } diff --git a/rust/src/services/media_upload.rs b/rust/src/services/media_upload.rs index ba16ff34..d8ecd116 100644 --- a/rust/src/services/media_upload.rs +++ b/rust/src/services/media_upload.rs @@ -44,8 +44,6 @@ const RETRY_MARK_GRACE_SECONDS: i64 = 20; static MEDIA_LOCKS: LazyLock>>>> = LazyLock::new(|| SyncMutex::new(HashMap::new())); -static PREPROCESSING_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -static RETRANSMISSION_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); /// One media file is only ever prepared by one task. Preparation reserves a /// slot, encrypts, and writes request files, so a second concurrent run would @@ -433,7 +431,7 @@ impl MediaUploadService { /// Recovers every upload that a terminated process left mid-flight, and /// settles the transfers the server has meanwhile accepted or rejected. pub async fn finish_started_uploads(&self) -> Result<()> { - let _guard = PREPROCESSING_LOCK.lock().await; + let _guard = self.ctx.media_preprocessing.lock().await; let direct = DirectMediaUploadService::new(&self.ctx); if let Err(error) = direct.reconcile().await { tracing::warn!(%error, "direct media reconciliation failed"); @@ -514,7 +512,7 @@ impl MediaUploadService { /// Periodic maintenance over receipts whose media send never completed. pub async fn reupload_pending(&self) -> Result<()> { - let _guard = RETRANSMISSION_LOCK.lock().await; + let _guard = self.ctx.media_retransmission.lock().await; let now = chrono::Utc::now().timestamp(); let database = self.ctx.app_db.read().await.clone(); let receipts = sqlx::query_as::<_, RetransmissionReceipt>( diff --git a/rust/src/services/messages.rs b/rust/src/services/messages.rs index 3ab4778a..8bd14932 100644 --- a/rust/src/services/messages.rs +++ b/rust/src/services/messages.rs @@ -63,6 +63,18 @@ impl MessageService { Ok(()) } + /// Queues one message to every member from inside the caller's + /// transaction. + /// + /// Everything here runs on `t`. The app database has a single connection, + /// and the caller is holding it, so anything that went to the pool instead + /// would wait thirty seconds for a connection that cannot be freed until + /// this call returns. + /// + /// That is also why `message_id` has to stay `None` here: a persisted + /// message asks user discovery for a version, which needs it initialized, + /// and initializing it writes through the pool. Callers with a message id + /// go through [`Self::send_to_group`], which sends outside a transaction. pub async fn send_to_group_in_transaction( &self, t: &mut sqlx::Transaction<'_, sqlx::Sqlite>, @@ -96,6 +108,7 @@ impl MessageService { let mut contact_content = proto::EncryptedContent::decode(bytes.as_slice())?; decorate_content( &self.ctx, + t, contact_id, &mut contact_content, message_id.is_some(), @@ -116,9 +129,14 @@ impl MessageService { let mut retry_count = 0_i64; let mut last_retry = None; + // A resend of a message the server already took has already pushed + // this member once. The replacement receipt must not ask for a + // second alert about the same message. + let mut already_woken = false; if let Some(msg_id) = &message_id { let previous = sqlx::query!( - r#"SELECT COUNT(*) AS "count!: i64", MAX(last_retry) AS last_retry + r#"SELECT COUNT(*) AS "count!: i64", MAX(last_retry) AS last_retry, + MAX(ack_by_server_at) AS acknowledged FROM receipts WHERE contact_id = ? AND message_id = ?"#, contact_id, msg_id, @@ -127,6 +145,7 @@ impl MessageService { .await?; retry_count = previous.count; last_retry = previous.last_retry; + already_woken = previous.acknowledged.is_some(); sqlx::query!( "DELETE FROM receipts WHERE contact_id = ? AND message_id = ?", contact_id, @@ -140,11 +159,14 @@ impl MessageService { sqlx::query!( r#"UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?, - retry_count = ?, last_retry = ? WHERE receipt_id = ?"#, + retry_count = ?, last_retry = ?, + wake_receiver = CASE WHEN ? THEN 0 ELSE wake_receiver END + WHERE receipt_id = ?"#, message_id, false, // only_return_encrypted_data is false for send_to_group retry_count, last_retry, + already_woken, receipt_id ) .execute(&mut **t) @@ -177,11 +199,17 @@ impl MessageService { /// the other side before showing up here. So the row the UI renders from is /// committed and published on its own, in one transaction, and the send /// runs on a separate task. + /// Sends a text message. + /// + /// `additional_message_data` records where the text came from when the + /// sender did not type it -- today, the webxdc app that produced it. It is + /// stored beside the message so the chat can say so on both devices. pub async fn insert_and_send_text( &self, group_id: String, text: String, quote_message_id: Option, + additional_message_data: Option>, ) -> Result { let database = self.ctx.app_db.read().await.clone(); let message_id = uuid::Uuid::new_v4().to_string(); @@ -195,12 +223,14 @@ impl MessageService { .execute(&mut *t) .await?; sqlx::query!( - r#"INSERT INTO messages(group_id, message_id, type, content, quotes_message_id, created_at) - VALUES (?, ?, 'text', ?, ?, ?)"#, + r#"INSERT INTO messages(group_id, message_id, type, content, quotes_message_id, + additional_message_data, created_at) + VALUES (?, ?, 'text', ?, ?, ?, ?)"#, group_id, message_id, text, quote_message_id, + additional_message_data, timestamp / 1000, ) .execute(&mut *t) @@ -215,6 +245,7 @@ impl MessageService { text, timestamp, quote_message_id, + additional_message_data, }), ..Default::default() } @@ -241,26 +272,36 @@ impl MessageService { Ok(message_id) } + /// Sends one additional-data message and, unless it is hidden, records it + /// in the chat. + /// + /// `hidden` marks state a feature exchanges rather than something a person + /// sent: no row is written, so it cannot show up in a chat or be swept up + /// by the deletion timer, and the receiving side neither notifies nor wakes + /// for it. pub async fn insert_and_send_additional_data( &self, group_id: String, message_type: String, additional_data: Vec, + hidden: bool, ) -> Result { let database = self.ctx.app_db.read().await.clone(); let message_id = uuid::Uuid::new_v4().to_string(); let timestamp = chrono::Utc::now().timestamp_millis(); - sqlx::query!( - r#"INSERT INTO messages(group_id, message_id, type, additional_message_data, created_at) - VALUES (?, ?, ?, ?, ?)"#, - group_id, - message_id, - message_type, - additional_data, - timestamp / 1000, - ) - .execute(&database.pool) - .await?; + if !hidden { + sqlx::query!( + r#"INSERT INTO messages(group_id, message_id, type, additional_message_data, created_at) + VALUES (?, ?, ?, ?, ?)"#, + group_id, + message_id, + message_type, + additional_data, + timestamp / 1000, + ) + .execute(&database.pool) + .await?; + } self.send_to_group( group_id, proto::EncryptedContent { @@ -269,11 +310,21 @@ impl MessageService { additional_message_data: Some(additional_data), timestamp, r#type: message_type, + hidden, }), ..Default::default() } .encode_to_vec(), - Some(message_id.clone()), + // A receipt's `message_id` is a foreign key into `messages`, and a + // hidden message has no row there. It is also what ties a receipt + // to a chat message so delivery state can be shown and a retry + // deduplicated -- neither of which a hidden message has any use + // for, so it goes out unattached. + if hidden { + None + } else { + Some(message_id.clone()) + }, false, ) .await?; @@ -329,9 +380,12 @@ impl MessageService { contacts, restored_flame_counter: None, ask_about_user_id: None, + webxdc_app: None, + webxdc_update: None, + webxdc_origin: None, } .encode_to_vec(); - self.insert_and_send_additional_data(group_id, "contacts".into(), data) + self.insert_and_send_additional_data(group_id, "contacts".into(), data, false) .await } @@ -361,9 +415,12 @@ impl MessageService { contacts: Vec::new(), restored_flame_counter: None, ask_about_user_id: Some(ask_about_user_id), + webxdc_app: None, + webxdc_update: None, + webxdc_origin: None, } .encode_to_vec(); - self.insert_and_send_additional_data(group_id, "askAboutUser".into(), data) + self.insert_and_send_additional_data(group_id, "askAboutUser".into(), data, false) .await } diff --git a/rust/src/services/mod.rs b/rust/src/services/mod.rs index 45c8e323..3adf7b13 100644 --- a/rust/src/services/mod.rs +++ b/rust/src/services/mod.rs @@ -16,3 +16,4 @@ pub mod mediafiles; pub mod messages; pub mod notifications; pub mod outbox_dispatch; +pub mod webxdc; diff --git a/rust/src/services/notifications.rs b/rust/src/services/notifications.rs index cc3c0918..7a75f3c7 100644 --- a/rust/src/services/notifications.rs +++ b/rust/src/services/notifications.rs @@ -26,16 +26,76 @@ static EN_TRANSLATIONS: LazyLock> = static DE_TRANSLATIONS: LazyLock> = LazyLock::new(|| parse_arb(DE_ARB, "de")); -pub(crate) fn should_wake_receiver(content: &proto::EncryptedContent) -> bool { - content.text_message.is_some() - || content.additional_data_message.is_some() - || content.group_create.is_some() +/// The announcement a webxdc update carries, if it has one. +/// +/// The update itself is hidden -- app state rather than something a person +/// sent -- but an `info` is written to be read, and becomes a chat row of its +/// own on arrival. That row is what the receiver is woken and notified for. +fn webxdc_announcement(message: &encrypted_content::AdditionalDataMessage) -> Option { + let data = ::decode( + message.additional_message_data.as_deref()?, + ) + .ok()?; + crate::services::webxdc::announcement(&data.webxdc_update?) +} + +/// Who wrote the message with this id, as this device has it. +/// +/// The two layers say different things and both matter here: the outer `None` +/// means this device has no such message, while an inner `None` means this +/// device wrote it, which is how an own message is stored. +async fn message_sender( + transaction: &mut Transaction<'_, Sqlite>, + message_id: &str, +) -> Result>> { + Ok(sqlx::query_scalar!( + "SELECT sender_id FROM messages WHERE message_id = ?", + message_id + ) + .fetch_optional(&mut **transaction) + .await?) +} + +/// Whether this envelope is worth spending the recipient's push budget on. +/// +/// A wake-up is not free to get wrong. iOS renders the alert the push carried +/// no matter what the extension finds behind it, so an envelope that wakes a +/// device without leaving it anything to show reaches the user as a bare "You +/// got a new message" -- which is why every arm here has to match something +/// [`record_incoming_event`] will actually record. +pub(crate) async fn should_wake_receiver( + transaction: &mut Transaction<'_, Sqlite>, + target_user_id: i64, + content: &proto::EncryptedContent, +) -> Result { + // A reaction concerns the person who wrote the message it lands on and + // nobody else: in a group it wakes that one member rather than all of + // them, and a reaction to one's own message wakes nobody at all. + let reaction_reaches_author = match content.reaction.as_ref().filter(|value| !value.remove) { + Some(reaction) => { + message_sender(transaction, &reaction.target_message_id) + .await? + .flatten() + == Some(target_user_id) + } + None => false, + }; + + Ok(content.text_message.is_some() || content - .media + .additional_data_message .as_ref() - .and_then(|value| encrypted_content::media::Type::try_from(value.r#type).ok()) - .is_some_and(|kind| kind != encrypted_content::media::Type::Reupload) - || content.reaction.as_ref().is_some_and(|value| !value.remove) + .is_some_and(|message| !message.hidden || webxdc_announcement(message).is_some()) + || content.group_create.is_some() + || content.media.as_ref().is_some_and(|value| { + // Widget media lands already opened and is deliberately recorded + // without a notification, so waking for it buys the recipient an + // alert with nothing behind it. + value.widget_only != Some(true) + && encrypted_content::media::Type::try_from(value.r#type) + .is_ok_and(|kind| kind != encrypted_content::media::Type::Reupload) + }) + || reaction_reaches_author || content .media_update .as_ref() @@ -57,7 +117,7 @@ pub(crate) fn should_wake_receiver(content: &proto::EncryptedContent) -> bool { encrypted_content::contact_request::Type::Request | encrypted_content::contact_request::Type::Accept ) - }) + })) } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -205,18 +265,52 @@ pub(crate) async fn record_incoming_event( }) } } else if let Some(message) = content.additional_data_message.as_ref() { - Some(NotificationDraft { + // A hidden message is state a feature exchanges and leaves no row of + // its own, so the only thing in one worth an alert is an app's + // announcement -- and only once the row it materialised into exists, + // which it does not when the update was dropped for an unknown + // instance, a mismatched chat, or an app that is over budget. + let announcement = message + .hidden + .then(|| webxdc_announcement(message)) + .flatten(); + let message_id = if message.hidden { + crate::services::webxdc::WebxdcService::info_message_id(&message.sender_message_id) + } else { + message.sender_message_id.clone() + }; + let worth_an_alert = if message.hidden { + announcement.is_some() && message_sender(transaction, &message_id).await?.is_some() + } else { + true + }; + worth_an_alert.then(|| NotificationDraft { event_id: receipt_id.to_owned(), notification_id: message.sender_message_id.clone(), conversation_id, sender_id: from_user_id, - message_id: Some(message.sender_message_id.clone()), - kind: "text", - content: None, + message_id: Some(message_id), + // An app speaks for itself: the alert reads "Alice is on turn" + // rather than "sent a message", so a game's traffic stays + // distinguishable from the people in the chat talking. + kind: if announcement.is_some() { + "webxdc" + } else { + "text" + }, + content: announcement, created_at: milliseconds_to_seconds(message.timestamp), }) } else if let Some(reaction) = content.reaction.as_ref().filter(|value| !value.remove) { - Some(NotificationDraft { + // Only the author of a message hears about a reaction to it. Everybody + // else in the group watched it land on somebody else's message and has + // nothing to be told, and a reaction the sender put on their own + // message concerns nobody here at all. + matches!( + message_sender(transaction, &reaction.target_message_id).await?, + Some(None) + ) + .then(|| NotificationDraft { event_id: receipt_id.to_owned(), notification_id: receipt_id.to_owned(), conversation_id, @@ -291,7 +385,7 @@ async fn clear_stale_opened(database: &Arc) -> Result<()> { UPDATE notification_outbox SET cleared_at = ? WHERE cleared_at IS NULL - AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly') + AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc') AND EXISTS ( SELECT 1 FROM messages @@ -606,7 +700,7 @@ pub(crate) async fn clear_opened_messages( SET cleared_at = ? WHERE message_id = ? AND cleared_at IS NULL - AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly') + AND kind IN ('text', 'response', 'image', 'video', 'audio', 'twonly', 'webxdc') "#, ) .bind(cleared_at) @@ -672,6 +766,14 @@ fn localized_body(locale: &str, row: &PendingRow) -> String { String::new() }; + // An app's announcement is already a whole sentence written for the + // reader, so it is shown as it is rather than described. + if row.kind == "webxdc" { + if let Some(announcement) = row.content.as_deref().filter(|text| !text.is_empty()) { + return announcement.to_owned(); + } + } + let key = match row.kind.as_str() { "text" => "notificationText", "response" => "notificationResponse", @@ -716,6 +818,45 @@ pub(crate) fn notification_avatar_path( mod tests { use super::*; + fn webxdc_update(info: Option<&str>) -> proto::EncryptedContent { + let data = proto::AdditionalMessageData { + r#type: proto::additional_message_data::Type::WebxdcUpdate as i32, + webxdc_update: Some(proto::WebxdcUpdate { + instance_id: "instance".into(), + payload: "{}".into(), + info: info.map(str::to_owned), + ..Default::default() + }), + ..Default::default() + }; + proto::EncryptedContent { + additional_data_message: Some(encrypted_content::AdditionalDataMessage { + sender_message_id: "message".into(), + timestamp: 0, + r#type: "webxdcUpdate".into(), + additional_message_data: Some(prost::Message::encode_to_vec(&data)), + hidden: true, + }), + ..Default::default() + } + } + + fn announcement_of(content: &proto::EncryptedContent) -> Option { + webxdc_announcement(content.additional_data_message.as_ref().unwrap()) + } + + #[test] + fn an_app_announcement_is_what_a_hidden_update_is_worth_waking_for() { + assert_eq!( + announcement_of(&webxdc_update(Some("Alice is on turn"))).as_deref(), + Some("Alice is on turn") + ); + // A move carries no announcement, and neither does one that is only + // whitespace once the chat row's own bounds are applied to it. + assert!(announcement_of(&webxdc_update(None)).is_none()); + assert!(announcement_of(&webxdc_update(Some(" \u{200e} "))).is_none()); + } + fn pending_row(kind: &str) -> PendingRow { PendingRow { event_id: "event".into(), @@ -758,6 +899,21 @@ mod tests { ); } + #[test] + fn an_app_announcement_is_shown_as_the_app_wrote_it() { + let mut announcement = pending_row("webxdc"); + announcement.content = Some("Alice is on turn".into()); + // Not "sent a message", and not translated either: the app already + // localised the sentence for the reader. + assert_eq!(localized_body("en", &announcement), "Alice is on turn"); + assert_eq!(localized_body("de", &announcement), "Alice is on turn"); + + // An app that manages to lose its own text still reads like a message + // rather than an empty alert. + announcement.content = None; + assert_eq!(localized_body("en", &announcement), "sent a message."); + } + #[test] fn interpolates_group_and_reaction_placeholders() { let mut group = pending_row("text"); diff --git a/rust/src/services/webxdc/bundle.rs b/rust/src/services/webxdc/bundle.rs new file mode 100644 index 00000000..024bf3b7 --- /dev/null +++ b/rust/src/services/webxdc/bundle.rs @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026, Tobias MΓΌller git@tsmr.eu + * + */ + +//! Serving the contents of an `.xdc` bundle to a webview. +//! +//! The bundle is never unpacked. Every request the webview makes is answered by +//! an exact-name lookup in the zip's central directory, which is what makes +//! path traversal structurally impossible rather than merely filtered: there is +//! no filesystem path built from anything the app or a peer controls. +//! +//! This module is the only place bundle bytes reach the webview, so it is also +//! where the response headers that keep the app boxed in are attached. + +use crate::error::{Result, TwonlyError}; +use std::io::Read; +use std::path::Path; + +/// Refuses to expand a single entry beyond this, whatever the zip claims. +const MAX_ENTRY_BYTES: u64 = 32 * 1024 * 1024; + +/// Denies the app every way out of its own origin. +/// +/// The scheme handler already answers requests for anything else with a 403 +/// without touching the network, so this is defence in depth: CSP has a history +/// of gaps (WebRTC, prefetch, plugins) and is not load bearing on its own. +const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \ + script-src 'self' 'unsafe-inline' 'unsafe-eval'; \ + style-src 'self' 'unsafe-inline'; \ + img-src 'self' data: blob:; \ + media-src 'self' data: blob:; \ + font-src 'self' data:; \ + connect-src 'self' data: blob:; \ + worker-src 'self' blob:; \ + object-src 'none'; \ + base-uri 'none'; \ + form-action 'none'; \ + frame-src 'none'; \ + frame-ancestors 'none'"; + +/// Every powerful feature a webview can hand a page, switched off. The native +/// permission callbacks deny these too; a header costs nothing and covers the +/// features whose prompts a platform might answer on its own. +const PERMISSIONS_POLICY: &str = "accelerometer=(), ambient-light-sensor=(), autoplay=(), \ + camera=(), display-capture=(), encrypted-media=(), geolocation=(), gyroscope=(), \ + magnetometer=(), microphone=(), midi=(), payment=(), publickey-credentials-get=(), \ + screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()"; + +pub struct HttpResponse { + pub status: u16, + pub mime: String, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl HttpResponse { + fn new(status: u16, mime: &str, body: Vec) -> Self { + Self { + status, + mime: mime.to_string(), + headers: vec![ + ( + "Content-Security-Policy".into(), + CONTENT_SECURITY_POLICY.into(), + ), + ("Permissions-Policy".into(), PERMISSIONS_POLICY.into()), + ("X-Content-Type-Options".into(), "nosniff".into()), + ("Referrer-Policy".into(), "no-referrer".into()), + ("Cross-Origin-Opener-Policy".into(), "same-origin".into()), + ("Cross-Origin-Embedder-Policy".into(), "require-corp".into()), + ("Cross-Origin-Resource-Policy".into(), "same-origin".into()), + ("Cache-Control".into(), "no-store".into()), + ], + body, + } + } + + fn forbidden() -> Self { + Self::new(403, "text/plain", b"forbidden".to_vec()) + } + + fn not_found() -> Self { + Self::new(404, "text/plain", b"not found".to_vec()) + } +} + +/// The API implementation served in place of anything the bundle ships under +/// that name. Serving it from here rather than injecting it into the document +/// means it is subject to the same CSP as the rest of the app. +const WEBXDC_JS: &str = include_str!("webxdc.js"); + +/// Turns a URL path into the zip entry name it may read, or `None` if it may +/// not read anything. +/// +/// Rejects rather than sanitises: a request that needed cleaning up is a +/// request no honest app makes, and rewriting it would only hide that. +fn entry_name(request_path: &str) -> Option { + let path = request_path + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim_start_matches('/'); + let path = percent_decode(path)?; + + if path.is_empty() { + return Some("index.html".to_string()); + } + if path.len() > 512 + || path.contains('\\') + || path.contains('\0') + || path + .split('/') + .any(|segment| segment == ".." || segment == ".") + { + return None; + } + if path.ends_with('/') { + return Some(format!("{path}index.html")); + } + Some(path) +} + +fn percent_decode(input: &str) -> Option { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let hex = input.get(index + 1..index + 3)?; + out.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + out.push(bytes[index]); + index += 1; + } + } + String::from_utf8(out).ok() +} + +/// Only types a webxdc app has a reason to ship. Anything else is served as an +/// opaque download with `nosniff`, so an unexpected file cannot become script. +fn mime_for(name: &str) -> &'static str { + let extension = name.rsplit('.').next().unwrap_or_default().to_lowercase(); + match extension.as_str() { + "html" | "htm" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "txt" | "md" => "text/plain; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "ico" => "image/x-icon", + "wasm" => "application/wasm", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + "mp3" => "audio/mpeg", + "ogg" | "oga" => "audio/ogg", + "wav" => "audio/wav", + "mp4" => "video/mp4", + "webm" => "video/webm", + _ => "application/octet-stream", + } +} + +/// Answers one webview request against one bundle. +/// +/// `request_path` is the path component of the URL the webview asked for; the +/// caller has already established that the origin belongs to this instance. +/// `init` is the JSON object substituted into `webxdc.js`; it is produced by +/// the caller and never passes through the page. +pub fn serve(bundle_path: &Path, request_path: &str, init: &str) -> HttpResponse { + let Some(name) = entry_name(request_path) else { + return HttpResponse::forbidden(); + }; + + if name == "webxdc.js" { + // Served in place of anything the bundle ships under that name, and + // subject to the same CSP as the rest of the app because it arrives as + // a response rather than as an injected script. Serving it also removes + // the race an injection at document start would have with the page's + // own scripts. + return HttpResponse::new( + 200, + "text/javascript; charset=utf-8", + WEBXDC_JS + .replace("__TWONLY_WEBXDC_INIT__", init) + .into_bytes(), + ); + } + + match read_entry(bundle_path, &name) { + Ok(Some(body)) => HttpResponse::new(200, mime_for(&name), body), + Ok(None) => HttpResponse::not_found(), + Err(error) => { + tracing::warn!("serving {name} from a webxdc bundle failed: {error}"); + HttpResponse::not_found() + } + } +} + +fn read_entry(bundle_path: &Path, name: &str) -> Result>> { + let file = std::fs::File::open(bundle_path)?; + let mut archive = zip::ZipArchive::new(std::io::BufReader::new(file))?; + let mut entry = match archive.by_name(name) { + Ok(entry) => entry, + Err(zip::result::ZipError::FileNotFound) => return Ok(None), + Err(error) => return Err(error.into()), + }; + if !entry.is_file() { + return Ok(None); + } + if entry.size() > MAX_ENTRY_BYTES { + return Err(TwonlyError::Generic(format!( + "webxdc entry {name} declares {} bytes", + entry.size() + ))); + } + + // Reads through a limited reader as well as checking the declared size: the + // declaration is attacker controlled and a lying header must not be able to + // turn into an unbounded allocation. + let mut body = Vec::with_capacity(entry.size().min(1024 * 1024) as usize); + std::io::Read::take(&mut entry, MAX_ENTRY_BYTES + 1).read_to_end(&mut body)?; + if body.len() as u64 > MAX_ENTRY_BYTES { + return Err(TwonlyError::Generic(format!( + "webxdc entry {name} expanded past the entry limit" + ))); + } + Ok(Some(body)) +} + +#[cfg(test)] +mod tests { + use super::entry_name; + + #[test] + fn empty_and_directory_paths_resolve_to_index() { + assert_eq!(entry_name("/").as_deref(), Some("index.html")); + assert_eq!(entry_name("").as_deref(), Some("index.html")); + assert_eq!(entry_name("/sub/").as_deref(), Some("sub/index.html")); + assert_eq!( + entry_name("/index.html?v=2#top").as_deref(), + Some("index.html") + ); + } + + #[test] + fn traversal_is_refused_in_every_encoding() { + for path in [ + "/../secrets", + "/a/../../b", + "/%2e%2e/secrets", + "/%2E%2E%2Fsecrets", + "/a/./b", + "/a\\b", + "/a%00.png", + ] { + assert!(entry_name(path).is_none(), "{path} was not refused"); + } + } + + #[test] + fn ordinary_paths_survive() { + assert_eq!(entry_name("/main.js").as_deref(), Some("main.js")); + assert_eq!( + entry_name("/assets/my%20image.png").as_deref(), + Some("assets/my image.png") + ); + } +} diff --git a/rust/src/services/webxdc/mod.rs b/rust/src/services/webxdc/mod.rs new file mode 100644 index 00000000..daa5aa5d --- /dev/null +++ b/rust/src/services/webxdc/mod.rs @@ -0,0 +1,909 @@ +/* + * Copyright (c) 2026, Tobias MΓΌller git@tsmr.eu + * + */ + +//! Webxdc app instances and their update log. +//! +//! An instance is one app placed into one chat. Its state is the ordered log of +//! updates in `webxdc_updates`, which lives outside `messages` on purpose: the +//! per-chat purge deletes message rows one by one, and a log with holes in it +//! rebuilds a state that is wrong rather than empty. + +pub mod bundle; +pub mod store; + +use crate::api::proto::client as proto; +use crate::context::Context; +use crate::database::app::tables::{MessageType, NewMessage}; +use crate::error::{Result, TwonlyError}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use sqlx::{Sqlite, Transaction}; +use std::sync::Arc; + +/// The spec's default ceiling for one serialised update. +pub const SEND_UPDATE_MAX_SIZE: usize = 128_000; +/// The spec's default pacing hint, advertised to apps as `sendUpdateInterval`. +pub const SEND_UPDATE_INTERVAL_MS: i64 = 10_000; + +/// Bounds on how much an app may accumulate. An app is third-party code that +/// can call `sendUpdate` in a loop, so both the count and the total size need a +/// ceiling that does not depend on the app behaving. +const MAX_UPDATES_PER_INSTANCE: i64 = 100_000; +const MAX_PAYLOAD_BYTES_PER_INSTANCE: i64 = 32 * 1024 * 1024; +/// Sending burst allowance, counted over the trailing minute. +const MAX_UPDATES_PER_MINUTE: i64 = 12; + +/// Text an app can put in front of the user, outside its own sandbox. Bounded +/// so a chat row or a notification cannot be filled with app-controlled text. +const MAX_INFO_CHARS: usize = 200; +const MAX_SUMMARY_CHARS: usize = 64; +const MAX_DOCUMENT_CHARS: usize = 128; + +pub struct WebxdcInstance { + pub instance_id: String, + pub group_id: String, + pub app_id: String, + pub version: i64, + pub bundle_sha256: Option, + pub origin_token: String, + pub summary: Option, + pub document: Option, +} + +pub struct WebxdcUpdate { + pub serial: i64, + pub payload: String, + pub info: Option, + pub href: Option, + /// `None` when this device sent the update. + pub sender_id: Option, +} + +pub struct WebxdcService { + ctx: Arc, +} + +impl WebxdcService { + pub fn new(ctx: &Arc) -> Self { + Self { ctx: ctx.clone() } + } + + fn store(&self) -> store::WebxdcStore { + store::WebxdcStore::new(&self.ctx) + } + + /// Places an app into a chat and tells the other members which app and + /// which version, so every participant runs the same code. + pub async fn create_instance( + &self, + group_id: String, + app_id: String, + version: i64, + ) -> Result { + // Resolved before anything is sent: pointing a chat at an app the store + // does not offer should fail here rather than on every recipient's + // device. + self.store().resolve_bundle_sha256(&app_id, version).await?; + + let data = proto::AdditionalMessageData { + r#type: proto::additional_message_data::Type::WebxdcApp as i32, + webxdc_app: Some(proto::WebxdcApp { + app_id: app_id.clone(), + version, + }), + ..Default::default() + }; + let message_id = crate::services::messages::MessageService::new(&self.ctx) + .insert_and_send_additional_data( + group_id.clone(), + "webxdcApp".into(), + prost::Message::encode_to_vec(&data), + // The card is a real message: it shows in the chat and is worth + // a notification. + false, + ) + .await?; + + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + Self::insert_instance(&mut transaction, &message_id, &group_id, &app_id, version).await?; + transaction.commit().await?; + Ok(message_id) + } + + /// Records the instance a peer placed into a chat. Nothing is downloaded + /// here: a message must not be able to make a device fetch a bundle, only + /// the user tapping Start may. + pub(crate) async fn handle_incoming_app( + transaction: &mut Transaction<'_, Sqlite>, + message_id: &str, + group_id: &str, + app: &proto::WebxdcApp, + ) -> Result<()> { + Self::insert_instance(transaction, message_id, group_id, &app.app_id, app.version).await + } + + async fn insert_instance( + transaction: &mut Transaction<'_, Sqlite>, + instance_id: &str, + group_id: &str, + app_id: &str, + version: i64, + ) -> Result<()> { + // Unguessable and per instance: this becomes the webview origin, and + // the origin is what keeps one instance's stored data out of reach of + // every other instance. + let origin_token = uuid::Uuid::new_v4().simple().to_string(); + sqlx::query!( + r#"INSERT INTO webxdc_instances + (instance_id, group_id, app_id, version, origin_token) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(instance_id) DO NOTHING"#, + instance_id, + group_id, + app_id, + version, + origin_token, + ) + .execute(&mut **transaction) + .await?; + Ok(()) + } + + /// Appends a peer's update to the log. + /// + /// The instance is looked up by the id in the payload and checked against + /// the chat the message actually arrived in, so a member of one chat cannot + /// inject state into a game being played in another. + pub(crate) async fn handle_incoming_update( + transaction: &mut Transaction<'_, Sqlite>, + message_id: &str, + group_id: &str, + sender_id: i64, + update: &proto::WebxdcUpdate, + received_at: i64, + ) -> Result<()> { + let instance = sqlx::query!( + r#"SELECT group_id AS "group_id!: String", app_id AS "app_id!: String", + version AS "version!: i64" + FROM webxdc_instances WHERE instance_id = ?"#, + update.instance_id, + ) + .fetch_optional(&mut **transaction) + .await?; + + let Some(instance) = instance else { + // The card may still be on its way, or may already have been + // deleted. Dropping the update is the only safe answer: a log entry + // with no instance cannot be replayed in order. + tracing::info!("webxdc update for an unknown instance, dropping"); + return Ok(()); + }; + if instance.group_id != group_id { + tracing::warn!("webxdc update arrived in the wrong chat, dropping"); + return Ok(()); + } + if update.payload.len() > SEND_UPDATE_MAX_SIZE { + tracing::warn!("webxdc update exceeds the payload limit, dropping"); + return Ok(()); + } + if !Self::within_instance_budget(transaction, &update.instance_id).await? { + tracing::warn!("webxdc instance is over budget, dropping update"); + return Ok(()); + } + + let info = announcement(update); + let href = update.href.as_deref().and_then(sanitize_href); + let summary = sanitize(update.summary.as_deref(), MAX_SUMMARY_CHARS); + let document = sanitize(update.document.as_deref(), MAX_DOCUMENT_CHARS); + + Self::append_update( + transaction, + &update.instance_id, + message_id, + Some(sender_id), + &update.payload, + info.as_deref(), + href.as_deref(), + received_at, + ) + .await?; + Self::apply_instance_labels( + transaction, + &update.instance_id, + summary.as_deref(), + document.as_deref(), + received_at, + ) + .await?; + + if let Some(info) = info.as_deref() { + Self::insert_info_message( + transaction, + &update.instance_id, + &instance.app_id, + instance.version, + group_id, + message_id, + Some(sender_id), + info, + received_at, + ) + .await?; + } + + Ok(()) + } + + /// Writes an update's `info` into the chat as a message of its own. + /// + /// An app announces things a person is meant to read -- "a new game + /// started", "it is your turn". The update carrying it is state rather than + /// a message and stays hidden, so the announcement is materialised here + /// instead: an ordinary chat row that names the app it came from and points + /// back at its card. + /// + /// Both devices build the row out of the update they already have, so + /// nothing extra is sent, and the id derived from the update's own message + /// id makes a redelivery a no-op rather than a second announcement. + #[allow(clippy::too_many_arguments)] + async fn insert_info_message( + transaction: &mut Transaction<'_, Sqlite>, + instance_id: &str, + app_id: &str, + version: i64, + group_id: &str, + update_message_id: &str, + sender_id: Option, + info: &str, + created_at: i64, + ) -> Result<()> { + let origin = proto::AdditionalMessageData { + r#type: proto::additional_message_data::Type::WebxdcSent as i32, + webxdc_origin: Some(proto::WebxdcOrigin { + instance_id: instance_id.to_owned(), + app_id: app_id.to_owned(), + version, + }), + ..Default::default() + }; + let origin = prost::Message::encode_to_vec(&origin); + + NewMessage::builder() + .group_id(group_id) + .message_id(&Self::info_message_id(update_message_id)) + .message_type(MessageType::Text) + .content(info) + .maybe_sender_id(sender_id) + .additional_message_data(&origin) + .created_at(created_at) + // The announcement is built locally from an update that has already + // arrived, so there is nothing left to wait for. + .ack_by_server(created_at) + .build() + .insert(transaction) + .await + } + + /// The id of the chat row an update's `info` becomes. + pub(crate) fn info_message_id(update_message_id: &str) -> String { + format!("{update_message_id}-info") + } + + /// Assigns the next local serial and stores the entry. + /// + /// Serials are per instance, gap free and never reused, which is exactly + /// what `setUpdateListener` promises the app. They are local: two devices + /// number the same update differently, and neither ever sends its numbering + /// to the other. + #[allow(clippy::too_many_arguments)] + async fn append_update( + transaction: &mut Transaction<'_, Sqlite>, + instance_id: &str, + message_id: &str, + sender_id: Option, + payload: &str, + info: Option<&str>, + href: Option<&str>, + received_at: i64, + ) -> Result<()> { + let next_serial: i64 = sqlx::query_scalar!( + r#"SELECT COALESCE(MAX(serial), 0) + 1 AS "next!: i64" + FROM webxdc_updates WHERE instance_id = ?"#, + instance_id, + ) + .fetch_one(&mut **transaction) + .await?; + + // `message_id` is unique, so a redelivered message is a no-op rather + // than a duplicated move. + sqlx::query!( + r#"INSERT INTO webxdc_updates + (instance_id, serial, message_id, sender_id, payload, info, href, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(message_id) DO NOTHING"#, + instance_id, + next_serial, + message_id, + sender_id, + payload, + info, + href, + received_at, + ) + .execute(&mut **transaction) + .await?; + Ok(()) + } + + async fn apply_instance_labels( + transaction: &mut Transaction<'_, Sqlite>, + instance_id: &str, + summary: Option<&str>, + document: Option<&str>, + received_at: i64, + ) -> Result<()> { + sqlx::query!( + r#"UPDATE webxdc_instances + SET summary = COALESCE(?, summary), + document = COALESCE(?, document), + last_update_at = ? + WHERE instance_id = ?"#, + summary, + document, + received_at, + instance_id, + ) + .execute(&mut **transaction) + .await?; + Ok(()) + } + + async fn within_instance_budget( + transaction: &mut Transaction<'_, Sqlite>, + instance_id: &str, + ) -> Result { + let row = sqlx::query!( + r#"SELECT COUNT(*) AS "count!: i64", + COALESCE(SUM(LENGTH(payload)), 0) AS "bytes!: i64" + FROM webxdc_updates WHERE instance_id = ?"#, + instance_id, + ) + .fetch_one(&mut **transaction) + .await?; + Ok(row.count < MAX_UPDATES_PER_INSTANCE && row.bytes < MAX_PAYLOAD_BYTES_PER_INSTANCE) + } + + /// Sends an update the running app produced. + /// + /// Everything crossing the bridge from the webview is checked here rather + /// than in `webxdc.js`, which the app can rewrite at will. + pub async fn send_update( + &self, + instance_id: String, + payload: String, + info: Option, + href: Option, + summary: Option, + document: Option, + ) -> Result<()> { + if payload.len() > SEND_UPDATE_MAX_SIZE { + return Err(TwonlyError::Generic(format!( + "update of {} bytes exceeds sendUpdateMaxSize", + payload.len() + ))); + } + let instance = self + .instance(&instance_id) + .await? + .ok_or_else(|| TwonlyError::Generic("unknown webxdc instance".into()))?; + + let database = self.ctx.app_db.read().await.clone(); + let now = chrono::Utc::now().timestamp(); + let minute_ago = now - 60; + let recent: i64 = sqlx::query_scalar!( + r#"SELECT COUNT(*) AS "count!: i64" FROM webxdc_updates + WHERE instance_id = ? AND sender_id IS NULL AND received_at > ?"#, + instance_id, + minute_ago, + ) + .fetch_one(&database.pool) + .await?; + if recent >= MAX_UPDATES_PER_MINUTE { + return Err(TwonlyError::Generic( + "this app is sending updates too quickly".into(), + )); + } + + let info = sanitize(info.as_deref(), MAX_INFO_CHARS); + let href = href.as_deref().and_then(sanitize_href); + let summary = sanitize(summary.as_deref(), MAX_SUMMARY_CHARS); + let document = sanitize(document.as_deref(), MAX_DOCUMENT_CHARS); + + let data = proto::AdditionalMessageData { + r#type: proto::additional_message_data::Type::WebxdcUpdate as i32, + webxdc_update: Some(proto::WebxdcUpdate { + instance_id: instance_id.clone(), + payload: payload.clone(), + info: info.clone(), + href: href.clone(), + summary: summary.clone(), + document: document.clone(), + }), + ..Default::default() + }; + let message_id = crate::services::messages::MessageService::new(&self.ctx) + .insert_and_send_additional_data( + instance.group_id.clone(), + "webxdcUpdate".into(), + prost::Message::encode_to_vec(&data), + // App state, not a message. The log in `webxdc_updates` is the + // record; a chat row would only be noise, and a notification + // for every move would be worse. + true, + ) + .await?; + + let mut transaction = database.pool.begin().await?; + Self::append_update( + &mut transaction, + &instance_id, + &message_id, + None, + &payload, + info.as_deref(), + href.as_deref(), + now, + ) + .await?; + Self::apply_instance_labels( + &mut transaction, + &instance_id, + summary.as_deref(), + document.as_deref(), + now, + ) + .await?; + + // The same row every other member of the chat builds when the update + // reaches them, so what was announced reads the same everywhere. + if let Some(info) = info.as_deref() { + Self::insert_info_message( + &mut transaction, + &instance_id, + &instance.app_id, + instance.version, + &instance.group_id, + &message_id, + None, + info, + now, + ) + .await?; + } + + transaction.commit().await?; + Ok(()) + } + + pub async fn instance(&self, instance_id: &str) -> Result> { + let database = self.ctx.app_db.read().await.clone(); + let row = sqlx::query!( + r#"SELECT instance_id AS "instance_id!: String", group_id AS "group_id!: String", + app_id AS "app_id!: String", version AS "version!: i64", + bundle_sha256 AS "bundle_sha256: String", + origin_token AS "origin_token!: String", + summary AS "summary: String", document AS "document: String" + FROM webxdc_instances WHERE instance_id = ?"#, + instance_id, + ) + .fetch_optional(&database.pool) + .await?; + Ok(row.map(|row| WebxdcInstance { + instance_id: row.instance_id, + group_id: row.group_id, + app_id: row.app_id, + version: row.version, + bundle_sha256: row.bundle_sha256, + origin_token: row.origin_token, + summary: row.summary, + document: row.document, + })) + } + + /// Everything the app has not seen yet, in serial order. + pub async fn updates_after(&self, instance_id: &str, serial: i64) -> Result> { + let database = self.ctx.app_db.read().await.clone(); + let rows = sqlx::query!( + r#"SELECT serial AS "serial!: i64", payload AS "payload!: String", + info AS "info: String", href AS "href: String", + sender_id AS "sender_id: i64" + FROM webxdc_updates + WHERE instance_id = ? AND serial > ? + ORDER BY serial ASC"#, + instance_id, + serial, + ) + .fetch_all(&database.pool) + .await?; + Ok(rows + .into_iter() + .map(|row| WebxdcUpdate { + serial: row.serial, + payload: row.payload, + info: row.info, + href: row.href, + sender_id: row.sender_id, + }) + .collect()) + } + + /// Starts an app: checks the store for a newer version, and makes the + /// bundle the instance ends up on available on disk. + /// + /// Called when the user starts the app, never on message arrival, and never + /// again while the app runs. This is the one moment an update may land: the + /// code behind a game changes between two of its runs, never between two of + /// its own updates. + pub async fn prepare_bundle(&self, instance_id: &str) -> Result { + let instance = self + .instance(instance_id) + .await? + .ok_or_else(|| TwonlyError::Generic("unknown webxdc instance".into()))?; + + // A refresh that fails means this device is offline or the server is + // down. Neither is a reason to refuse to start an app whose bundle is + // already here, so the failure is logged and the instance keeps the + // version it has. + if let Err(error) = self.store().refresh_catalog().await { + tracing::info!("checking for a webxdc update failed: {error}"); + } + if let Some(path) = self.apply_update(&instance, instance_id).await? { + return Ok(path); + } + self.pinned_bundle(instance_id).await + } + + /// The bundle an instance is pinned to, on disk and verified. + /// + /// No catalog lookup and no update check: this answers every file the + /// running app asks for, so all of them have to come out of the one bundle + /// the app started with. Only an instance that has never run here still + /// needs its hash resolved, which is what the fallback is for. + pub async fn pinned_bundle(&self, instance_id: &str) -> Result { + let instance = self + .instance(instance_id) + .await? + .ok_or_else(|| TwonlyError::Generic("unknown webxdc instance".into()))?; + let store = self.store(); + + let sha256 = match instance.bundle_sha256 { + Some(sha256) => sha256, + None => { + let sha256 = store + .resolve_bundle_sha256(&instance.app_id, instance.version) + .await?; + Self::pin(&self.ctx, instance_id, instance.version, &sha256).await?; + sha256 + } + }; + store.ensure_bundle(&sha256).await + } + + /// Moves an instance to a newer published version, if there is one. + /// + /// Returns the bundle it moved to, or `None` when the instance is to stay + /// where it is -- because nothing newer is published, or because the newer + /// bundle could not be fetched. The pin is written only after the download + /// verified: an instance must never end up pointing at code this device + /// does not have, when it does have the code it was running before. + async fn apply_update( + &self, + instance: &WebxdcInstance, + instance_id: &str, + ) -> Result> { + let store = self.store(); + let Some((version, sha256)) = store.newest_published(&instance.app_id).await? else { + return Ok(None); + }; + if version <= instance.version { + return Ok(None); + } + + match store.ensure_bundle(&sha256).await { + Ok(path) => { + Self::pin(&self.ctx, instance_id, version, &sha256).await?; + tracing::info!( + "webxdc {} moved from version {} to {version}", + instance.app_id, + instance.version, + ); + if let Some(previous) = &instance.bundle_sha256 { + self.discard_unused_bundle(previous).await; + } + Ok(Some(path)) + } + // Nothing has been written yet, so the caller starts the version + // the instance already had. + Err(error) if instance.bundle_sha256.is_some() => { + tracing::warn!("fetching the webxdc update failed: {error}"); + Ok(None) + } + // Nothing to fall back to: this instance has never run here. + Err(error) => Err(error), + } + } + + /// Deletes a bundle nothing points at any more, after an update moved the + /// last instance off it. + /// + /// Bundles are content addressed and shared between instances, so the file + /// goes only once no instance is pinned to it. Best effort: a bundle left + /// behind costs disk space and nothing else. + async fn discard_unused_bundle(&self, sha256: &str) { + let database = self.ctx.app_db.read().await.clone(); + let still_pinned = sqlx::query_scalar!( + r#"SELECT COUNT(*) AS "count!: i64" FROM webxdc_instances WHERE bundle_sha256 = ?"#, + sha256, + ) + .fetch_one(&database.pool) + .await; + if !matches!(still_pinned, Ok(0)) { + return; + } + if let Err(error) = std::fs::remove_file(self.store().bundle_path(sha256)) { + tracing::info!("the superseded webxdc bundle stayed on disk: {error}"); + } + } + + /// Records the exact code an instance runs. Everything for the rest of this + /// run -- the files served, the bundle verified -- follows from it. + async fn pin(ctx: &Arc, instance_id: &str, version: i64, sha256: &str) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + sqlx::query!( + "UPDATE webxdc_instances SET version = ?, bundle_sha256 = ? WHERE instance_id = ?", + version, + sha256, + instance_id, + ) + .execute(&database.pool) + .await?; + Ok(()) + } + + /// The values `webxdc.js` is served with, as a JSON object. + /// + /// Produced here rather than passed in, so what the app is told about its + /// user is decided on this side. `selfAddr` is a per-instance pseudonym; + /// `selfName` is the display name the chat already shows. + pub async fn init_script_values(&self, instance_id: &str) -> Result { + let display_name = crate::user_config::UserConfig::load_from(&self.ctx)? + .map(|config| config.display_name) + .unwrap_or_default(); + let user_id = crate::user_config::UserConfig::load_from(&self.ctx)? + .map(|config| config.user_id) + .unwrap_or_default(); + Ok(serde_json::json!({ + "selfAddr": Self::address_for(instance_id, user_id), + "selfName": display_name, + "sendUpdateInterval": SEND_UPDATE_INTERVAL_MS, + "sendUpdateMaxSize": SEND_UPDATE_MAX_SIZE, + }) + .to_string()) + } + + /// The address the app sees for a participant. + /// + /// Derived from the instance id, which every participant knows and nobody + /// outside the chat does, so the same peer looks identical on every device + /// while the same user in two different chats does not. + pub fn address_for(instance_id: &str, user_id: i64) -> String { + let mut mac = as Mac>::new_from_slice(instance_id.as_bytes()) + .expect("hmac accepts keys of any length"); + mac.update(&user_id.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + format!("{}@twonly", hex::encode(&digest[..8])) + } + + /// Removes an instance and everything it accumulated, returning the origin + /// whose web storage the platform layer still has to clear. The update log + /// is only half the state: an app is free to keep everything in + /// `localStorage`, which no database delete can reach. + pub async fn delete_instance(&self, instance_id: &str) -> Result> { + let Some(instance) = self.instance(instance_id).await? else { + return Ok(None); + }; + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + sqlx::query!( + "DELETE FROM webxdc_updates WHERE instance_id = ?", + instance_id + ) + .execute(&mut *transaction) + .await?; + sqlx::query!( + "DELETE FROM webxdc_instances WHERE instance_id = ?", + instance_id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(Some(instance.origin_token)) + } +} + +/// Strips what has no business in a chat row and bounds the length. +/// +/// `info`, `summary` and `document` are written by the app and rendered outside +/// its sandbox, so they are plain text: no markup, no formatting, and no +/// direction overrides, which are otherwise enough to make a row read as +/// something it is not. +/// The announcement an update carries, bounded and stripped exactly as the +/// chat row built from it will be. +/// +/// Every reader of an update's `info` goes through this, so what a device wakes +/// for, what it notifies for, and what it writes into the chat can never +/// disagree -- an app cannot buy a push with an `info` that turns out to be +/// nothing but spaces. +pub(crate) fn announcement(update: &proto::WebxdcUpdate) -> Option { + sanitize(update.info.as_deref(), MAX_INFO_CHARS) +} + +fn sanitize(text: Option<&str>, max_chars: usize) -> Option { + let text = text?; + let cleaned: String = text + .chars() + .filter(|c| { + !c.is_control() + && !matches!( + c, + '\u{200e}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' + ) + }) + .take(max_chars) + .collect(); + let cleaned = cleaned.trim().to_string(); + (!cleaned.is_empty()).then_some(cleaned) +} + +/// `href` decides which document the app opens when a chat row is tapped, so it +/// may only ever address the app's own files. +fn sanitize_href(href: &str) -> Option { + let href = href.trim(); + if href.is_empty() || href.len() > 512 { + return None; + } + // Anything that could name another origin or another scheme is refused + // outright rather than rewritten. + if href.starts_with("//") || href.contains(':') || href.contains('\\') || href.contains('\0') { + return None; + } + let path = href.split(['?', '#']).next().unwrap_or_default(); + if path.split('/').any(|segment| segment == "..") { + return None; + } + Some(href.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{proto, sanitize, sanitize_href, Context, Result, WebxdcService}; + + /// The announcement lands in the chat as an ordinary message, and lands + /// there once however often the update it came with is delivered. + #[tokio::test] + async fn an_announcement_becomes_a_chat_message_of_its_own() -> Result<()> { + let directory = tempfile::tempdir()?; + let context = + Context::init_for_testing(directory.path().join("db"), directory.path().join("data")) + .await?; + let database = context.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + + // A chat with an app card in it, which is what an instance is keyed by. + sqlx::query!("INSERT INTO groups(group_id, group_name) VALUES ('group-1', 'Chat')") + .execute(&mut *transaction) + .await?; + sqlx::query!( + r#"INSERT INTO messages(group_id, message_id, type, created_at) + VALUES ('group-1', 'card-1', 'webxdcApp', 0)"# + ) + .execute(&mut *transaction) + .await?; + + for _ in 0..2 { + WebxdcService::insert_info_message( + &mut transaction, + "card-1", + "mills", + 3, + "group-1", + "update-1", + None, + "Alice is on turn", + 1234, + ) + .await?; + } + + let rows = sqlx::query!( + r#"SELECT message_id AS "message_id!: String", content AS "content: String", + additional_message_data AS "data: Vec", created_at AS "created_at!: i64" + FROM messages WHERE type = 'text'"# + ) + .fetch_all(&mut *transaction) + .await?; + + assert_eq!(rows.len(), 1, "the same update announced twice"); + assert_eq!( + rows[0].message_id, + WebxdcService::info_message_id("update-1") + ); + assert_eq!(rows[0].content.as_deref(), Some("Alice is on turn")); + assert_eq!(rows[0].created_at, 1234); + + // The card the row points back at. + let data = ::decode( + rows[0].data.as_deref().expect("no origin recorded"), + ) + .expect("the origin does not decode"); + let origin = data.webxdc_origin.expect("no app named"); + assert_eq!(origin.instance_id, "card-1"); + assert_eq!(origin.app_id, "mills"); + assert_eq!(origin.version, 3); + + Ok(()) + } + + #[test] + fn sanitize_drops_control_and_direction_characters() { + assert_eq!( + sanitize(Some("your\u{202e}turn\n"), 200).as_deref(), + Some("yourturn") + ); + assert_eq!(sanitize(Some(" "), 200), None); + assert_eq!(sanitize(Some("abcdef"), 3).as_deref(), Some("abc")); + } + + #[test] + fn hrefs_may_only_address_the_app_itself() { + assert_eq!( + sanitize_href("board.html?id=3").as_deref(), + Some("board.html?id=3") + ); + assert_eq!( + sanitize_href("/sub/page.html").as_deref(), + Some("/sub/page.html") + ); + for href in [ + "https://example.com", + "//example.com", + "javascript:alert(1)", + "data:text/html,x", + "../../etc/passwd", + "a\\b", + ] { + assert!(sanitize_href(href).is_none(), "{href} was not refused"); + } + } + + #[test] + fn an_announcement_belongs_to_the_update_it_arrived_with() { + // Derived rather than random, so the same update redelivered writes the + // same row instead of announcing twice. + let id = WebxdcService::info_message_id("update-1"); + assert_eq!(id, WebxdcService::info_message_id("update-1")); + assert_ne!(id, WebxdcService::info_message_id("update-2")); + assert_ne!(id, "update-1"); + } + + #[test] + fn addresses_are_stable_per_instance_and_differ_across_instances() { + let a = WebxdcService::address_for("instance-a", 42); + assert_eq!(a, WebxdcService::address_for("instance-a", 42)); + assert_ne!(a, WebxdcService::address_for("instance-b", 42)); + assert_ne!(a, WebxdcService::address_for("instance-a", 43)); + } +} diff --git a/rust/src/services/webxdc/store.rs b/rust/src/services/webxdc/store.rs new file mode 100644 index 00000000..73c735d7 --- /dev/null +++ b/rust/src/services/webxdc/store.rs @@ -0,0 +1,378 @@ +/* + * Copyright (c) 2026, Tobias MΓΌller git@tsmr.eu + * + */ + +//! The twonly webxdc store: catalog metadata and bundle downloads. +//! +//! Apps are published through the admin panel and served by the API, so a +//! sender can only ever point at code twonly has already published. What +//! arrives in a message is an id and a version, never a bundle. + +use crate::api::proto::http_requests::{WebxdcCatalog, WebxdcCatalogEntry}; +use crate::bridge::api::RustApi; +use crate::context::Context; +use crate::error::{Result, TwonlyError}; +use prost::Message as ProstMessage; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use std::sync::Arc; + +/// No published app may be larger than this, whatever the catalog claims. The +/// catalog is fetched over TLS from our own server, but it is still the input +/// that decides how much a client downloads and stores. +const MAX_BUNDLE_BYTES: i64 = 32 * 1024 * 1024; + +/// What the server accepts in a manifest, checked again here. A description is +/// one line under an app's name in the store, whatever the catalog carries. +const MAX_NAME_CHARS: usize = 128; +const MAX_DESCRIPTION_CHARS: usize = 200; +const MAX_LANGUAGES: usize = 16; + +/// Ids are opaque and are never used to build a path or a URL beyond the +/// catalog lookup, but a strict charset keeps the failure obvious if that ever +/// changes. +fn is_valid_app_id(app_id: &str) -> bool { + !app_id.is_empty() + && app_id.len() <= 64 + && app_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + && !app_id.starts_with('.') +} + +/// One translated catalog field -- a name or a description -- in the language +/// the reader asked for. +/// +/// `languages` is what the UI prefers, most preferred first. An exact tag wins, +/// then one sharing its primary language (`de-at` for a reader of `de`), then +/// English, then whatever the app has at all: a name or a description in the +/// wrong language still says more about an app than a blank line does. +pub fn pick_localized(translations: &str, languages: &[String]) -> Option { + let by_language: std::collections::BTreeMap = + serde_json::from_str(translations).ok()?; + for language in languages.iter().map(|language| language.to_lowercase()) { + if let Some(text) = by_language.get(&language) { + return Some(text.clone()); + } + let primary = language.split('-').next().unwrap_or(&language); + if let Some((_, text)) = by_language + .iter() + .find(|(tag, _)| tag.split('-').next() == Some(primary)) + { + return Some(text.clone()); + } + } + by_language + .get("en") + .or_else(|| by_language.values().next()) + .cloned() +} + +pub struct WebxdcStore { + ctx: Arc, +} + +impl WebxdcStore { + pub fn new(ctx: &Arc) -> Self { + Self { ctx: ctx.clone() } + } + + fn url(path: &str) -> String { + format!("{}webxdc/{path}", RustApi::api_base_url("https".into())) + } + + fn client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| TwonlyError::Generic(error.to_string())) + } + + /// Brings the cached catalog in line with what the server publishes. + /// + /// Only metadata is fetched: names, icons, sizes and hashes. Bundles are + /// downloaded when a user actually starts an app, so browsing the store + /// costs one small request rather than every app. + /// + /// An app the server no longer offers is not dropped if an instance on this + /// device points at it: unpublishing takes an app out of the store, it does + /// not take it away from the chats it was already placed in. + pub async fn refresh_catalog(&self) -> Result<()> { + let response = Self::client()? + .get(Self::url("catalog")) + .send() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + if !response.status().is_success() { + return Err(TwonlyError::Generic(format!( + "webxdc catalog returned {}", + response.status() + ))); + } + let bytes = response + .bytes() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let catalog = WebxdcCatalog::decode(bytes.as_ref())?; + + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + // Everything the catalog no longer carries goes, except what a chat on + // this device is still running. What survives is marked unpublished, so + // it can be started but not placed into another chat; the rows the + // catalog does carry are marked published again right below. + sqlx::query!( + r#"DELETE FROM webxdc_apps + WHERE NOT EXISTS (SELECT 1 FROM webxdc_instances + WHERE webxdc_instances.app_id = webxdc_apps.app_id + AND webxdc_instances.version = webxdc_apps.version)"# + ) + .execute(&mut *transaction) + .await?; + sqlx::query!("UPDATE webxdc_apps SET published = 0") + .execute(&mut *transaction) + .await?; + let now = chrono::Utc::now().timestamp(); + for entry in &catalog.entries { + if !Self::entry_is_sane(entry) { + tracing::warn!(app_id = entry.app_id, "skipping unusable catalog entry"); + continue; + } + let icon = (!entry.icon.is_empty()).then(|| entry.icon.clone()); + // Every translation is kept, and the one to show is chosen when the + // store is opened: the language a user reads is not something this + // device tells the server by asking for one. + let description = + serde_json::to_string(&entry.description).unwrap_or_else(|_| "{}".to_string()); + let name_translations = serde_json::to_string(&entry.name_translations) + .unwrap_or_else(|_| "{}".to_string()); + sqlx::query!( + r#"INSERT INTO webxdc_apps + (app_id, version, name, name_translations, source_code_url, + description, icon, bundle_sha256, bundle_bytes, published, + cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + ON CONFLICT(app_id, version) DO UPDATE SET + name = excluded.name, + name_translations = excluded.name_translations, + source_code_url = excluded.source_code_url, + description = excluded.description, + icon = excluded.icon, + bundle_sha256 = excluded.bundle_sha256, + bundle_bytes = excluded.bundle_bytes, + published = 1, + cached_at = excluded.cached_at"#, + entry.app_id, + entry.version, + entry.name, + name_translations, + entry.source_code_url, + description, + icon, + entry.bundle_sha256, + entry.bundle_bytes, + now, + ) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(()) + } + + fn entry_is_sane(entry: &WebxdcCatalogEntry) -> bool { + is_valid_app_id(&entry.app_id) + && entry.version >= 0 + && !entry.name.is_empty() + && entry.name.chars().count() <= MAX_NAME_CHARS + && entry.name_translations.len() <= MAX_LANGUAGES + && entry + .name_translations + .values() + .all(|name| name.chars().count() <= MAX_NAME_CHARS) + && entry.description.len() <= MAX_LANGUAGES + && entry + .description + .values() + .all(|text| text.chars().count() <= MAX_DESCRIPTION_CHARS) + && entry.bundle_bytes > 0 + && entry.bundle_bytes <= MAX_BUNDLE_BYTES + && entry.bundle_sha256.len() == 64 + && entry + .bundle_sha256 + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + } + + fn bundles_dir(&self) -> PathBuf { + PathBuf::from(&self.ctx.config.data_dir) + .join("webxdc") + .join("bundles") + } + + /// Bundles are content addressed, so two instances of the same app share + /// one file and a changed bundle can never land on an existing path. + pub fn bundle_path(&self, sha256: &str) -> PathBuf { + self.bundles_dir().join(format!("{sha256}.xdc")) + } + + /// Resolves the hash a `(app_id, version)` pair maps to. + /// + /// Callers pin the result on the instance, and keep running it until the + /// next time the app is started: a refresh in between must not change the + /// code behind a game that is being played. + pub async fn resolve_bundle_sha256(&self, app_id: &str, version: i64) -> Result { + if !is_valid_app_id(app_id) { + return Err(TwonlyError::Generic("invalid webxdc app id".into())); + } + match self.known_bundle(app_id, version).await? { + Some(sha256) => Ok(sha256), + None => { + // The sender may be running a version this device has not seen + // advertised yet. + self.refresh_catalog().await?; + self.known_bundle(app_id, version).await?.ok_or_else(|| { + TwonlyError::Generic(format!("webxdc app {app_id}@{version} is not published")) + }) + } + } + } + + /// The hash a version maps to, published or merely still cached for an + /// instance that runs it. + async fn known_bundle(&self, app_id: &str, version: i64) -> Result> { + let database = self.ctx.app_db.read().await.clone(); + let row = sqlx::query_scalar!( + r#"SELECT bundle_sha256 AS "bundle_sha256!: String" + FROM webxdc_apps WHERE app_id = ? AND version = ?"#, + app_id, + version, + ) + .fetch_optional(&database.pool) + .await?; + Ok(row) + } + + /// The newest version of an app the store currently offers. + /// + /// Read from the cache, so it says what the last refresh saw. Callers that + /// care about being current refresh first and treat a failure as "no update + /// today" rather than as a reason not to start the app. + pub async fn newest_published(&self, app_id: &str) -> Result> { + let database = self.ctx.app_db.read().await.clone(); + let row = sqlx::query!( + r#"SELECT version AS "version!: i64", bundle_sha256 AS "bundle_sha256!: String" + FROM webxdc_apps + WHERE app_id = ? AND published = 1 + ORDER BY version DESC + LIMIT 1"#, + app_id, + ) + .fetch_optional(&database.pool) + .await?; + Ok(row.map(|row| (row.version, row.bundle_sha256))) + } + + /// Downloads the bundle unless it is already on disk, and returns the path + /// only once its hash matches what was asked for. + pub async fn ensure_bundle(&self, sha256: &str) -> Result { + let path = self.bundle_path(sha256); + if path.is_file() { + return Ok(path); + } + std::fs::create_dir_all(self.bundles_dir())?; + + let mut response = Self::client()? + .get(Self::url(&format!("blob/{sha256}"))) + .send() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + if !response.status().is_success() { + return Err(TwonlyError::Generic(format!( + "webxdc blob returned {}", + response.status() + ))); + } + + // Streamed with a running hash and a hard byte ceiling: a + // `Content-Length` is a claim, not a limit. + let mut hasher = Sha256::new(); + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))? + { + if body.len() as i64 + chunk.len() as i64 > MAX_BUNDLE_BYTES { + return Err(TwonlyError::Generic("webxdc bundle is too large".into())); + } + hasher.update(&chunk); + body.extend_from_slice(&chunk); + } + + let digest = hex::encode(hasher.finalize()); + if digest != sha256 { + return Err(TwonlyError::Generic(format!( + "webxdc bundle hash mismatch: expected {sha256}, got {digest}" + ))); + } + + // Written beside the target and renamed, so a bundle only ever appears + // at its content-addressed path complete and verified. + let temporary = self.bundles_dir().join(format!("{sha256}.partial")); + std::fs::write(&temporary, &body)?; + std::fs::rename(&temporary, &path)?; + Ok(path) + } +} + +#[cfg(test)] +mod tests { + use super::pick_localized; + + fn languages(preferred: &[&str]) -> Vec { + preferred.iter().map(|tag| (*tag).to_string()).collect() + } + + #[test] + fn shows_the_field_in_the_language_the_reader_asked_for() { + let both = r#"{"de":"Spiele Dame.","en":"Play checkers."}"#; + assert_eq!( + pick_localized(both, &languages(&["de"])).as_deref(), + Some("Spiele Dame.") + ); + // A regional locale reads the language it belongs to. + assert_eq!( + pick_localized(both, &languages(&["de-AT"])).as_deref(), + Some("Spiele Dame.") + ); + // And a reader of the language takes a regional translation over none. + assert_eq!( + pick_localized(r#"{"de-at":"Spiele Dame."}"#, &languages(&["de"])).as_deref(), + Some("Spiele Dame.") + ); + } + + #[test] + fn falls_back_rather_than_showing_nothing() { + let english_only = r#"{"en":"Play checkers."}"#; + assert_eq!( + pick_localized(english_only, &languages(&["de"])).as_deref(), + Some("Play checkers.") + ); + // Not a language anyone asked for, but better than a blank line. + assert_eq!( + pick_localized(r#"{"fr":"Jouez aux dames."}"#, &languages(&["de"])).as_deref(), + Some("Jouez aux dames.") + ); + } + + #[test] + fn has_nothing_to_show_for_an_app_that_describes_itself_nowhere() { + assert_eq!(pick_localized("{}", &languages(&["de"])), None); + // Whatever this is, it is not a catalog description. + assert_eq!(pick_localized("null", &languages(&["de"])), None); + assert_eq!(pick_localized("", &languages(&["de"])), None); + } +} diff --git a/rust/src/services/webxdc/webxdc.js b/rust/src/services/webxdc/webxdc.js new file mode 100644 index 00000000..574bcbe9 --- /dev/null +++ b/rust/src/services/webxdc/webxdc.js @@ -0,0 +1,169 @@ +// The webxdc API, served at /webxdc.js for every app and overriding whatever +// the bundle ships under that name. +// +// Nothing here is a security boundary: the page can rewrite `window.webxdc` +// the moment this script finishes. Every limit expressed below is a courtesy +// to honest apps and is enforced again on the Rust side, which is where the +// real checks live. +'use strict'; + +(function () { + // Substituted by the host when this file is served. The values never travel + // through the page, so app code cannot influence what they say. + const init = __TWONLY_WEBXDC_INIT__; + + // The transport the platform installed before any page script ran. Captured + // now so a later overwrite by app code cannot redirect our own calls. + // + // Android answers synchronously and returns the reply; iOS posts and the + // reply arrives through `__twonlyWebxdcDeliver`. Both are handled below. + const androidBridge = window.__twonlyWebxdcBridge; + const iosBridge = + window.webkit && + window.webkit.messageHandlers && + window.webkit.messageHandlers.twonlyWebxdc; + + function transport(json) { + if (androidBridge && androidBridge.call) { + return androidBridge.call(json); + } + if (iosBridge) { + iosBridge.postMessage(json); + return null; + } + throw new Error('the webxdc bridge is unavailable'); + } + + let nextCallId = 1; + const pending = new Map(); + + function call(method, params) { + return new Promise(function (resolve, reject) { + const id = nextCallId++; + pending.set(id, { resolve: resolve, reject: reject }); + try { + const reply = transport( + JSON.stringify({ id: id, method: method, params: params || {} }) + ); + if (typeof reply === 'string' && reply.length > 0) { + window.__twonlyWebxdcDeliver(JSON.parse(reply)); + } + } catch (error) { + pending.delete(id); + reject(error); + } + }); + } + + function decodeBase64(value) { + const binary = atob(value || ''); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; + } + + let listener = null; + let lastSerial = 0; + + // The host calls this; apps have no reason to. + window.__twonlyWebxdcDeliver = function (message) { + if (message.id && pending.has(message.id)) { + const entry = pending.get(message.id); + pending.delete(message.id); + if (message.error) entry.reject(new Error(message.error)); + else entry.resolve(message.result); + return; + } + if (message.method === 'update' && listener) { + for (const update of message.params.updates) { + lastSerial = update.serial; + try { + listener(update); + } catch (error) { + console.error('webxdc update listener threw', error); + } + } + } + }; + + const webxdc = { + selfAddr: init.selfAddr, + selfName: init.selfName, + + // Advertised so apps can pace themselves. The host applies both limits + // independently and rejects what exceeds them. + sendUpdateInterval: init.sendUpdateInterval, + sendUpdateMaxSize: init.sendUpdateMaxSize, + + sendUpdate: function (update, descr) { + return call('sendUpdate', { + payload: update.payload === undefined ? null : update.payload, + info: update.info, + href: update.href, + summary: update.summary, + document: update.document, + notify: update.notify, + descr: descr, + }); + }, + + setUpdateListener: function (callback, serial) { + listener = callback; + lastSerial = serial || 0; + return call('catchUp', { serial: lastSerial }); + }, + + sendToChat: function (message) { + // Always routed through a picker on the host side: an app never chooses + // who receives something on the user's behalf, and the user may abandon + // it entirely. + // + // Only the text is carried. twonly has no message type for an arbitrary + // file, so a call with one is refused rather than half honoured -- and + // the file is not read here, since reading it could only be wasted work. + var file = message && message.file; + return call('sendToChat', { + text: message && message.text, + file: file ? { name: file.name } : undefined, + }); + }, + + importFiles: function (filters) { + return call('importFiles', filters || {}).then(function (files) { + // The host hands back bytes; the File objects the app expects can only + // be built in here. + return (files || []).map(function (file) { + return new File([decodeBase64(file.base64)], file.name, { + type: file.type || '', + }); + }); + }); + }, + + joinRealtimeChannel: function () { + // Not implemented on purpose. A peer-to-peer channel would give the app + // the network access the rest of this design spends its effort denying, + // and would expose both peers' addresses to each other. + console.warn('webxdc: realtime channels are not available in twonly'); + return Object.freeze({ + setListener: function () {}, + send: function () {}, + leave: function () {}, + }); + }, + }; + + // Left writable on purpose. The API hands out a single update listener, so an + // app that needs two consumers -- a game and its scoreboard, say -- has + // nowhere to multiplex them but here, and wrapping `setUpdateListener` is how + // apps in the wild do it. A frozen object broke those apps silently: outside + // strict mode the assignment is discarded without an error, and the app then + // registers listeners that never fire. + // + // Nothing is given up by allowing it. The transport is closed over rather than + // reachable through this object, and every limit is applied again in Rust, so + // what the page does to `window.webxdc` only ever affects the page itself. + window.webxdc = webxdc; +})(); diff --git a/rust/tests/api.rs b/rust/tests/api.rs index 93e7becf..e28d6e4b 100644 --- a/rust/tests/api.rs +++ b/rust/tests/api.rs @@ -121,7 +121,12 @@ async fn test_connect_to_dev_server() -> anyhow::Result<()> { // The sender_profile_counter is incremented in user.json, so the text message // will carry a higher counter, prompting Tester B to request a profile update. MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Hello with new profile!".into(), None) + .insert_and_send_text( + group_id.clone(), + "Hello with new profile!".into(), + None, + None, + ) .await?; // 3. Wait until the Tester B username has the new username in the contact table @@ -153,7 +158,7 @@ async fn test_connect_to_dev_server() -> anyhow::Result<()> { // TesterA -> TesterB: Send a text message let message_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Initial text".into(), None) + .insert_and_send_text(group_id.clone(), "Initial text".into(), None, None) .await?; tester_b .wait_for_text_message(&message_id, tester_a.user_id, "Initial text") @@ -193,6 +198,7 @@ async fn test_connect_to_dev_server() -> anyhow::Result<()> { group_id.clone(), "Replying to initial text".into(), Some(message_id.clone()), + None, ) .await?; tester_a @@ -344,7 +350,7 @@ async fn test_connect_to_dev_server() -> anyhow::Result<()> { // 3. Send a text message in the group let group_msg_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Hello group!".into(), None) + .insert_and_send_text(group_id.clone(), "Hello group!".into(), None, None) .await?; tester_b @@ -392,22 +398,12 @@ async fn test_connect_to_dev_server() -> anyhow::Result<()> { // 6. Promote tester_b to admin // tester_a needs tester_b's public key to promote them. tester_b - // announces it when it learns of the group, so this asserts the - // announcement arrived rather than repairing the state by hand. - { - let db_a = tester_a.context.app_db.read().await.clone(); - let public_key = sqlx::query_scalar!( - "SELECT group_public_key FROM group_members WHERE group_id = ? AND contact_id = ?", - group_id, - tester_b.user_id - ) - .fetch_one(&db_a.pool) + // announces it when it learns of the group, so this waits for the + // announcement to land rather than asking for the key or repairing the + // state by hand. + tester_a + .wait_for_group_public_key(&group_id, tester_b.user_id) .await?; - assert!( - public_key.is_some(), - "tester_a should have tester_b's group public key without asking for it" - ); - } group_service_a .manage_admin(group_id.clone(), tester_b.user_id, false) diff --git a/rust/tests/api/contacts.rs b/rust/tests/api/contacts.rs index fbab4a3b..ba088e76 100644 --- a/rust/tests/api/contacts.rs +++ b/rust/tests/api/contacts.rs @@ -49,7 +49,12 @@ async fn test_contact_cross_request_auto_accept() -> anyhow::Result<()> { // Verify direct chat group exists on both let group_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id); let msg_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Hello after cross-request!".into(), None) + .insert_and_send_text( + group_id.clone(), + "Hello after cross-request!".into(), + None, + None, + ) .await?; tester_b .wait_for_text_message(&msg_id, tester_a.user_id, "Hello after cross-request!") @@ -204,7 +209,7 @@ async fn test_accept_lands_for_contacts_met_in_a_group() -> anyhow::Result<()> { // instead of bouncing back as a fresh contact request. let direct_chat_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id); let msg_id = MessageService::new(&tester_b.context) - .insert_and_send_text(direct_chat_id, "Hello from the group".into(), None) + .insert_and_send_text(direct_chat_id, "Hello from the group".into(), None, None) .await?; tester_a .wait_for_text_message(&msg_id, tester_b.user_id, "Hello from the group") diff --git a/rust/tests/api/direct_media_upload.rs b/rust/tests/api/direct_media_upload.rs index d8f8f34a..758aaa37 100644 --- a/rust/tests/api/direct_media_upload.rs +++ b/rust/tests/api/direct_media_upload.rs @@ -1,4 +1,5 @@ use super::{init_tracing, Tester}; +use anyhow::Context as _; use rust_lib_twonly::bridge::api::ApiConnectionState; use rust_lib_twonly::services::direct_media_upload::DirectMediaUploadService; use rust_lib_twonly::services::media_upload::MediaUploadService; @@ -21,16 +22,20 @@ async fn test_direct_media_upload_reconciliation() -> anyhow::Result<()> { let tester = create_authenticated_tester().await?; let service = DirectMediaUploadService::new(&tester.context); - let issued = service.preload_slots().await?; - assert!(issued > 0, "the server must issue direct-media slots"); + // The client fills its slot cache on its own the moment it authenticates, + // and the server issues no more than one account's worth, so this call is + // just as likely to be answered with nothing left to hand out. What has to + // hold is that the account ends up holding slots to send against. + service.preload_slots().await?; let (attachment_id, capability) = { let database = tester.context.app_db.read().await.clone(); sqlx::query_as::<_, (String, Vec)>( "SELECT attachment_id, capability FROM direct_media_upload_slots WHERE state = 'cached' LIMIT 1", ) - .fetch_one(&database.pool) + .fetch_optional(&database.pool) .await? + .context("the server must issue direct-media slots")? }; // Stand in for a media send whose native transfer was handed off but never diff --git a/rust/tests/api/group_resilience.rs b/rust/tests/api/group_resilience.rs index 6ee20f3b..44dff7e9 100644 --- a/rust/tests/api/group_resilience.rs +++ b/rust/tests/api/group_resilience.rs @@ -66,7 +66,12 @@ async fn test_group_membership_error_healing() -> anyhow::Result<()> { // Tester A sends a text message in the group let msg_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Message triggering heal".into(), None) + .insert_and_send_text( + group_id.clone(), + "Message triggering heal".into(), + None, + None, + ) .await?; // Tester B will report error, Tester A will heal and re-send GroupCreate, @@ -174,13 +179,14 @@ async fn test_admin_and_non_admin_leave_group() -> anyhow::Result<()> { .await?; // Ask for anything the announcements did not deliver. Forced, so the - // per-member request interval cannot skip it. - { - GroupService::new(&tester_a.context) - .fetch_missing_group_public_keys(Some(group_id.clone()), true) - .await?; - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } + // per-member request interval cannot skip it. The answer travels back as + // its own message, so wait for it rather than for a fixed span of time. + GroupService::new(&tester_a.context) + .fetch_missing_group_public_keys(Some(group_id.clone()), true) + .await?; + tester_a + .wait_for_group_public_key(&group_id, tester_b.user_id) + .await?; // Promote tester_b to admin so tester_a can leave later with an admin remaining GroupService::new(&tester_a.context) diff --git a/rust/tests/api/notifications.rs b/rust/tests/api/notifications.rs index 87ed0736..89f97b19 100644 --- a/rust/tests/api/notifications.rs +++ b/rust/tests/api/notifications.rs @@ -119,7 +119,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { // let first_message_id = { let message_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Notify me".into(), None) + .insert_and_send_text(group_id.clone(), "Notify me".into(), None, None) .await?; let text = tester_b @@ -144,6 +144,17 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { 1 ); + // The push is spent once the server has the envelope. The receipt + // itself lives on until B's own receipt comes back, and every message + // B sends marks it for retry again, so a receipt that still asked to + // wake would alert B once more for a message they already have. + tester_a.wait_for_message_ack_by_server(&message_id).await?; + assert_eq!( + tester_a.receipts_still_waking(&message_id).await?, + 0, + "an accepted envelope must not ask the server to push again on retransmission" + ); + tester_b .acknowledge_notifications(&[text.event_id.clone()]) .await?; @@ -158,7 +169,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { // { let message_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Open before alert".into(), None) + .insert_and_send_text(group_id.clone(), "Open before alert".into(), None, None) .await?; let notification = tester_b .wait_for_notification("text", tester_a.user_id) @@ -191,7 +202,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { // { MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Zweite Nachricht".into(), None) + .insert_and_send_text(group_id.clone(), "Zweite Nachricht".into(), None, None) .await?; let pending = tester_b .wait_for_notification("text", tester_a.user_id) @@ -229,7 +240,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { Some("".into()), )?; MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Now with avatar".into(), None) + .insert_and_send_text(group_id.clone(), "Now with avatar".into(), None, None) .await?; tester_b .wait_for_contact_avatar_exists(tester_a.user_id) @@ -278,6 +289,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { group_id.clone(), "Quoting you".into(), Some(first_message_id.clone()), + None, ) .await?; let reply = tester_b @@ -290,7 +302,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { // B owns a message that A can react to. let owned_by_b = MessageService::new(&tester_b.context) - .insert_and_send_text(group_id.clone(), "React to this".into(), None) + .insert_and_send_text(group_id.clone(), "React to this".into(), None, None) .await?; tester_a .wait_for_text_message(&owned_by_b, tester_b.user_id, "React to this") @@ -327,7 +339,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { .wait_for_reaction_deleted(&owned_by_b, tester_a.user_id, "πŸ‘") .await?; MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "After the removal".into(), None) + .insert_and_send_text(group_id.clone(), "After the removal".into(), None, None) .await?; let next = tester_b .wait_for_notification("text", tester_a.user_id) @@ -346,6 +358,38 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { tester_b .acknowledge_notifications(&[next.event_id.clone()]) .await?; + + // A reaction only concerns whoever wrote the message it lands on. A + // reacts to a message A wrote, so B -- who is only a bystander to it -- + // must hear nothing, however the reaction itself still reaches them. + let owned_by_a = MessageService::new(&tester_a.context) + .insert_and_send_text(group_id.clone(), "My own message".into(), None, None) + .await?; + let own = tester_b + .wait_for_notification("text", tester_a.user_id) + .await?; + tester_b + .acknowledge_notifications(&[own.event_id.clone()]) + .await?; + + MessageService::new(&tester_a.context) + .react(group_id.clone(), owned_by_a.clone(), "πŸŽ‰".into(), false) + .await?; + tester_b + .wait_for_reaction(&owned_by_a, tester_a.user_id, "πŸŽ‰") + .await?; + let bystander_kinds: Vec = tester_b + .notification_batch("en") + .await? + .additions + .into_iter() + .map(|addition| addition.kind) + .collect(); + assert!( + !bystander_kinds.contains(&"reaction".to_owned()), + "a reaction to the sender's own message must not notify anybody else, \ + got {bystander_kinds:?}" + ); } // @@ -375,7 +419,12 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { assert_eq!(added.body, format!("has added you to \"{group_name}\"")); MessageService::new(&tester_a.context) - .insert_and_send_text(group_conversation_id.clone(), "Hello group".into(), None) + .insert_and_send_text( + group_conversation_id.clone(), + "Hello group".into(), + None, + None, + ) .await?; let group_text = tester_b .wait_for_notification("text", tester_a.user_id) @@ -486,7 +535,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { tester_b.set_contact_blocked(tester_a.user_id, true).await?; let blocked_message_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "You blocked me".into(), None) + .insert_and_send_text(group_id.clone(), "You blocked me".into(), None, None) .await?; tester_b .wait_for_text_message(&blocked_message_id, tester_a.user_id, "You blocked me") @@ -508,7 +557,7 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { .set_contact_blocked(tester_a.user_id, false) .await?; let unblocked_message_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Unblocked again".into(), None) + .insert_and_send_text(group_id.clone(), "Unblocked again".into(), None, None) .await?; let unblocked = tester_b .wait_for_notification("text", tester_a.user_id) diff --git a/rust/tests/api/session_recovery.rs b/rust/tests/api/session_recovery.rs index 51cef751..47e7e8bf 100644 --- a/rust/tests/api/session_recovery.rs +++ b/rust/tests/api/session_recovery.rs @@ -36,7 +36,7 @@ async fn test_signal_session_auto_recovery_on_missing_session() -> anyhow::Resul // Initial message let msg1_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Initial message".into(), None) + .insert_and_send_text(group_id.clone(), "Initial message".into(), None, None) .await?; tester_b .wait_for_text_message(&msg1_id, tester_a.user_id, "Initial message") @@ -57,7 +57,7 @@ async fn test_signal_session_auto_recovery_on_missing_session() -> anyhow::Resul // `encrypt_v2_with_session_recovery` should catch the missing session error, // fetch Tester B's prekey bundle from the server, rebuild the session, and deliver. let msg2_id = MessageService::new(&tester_a.context) - .insert_and_send_text(group_id.clone(), "Recovered message".into(), None) + .insert_and_send_text(group_id.clone(), "Recovered message".into(), None, None) .await?; tester_b diff --git a/rust/tests/api/tester.rs b/rust/tests/api/tester.rs index 37e02511..94c45930 100644 --- a/rust/tests/api/tester.rs +++ b/rust/tests/api/tester.rs @@ -341,6 +341,36 @@ impl Tester { )) } + /// Waits until a member's group public key has landed here. + /// + /// The key travels on its own message -- announced when the member learns + /// of the group, or answered on request -- so it arrives after the group + /// itself and cannot be assumed present the moment the group is. + pub async fn wait_for_group_public_key( + &self, + group_id: &str, + contact_id: i64, + ) -> anyhow::Result<()> { + for _ in 0..100 { + let database = self.context.app_db.read().await.clone(); + let key = sqlx::query_scalar!( + "SELECT group_public_key FROM group_members WHERE group_id = ? AND contact_id = ?", + group_id, + contact_id, + ) + .fetch_optional(&database.pool) + .await? + .flatten(); + if key.is_some() { + return Ok(()); + } + sleep(Duration::from_millis(100)).await; + } + Err(anyhow::anyhow!( + "group public key of {contact_id} in group {group_id} did not arrive" + )) + } + pub async fn wait_for_group_member_removed( &self, group_id: &str, @@ -608,6 +638,19 @@ impl Tester { .await?) } + /// Whether any receipt still open for `message_id` would ask the server to + /// push the recipient again. A receipt outlives its delivery, so this is + /// what proves a retransmission cannot re-alert somebody. + pub async fn receipts_still_waking(&self, message_id: &str) -> anyhow::Result { + let database = self.context.app_db.read().await.clone(); + Ok(sqlx::query_scalar!( + "SELECT COUNT(*) FROM receipts WHERE message_id = ? AND wake_receiver = 1", + message_id + ) + .fetch_one(&database.pool) + .await?) + } + pub async fn notification_rows_for_message(&self, message_id: &str) -> anyhow::Result { let database = self.context.app_db.read().await.clone(); Ok(sqlx::query_scalar!( @@ -1049,15 +1092,22 @@ impl Tester { } } + /// Waits for the API connection to reach `required`. + /// + /// The budget is generous because this is the one wait that includes a + /// live handshake: a full suite run opens dozens of websockets against the + /// same development server at once, and a connect that queues behind them + /// is slow rather than broken. Reaching the state ends the loop, so the + /// budget costs nothing when the server is idle. pub(crate) async fn wait_until(&self, required: ApiConnectionState) -> anyhow::Result<()> { let mut state = ApiRuntime::connection_state(&self.context).await?; - for _ in 0..1_000 { + for _ in 0..1_200 { state = ApiRuntime::connection_state(&self.context).await?; if state == required { break; } - sleep(Duration::from_millis(10)).await; + sleep(Duration::from_millis(25)).await; } assert_eq!( diff --git a/rust/tests/api/user_discovery.rs b/rust/tests/api/user_discovery.rs index efe9b0bf..f24adbc0 100644 --- a/rust/tests/api/user_discovery.rs +++ b/rust/tests/api/user_discovery.rs @@ -35,7 +35,7 @@ async fn send_trigger(from: &Tester, to: &Tester, label: &str) -> anyhow::Result let group_id = rust_lib_twonly::database::app::tables::Group::direct_chat_id(from.user_id, to.user_id); MessageService::new(&from.context) - .insert_and_send_text(group_id, label.to_owned(), None) + .insert_and_send_text(group_id, label.to_owned(), None, None) .await?; Ok(()) }