mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 12:54:07 +00:00
improving widget and apps
This commit is contained in:
parent
6da70abc0f
commit
d0b4dd5249
120 changed files with 12800 additions and 1842 deletions
|
|
@ -59,6 +59,13 @@
|
||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<!-- Hybrid Composition++ for the webxdc webview: the engine hands the
|
||||||
|
platform view to SurfaceControl instead of merging the raster and
|
||||||
|
platform threads, which is what keeps a route transition smooth
|
||||||
|
while an app is on screen. Ignored below Vulkan on API 34, where
|
||||||
|
the view falls back to the texture path on its own. -->
|
||||||
|
<meta-data android:name="io.flutter.embedding.android.EnableHcpp" android:value="true" />
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import eu.twonly.notifications.NotificationTapChannel
|
import eu.twonly.notifications.NotificationTapChannel
|
||||||
|
import eu.twonly.webxdc.WebxdcChannel
|
||||||
import eu.twonly.widget.WidgetRuntimeChannel
|
import eu.twonly.widget.WidgetRuntimeChannel
|
||||||
|
|
||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
|
|
@ -46,6 +47,14 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
// A running webxdc app is the only thing that opens a picker from here.
|
||||||
|
if (eu.twonly.webxdc.WebxdcView.handleActivityResult(requestCode, resultCode, data)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|
@ -72,6 +81,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
|
|
||||||
NotificationTapChannel.configure(flutterEngine, applicationContext)
|
NotificationTapChannel.configure(flutterEngine, applicationContext)
|
||||||
WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
|
WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
|
||||||
|
WebxdcChannel.configure(flutterEngine, this)
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
|
@ -107,6 +117,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
NotificationTapChannel.detach()
|
NotificationTapChannel.detach()
|
||||||
WidgetRuntimeChannel.detach()
|
WidgetRuntimeChannel.detach()
|
||||||
|
WebxdcChannel.detach()
|
||||||
super.cleanUpFlutterEngine(flutterEngine)
|
super.cleanUpFlutterEngine(flutterEngine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
165
android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcChannel.kt
Normal file
165
android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcChannel.kt
Normal file
|
|
@ -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<String>("instanceId")
|
||||||
|
val message = call.argument<String>("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<String>("instanceId")
|
||||||
|
val paused = call.argument<Boolean>("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<String>("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<String, Any?> ?: 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<String, Any?>,
|
||||||
|
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<String>,
|
||||||
|
val headerValues: List<String>,
|
||||||
|
val body: ByteArray,
|
||||||
|
)
|
||||||
|
}
|
||||||
425
android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcView.kt
Normal file
425
android/app/src/main/kotlin/eu/twonly/webxdc/WebxdcView.kt
Normal file
|
|
@ -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<String, Any?> ?: 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<String> = 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<Uri> {
|
||||||
|
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<JSONObject, Int>? {
|
||||||
|
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<String> {
|
||||||
|
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<String, String>()
|
||||||
|
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<Array<Uri>>,
|
||||||
|
fileChooserParams: FileChooserParams,
|
||||||
|
): Boolean {
|
||||||
|
filePathCallback.onReceiveValue(null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,12 +85,7 @@ class TwonlyWidgetConfigureActivity : AppCompatActivity() {
|
||||||
TwonlyWidgetProvider.preferences(this)
|
TwonlyWidgetProvider.preferences(this)
|
||||||
.edit().putStringSet(TwonlyWidgetProvider.groupsKey(widgetId), selected).apply()
|
.edit().putStringSet(TwonlyWidgetProvider.groupsKey(widgetId), selected).apply()
|
||||||
persistNativeConfiguration(this)
|
persistNativeConfiguration(this)
|
||||||
TwonlyWidgetProvider.update(
|
TwonlyWidgetProvider.update(this, AppWidgetManager.getInstance(this), widgetId)
|
||||||
this,
|
|
||||||
AppWidgetManager.getInstance(this),
|
|
||||||
widgetId,
|
|
||||||
advance = false,
|
|
||||||
)
|
|
||||||
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
|
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import android.appwidget.AppWidgetManager
|
||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
|
|
@ -15,108 +14,72 @@ import org.json.JSONObject
|
||||||
|
|
||||||
class TwonlyWidgetProvider : AppWidgetProvider() {
|
class TwonlyWidgetProvider : AppWidgetProvider() {
|
||||||
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
|
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
|
||||||
ids.forEach { update(context, manager, it, advance = true) }
|
ids.forEach { update(context, manager, it) }
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
val preferences = preferences(context)
|
val preferences = preferences(context)
|
||||||
appWidgetIds.forEach { id ->
|
appWidgetIds.forEach { id -> preferences.edit().remove(groupsKey(id)).apply() }
|
||||||
preferences.edit().remove(groupsKey(id)).remove(indexKey(id)).remove(newestKey(id)).apply()
|
|
||||||
}
|
|
||||||
TwonlyWidgetConfigureActivity.persistNativeConfiguration(context)
|
TwonlyWidgetConfigureActivity.persistNativeConfiguration(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ACTION_ADVANCE = "eu.twonly.widget.ADVANCE"
|
|
||||||
private const val PREFERENCES = "twonly_home_widgets"
|
private const val PREFERENCES = "twonly_home_widgets"
|
||||||
|
|
||||||
fun preferences(context: Context) = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
fun preferences(context: Context) = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
fun groupsKey(widgetId: Int) = "groups_$widgetId"
|
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) {
|
fun refreshAll(context: Context) {
|
||||||
val manager = AppWidgetManager.getInstance(context)
|
val manager = AppWidgetManager.getInstance(context)
|
||||||
val ids = manager.getAppWidgetIds(ComponentName(context, TwonlyWidgetProvider::class.java))
|
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 views = RemoteViews(context.packageName, R.layout.twonly_widget)
|
||||||
val selected = preferences(context).getStringSet(groupsKey(widgetId), emptySet()).orEmpty()
|
val selected = preferences(context).getStringSet(groupsKey(widgetId), emptySet()).orEmpty()
|
||||||
val manifestFile = File(context.filesDir, "widget/manifest.json")
|
val manifestFile = File(context.filesDir, "widget/manifest.json")
|
||||||
val matching = runCatching {
|
val image = runCatching {
|
||||||
val images = JSONObject(manifestFile.readText()).getJSONArray("images")
|
val images = JSONObject(manifestFile.readText()).getJSONArray("images")
|
||||||
buildList {
|
(0 until images.length())
|
||||||
for (index in 0 until images.length()) {
|
.map { images.getJSONObject(it) }
|
||||||
val image = images.getJSONObject(index)
|
.firstOrNull { candidate ->
|
||||||
val groups = image.getJSONArray("group_ids")
|
val groups = candidate.getJSONArray("group_ids")
|
||||||
val visible = (0 until groups.length()).any { selected.contains(groups.getLong(it).toString()) }
|
(0 until groups.length()).any { selected.contains(groups.getLong(it).toString()) }
|
||||||
if (visible && image.optLong("expires_at") > System.currentTimeMillis() / 1000) add(image)
|
|
||||||
}
|
}
|
||||||
}
|
}.getOrNull()
|
||||||
}.getOrDefault(emptyList())
|
|
||||||
|
|
||||||
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.setImageViewResource(R.id.twonly_widget_image, R.drawable.logo)
|
||||||
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110)
|
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110)
|
||||||
views.setViewVisibility(R.id.twonly_widget_sender, View.GONE)
|
views.setViewVisibility(R.id.twonly_widget_sender, View.GONE)
|
||||||
} else {
|
} else {
|
||||||
// The manifest is newest first, so an arriving image is prepended
|
views.setImageViewBitmap(R.id.twonly_widget_image, bitmap)
|
||||||
// and the stored index keeps pointing at an older one: a redraw
|
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 255)
|
||||||
// alone would never show what just came in. Remembering which
|
views.setTextViewText(R.id.twonly_widget_sender, image.optString("sender"))
|
||||||
// image was newest last time is what separates an arrival from
|
views.setViewVisibility(R.id.twonly_widget_sender, View.VISIBLE)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = Intent(context, TwonlyWidgetProvider::class.java).apply {
|
// Nothing to rotate through any more, so a tap opens twonly.
|
||||||
action = ACTION_ADVANCE
|
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
|
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)
|
manager.updateAppWidget(widgetId, views)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
|
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
|
||||||
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; };
|
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; };
|
||||||
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100082F70000100D1A004 /* NativeGallery.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 */; };
|
F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
|
@ -134,6 +135,7 @@
|
||||||
D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = "<group>"; };
|
D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = "<group>"; };
|
||||||
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoCodec.swift; sourceTree = "<group>"; };
|
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoCodec.swift; sourceTree = "<group>"; };
|
||||||
D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = "<group>"; };
|
D3A100082F70000100D1A004 /* NativeGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeGallery.swift; sourceTree = "<group>"; };
|
||||||
|
D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebxdcHost.swift; sourceTree = "<group>"; };
|
||||||
DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
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 = "<group>"; };
|
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 = "<group>"; };
|
||||||
|
|
@ -303,6 +305,7 @@
|
||||||
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
|
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
|
||||||
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
|
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
|
||||||
D3A100082F70000100D1A004 /* NativeGallery.swift */,
|
D3A100082F70000100D1A004 /* NativeGallery.swift */,
|
||||||
|
D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */,
|
||||||
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */,
|
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */,
|
||||||
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */,
|
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */,
|
||||||
D2265DD42D920142000D99BB /* Runner.entitlements */,
|
D2265DD42D920142000D99BB /* Runner.entitlements */,
|
||||||
|
|
@ -736,6 +739,7 @@
|
||||||
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
|
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
|
||||||
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
|
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
|
||||||
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
|
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
|
||||||
|
D3A1000B2F70000100D1A0FE /* WebxdcHost.swift in Sources */,
|
||||||
D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */,
|
D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */,
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ import flutter_sharing_intent
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
RuntimeStorageChannel.register(with: engineBridge.pluginRegistry)
|
RuntimeStorageChannel.register(with: engineBridge.pluginRegistry)
|
||||||
NativeNotificationChannel.register(with: engineBridge.pluginRegistry)
|
NativeNotificationChannel.register(with: engineBridge.pluginRegistry)
|
||||||
|
WebxdcHostChannel.register(with: engineBridge.pluginRegistry)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func userNotificationCenter(
|
override func userNotificationCenter(
|
||||||
|
|
|
||||||
470
ios/Runner/WebxdcHost.swift
Normal file
470
ios/Runner/WebxdcHost.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,14 +44,17 @@ struct ManifestImage: Decodable {
|
||||||
let mediaId: String
|
let mediaId: String
|
||||||
let path: String
|
let path: String
|
||||||
let sender: 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 groupIds: [Int64]
|
||||||
let expiresAt: Int64
|
let receivedAt: Int64
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case path, sender
|
case path, sender
|
||||||
case mediaId = "media_id"
|
case mediaId = "media_id"
|
||||||
case groupIds = "group_ids"
|
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: "-")
|
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.
|
/// Records this widget's contact groups for Rust to import.
|
||||||
///
|
///
|
||||||
/// Each placed widget builds its own timeline, so this only ever knows about
|
/// Each placed widget builds its own timeline, so this only ever knows about
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,6 @@ import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
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.
|
/// Why the widget has nothing to show, so the placeholder can say it.
|
||||||
enum EmptyReason: Equatable {
|
enum EmptyReason: Equatable {
|
||||||
case notConfigured
|
case notConfigured
|
||||||
|
|
@ -31,8 +15,8 @@ enum EmptyReason: Equatable {
|
||||||
case .notConfigured:
|
case .notConfigured:
|
||||||
return "Hold to choose contact groups"
|
return "Hold to choose contact groups"
|
||||||
case .noMatchingImages(let total):
|
case .noMatchingImages(let total):
|
||||||
// The count separates "the app never delivered anything" from "images
|
// The count separates "the app never delivered anything" from "an image
|
||||||
// arrived but carry other groups than the ones this widget selects".
|
// is being held for other groups than the ones this widget selects".
|
||||||
return total == 0 ? "No images yet" : "\(total) shared, none in your groups"
|
return total == 0 ? "No images yet" : "\(total) shared, none in your groups"
|
||||||
case .fault(let fault):
|
case .fault(let fault):
|
||||||
return fault.summary
|
return fault.summary
|
||||||
|
|
@ -43,18 +27,18 @@ enum EmptyReason: Equatable {
|
||||||
struct TwonlyEntry: TimelineEntry {
|
struct TwonlyEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
let image: ManifestImage?
|
let image: ManifestImage?
|
||||||
let groupIds: [Int64]
|
|
||||||
let emptyReason: EmptyReason?
|
let emptyReason: EmptyReason?
|
||||||
/// Longest edge this entry will ever be drawn at, in pixels. Decoding to the
|
/// 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.
|
/// size actually shown is what keeps the extension inside its memory budget.
|
||||||
let maxPixelSize: CGFloat
|
let maxPixelSize: CGFloat
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WidgetKit renders every timeline entry up front, so a timeline is a
|
/// A widget shows the image that arrived last and nothing else, so its
|
||||||
/// multiplier on whatever one entry costs. Eight half-hour steps keep the
|
/// timeline is a single entry. The app pushes a reload whenever the manifest
|
||||||
/// rotation going for four hours on a fraction of the memory a full day took.
|
/// changes, which is what actually puts a new image on the home screen; this
|
||||||
private let timelineEntryCount = 8
|
/// interval only keeps the widget rebuilding while nothing arrives, because a
|
||||||
private let timelineStepMinutes = 30
|
/// rebuild is the sole evidence that the widget is still placed.
|
||||||
|
private let timelineRefreshHours = 4
|
||||||
|
|
||||||
struct TwonlyProvider: AppIntentTimelineProvider {
|
struct TwonlyProvider: AppIntentTimelineProvider {
|
||||||
/// The widget is never drawn larger than its container, so this is the most
|
/// The widget is never drawn larger than its container, so this is the most
|
||||||
|
|
@ -68,7 +52,6 @@ struct TwonlyProvider: AppIntentTimelineProvider {
|
||||||
TwonlyEntry(
|
TwonlyEntry(
|
||||||
date: .now,
|
date: .now,
|
||||||
image: nil,
|
image: nil,
|
||||||
groupIds: [],
|
|
||||||
emptyReason: .noMatchingImages(total: 0),
|
emptyReason: .noMatchingImages(total: 0),
|
||||||
maxPixelSize: maxPixelSize(in: context)
|
maxPixelSize: maxPixelSize(in: context)
|
||||||
)
|
)
|
||||||
|
|
@ -79,7 +62,6 @@ struct TwonlyProvider: AppIntentTimelineProvider {
|
||||||
return entry(
|
return entry(
|
||||||
images: WidgetStorage.images(),
|
images: WidgetStorage.images(),
|
||||||
ids: ids,
|
ids: ids,
|
||||||
index: WidgetStorage.index(for: ids),
|
|
||||||
date: .now,
|
date: .now,
|
||||||
maxPixelSize: maxPixelSize(in: context)
|
maxPixelSize: maxPixelSize(in: context)
|
||||||
)
|
)
|
||||||
|
|
@ -88,48 +70,24 @@ struct TwonlyProvider: AppIntentTimelineProvider {
|
||||||
func timeline(for configuration: TwonlyWidgetIntent, in context: Context) async -> Timeline<TwonlyEntry> {
|
func timeline(for configuration: TwonlyWidgetIntent, in context: Context) async -> Timeline<TwonlyEntry> {
|
||||||
let ids = (configuration.groups ?? []).compactMap { Int64($0.id) }
|
let ids = (configuration.groups ?? []).compactMap { Int64($0.id) }
|
||||||
WidgetStorage.persistSelection(ids)
|
WidgetStorage.persistSelection(ids)
|
||||||
// Read once: an extension that re-reads the manifest for every entry spends
|
let entry = entry(
|
||||||
// its whole budget on JSON.
|
images: WidgetStorage.images(),
|
||||||
let available = WidgetStorage.images()
|
ids: ids,
|
||||||
// The rotation starts at whatever just arrived, so an image shared into
|
date: .now,
|
||||||
// this widget is on the home screen as soon as the timeline is rebuilt
|
maxPixelSize: maxPixelSize(in: context)
|
||||||
// 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 pixels = maxPixelSize(in: context)
|
let next =
|
||||||
let entries = (0..<timelineEntryCount).map { offset in
|
Calendar.current.date(byAdding: .hour, value: timelineRefreshHours, to: .now) ?? .now
|
||||||
entry(
|
widgetLog.debug("built 1 entry at \(entry.maxPixelSize, privacy: .public)px")
|
||||||
images: available,
|
return Timeline(entries: [entry], policy: .after(next))
|
||||||
ids: ids,
|
|
||||||
index: start + offset,
|
|
||||||
date: Calendar.current.date(
|
|
||||||
byAdding: .minute, value: offset * timelineStepMinutes, to: .now) ?? .now,
|
|
||||||
maxPixelSize: pixels
|
|
||||||
)
|
|
||||||
}
|
|
||||||
widgetLog.debug(
|
|
||||||
"built \(entries.count, privacy: .public) entries at \(pixels, privacy: .public)px")
|
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The most recent image this selection can show right now. The manifest is
|
|
||||||
/// written newest first, so that is simply the first one that matches.
|
|
||||||
private func newestMatching(
|
|
||||||
in images: Result<[ManifestImage], WidgetStorage.ManifestFault>,
|
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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(
|
private func entry(
|
||||||
images: Result<[ManifestImage], WidgetStorage.ManifestFault>,
|
images: Result<[ManifestImage], WidgetStorage.ManifestFault>,
|
||||||
ids: [Int64],
|
ids: [Int64],
|
||||||
index: Int,
|
|
||||||
date: Date,
|
date: Date,
|
||||||
maxPixelSize: CGFloat
|
maxPixelSize: CGFloat
|
||||||
) -> TwonlyEntry {
|
) -> TwonlyEntry {
|
||||||
|
|
@ -139,7 +97,7 @@ struct TwonlyProvider: AppIntentTimelineProvider {
|
||||||
available = value
|
available = value
|
||||||
case .failure(let fault):
|
case .failure(let fault):
|
||||||
return TwonlyEntry(
|
return TwonlyEntry(
|
||||||
date: date, image: nil, groupIds: ids,
|
date: date, image: nil,
|
||||||
emptyReason: .fault(fault), maxPixelSize: maxPixelSize)
|
emptyReason: .fault(fault), maxPixelSize: maxPixelSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,36 +106,31 @@ struct TwonlyProvider: AppIntentTimelineProvider {
|
||||||
guard !ids.isEmpty else {
|
guard !ids.isEmpty else {
|
||||||
widgetLog.notice("no contact group configured; nothing can match")
|
widgetLog.notice("no contact group configured; nothing can match")
|
||||||
return TwonlyEntry(
|
return TwonlyEntry(
|
||||||
date: date, image: nil, groupIds: ids,
|
date: date, image: nil,
|
||||||
emptyReason: .notConfigured, maxPixelSize: maxPixelSize)
|
emptyReason: .notConfigured, maxPixelSize: maxPixelSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
let selected = Set(ids)
|
let selected = Set(ids)
|
||||||
let now = Int64(date.timeIntervalSince1970)
|
guard let newest = available.first(where: { !selected.isDisjoint(with: $0.groupIds) }) else {
|
||||||
let unexpired = available.filter { $0.expiresAt > now }
|
// The offered groups are what separates "nobody has sent anything" from
|
||||||
let matching = unexpired.filter { !selected.isDisjoint(with: $0.groupIds) }
|
// "the sender is in groups this widget did not select", which look
|
||||||
guard !matching.isEmpty else {
|
// identical on screen.
|
||||||
// The two counts separate "everything aged out" from "the sender is in
|
|
||||||
// groups this widget did not select", which look identical on screen.
|
|
||||||
widgetLog.notice(
|
widgetLog.notice(
|
||||||
"""
|
"""
|
||||||
no match: selected \(ids, privacy: .public), \
|
no match: selected \(ids, privacy: .public), \
|
||||||
\(available.count, privacy: .public) images, \
|
\(available.count, privacy: .public) images, \
|
||||||
\(unexpired.count, privacy: .public) unexpired, \
|
|
||||||
offered groups \(Set(available.flatMap(\.groupIds)).sorted(), privacy: .public)
|
offered groups \(Set(available.flatMap(\.groupIds)).sorted(), privacy: .public)
|
||||||
""")
|
""")
|
||||||
return TwonlyEntry(
|
return TwonlyEntry(
|
||||||
date: date,
|
date: date,
|
||||||
image: nil,
|
image: nil,
|
||||||
groupIds: ids,
|
|
||||||
emptyReason: .noMatchingImages(total: available.count),
|
emptyReason: .noMatchingImages(total: available.count),
|
||||||
maxPixelSize: maxPixelSize
|
maxPixelSize: maxPixelSize
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return TwonlyEntry(
|
return TwonlyEntry(
|
||||||
date: date,
|
date: date,
|
||||||
image: matching[index % matching.count],
|
image: newest,
|
||||||
groupIds: ids,
|
|
||||||
emptyReason: nil,
|
emptyReason: nil,
|
||||||
maxPixelSize: maxPixelSize
|
maxPixelSize: maxPixelSize
|
||||||
)
|
)
|
||||||
|
|
@ -224,13 +177,10 @@ struct TwonlyWidgetView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.overlay {
|
// Opens the containing app. The scheme is not registered anywhere and
|
||||||
Button(intent: AdvanceWidgetIntent(groupIds: entry.groupIds)) {
|
// does not need to be: WidgetKit hands the URL to twonly itself, and the
|
||||||
Color.clear
|
// app ignores links it does not recognise.
|
||||||
}
|
.widgetURL(URL(string: "twonly://widget"))
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Show next image")
|
|
||||||
}
|
|
||||||
.containerBackground(for: .widget) { brandBackground }
|
.containerBackground(for: .widget) { brandBackground }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -475,10 +475,12 @@ class RustApi {
|
||||||
required String groupId,
|
required String groupId,
|
||||||
required String messageType,
|
required String messageType,
|
||||||
required List<int> additionalData,
|
required List<int> additionalData,
|
||||||
|
required bool hidden,
|
||||||
}) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendAdditionalData(
|
}) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendAdditionalData(
|
||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
messageType: messageType,
|
messageType: messageType,
|
||||||
additionalData: additionalData,
|
additionalData: additionalData,
|
||||||
|
hidden: hidden,
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<String> insertAndSendAskAboutUser({
|
static Future<String> insertAndSendAskAboutUser({
|
||||||
|
|
@ -501,10 +503,12 @@ class RustApi {
|
||||||
required String groupId,
|
required String groupId,
|
||||||
required String text,
|
required String text,
|
||||||
String? quoteMessageId,
|
String? quoteMessageId,
|
||||||
|
Uint8List? additionalMessageData,
|
||||||
}) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendText(
|
}) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendText(
|
||||||
groupId: groupId,
|
groupId: groupId,
|
||||||
text: text,
|
text: text,
|
||||||
quoteMessageId: quoteMessageId,
|
quoteMessageId: quoteMessageId,
|
||||||
|
additionalMessageData: additionalMessageData,
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<void> ipaPurchase({
|
static Future<void> ipaPurchase({
|
||||||
|
|
|
||||||
260
lib/core/bridge/webxdc.dart
Normal file
260
lib/core/bridge/webxdc.dart
Normal file
|
|
@ -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<void> 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<List<WebxdcStoreApp>> catalog({required List<String> languages}) =>
|
||||||
|
RustLib.instance.api.crateBridgeWebxdcCatalog(languages: languages);
|
||||||
|
|
||||||
|
/// Places an app into a chat and returns the id of the message that carries it.
|
||||||
|
Future<String> createInstance({
|
||||||
|
required String groupId,
|
||||||
|
required String appId,
|
||||||
|
required PlatformInt64 version,
|
||||||
|
}) => RustLib.instance.api.crateBridgeWebxdcCreateInstance(
|
||||||
|
groupId: groupId,
|
||||||
|
appId: appId,
|
||||||
|
version: version,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<WebxdcInstanceInfo?> 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<String> 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<WebxdcResponse> serve({
|
||||||
|
required String instanceId,
|
||||||
|
required String requestPath,
|
||||||
|
}) => RustLib.instance.api.crateBridgeWebxdcServe(
|
||||||
|
instanceId: instanceId,
|
||||||
|
requestPath: requestPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<List<WebxdcUpdateEntry>> updatesAfter({
|
||||||
|
required String instanceId,
|
||||||
|
required PlatformInt64 serial,
|
||||||
|
}) => RustLib.instance.api.crateBridgeWebxdcUpdatesAfter(
|
||||||
|
instanceId: instanceId,
|
||||||
|
serial: serial,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> 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<String> 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<String?> 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<String> headerNames;
|
||||||
|
final List<String> 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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ import 'bridge/callbacks.dart';
|
||||||
import 'bridge/groups.dart';
|
import 'bridge/groups.dart';
|
||||||
import 'bridge/logging.dart';
|
import 'bridge/logging.dart';
|
||||||
import 'bridge/user_config.dart';
|
import 'bridge/user_config.dart';
|
||||||
|
import 'bridge/webxdc.dart';
|
||||||
import 'bridge/wrapper.dart';
|
import 'bridge/wrapper.dart';
|
||||||
import 'bridge/wrapper/app_database.dart';
|
import 'bridge/wrapper/app_database.dart';
|
||||||
import 'bridge/wrapper/backup.dart';
|
import 'bridge/wrapper/backup.dart';
|
||||||
|
|
@ -125,6 +126,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
UserConfig dco_decode_box_autoadd_user_config(dynamic raw);
|
UserConfig dco_decode_box_autoadd_user_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
double dco_decode_f_64(dynamic raw);
|
double dco_decode_f_64(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -234,6 +238,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
|
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcStoreApp> dco_decode_list_webxdc_store_app(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcUpdateEntry> dco_decode_list_webxdc_update_entry(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
LogLevel dco_decode_log_level(dynamic raw);
|
LogLevel dco_decode_log_level(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -281,6 +291,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw);
|
UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo? dco_decode_opt_box_autoadd_webxdc_instance_info(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
List<String>? dco_decode_opt_list_String(dynamic raw);
|
List<String>? dco_decode_opt_list_String(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -348,6 +363,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw);
|
TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int dco_decode_u_16(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
int dco_decode_u_32(dynamic raw);
|
int dco_decode_u_32(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -372,6 +390,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
BigInt dco_decode_usize(dynamic raw);
|
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
|
@protected
|
||||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -475,6 +505,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer);
|
UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo sse_decode_box_autoadd_webxdc_instance_info(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
double sse_decode_f_64(SseDeserializer deserializer);
|
double sse_decode_f_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -606,6 +641,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
|
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcStoreApp> sse_decode_list_webxdc_store_app(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcUpdateEntry> sse_decode_list_webxdc_update_entry(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
LogLevel sse_decode_log_level(SseDeserializer deserializer);
|
LogLevel sse_decode_log_level(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -661,6 +706,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo? sse_decode_opt_box_autoadd_webxdc_instance_info(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
|
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -738,6 +788,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer);
|
TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int sse_decode_u_16(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
int sse_decode_u_32(SseDeserializer deserializer);
|
int sse_decode_u_32(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -762,6 +815,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
BigInt sse_decode_usize(SseDeserializer deserializer);
|
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
|
@protected
|
||||||
void sse_encode_AnyhowException(
|
void sse_encode_AnyhowException(
|
||||||
AnyhowException self,
|
AnyhowException self,
|
||||||
|
|
@ -903,6 +972,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_webxdc_instance_info(
|
||||||
|
WebxdcInstanceInfo self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_f_64(double self, SseSerializer serializer);
|
void sse_encode_f_64(double self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1068,6 +1143,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
|
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_webxdc_store_app(
|
||||||
|
List<WebxdcStoreApp> self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_webxdc_update_entry(
|
||||||
|
List<WebxdcUpdateEntry> self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
|
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1137,6 +1224,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_webxdc_instance_info(
|
||||||
|
WebxdcInstanceInfo? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
|
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1236,6 +1329,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_u_16(int self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1259,6 +1355,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_usize(BigInt self, SseSerializer serializer);
|
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
|
// Section: wire_class
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import 'bridge/callbacks.dart';
|
||||||
import 'bridge/groups.dart';
|
import 'bridge/groups.dart';
|
||||||
import 'bridge/logging.dart';
|
import 'bridge/logging.dart';
|
||||||
import 'bridge/user_config.dart';
|
import 'bridge/user_config.dart';
|
||||||
|
import 'bridge/webxdc.dart';
|
||||||
import 'bridge/wrapper.dart';
|
import 'bridge/wrapper.dart';
|
||||||
import 'bridge/wrapper/app_database.dart';
|
import 'bridge/wrapper/app_database.dart';
|
||||||
import 'bridge/wrapper/backup.dart';
|
import 'bridge/wrapper/backup.dart';
|
||||||
|
|
@ -127,6 +128,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
UserConfig dco_decode_box_autoadd_user_config(dynamic raw);
|
UserConfig dco_decode_box_autoadd_user_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
double dco_decode_f_64(dynamic raw);
|
double dco_decode_f_64(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -236,6 +240,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
|
List<SqlValue> dco_decode_list_sql_value(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcStoreApp> dco_decode_list_webxdc_store_app(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcUpdateEntry> dco_decode_list_webxdc_update_entry(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
LogLevel dco_decode_log_level(dynamic raw);
|
LogLevel dco_decode_log_level(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -283,6 +293,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw);
|
UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo? dco_decode_opt_box_autoadd_webxdc_instance_info(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
List<String>? dco_decode_opt_list_String(dynamic raw);
|
List<String>? dco_decode_opt_list_String(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -350,6 +365,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw);
|
TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int dco_decode_u_16(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
int dco_decode_u_32(dynamic raw);
|
int dco_decode_u_32(dynamic raw);
|
||||||
|
|
||||||
|
|
@ -374,6 +392,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
BigInt dco_decode_usize(dynamic raw);
|
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
|
@protected
|
||||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -477,6 +507,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer);
|
UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo sse_decode_box_autoadd_webxdc_instance_info(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
double sse_decode_f_64(SseDeserializer deserializer);
|
double sse_decode_f_64(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -608,6 +643,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
|
List<SqlValue> sse_decode_list_sql_value(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcStoreApp> sse_decode_list_webxdc_store_app(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
List<WebxdcUpdateEntry> sse_decode_list_webxdc_update_entry(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
LogLevel sse_decode_log_level(SseDeserializer deserializer);
|
LogLevel sse_decode_log_level(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -663,6 +708,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
WebxdcInstanceInfo? sse_decode_opt_box_autoadd_webxdc_instance_info(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
|
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -740,6 +790,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer);
|
TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
int sse_decode_u_16(SseDeserializer deserializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
int sse_decode_u_32(SseDeserializer deserializer);
|
int sse_decode_u_32(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
|
@ -764,6 +817,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
BigInt sse_decode_usize(SseDeserializer deserializer);
|
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
|
@protected
|
||||||
void sse_encode_AnyhowException(
|
void sse_encode_AnyhowException(
|
||||||
AnyhowException self,
|
AnyhowException self,
|
||||||
|
|
@ -905,6 +974,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_webxdc_instance_info(
|
||||||
|
WebxdcInstanceInfo self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_f_64(double self, SseSerializer serializer);
|
void sse_encode_f_64(double self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1070,6 +1145,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
|
void sse_encode_list_sql_value(List<SqlValue> self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_webxdc_store_app(
|
||||||
|
List<WebxdcStoreApp> self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_list_webxdc_update_entry(
|
||||||
|
List<WebxdcUpdateEntry> self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
|
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1139,6 +1226,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_webxdc_instance_info(
|
||||||
|
WebxdcInstanceInfo? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
|
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1238,6 +1331,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_u_16(int self, SseSerializer serializer);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||||
|
|
||||||
|
|
@ -1261,6 +1357,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_usize(BigInt self, SseSerializer serializer);
|
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
|
// Section: wire_class
|
||||||
|
|
|
||||||
|
|
@ -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/fcm.notifications.dart';
|
||||||
import 'package:twonly/src/services/notifications/native.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/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/log.dart';
|
||||||
import 'package:twonly/src/utils/startup_guard.dart';
|
import 'package:twonly/src/utils/startup_guard.dart';
|
||||||
import 'package:twonly/src/visual/themes/light.dart';
|
import 'package:twonly/src/visual/themes/light.dart';
|
||||||
|
|
@ -190,6 +191,9 @@ Future<StartupResult> _startup(SettingsChangeProvider settings) async {
|
||||||
|
|
||||||
await notificationSetup;
|
await notificationSetup;
|
||||||
NativeNotificationService.init();
|
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
|
// 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.
|
// is. Repainting the splash costs nothing; the app UI is built after this.
|
||||||
|
|
@ -289,7 +293,7 @@ Future<void> postStartupTasks() async {
|
||||||
await twonlyDB.messagesDao.purgeMessageTable();
|
await twonlyDB.messagesDao.purgeMessageTable();
|
||||||
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
|
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
|
||||||
unawaited(MediaFileService.purgeTempFolder());
|
unawaited(MediaFileService.purgeTempFolder());
|
||||||
unawaited(HomeWidgetService.purgeExpiredMedia());
|
unawaited(HomeWidgetService.pruneSupersededMedia());
|
||||||
unawaited(HomeWidgetService.syncPermissions());
|
unawaited(HomeWidgetService.syncPermissions());
|
||||||
|
|
||||||
// 2. Service initializations
|
// 2. Service initializations
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ class Routes {
|
||||||
static const String settingsPrivacyUserDiscovery =
|
static const String settingsPrivacyUserDiscovery =
|
||||||
'/settings/privacy/user_discovery';
|
'/settings/privacy/user_discovery';
|
||||||
static const String settingsNotification = '/settings/notification';
|
static const String settingsNotification = '/settings/notification';
|
||||||
static const String settingsWidgets = '/settings/widgets';
|
|
||||||
static const String settingsStorage = '/settings/storage_data';
|
static const String settingsStorage = '/settings/storage_data';
|
||||||
static const String settingsStorageManage = '/settings/storage_data/manage';
|
static const String settingsStorageManage = '/settings/storage_data/manage';
|
||||||
static const String settingsStorageImport = '/settings/storage_data/import';
|
static const String settingsStorageImport = '/settings/storage_data/import';
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
// so ensuring that this message is not shown in the messages anymore
|
// so ensuring that this message is not shown in the messages anymore
|
||||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
messages.openedAt.isNull() |
|
messages.openedAt.isNull() |
|
||||||
messages.mediaStored.equals(true)) &
|
messages.mediaStored.equals(true) |
|
||||||
|
messages.type.equals(MessageType.webxdcApp.name)) &
|
||||||
(mediaFiles.downloadState
|
(mediaFiles.downloadState
|
||||||
.equals(DownloadState.reuploadRequested.name)
|
.equals(DownloadState.reuploadRequested.name)
|
||||||
.not() |
|
.not() |
|
||||||
|
|
@ -208,7 +209,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
messages.groupId.equals(groupId) &
|
messages.groupId.equals(groupId) &
|
||||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
messages.openedAt.isNull() |
|
messages.openedAt.isNull() |
|
||||||
messages.mediaStored.equals(true)) &
|
messages.mediaStored.equals(true) |
|
||||||
|
messages.type.equals(MessageType.webxdcApp.name)) &
|
||||||
(messages.isDeletedFromSender.equals(true) |
|
(messages.isDeletedFromSender.equals(true) |
|
||||||
(messages.type.equals(MessageType.text.name).not() &
|
(messages.type.equals(MessageType.text.name).not() &
|
||||||
messages.type.equals(MessageType.media.name).not()) |
|
messages.type.equals(MessageType.media.name).not()) |
|
||||||
|
|
@ -256,7 +258,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
))) &
|
))) &
|
||||||
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
(messages.openedAt.isBiggerThanValue(deletionTime) |
|
||||||
messages.openedAt.isNull() |
|
messages.openedAt.isNull() |
|
||||||
messages.mediaStored.equals(true)) &
|
messages.mediaStored.equals(true) |
|
||||||
|
messages.type.equals(MessageType.webxdcApp.name)) &
|
||||||
(messages.isDeletedFromSender.equals(true) |
|
(messages.isDeletedFromSender.equals(true) |
|
||||||
(messages.type.equals(MessageType.text.name).not() &
|
(messages.type.equals(MessageType.text.name).not() &
|
||||||
messages.type.equals(MessageType.media.name).not()) |
|
messages.type.equals(MessageType.media.name).not()) |
|
||||||
|
|
@ -310,6 +313,12 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
||||||
await (delete(messages)..where(
|
await (delete(messages)..where(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.groupId.isIn(groupIds) &
|
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.mediaStored.equals(true) &
|
||||||
m.isDeletedFromSender.equals(true)) |
|
m.isDeletedFromSender.equals(true)) |
|
||||||
m.mediaStored.equals(false)) &
|
m.mediaStored.equals(false)) &
|
||||||
|
|
|
||||||
|
|
@ -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/groups.table.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.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')
|
@DataClassName('Message')
|
||||||
@TableIndex(
|
@TableIndex(
|
||||||
|
|
|
||||||
82
lib/src/database/tables/webxdc.table.dart
Normal file
82
lib/src/database/tables/webxdc.table.dart
Normal file
|
|
@ -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<Column> 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<Column> 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<Column> get primaryKey => {instanceId, serial};
|
||||||
|
}
|
||||||
|
|
@ -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/reactions.table.dart';
|
||||||
import 'package:twonly/src/database/tables/receipts.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/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/database/twonly.db.steps.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
|
|
@ -48,6 +49,9 @@ part 'twonly.db.g.dart';
|
||||||
UserDiscoveryShares,
|
UserDiscoveryShares,
|
||||||
ContactGroups,
|
ContactGroups,
|
||||||
ContactGroupMembers,
|
ContactGroupMembers,
|
||||||
|
WebxdcApps,
|
||||||
|
WebxdcInstances,
|
||||||
|
WebxdcUpdates,
|
||||||
],
|
],
|
||||||
daos: [
|
daos: [
|
||||||
MessagesDao,
|
MessagesDao,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4676,144 +4676,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Upgrade plan'**
|
/// **'Upgrade plan'**
|
||||||
String get fileLimitReachedUpgrade;
|
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.
|
/// No description provided for @contactGroupUsedByWidget.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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.}}'**
|
/// **'{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);
|
String contactGroupDeleteBlockedByWidget(int count);
|
||||||
|
|
||||||
/// No description provided for @widgetsQueryFailed.
|
/// No description provided for @webxdcStoreMenu.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Could not read the widgets on your home screen, so this list may be out of date.'**
|
/// **'App'**
|
||||||
String get widgetsQueryFailed;
|
String get webxdcStoreMenu;
|
||||||
|
|
||||||
/// No description provided for @widgetsSizeSmall.
|
/// No description provided for @webxdcStoreTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Small widget'**
|
/// **'Add an app'**
|
||||||
String get widgetsSizeSmall;
|
String get webxdcStoreTitle;
|
||||||
|
|
||||||
/// No description provided for @widgetsSizeMedium.
|
/// No description provided for @webxdcStoreEmpty.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Medium widget'**
|
/// **'No apps are available yet.'**
|
||||||
String get widgetsSizeMedium;
|
String get webxdcStoreEmpty;
|
||||||
|
|
||||||
/// No description provided for @widgetsSizeLarge.
|
/// No description provided for @webxdcStoreOffline.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Large widget'**
|
/// **'The app list could not be loaded. Check your connection and try again.'**
|
||||||
String get widgetsSizeLarge;
|
String get webxdcStoreOffline;
|
||||||
|
|
||||||
/// No description provided for @widgetsSizeUnknown.
|
/// No description provided for @webxdcStoreFailed.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Widget'**
|
/// **'The app could not be added to this chat.'**
|
||||||
String get widgetsSizeUnknown;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -2700,91 +2700,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get fileLimitReachedUpgrade => 'Tarif wechseln';
|
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
|
@override
|
||||||
String get contactGroupUsedByWidget => 'Homebildschirm-Widget';
|
String get contactGroupUsedByWidget => 'Homebildschirm-Widget';
|
||||||
|
|
||||||
|
|
@ -2815,18 +2730,33 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get widgetsQueryFailed =>
|
String get webxdcStoreMenu => 'App';
|
||||||
'Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get widgetsSizeSmall => 'Kleines Widget';
|
String get webxdcStoreTitle => 'App hinzufügen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get widgetsSizeMedium => 'Mittleres Widget';
|
String get webxdcStoreEmpty => 'Es sind noch keine Apps verfügbar.';
|
||||||
|
|
||||||
@override
|
@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
|
@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:';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2675,91 +2675,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get fileLimitReachedUpgrade => 'Upgrade plan';
|
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
|
@override
|
||||||
String get contactGroupUsedByWidget => 'Home screen widget';
|
String get contactGroupUsedByWidget => 'Home screen widget';
|
||||||
|
|
||||||
|
|
@ -2788,18 +2703,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get widgetsQueryFailed =>
|
String get webxdcStoreMenu => 'App';
|
||||||
'Could not read the widgets on your home screen, so this list may be out of date.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get widgetsSizeSmall => 'Small widget';
|
String get webxdcStoreTitle => 'Add an app';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get widgetsSizeMedium => 'Medium widget';
|
String get webxdcStoreEmpty => 'No apps are available yet.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get widgetsSizeLarge => 'Large widget';
|
String get webxdcStoreOffline =>
|
||||||
|
'The app list could not be loaded. Check your connection and try again.';
|
||||||
|
|
||||||
@override
|
@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:';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1013,57 +1013,6 @@
|
||||||
"fileLimitReachedHint": "Nimm ein kürzeres Video auf und sende es erneut.",
|
"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.",
|
"fileLimitReachedHintFree": "Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.",
|
||||||
"fileLimitReachedUpgrade": "Tarif wechseln",
|
"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",
|
"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": "{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": {
|
"@contactGroupUsedByWidgetSubtitle": {
|
||||||
|
|
@ -1081,9 +1030,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgetsQueryFailed": "Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.",
|
"webxdcStoreMenu": "App",
|
||||||
"widgetsSizeSmall": "Kleines Widget",
|
"webxdcStoreTitle": "App hinzufügen",
|
||||||
"widgetsSizeMedium": "Mittleres Widget",
|
"webxdcStoreEmpty": "Es sind noch keine Apps verfügbar.",
|
||||||
"widgetsSizeLarge": "Großes Widget",
|
"webxdcStoreOffline": "Die App-Liste konnte nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.",
|
||||||
"widgetsSizeUnknown": "Widget"
|
"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:"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1023,57 +1023,6 @@
|
||||||
"fileLimitReachedHint": "Record a shorter video and send it again.",
|
"fileLimitReachedHint": "Record a shorter video and send it again.",
|
||||||
"fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.",
|
"fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.",
|
||||||
"fileLimitReachedUpgrade": "Upgrade plan",
|
"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",
|
"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": "{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": {
|
"@contactGroupUsedByWidgetSubtitle": {
|
||||||
|
|
@ -1091,9 +1040,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"widgetsQueryFailed": "Could not read the widgets on your home screen, so this list may be out of date.",
|
"webxdcStoreMenu": "App",
|
||||||
"widgetsSizeSmall": "Small widget",
|
"webxdcStoreTitle": "Add an app",
|
||||||
"widgetsSizeMedium": "Medium widget",
|
"webxdcStoreEmpty": "No apps are available yet.",
|
||||||
"widgetsSizeLarge": "Large widget",
|
"webxdcStoreOffline": "The app list could not be loaded. Check your connection and try again.",
|
||||||
"widgetsSizeUnknown": "Widget"
|
"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:"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,9 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
|
||||||
$core.Iterable<SharedContact>? contacts,
|
$core.Iterable<SharedContact>? contacts,
|
||||||
$fixnum.Int64? restoredFlameCounter,
|
$fixnum.Int64? restoredFlameCounter,
|
||||||
$fixnum.Int64? askAboutUserId,
|
$fixnum.Int64? askAboutUserId,
|
||||||
|
WebxdcApp? webxdcApp,
|
||||||
|
WebxdcUpdate? webxdcUpdate,
|
||||||
|
WebxdcOrigin? webxdcOrigin,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (type != null) result.type = type;
|
if (type != null) result.type = type;
|
||||||
|
|
@ -114,6 +117,9 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
|
||||||
if (restoredFlameCounter != null)
|
if (restoredFlameCounter != null)
|
||||||
result.restoredFlameCounter = restoredFlameCounter;
|
result.restoredFlameCounter = restoredFlameCounter;
|
||||||
if (askAboutUserId != null) result.askAboutUserId = askAboutUserId;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +142,12 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
|
||||||
subBuilder: SharedContact.create)
|
subBuilder: SharedContact.create)
|
||||||
..aInt64(4, _omitFieldNames ? '' : 'restoredFlameCounter')
|
..aInt64(4, _omitFieldNames ? '' : 'restoredFlameCounter')
|
||||||
..aInt64(5, _omitFieldNames ? '' : 'askAboutUserId')
|
..aInt64(5, _omitFieldNames ? '' : 'askAboutUserId')
|
||||||
|
..aOM<WebxdcApp>(6, _omitFieldNames ? '' : 'webxdcApp',
|
||||||
|
subBuilder: WebxdcApp.create)
|
||||||
|
..aOM<WebxdcUpdate>(7, _omitFieldNames ? '' : 'webxdcUpdate',
|
||||||
|
subBuilder: WebxdcUpdate.create)
|
||||||
|
..aOM<WebxdcOrigin>(8, _omitFieldNames ? '' : 'webxdcOrigin',
|
||||||
|
subBuilder: WebxdcOrigin.create)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$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);
|
$core.bool hasAskAboutUserId() => $_has(4);
|
||||||
@$pb.TagNumber(5)
|
@$pb.TagNumber(5)
|
||||||
void clearAskAboutUserId() => $_clearField(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<WebxdcOrigin>(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<WebxdcApp>(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<WebxdcUpdate>(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 =
|
const $core.bool _omitFieldNames =
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
|
||||||
2, _omitEnumNames ? '' : 'RESTORED_FLAME_COUNTER');
|
2, _omitEnumNames ? '' : 'RESTORED_FLAME_COUNTER');
|
||||||
static const AdditionalMessageData_Type ASK_ABOUT_USER =
|
static const AdditionalMessageData_Type ASK_ABOUT_USER =
|
||||||
AdditionalMessageData_Type._(3, _omitEnumNames ? '' : '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<AdditionalMessageData_Type> values =
|
static const $core.List<AdditionalMessageData_Type> values =
|
||||||
<AdditionalMessageData_Type>[
|
<AdditionalMessageData_Type>[
|
||||||
|
|
@ -31,10 +37,13 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
|
||||||
CONTACTS,
|
CONTACTS,
|
||||||
RESTORED_FLAME_COUNTER,
|
RESTORED_FLAME_COUNTER,
|
||||||
ASK_ABOUT_USER,
|
ASK_ABOUT_USER,
|
||||||
|
WEBXDC_APP,
|
||||||
|
WEBXDC_UPDATE,
|
||||||
|
WEBXDC_SENT,
|
||||||
];
|
];
|
||||||
|
|
||||||
static final $core.List<AdditionalMessageData_Type?> _byValue =
|
static final $core.List<AdditionalMessageData_Type?> _byValue =
|
||||||
$pb.ProtobufEnum.$_initByValueList(values, 3);
|
$pb.ProtobufEnum.$_initByValueList(values, 6);
|
||||||
static AdditionalMessageData_Type? valueOf($core.int value) =>
|
static AdditionalMessageData_Type? valueOf($core.int value) =>
|
||||||
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
value < 0 || value >= _byValue.length ? null : _byValue[value];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,45 @@ const AdditionalMessageData$json = {
|
||||||
'10': 'askAboutUserId',
|
'10': 'askAboutUserId',
|
||||||
'17': true
|
'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],
|
'4': [AdditionalMessageData_Type$json],
|
||||||
'8': [
|
'8': [
|
||||||
{'1': '_link'},
|
{'1': '_link'},
|
||||||
{'1': '_restored_flame_counter'},
|
{'1': '_restored_flame_counter'},
|
||||||
{'1': '_ask_about_user_id'},
|
{'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': 'CONTACTS', '2': 1},
|
||||||
{'1': 'RESTORED_FLAME_COUNTER', '2': 2},
|
{'1': 'RESTORED_FLAME_COUNTER', '2': 2},
|
||||||
{'1': 'ASK_ABOUT_USER', '2': 3},
|
{'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'
|
'dlRGF0YS5UeXBlUgR0eXBlEhcKBGxpbmsYAiABKAlIAFIEbGlua4gBARIqCghjb250YWN0cxgD'
|
||||||
'IAMoCzIOLlNoYXJlZENvbnRhY3RSCGNvbnRhY3RzEjkKFnJlc3RvcmVkX2ZsYW1lX2NvdW50ZX'
|
'IAMoCzIOLlNoYXJlZENvbnRhY3RSCGNvbnRhY3RzEjkKFnJlc3RvcmVkX2ZsYW1lX2NvdW50ZX'
|
||||||
'IYBCABKANIAVIUcmVzdG9yZWRGbGFtZUNvdW50ZXKIAQESLgoRYXNrX2Fib3V0X3VzZXJfaWQY'
|
'IYBCABKANIAVIUcmVzdG9yZWRGbGFtZUNvdW50ZXKIAQESLgoRYXNrX2Fib3V0X3VzZXJfaWQY'
|
||||||
'BSABKANIAlIOYXNrQWJvdXRVc2VySWSIAQEiTgoEVHlwZRIICgRMSU5LEAASDAoIQ09OVEFDVF'
|
'BSABKANIAlIOYXNrQWJvdXRVc2VySWSIAQESLgoKd2VieGRjX2FwcBgGIAEoCzIKLldlYnhkY0'
|
||||||
'MQARIaChZSRVNUT1JFRF9GTEFNRV9DT1VOVEVSEAISEgoOQVNLX0FCT1VUX1VTRVIQA0IHCgVf'
|
'FwcEgDUgl3ZWJ4ZGNBcHCIAQESNwoNd2VieGRjX3VwZGF0ZRgHIAEoCzINLldlYnhkY1VwZGF0'
|
||||||
'bGlua0IZChdfcmVzdG9yZWRfZmxhbWVfY291bnRlckIUChJfYXNrX2Fib3V0X3VzZXJfaWQ=');
|
'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=');
|
||||||
|
|
|
||||||
|
|
@ -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/settings_main.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/share_with_friends.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/subscription/subscription.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart';
|
|
||||||
|
|
||||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
|
@ -229,10 +228,6 @@ final routerProvider = GoRouter(
|
||||||
path: 'notification',
|
path: 'notification',
|
||||||
builder: (context, state) => const NotificationView(),
|
builder: (context, state) => const NotificationView(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
path: 'widgets',
|
|
||||||
builder: (context, state) => const WidgetsSettingsView(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'storage_data',
|
path: 'storage_data',
|
||||||
builder: (context, state) => const DataAndStorageView(),
|
builder: (context, state) => const DataAndStorageView(),
|
||||||
|
|
|
||||||
|
|
@ -13,41 +13,11 @@ class PlacedWidget {
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.platform,
|
required this.platform,
|
||||||
required this.contactGroupIds,
|
required this.contactGroupIds,
|
||||||
this.family,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String platform;
|
final String platform;
|
||||||
final List<int> contactGroupIds;
|
final List<int> 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<int> 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
|
/// 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.
|
/// Drops the cached reconcile so the next read asks the system again.
|
||||||
static void invalidate() => _cachedReport = null;
|
static void invalidate() => _cachedReport = null;
|
||||||
|
|
||||||
static Future<void> 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<void> pruneSupersededMedia() async {
|
||||||
await RustApi.purgeWidgetMedia();
|
await RustApi.purgeWidgetMedia();
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
@ -145,12 +118,6 @@ class HomeWidgetService {
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes one image from every widget showing it, and from disk.
|
|
||||||
static Future<void> deleteImage(String mediaId) async {
|
|
||||||
await RustApi.deleteWidgetMedia(mediaId: mediaId);
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Asks the placed widgets to redraw from the manifest Rust just wrote.
|
/// Asks the placed widgets to redraw from the manifest Rust just wrote.
|
||||||
///
|
///
|
||||||
/// Neither platform notices the rewrite on its own: WidgetKit keeps the
|
/// 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
|
/// the file whenever a widget is added or removed, so there the file is the
|
||||||
/// authority.
|
/// authority.
|
||||||
///
|
///
|
||||||
/// The returned `error` is set when iOS could not be asked; the widgets are
|
/// When iOS cannot be asked, this falls back to whatever the file last
|
||||||
/// then whatever the file last recorded, which may name widgets that are
|
/// recorded, which may name widgets that are already gone.
|
||||||
/// already gone.
|
static Future<List<PlacedWidget>> placedWidgets() async {
|
||||||
static Future<({List<PlacedWidget> widgets, String? error})>
|
if (!Platform.isIOS) return _widgetsFromFile();
|
||||||
placedWidgetsResult() async {
|
|
||||||
if (!Platform.isIOS) {
|
|
||||||
return (widgets: await _widgetsFromFile(), error: null);
|
|
||||||
}
|
|
||||||
final report = await reconcileReport();
|
final report = await reconcileReport();
|
||||||
if (report == null || report['error'] != null) {
|
if (report == null || report['error'] != null) return _widgetsFromFile();
|
||||||
return (
|
|
||||||
widgets: await _widgetsFromFile(),
|
|
||||||
error: '${report?['error'] ?? 'unknown'}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final reported = ((report['widgets'] as List?) ?? const [])
|
final reported = ((report['widgets'] as List?) ?? const [])
|
||||||
.cast<Map<Object?, Object?>>()
|
.cast<Map<Object?, Object?>>()
|
||||||
.where((entry) => entry['mine'] == true && entry['live'] == true);
|
.where((entry) => entry['mine'] == true && entry['live'] == true);
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
return (
|
return [
|
||||||
widgets: [
|
for (final entry in reported)
|
||||||
for (final entry in reported)
|
if (seen.add('${entry['id']}'))
|
||||||
if (seen.add('${entry['id']}'))
|
PlacedWidget(
|
||||||
PlacedWidget(
|
id: '${entry['id']}',
|
||||||
id: '${entry['id']}',
|
platform: 'ios',
|
||||||
platform: 'ios',
|
contactGroupIds: ((entry['group_ids'] as List?) ?? const [])
|
||||||
family: entry['family'] as String?,
|
.map((id) => (id as num).toInt())
|
||||||
contactGroupIds: ((entry['group_ids'] as List?) ?? const [])
|
.toList(),
|
||||||
.map((id) => (id as num).toInt())
|
),
|
||||||
.toList(),
|
];
|
||||||
),
|
|
||||||
],
|
|
||||||
error: null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<PlacedWidget>> placedWidgets() async =>
|
|
||||||
(await placedWidgetsResult()).widgets;
|
|
||||||
|
|
||||||
static Future<List<PlacedWidget>> _widgetsFromFile() async {
|
static Future<List<PlacedWidget>> _widgetsFromFile() async {
|
||||||
final file = File('${_root.path}/native-config.json');
|
final file = File('${_root.path}/native-config.json');
|
||||||
if (!file.existsSync()) return const [];
|
if (!file.existsSync()) return const [];
|
||||||
|
|
@ -252,44 +203,4 @@ class HomeWidgetService {
|
||||||
lastSeen.toInt() * 1000,
|
lastSeen.toInt() * 1000,
|
||||||
).isAfter(oldestLive);
|
).isAfter(oldestLive);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every unexpired image Rust has published to the widgets, newest first.
|
|
||||||
static Future<List<WidgetImage>> images() async {
|
|
||||||
final file = File('${_root.path}/manifest.json');
|
|
||||||
if (!file.existsSync()) return const [];
|
|
||||||
try {
|
|
||||||
final decoded =
|
|
||||||
jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
|
||||||
final images = ((decoded['images'] as List?) ?? const [])
|
|
||||||
.cast<Map<String, dynamic>>();
|
|
||||||
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<List<WidgetImage>> imagesFor(PlacedWidget widget) async {
|
|
||||||
final selected = widget.contactGroupIds.toSet();
|
|
||||||
final all = await images();
|
|
||||||
return all
|
|
||||||
.where((image) => image.contactGroupIds.any(selected.contains))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,11 @@ class NativeNotificationService {
|
||||||
/// Emits metadata for every native notification tapped while the app runs.
|
/// Emits metadata for every native notification tapped while the app runs.
|
||||||
static Stream<NativeNotificationTap> get taps => _taps.stream;
|
static Stream<NativeNotificationTap> get taps => _taps.stream;
|
||||||
|
|
||||||
/// Text messages (including quoted replies) open their conversation. Media
|
/// Text messages (including quoted replies) and the announcements an app
|
||||||
/// and every other notification kind stay on the chat overview.
|
/// writes into a chat open their conversation. Media and every other
|
||||||
|
/// notification kind stay on the chat overview.
|
||||||
static bool opensConversation(String? kind) =>
|
static bool opensConversation(String? kind) =>
|
||||||
kind == 'text' || kind == 'response';
|
kind == 'text' || kind == 'response' || kind == 'webxdc';
|
||||||
|
|
||||||
static void init() {
|
static void init() {
|
||||||
_startBadgeSync();
|
_startBadgeSync();
|
||||||
|
|
|
||||||
259
lib/src/services/webxdc/webxdc.service.dart
Normal file
259
lib/src/services/webxdc/webxdc.service.dart
Normal file
|
|
@ -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<String, WebxdcInstance> _instanceCache = {};
|
||||||
|
static final Map<String, WebxdcApp> _appCache = {};
|
||||||
|
|
||||||
|
static String _appKey(String appId, int version) => '$appId:$version';
|
||||||
|
|
||||||
|
/// Refreshes the store listing from the API. Metadata only, no bundles.
|
||||||
|
static Future<bool> 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<List<rust_webxdc.WebxdcStoreApp>> catalog(
|
||||||
|
List<String> 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<String?> 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<String?> 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<rust_webxdc.WebxdcInstanceInfo?> 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<List<rust_webxdc.WebxdcUpdateEntry>> 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<void> 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<void> 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<WebxdcApp?> appFor(WebxdcInstance instance) =>
|
||||||
|
appNamed(instance.appId, instance.version);
|
||||||
|
|
||||||
|
static Future<WebxdcApp?> 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<String> languages) {
|
||||||
|
Map<String, dynamic> byLanguage;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(app.nameTranslations);
|
||||||
|
if (decoded is! Map<String, dynamic>) 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<WebxdcInstance?> 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<WebxdcInstance?> 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<void> 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<void> Function(String origin)? clear;
|
||||||
|
|
||||||
|
static Future<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
495
lib/src/services/webxdc/webxdc_host.dart
Normal file
495
lib/src/services/webxdc/webxdc_host.dart
Normal file
|
|
@ -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<void>? _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<void> _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<void> setPaused({required bool paused}) async {
|
||||||
|
if (defaultTargetPlatform != TargetPlatform.android) return;
|
||||||
|
final instanceId = _openInstanceId;
|
||||||
|
if (instanceId == null) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('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<WebxdcLaunch> prepare(
|
||||||
|
String instanceId,
|
||||||
|
List<String> 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<void> _clearOrigin(String origin) async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('clearOrigin', {'origin': origin});
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
Log.warn('clearing webxdc origin failed: ${error.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Object?> _handleNativeCall(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case 'serve':
|
||||||
|
return _serve(call.arguments as Map<Object?, Object?>);
|
||||||
|
case 'bridge':
|
||||||
|
return _bridge(call.arguments as Map<Object?, Object?>);
|
||||||
|
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<Object?, Object?>);
|
||||||
|
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<Map<String, Object?>> _serve(
|
||||||
|
Map<Object?, Object?> 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<String> _bridge(Map<Object?, Object?> arguments) async {
|
||||||
|
final instanceId = _openInstanceId;
|
||||||
|
final raw = arguments['message']! as String;
|
||||||
|
|
||||||
|
Map<String, dynamic> message;
|
||||||
|
try {
|
||||||
|
message = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
} 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<String, dynamic>?) ?? {};
|
||||||
|
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<void> _confirmExternalLink(
|
||||||
|
Map<Object?, Object?> 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<bool>(
|
||||||
|
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<String, dynamic> 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<void> _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<void> _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<void> _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<int>?;
|
||||||
|
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<void> _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<void>('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;
|
||||||
|
}
|
||||||
|
|
@ -386,6 +386,19 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
bool reportOpened = false,
|
bool reportOpened = false,
|
||||||
}) async {
|
}) async {
|
||||||
final newestKnownAt = _animationState.newestKnownAt;
|
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) {
|
for (final msg in newMessages) {
|
||||||
// Only messages appended after the newest one already loaded are new to
|
// Only messages appended after the newest one already loaded are new to
|
||||||
// the user. Messages fetched by scrolling up are older and must not
|
// the user. Messages fetched by scrolling up are older and must not
|
||||||
|
|
@ -475,11 +488,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final wasSentByMe =
|
|
||||||
_animationState.hasReceivedFirstBatch &&
|
|
||||||
newMessages.isNotEmpty &&
|
|
||||||
newMessages.last.senderId == null;
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_data.chatItems = chatItems.reversed.toList();
|
_data.chatItems = chatItems.reversed.toList();
|
||||||
_notifyMessageDataChanged();
|
_notifyMessageDataChanged();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/utils/misc.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';
|
import 'package:twonly/src/visual/views/shared/select_contacts.view.dart';
|
||||||
|
|
||||||
class ShareAdditionalView extends StatefulWidget {
|
class ShareAdditionalView extends StatefulWidget {
|
||||||
|
|
@ -62,6 +63,16 @@ class _ShareAdditionalViewState extends State<ShareAdditionalView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> openAppStore() async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => WebxdcStoreView(group: widget.group),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
|
@ -95,27 +106,17 @@ class _ShareAdditionalViewState extends State<ShareAdditionalView> {
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
spacing: 32,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
_entry(
|
||||||
|
icon: FontAwesomeIcons.circleUser,
|
||||||
|
label: context.lang.shareContactsMenu,
|
||||||
onTap: openShareContactView,
|
onTap: openShareContactView,
|
||||||
child: Column(
|
),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
_entry(
|
||||||
children: [
|
icon: Icons.apps_rounded,
|
||||||
Container(
|
label: context.lang.webxdcStoreMenu,
|
||||||
padding: const EdgeInsets.all(20),
|
onTap: openAppStore,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -125,4 +126,33 @@ class _ShareAdditionalViewState extends State<ShareAdditionalView> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<WebxdcStoreView> createState() => _WebxdcStoreViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebxdcStoreViewState extends State<WebxdcStoreView> {
|
||||||
|
List<rust_webxdc.WebxdcStoreApp>? _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<void> _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<void> _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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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/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_context_menu.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_reply_drag.dart';
|
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_reply_drag.dart';
|
||||||
|
|
@ -144,6 +146,16 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
||||||
BubbleInfo info,
|
BubbleInfo info,
|
||||||
) {
|
) {
|
||||||
if (widget.message.type == MessageType.text.name) {
|
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(
|
return ChatTextEntry(
|
||||||
message: widget.message,
|
message: widget.message,
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
|
|
@ -198,6 +210,14 @@ class _ChatListEntryState extends State<ChatListEntry> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (widget.message.type == MessageType.webxdcApp.name) {
|
||||||
|
return ChatWebxdcEntry(
|
||||||
|
message: widget.message,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
info: info,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return const ChatUnknownEntry();
|
return const ChatUnknownEntry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ChatWebxdcEntry> createState() => _ChatWebxdcEntryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatWebxdcEntryState extends State<ChatWebxdcEntry> {
|
||||||
|
/// 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<WebxdcInstance?>? _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<void> _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<void> _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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ChatWebxdcMessageEntry> createState() => _ChatWebxdcMessageEntryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatWebxdcMessageEntryState extends State<ChatWebxdcMessageEntry> {
|
||||||
|
/// 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<void> _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<void> _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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,14 @@ BubbleInfo getBubbleInfo(
|
||||||
return info;
|
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<String> readerLanguages(BuildContext context) => [
|
||||||
|
Localizations.localeOf(context).toLanguageTag(),
|
||||||
|
];
|
||||||
|
|
||||||
/// Laying text out is expensive and `getBubbleInfo` runs for every visible
|
/// Laying text out is expensive and `getBubbleInfo` runs for every visible
|
||||||
/// bubble on every rebuild, while the same message content is measured over and
|
/// 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
|
/// over. Keep the last few hundred results around, in insertion order, so the
|
||||||
|
|
|
||||||
|
|
@ -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<String, MemoryImage> _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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'
|
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'
|
||||||
as pb;
|
as pb;
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
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/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
|
import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
|
||||||
import 'package:twonly/src/visual/context_menu/context_menu.helper.dart';
|
import 'package:twonly/src/visual/context_menu/context_menu.helper.dart';
|
||||||
|
|
@ -185,6 +186,14 @@ class MessageContextMenu extends StatelessWidget {
|
||||||
group,
|
group,
|
||||||
galleryItems,
|
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') {
|
if (action == 'delete_for_all') {
|
||||||
await twonlyDB.messagesDao.handleMessageDeletion(
|
await twonlyDB.messagesDao.handleMessageDeletion(
|
||||||
null,
|
null,
|
||||||
|
|
@ -285,6 +294,18 @@ Future<String?> showDeleteMessageOptions(
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 24),
|
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) ...[
|
if (isForAll) ...[
|
||||||
Center(
|
Center(
|
||||||
child: MyButton(
|
child: MyButton(
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,10 @@ class _HomeWidgetDeveloperViewState extends State<HomeWidgetDeveloperView> {
|
||||||
|
|
||||||
final groups = (decoded['groups'] as List?) ?? [];
|
final groups = (decoded['groups'] as List?) ?? [];
|
||||||
final images = (decoded['images'] 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
|
// 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
|
// when its sender's groups intersect the groups the widget was configured
|
||||||
|
|
@ -141,14 +144,17 @@ class _HomeWidgetDeveloperViewState extends State<HomeWidgetDeveloperView> {
|
||||||
for (final group in groups.cast<Map<String, dynamic>>()) {
|
for (final group in groups.cast<Map<String, dynamic>>()) {
|
||||||
lines.add(' group ${group['id']}: ${group['name']}');
|
lines.add(' group ${group['id']}: ${group['name']}');
|
||||||
}
|
}
|
||||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
||||||
for (final image in images.cast<Map<String, dynamic>>()) {
|
for (final image in images.cast<Map<String, dynamic>>()) {
|
||||||
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();
|
final exists = File('${image['path']}').existsSync();
|
||||||
lines.add(
|
lines.add(
|
||||||
' ${image['media_id']} from ${image['sender']} '
|
' ${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']} '
|
'groups=${image['group_ids']} '
|
||||||
'${expiresAt > now ? 'valid' : 'EXPIRED'} '
|
'received ${receivedAt.toLocal()} '
|
||||||
'${exists ? '' : 'FILE MISSING'}',
|
'${exists ? '' : 'FILE MISSING'}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,11 +110,6 @@ class SettingsMainView extends StatelessWidget {
|
||||||
text: context.lang.settingsNotification,
|
text: context.lang.settingsNotification,
|
||||||
onTap: () => context.push(Routes.settingsNotification),
|
onTap: () => context.push(Routes.settingsNotification),
|
||||||
),
|
),
|
||||||
BetterListTile(
|
|
||||||
icon: Icons.widgets_rounded,
|
|
||||||
text: context.lang.settingsWidgets,
|
|
||||||
onTap: () => context.push(Routes.settingsWidgets),
|
|
||||||
),
|
|
||||||
BetterListTile(
|
BetterListTile(
|
||||||
icon: FontAwesomeIcons.chartPie,
|
icon: FontAwesomeIcons.chartPie,
|
||||||
iconSize: 15,
|
iconSize: 15,
|
||||||
|
|
|
||||||
|
|
@ -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<WidgetDetailView> createState() => _WidgetDetailViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WidgetDetailViewState extends State<WidgetDetailView> {
|
|
||||||
List<WidgetImage>? _images;
|
|
||||||
List<ContactGroup> _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<void> _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<void> _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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<WidgetsSettingsView> createState() => _WidgetsSettingsViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WidgetsSettingsViewState extends State<WidgetsSettingsView> {
|
|
||||||
List<PlacedWidget>? _widgets;
|
|
||||||
String? _queryError;
|
|
||||||
Map<int, ContactGroup> _groups = const {};
|
|
||||||
Map<String, int> _imageCounts = const {};
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
unawaited(_load());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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 = <String, int>{};
|
|
||||||
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;
|
|
||||||
153
lib/src/visual/views/webxdc/webxdc_app.view.dart
Normal file
153
lib/src/visual/views/webxdc/webxdc_app.view.dart
Normal file
|
|
@ -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<WebxdcAppView> createState() => _WebxdcAppViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebxdcAppViewState extends State<WebxdcAppView> {
|
||||||
|
Animation<double>? _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 = <String, dynamic>{
|
||||||
|
'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 <Factory<OneSequenceGestureRecognizer>>{},
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "UPDATE webxdc_apps SET published = 0",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 0
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3c6a9504fe958ab1dfd2330078d761fe69934fb2118cf48ff845a3524c7ffead"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -14,6 +14,12 @@
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"type_info": "Integer",
|
"type_info": "Integer",
|
||||||
"origin": "Expression"
|
"origin": "Expression"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acknowledged",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|
@ -21,8 +27,9 @@
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "cf821c9b1530db447a1ba030c3be50c5eeeb78cb28fe74481d6493a6a0af4161"
|
"hash": "674895e43478d91c66caeb1cee349fd22ab22b783f8d985fe2ce92501503dc83"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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<u8>\", 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<u8>",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM webxdc_updates WHERE instance_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8c0e68f56a7d625bfc87bb5991ac55a77dec6c9192bcd72c3f02858fa2b23657"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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<u8>\", 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<u8>",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM webxdc_instances WHERE instance_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "cfa0b29edf334cdec34ade89ff33e9ad70f36f2616333a71d69bc5cc7a2781e3"
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 5
|
"Right": 6
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "fedeeeaee2ad31906726430055edac23359bb61ff80d53632c95b40837d504f3"
|
"hash": "cffb25fc9ca6a574dbafd9a4f661b64e9e55d162dd5b8b3b3791c82150e4a73f"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Right": 5
|
"Right": 6
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "732496b1ad12c49c3aca6925d45abc4c801838e606d9bfd3f728f4e1dd91dd3a"
|
"hash": "e1154392dd25c96e3ac0e5dd3c5a2805fd8ebaaf5400b87b73b4439e43080c19"
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|
@ -8,5 +8,5 @@
|
||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "7ba6ad25a53db6d9ee725ad8dd06a58c119e4f445edf966b97016fb6d598f09a"
|
"hash": "e6a42d65c955aa13817fd01b4be533c53e52f20e6deca7054fd1161277a9e27e"
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -14,6 +14,12 @@
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"type_info": "Integer",
|
"type_info": "Integer",
|
||||||
"origin": "Expression"
|
"origin": "Expression"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acknowledged",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Integer",
|
||||||
|
"origin": "Expression"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|
@ -21,8 +27,9 @@
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
false,
|
false,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "4ad8ea916d6113c45fef05150f2e619e06ac351ac0b7d0a9b340835ac7109fdf"
|
"hash": "eac6f09e21a49428007bb59568259c6163d5ba4daeced9f699aa6a9d9b15b1d3"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -145,6 +145,11 @@ fn client() -> Result<reqwest::Client> {
|
||||||
async fn success(response: reqwest::Response) -> Result<()> {
|
async fn success(response: reqwest::Response) -> Result<()> {
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
Ok(())
|
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 {
|
} else {
|
||||||
Err(TwonlyError::Generic(format!(
|
Err(TwonlyError::Generic(format!(
|
||||||
"group server returned {}",
|
"group server returned {}",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use crate::database::app::tables::Message;
|
||||||
use crate::database::app::tables::NewKeyVerification;
|
use crate::database::app::tables::NewKeyVerification;
|
||||||
use crate::database::app::tables::{MessageType, NewMessage};
|
use crate::database::app::tables::{MessageType, NewMessage};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
|
use crate::services::webxdc::WebxdcService;
|
||||||
use crate::utils::milliseconds_to_seconds;
|
use crate::utils::milliseconds_to_seconds;
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
use sqlx::{Sqlite, Transaction};
|
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?;
|
Message::check_message_owner(tr, &message.sender_message_id, from_user_id).await?;
|
||||||
let timestamp = milliseconds_to_seconds(message.timestamp);
|
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
|
||||||
// Additional metadata is optional. Keep accepting the containing
|
// it is malformed or a handler cannot complete.
|
||||||
// message if it is malformed or contact verification cannot complete.
|
let data = match message.additional_message_data.as_deref() {
|
||||||
if let Err(error) = verify_shared_contacts(ctx, tr, from_user_id, data).await {
|
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}");
|
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()
|
NewMessage::builder()
|
||||||
.group_id(group_id)
|
.group_id(group_id)
|
||||||
.message_id(&message.sender_message_id)
|
.message_id(&message.sender_message_id)
|
||||||
|
|
@ -45,6 +78,12 @@ pub(crate) async fn handle_additional_data_message(
|
||||||
.insert(tr)
|
.insert(tr)
|
||||||
.await?;
|
.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?;
|
Group::increase_last_message_exchange(tr, group_id, timestamp).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -54,17 +93,15 @@ async fn verify_shared_contacts(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
tr: &mut Transaction<'_, Sqlite>,
|
tr: &mut Transaction<'_, Sqlite>,
|
||||||
from_user_id: i64,
|
from_user_id: i64,
|
||||||
bytes: &[u8],
|
data: &proto::AdditionalMessageData,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let data = proto::AdditionalMessageData::decode(bytes)?;
|
|
||||||
|
|
||||||
if data.r#type != proto::additional_message_data::Type::Contacts as i32 {
|
if data.r#type != proto::additional_message_data::Type::Contacts as i32 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let signal_database = ctx.rust_db.read().await.clone();
|
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() {
|
if contact.public_identity_key.is_empty() {
|
||||||
tracing::info!("shared contact carries no public key, skipping verification");
|
tracing::info!("shared contact carries no public key, skipping verification");
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -15,27 +15,31 @@ use crate::utils::new_uuid_v4;
|
||||||
use prost::Message as ProstMessage;
|
use prost::Message as ProstMessage;
|
||||||
use proto::encrypted_content::error_messages::Type;
|
use proto::encrypted_content::error_messages::Type;
|
||||||
use sqlx::{Sqlite, Transaction};
|
use sqlx::{Sqlite, Transaction};
|
||||||
use std::sync::{Arc, LazyLock, Mutex, PoisonError};
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
static ALREADY_QUEUED_RECEIPTS: LazyLock<Mutex<HashMap<String, std::time::Instant>>> =
|
static ALREADY_QUEUED_RECEIPTS: LazyLock<Mutex<HashMap<String, std::time::Instant>>> =
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
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
|
/// 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
|
/// whole queue, so one mailbox page would otherwise start as many identical
|
||||||
/// scans as it carries messages — all of them competing for the single
|
/// scans as it carries messages — all of them competing for the single
|
||||||
/// app-database connection. A request that arrives while a flush runs only
|
/// 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.
|
/// marks it dirty, so the running flush queries once more before it returns.
|
||||||
static QUEUED_RECEIPT_FLUSH: LazyLock<Mutex<FlushClaim>> =
|
///
|
||||||
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)]
|
#[derive(Default)]
|
||||||
struct FlushClaim {
|
pub(crate) struct FlushClaim {
|
||||||
running: bool,
|
running: bool,
|
||||||
dirty: bool,
|
dirty: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -43,24 +47,27 @@ struct FlushClaim {
|
||||||
/// Held for as long as a flush owns the claim. Dropping it without releasing
|
/// 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
|
/// it — an error or a panic on the way out — frees the claim for the next
|
||||||
/// caller instead of blocking every later flush.
|
/// caller instead of blocking every later flush.
|
||||||
struct FlushGuard {
|
struct FlushGuard<'a> {
|
||||||
|
claim: &'a Mutex<FlushClaim>,
|
||||||
released: bool,
|
released: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FlushGuard {
|
impl<'a> FlushGuard<'a> {
|
||||||
/// Registers a flush for this caller. Returns `None` when one is already
|
/// 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.
|
/// running, in which case it is marked dirty and will query once more.
|
||||||
fn claim() -> Option<Self> {
|
fn claim(state: &'a Mutex<FlushClaim>) -> Option<Self> {
|
||||||
let mut claim = QUEUED_RECEIPT_FLUSH
|
let mut claim = state.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(PoisonError::into_inner);
|
|
||||||
if claim.running {
|
if claim.running {
|
||||||
claim.dirty = true;
|
claim.dirty = true;
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
claim.running = true;
|
claim.running = true;
|
||||||
claim.dirty = false;
|
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,
|
/// 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
|
/// Both happen under one lock so a request cannot be dropped between the
|
||||||
/// last query and the release.
|
/// last query and the release.
|
||||||
fn release_or_take_dirty(&mut self) -> bool {
|
fn release_or_take_dirty(&mut self) -> bool {
|
||||||
let mut claim = QUEUED_RECEIPT_FLUSH
|
let mut claim = self.claim.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(PoisonError::into_inner);
|
|
||||||
if claim.dirty {
|
if claim.dirty {
|
||||||
claim.dirty = false;
|
claim.dirty = false;
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -81,12 +86,12 @@ impl FlushGuard {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for FlushGuard {
|
impl Drop for FlushGuard<'_> {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if self.released {
|
if self.released {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
QUEUED_RECEIPT_FLUSH
|
self.claim
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(PoisonError::into_inner)
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
.running = false;
|
.running = false;
|
||||||
|
|
@ -101,7 +106,8 @@ pub(crate) async fn queue_encrypted_content(
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
Contact::ensure_exists(t, target_user_id).await?;
|
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 {
|
let message = proto::Message {
|
||||||
r#type: proto::message::Type::CiphertextV2 as i32,
|
r#type: proto::message::Type::CiphertextV2 as i32,
|
||||||
|
|
@ -584,10 +590,20 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
|
||||||
if receipt.contact_will_sends_receipt == 0 {
|
if receipt.contact_will_sends_receipt == 0 {
|
||||||
Receipt::delete(&mut *t, receipt_id).await?;
|
Receipt::delete(&mut *t, receipt_id).await?;
|
||||||
} else {
|
} 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!(
|
sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
UPDATE receipts SET
|
UPDATE receipts SET
|
||||||
ack_by_server_at = CAST(strftime('%s', 'now') AS INTEGER),
|
ack_by_server_at = CAST(strftime('%s', 'now') AS INTEGER),
|
||||||
|
wake_receiver = 0,
|
||||||
retry_count = retry_count + 1,
|
retry_count = retry_count + 1,
|
||||||
last_retry = CAST(strftime('%s', 'now') AS INTEGER),
|
last_retry = CAST(strftime('%s', 'now') AS INTEGER),
|
||||||
mark_for_retry = NULL
|
mark_for_retry = NULL
|
||||||
|
|
@ -643,7 +659,7 @@ pub(crate) async fn release_deferred_receipts(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn retransmit_queued_receipts(ctx: &Arc<Context>) -> Result<()> {
|
pub async fn retransmit_queued_receipts(ctx: &Arc<Context>) -> Result<()> {
|
||||||
let Some(mut guard) = FlushGuard::claim() else {
|
let Some(mut guard) = FlushGuard::claim(&ctx.queued_receipt_flush) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -789,8 +805,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_second_flush_request_marks_the_running_one_dirty() {
|
fn a_second_flush_request_marks_the_running_one_dirty() {
|
||||||
let mut guard = FlushGuard::claim().expect("nothing else holds the claim");
|
let state = Mutex::new(FlushClaim::default());
|
||||||
assert!(FlushGuard::claim().is_none());
|
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.
|
// The running flush sees the dirty flag and keeps its claim.
|
||||||
assert!(guard.release_or_take_dirty());
|
assert!(guard.release_or_take_dirty());
|
||||||
|
|
@ -799,8 +816,19 @@ mod tests {
|
||||||
|
|
||||||
// Dropping a guard that never released frees the claim too, so an
|
// Dropping a guard that never released frees the claim too, so an
|
||||||
// error on the way out cannot block every later flush.
|
// 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);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ pub(crate) async fn handle_text_message(
|
||||||
.sender_id(from_user_id)
|
.sender_id(from_user_id)
|
||||||
.content(&message.text)
|
.content(&message.text)
|
||||||
.maybe_quotes_message_id(message.quote_message_id.as_deref())
|
.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())
|
.ack_by_server(current_time().timestamp())
|
||||||
.build()
|
.build()
|
||||||
.insert(t)
|
.insert(t)
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,43 @@ use crate::context::Context;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::user_config::UserConfig;
|
use crate::user_config::UserConfig;
|
||||||
use prost::Message as _;
|
use prost::Message as _;
|
||||||
|
use sqlx::{Sqlite, Transaction};
|
||||||
use std::sync::Arc;
|
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(
|
pub(crate) async fn decorate_content(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
|
t: &mut Transaction<'_, Sqlite>,
|
||||||
contact_id: i64,
|
contact_id: i64,
|
||||||
content: &mut proto::EncryptedContent,
|
content: &mut proto::EncryptedContent,
|
||||||
is_persisted_message: bool,
|
is_persisted_message: bool,
|
||||||
|
|
@ -23,20 +56,19 @@ pub(crate) async fn decorate_content(
|
||||||
};
|
};
|
||||||
|
|
||||||
content.sender_profile_counter = Some(config.avatar_counter);
|
content.sender_profile_counter = Some(config.avatar_counter);
|
||||||
let database = ctx.app_db.read().await.clone();
|
|
||||||
content.widget_sharing_allowed = Some(
|
content.widget_sharing_allowed = Some(
|
||||||
sqlx::query_scalar!(
|
sqlx::query_scalar!(
|
||||||
"SELECT widget_sharing_granted FROM contacts WHERE user_id = ?",
|
"SELECT widget_sharing_granted FROM contacts WHERE user_id = ?",
|
||||||
contact_id,
|
contact_id,
|
||||||
)
|
)
|
||||||
.fetch_optional(&database.pool)
|
.fetch_optional(&mut **t)
|
||||||
.await?
|
.await?
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
!= 0,
|
!= 0,
|
||||||
);
|
);
|
||||||
if config.ask_for_friend_promotions {
|
if config.ask_for_friend_promotions {
|
||||||
let accepted = sqlx::query_scalar!("SELECT COUNT(*) FROM contacts WHERE accepted = 1")
|
let accepted = sqlx::query_scalar!("SELECT COUNT(*) FROM contacts WHERE accepted = 1")
|
||||||
.fetch_one(&database.pool)
|
.fetch_one(&mut **t)
|
||||||
.await?;
|
.await?;
|
||||||
if accepted <= 5 {
|
if accepted <= 5 {
|
||||||
content.ask_for_friend_promotions = Some(true);
|
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 {
|
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!(
|
let allowed = sqlx::query_scalar!(
|
||||||
r#"SELECT EXISTS(SELECT 1 FROM contacts WHERE user_id = ? AND accepted = 1
|
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
|
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.required_send_images,
|
||||||
config.user_discovery_requires_manual_approval,
|
config.user_discovery_requires_manual_approval,
|
||||||
)
|
)
|
||||||
.fetch_one(&database.pool)
|
.fetch_one(&mut **t)
|
||||||
.await?;
|
.await?;
|
||||||
if allowed != 0 {
|
if allowed != 0 {
|
||||||
content.sender_user_discovery_version =
|
content.sender_user_discovery_version =
|
||||||
|
|
@ -76,11 +106,13 @@ pub async fn send_c2c_message_to_contact(
|
||||||
) -> Result<Option<Vec<u8>>> {
|
) -> Result<Option<Vec<u8>>> {
|
||||||
let mut content = proto::EncryptedContent::decode(encrypted_content.as_slice())?;
|
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 db_app = ctx.app_db.read().await.clone();
|
||||||
let mut t = db_app.pool.begin().await?;
|
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 {
|
if only_send_if_no_receipts_are_open {
|
||||||
let count = sqlx::query_scalar!(
|
let count = sqlx::query_scalar!(
|
||||||
"SELECT COUNT(*) FROM receipts WHERE contact_id = ?",
|
"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 retry_count = 0_i64;
|
||||||
let mut last_retry = None;
|
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 {
|
if let Some(message_id) = &message_id {
|
||||||
let previous = sqlx::query!(
|
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 = ?"#,
|
FROM receipts WHERE contact_id = ? AND message_id = ?"#,
|
||||||
contact_id,
|
contact_id,
|
||||||
message_id,
|
message_id,
|
||||||
|
|
@ -106,6 +143,7 @@ pub async fn send_c2c_message_to_contact(
|
||||||
.await?;
|
.await?;
|
||||||
retry_count = previous.count;
|
retry_count = previous.count;
|
||||||
last_retry = previous.last_retry;
|
last_retry = previous.last_retry;
|
||||||
|
already_woken = previous.acknowledged.is_some();
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"DELETE FROM receipts WHERE contact_id = ? AND message_id = ?",
|
"DELETE FROM receipts WHERE contact_id = ? AND message_id = ?",
|
||||||
contact_id,
|
contact_id,
|
||||||
|
|
@ -128,11 +166,14 @@ pub async fn send_c2c_message_to_contact(
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
r#"UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,
|
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,
|
message_id,
|
||||||
only_return_encrypted_data,
|
only_return_encrypted_data,
|
||||||
retry_count,
|
retry_count,
|
||||||
last_retry,
|
last_retry,
|
||||||
|
already_woken,
|
||||||
receipt_id
|
receipt_id
|
||||||
)
|
)
|
||||||
.execute(&mut *t)
|
.execute(&mut *t)
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,30 @@ message GroupState {
|
||||||
bytes encrypted_group_state = 2;
|
bytes encrypted_group_state = 2;
|
||||||
repeated AppendGroupState appended_group_states = 3;
|
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<string, string> 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<string, string> name_translations = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WebxdcCatalog {
|
||||||
|
repeated WebxdcCatalogEntry entries = 1;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ message AdditionalMessageData {
|
||||||
CONTACTS = 1;
|
CONTACTS = 1;
|
||||||
RESTORED_FLAME_COUNTER = 2;
|
RESTORED_FLAME_COUNTER = 2;
|
||||||
ASK_ABOUT_USER = 3;
|
ASK_ABOUT_USER = 3;
|
||||||
|
WEBXDC_APP = 4;
|
||||||
|
WEBXDC_UPDATE = 5;
|
||||||
|
WEBXDC_SENT = 6;
|
||||||
}
|
}
|
||||||
Type type = 1;
|
Type type = 1;
|
||||||
|
|
||||||
|
|
@ -19,4 +22,39 @@ message AdditionalMessageData {
|
||||||
repeated SharedContact contacts = 3;
|
repeated SharedContact contacts = 3;
|
||||||
optional int64 restored_flame_counter = 4;
|
optional int64 restored_flame_counter = 4;
|
||||||
optional int64 ask_about_user_id = 5;
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -66,10 +66,13 @@ message EncryptedContent {
|
||||||
}
|
}
|
||||||
|
|
||||||
message TextMessage {
|
message TextMessage {
|
||||||
string sender_message_id = 1;
|
string sender_message_id = 1;
|
||||||
string text = 2;
|
string text = 2;
|
||||||
int64 timestamp = 3;
|
int64 timestamp = 3;
|
||||||
optional string quote_message_id = 4;
|
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 {
|
message AdditionalDataMessage {
|
||||||
|
|
@ -77,6 +80,14 @@ message EncryptedContent {
|
||||||
int64 timestamp = 2;
|
int64 timestamp = 2;
|
||||||
string type = 3;
|
string type = 3;
|
||||||
optional bytes additional_message_data = 4;
|
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 {
|
message Reaction {
|
||||||
|
|
|
||||||
|
|
@ -916,10 +916,11 @@ impl RustApi {
|
||||||
group_id: String,
|
group_id: String,
|
||||||
text: String,
|
text: String,
|
||||||
quote_message_id: Option<String>,
|
quote_message_id: Option<String>,
|
||||||
|
additional_message_data: Option<Vec<u8>>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let ctx = Context::get_static()?;
|
let ctx = Context::get_static()?;
|
||||||
crate::services::messages::MessageService::new(ctx)
|
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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -927,10 +928,11 @@ impl RustApi {
|
||||||
group_id: String,
|
group_id: String,
|
||||||
message_type: String,
|
message_type: String,
|
||||||
additional_data: Vec<u8>,
|
additional_data: Vec<u8>,
|
||||||
|
hidden: bool,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let ctx = Context::get_static()?;
|
let ctx = Context::get_static()?;
|
||||||
crate::services::messages::MessageService::new(ctx)
|
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
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ pub mod callbacks;
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod user_config;
|
pub mod user_config;
|
||||||
|
pub mod webxdc;
|
||||||
pub mod wrapper;
|
pub mod wrapper;
|
||||||
|
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
|
|
|
||||||
213
rust/src/bridge/webxdc.rs
Normal file
213
rust/src/bridge/webxdc.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
pub source_code_url: Option<String>,
|
||||||
|
pub icon: Option<Vec<u8>>,
|
||||||
|
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<String>,
|
||||||
|
pub document: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WebxdcUpdateEntry {
|
||||||
|
pub serial: i64,
|
||||||
|
pub payload: String,
|
||||||
|
pub info: Option<String>,
|
||||||
|
pub href: Option<String>,
|
||||||
|
/// `None` when this device sent it.
|
||||||
|
pub sender_id: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WebxdcResponse {
|
||||||
|
pub status: u16,
|
||||||
|
pub mime: String,
|
||||||
|
pub header_names: Vec<String>,
|
||||||
|
pub header_values: Vec<String>,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>) -> Result<Vec<WebxdcStoreApp>> {
|
||||||
|
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<u8>", 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<WebxdcStoreApp> = 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<String> {
|
||||||
|
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<Option<WebxdcInstanceInfo>> {
|
||||||
|
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<String> {
|
||||||
|
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<WebxdcResponse> {
|
||||||
|
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<Vec<WebxdcUpdateEntry>> {
|
||||||
|
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<String>,
|
||||||
|
href: Option<String>,
|
||||||
|
summary: Option<String>,
|
||||||
|
document: Option<String>,
|
||||||
|
) -> 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<Option<String>> {
|
||||||
|
let ctx = crate::context::Context::get_static()?;
|
||||||
|
WebxdcService::new(ctx).delete_instance(&instance_id).await
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,19 @@ pub struct Context {
|
||||||
/// bundle on the server (or has just published one). Per context rather
|
/// bundle on the server (or has just published one). Per context rather
|
||||||
/// than process-wide so two accounts in one process check independently.
|
/// than process-wide so two accounts in one process check independently.
|
||||||
pub(crate) pqc_bundle_verified: AtomicBool,
|
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<crate::api::messages::incoming::messages::FlushClaim>,
|
||||||
|
/// 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 {
|
impl Context {
|
||||||
|
|
@ -138,6 +151,9 @@ impl Context {
|
||||||
incoming_generation: AtomicU64::new(0),
|
incoming_generation: AtomicU64::new(0),
|
||||||
incoming_committed: Notify::new(),
|
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(()),
|
||||||
});
|
});
|
||||||
ApiRuntime::initialize(&ctx).await?;
|
ApiRuntime::initialize(&ctx).await?;
|
||||||
ApiRuntime::connect(&ctx).await?;
|
ApiRuntime::connect(&ctx).await?;
|
||||||
|
|
@ -321,7 +337,10 @@ impl Context {
|
||||||
mailbox_drained: Notify::new(),
|
mailbox_drained: Notify::new(),
|
||||||
incoming_generation: AtomicU64::new(0),
|
incoming_generation: AtomicU64::new(0),
|
||||||
incoming_committed: Notify::new(),
|
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 {
|
if let Err(error) = ctx.initialize_user_discovery_from_config().await {
|
||||||
tracing::warn!("failed to initialize user discovery: {error}");
|
tracing::warn!("failed to initialize user discovery: {error}");
|
||||||
|
|
@ -364,7 +383,10 @@ impl Context {
|
||||||
mailbox_drained: Notify::new(),
|
mailbox_drained: Notify::new(),
|
||||||
incoming_generation: AtomicU64::new(0),
|
incoming_generation: AtomicU64::new(0),
|
||||||
incoming_committed: Notify::new(),
|
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 {
|
if let Err(error) = ctx.initialize_user_discovery_from_config().await {
|
||||||
tracing::warn!("failed to initialize user discovery: {error}");
|
tracing::warn!("failed to initialize user discovery: {error}");
|
||||||
|
|
|
||||||
71
rust/src/database/app/migrations/0014_webxdc.sql
Normal file
71
rust/src/database/app/migrations/0014_webxdc.sql
Normal file
|
|
@ -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)
|
||||||
|
);
|
||||||
39
rust/src/database/app/migrations/0015_webxdc_store.sql
Normal file
39
rust/src/database/app/migrations/0015_webxdc_store.sql
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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 '{}';
|
||||||
|
|
@ -78,6 +78,12 @@ pub enum TwonlyError {
|
||||||
#[error("encrypted media is {bytes} bytes but the plan allows {limit}")]
|
#[error("encrypted media is {bytes} bytes but the plan allows {limit}")]
|
||||||
MediaTooLarge { bytes: i64, limit: i64 },
|
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
|
/// Content that will never be processable: it is malformed, or it uses a
|
||||||
/// feature this client does not implement. Callers must not retry it —
|
/// feature this client does not implement. Callers must not retry it —
|
||||||
/// a reliable-mailbox row carrying such a message is acknowledged so the
|
/// a reliable-mailbox row carrying such a message is acknowledged so the
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue