improving widget and apps

This commit is contained in:
otsmr 2026-09-05 01:59:17 +02:00
parent 6da70abc0f
commit d0b4dd5249
120 changed files with 12800 additions and 1842 deletions

View file

@ -59,6 +59,13 @@
<data android:mimeType="video/*" />
</intent-filter>
</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.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View file

@ -18,6 +18,7 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import io.flutter.plugin.common.MethodChannel
import eu.twonly.notifications.NotificationTapChannel
import eu.twonly.webxdc.WebxdcChannel
import eu.twonly.widget.WidgetRuntimeChannel
class MainActivity : FlutterFragmentActivity() {
@ -46,6 +47,14 @@ class MainActivity : FlutterFragmentActivity() {
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) {
super.onNewIntent(intent)
setIntent(intent)
@ -72,6 +81,7 @@ class MainActivity : FlutterFragmentActivity() {
NotificationTapChannel.configure(flutterEngine, applicationContext)
WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
WebxdcChannel.configure(flutterEngine, this)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
@ -107,6 +117,7 @@ class MainActivity : FlutterFragmentActivity() {
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
NotificationTapChannel.detach()
WidgetRuntimeChannel.detach()
WebxdcChannel.detach()
super.cleanUpFlutterEngine(flutterEngine)
}
}

View 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,
)
}

View 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
}
}
}

View file

@ -85,12 +85,7 @@ class TwonlyWidgetConfigureActivity : AppCompatActivity() {
TwonlyWidgetProvider.preferences(this)
.edit().putStringSet(TwonlyWidgetProvider.groupsKey(widgetId), selected).apply()
persistNativeConfiguration(this)
TwonlyWidgetProvider.update(
this,
AppWidgetManager.getInstance(this),
widgetId,
advance = false,
)
TwonlyWidgetProvider.update(this, AppWidgetManager.getInstance(this), widgetId)
setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
finish()
}

View file

@ -5,7 +5,6 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.view.View
import android.widget.RemoteViews
@ -15,108 +14,72 @@ import org.json.JSONObject
class TwonlyWidgetProvider : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
ids.forEach { update(context, manager, it, advance = true) }
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
if (intent.action != ACTION_ADVANCE) return
val widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
if (widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
update(context, AppWidgetManager.getInstance(context), widgetId, advance = true)
}
ids.forEach { update(context, manager, it) }
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
val preferences = preferences(context)
appWidgetIds.forEach { id ->
preferences.edit().remove(groupsKey(id)).remove(indexKey(id)).remove(newestKey(id)).apply()
}
appWidgetIds.forEach { id -> preferences.edit().remove(groupsKey(id)).apply() }
TwonlyWidgetConfigureActivity.persistNativeConfiguration(context)
}
companion object {
const val ACTION_ADVANCE = "eu.twonly.widget.ADVANCE"
private const val PREFERENCES = "twonly_home_widgets"
fun preferences(context: Context) = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun groupsKey(widgetId: Int) = "groups_$widgetId"
private fun indexKey(widgetId: Int) = "index_$widgetId"
/** The newest image this widget has already drawn. */
private fun newestKey(widgetId: Int) = "newest_$widgetId"
fun refreshAll(context: Context) {
val manager = AppWidgetManager.getInstance(context)
val ids = manager.getAppWidgetIds(ComponentName(context, TwonlyWidgetProvider::class.java))
ids.forEach { update(context, manager, it, advance = false) }
ids.forEach { update(context, manager, it) }
}
fun update(context: Context, manager: AppWidgetManager, widgetId: Int, advance: Boolean) {
/**
* Draws the one image this widget has: the most recent one the app
* published for any of its contact groups. The app keeps a single image
* per group and deletes the one it replaces, so the manifest written
* newest first offers nothing else to fall back on.
*/
fun update(context: Context, manager: AppWidgetManager, widgetId: Int) {
val views = RemoteViews(context.packageName, R.layout.twonly_widget)
val selected = preferences(context).getStringSet(groupsKey(widgetId), emptySet()).orEmpty()
val manifestFile = File(context.filesDir, "widget/manifest.json")
val matching = runCatching {
val image = runCatching {
val images = JSONObject(manifestFile.readText()).getJSONArray("images")
buildList {
for (index in 0 until images.length()) {
val image = images.getJSONObject(index)
val groups = image.getJSONArray("group_ids")
val visible = (0 until groups.length()).any { selected.contains(groups.getLong(it).toString()) }
if (visible && image.optLong("expires_at") > System.currentTimeMillis() / 1000) add(image)
(0 until images.length())
.map { images.getJSONObject(it) }
.firstOrNull { candidate ->
val groups = candidate.getJSONArray("group_ids")
(0 until groups.length()).any { selected.contains(groups.getLong(it).toString()) }
}
}
}.getOrDefault(emptyList())
}.getOrNull()
if (matching.isEmpty()) {
val bitmap = image?.let { BitmapFactory.decodeFile(it.getString("path")) }
if (bitmap == null) {
views.setImageViewResource(R.id.twonly_widget_image, R.drawable.logo)
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110)
views.setViewVisibility(R.id.twonly_widget_sender, View.GONE)
} else {
// The manifest is newest first, so an arriving image is prepended
// and the stored index keeps pointing at an older one: a redraw
// alone would never show what just came in. Remembering which
// image was newest last time is what separates an arrival from
// every other reason this widget is asked to redraw, and an
// arrival outranks the rotation — including a tap that has not
// seen the new image yet.
val preferences = preferences(context)
val newest = matching[0].optString("media_id")
val hasArrived = preferences.getString(newestKey(widgetId), null) != newest
val oldIndex = preferences.getInt(indexKey(widgetId), -1)
val index = when {
hasArrived -> 0
advance -> (oldIndex + 1).mod(matching.size)
else -> oldIndex.coerceAtLeast(0).mod(matching.size)
}
preferences.edit().putInt(indexKey(widgetId), index).putString(newestKey(widgetId), newest).apply()
val image = matching[index]
val bitmap = BitmapFactory.decodeFile(image.getString("path"))
if (bitmap == null) {
views.setImageViewResource(R.id.twonly_widget_image, R.drawable.logo)
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 110)
views.setViewVisibility(R.id.twonly_widget_sender, View.GONE)
} else {
views.setImageViewBitmap(R.id.twonly_widget_image, bitmap)
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 255)
views.setTextViewText(R.id.twonly_widget_sender, image.optString("sender"))
views.setViewVisibility(R.id.twonly_widget_sender, View.VISIBLE)
}
views.setImageViewBitmap(R.id.twonly_widget_image, bitmap)
views.setInt(R.id.twonly_widget_image, "setImageAlpha", 255)
views.setTextViewText(R.id.twonly_widget_sender, image.optString("sender"))
views.setViewVisibility(R.id.twonly_widget_sender, View.VISIBLE)
}
val intent = Intent(context, TwonlyWidgetProvider::class.java).apply {
action = ACTION_ADVANCE
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
// Nothing to rotate through any more, so a tap opens twonly.
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
if (launch != null) {
views.setOnClickPendingIntent(
R.id.twonly_widget_root,
PendingIntent.getActivity(
context,
widgetId,
launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
views.setOnClickPendingIntent(
R.id.twonly_widget_root,
PendingIntent.getBroadcast(
context,
widgetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
manager.updateAppWidget(widgetId, views)
}
}

View file

@ -31,6 +31,7 @@
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100062F70000100D1A003 /* NativeVideoCodec.swift */; };
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100082F70000100D1A004 /* NativeGallery.swift */; };
D3A1000B2F70000100D1A0FE /* WebxdcHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */; };
F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
@ -134,6 +135,7 @@
D3A100042F70000100D1A002 /* NativeImageCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageCodec.swift; sourceTree = "<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>"; };
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; };
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>"; };
@ -303,6 +305,7 @@
D3A100042F70000100D1A002 /* NativeImageCodec.swift */,
D3A100062F70000100D1A003 /* NativeVideoCodec.swift */,
D3A100082F70000100D1A004 /* NativeGallery.swift */,
D3A1000C2F70000100D1A0FE /* WebxdcHost.swift */,
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */,
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */,
D2265DD42D920142000D99BB /* Runner.entitlements */,
@ -736,6 +739,7 @@
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
D3A1000B2F70000100D1A0FE /* WebxdcHost.swift in Sources */,
D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,

View file

@ -63,6 +63,7 @@ import flutter_sharing_intent
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
RuntimeStorageChannel.register(with: engineBridge.pluginRegistry)
NativeNotificationChannel.register(with: engineBridge.pluginRegistry)
WebxdcHostChannel.register(with: engineBridge.pluginRegistry)
}
override func userNotificationCenter(

470
ios/Runner/WebxdcHost.swift Normal file
View 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)
}
}
}

View file

@ -44,14 +44,17 @@ struct ManifestImage: Decodable {
let mediaId: String
let path: String
let sender: String
/// The contact groups this image is the current one for. The app publishes
/// one image per group, so a widget shows the newest image whose groups meet
/// its own selection and never has a second one to fall back on.
let groupIds: [Int64]
let expiresAt: Int64
let receivedAt: Int64
enum CodingKeys: String, CodingKey {
case path, sender
case mediaId = "media_id"
case groupIds = "group_ids"
case expiresAt = "expires_at"
case receivedAt = "received_at"
}
}
@ -114,35 +117,6 @@ enum WidgetStorage {
ids.sorted().map(String.init).joined(separator: "-")
}
static func index(for ids: [Int64]) -> Int {
UserDefaults(suiteName: runtimeAppGroup)?.integer(forKey: "widget-index-\(selectionKey(ids))") ?? 0
}
/// Where the rotation starts, sent back to the front whenever an image has
/// arrived since this selection was last drawn.
///
/// The manifest is newest first, so an arriving image is prepended and a
/// stored index keeps pointing at an older one: a timeline reload alone would
/// never show what just came in. Recording which image was newest last time
/// is what separates an arrival from every other reason a timeline is rebuilt.
static func startIndex(for ids: [Int64], newestMediaId: String?) -> Int {
guard let newestMediaId else { return index(for: ids) }
let defaults = UserDefaults(suiteName: runtimeAppGroup)
let newestKey = "widget-newest-\(selectionKey(ids))"
guard defaults?.string(forKey: newestKey) != newestMediaId else {
return index(for: ids)
}
defaults?.set(newestMediaId, forKey: newestKey)
defaults?.set(0, forKey: "widget-index-\(selectionKey(ids))")
return 0
}
static func advance(_ ids: [Int64]) {
let defaults = UserDefaults(suiteName: runtimeAppGroup)
let key = "widget-index-\(selectionKey(ids))"
defaults?.set((defaults?.integer(forKey: key) ?? 0) + 1, forKey: key)
}
/// Records this widget's contact groups for Rust to import.
///
/// Each placed widget builds its own timeline, so this only ever knows about

View file

@ -4,22 +4,6 @@ import OSLog
import SwiftUI
import WidgetKit
struct AdvanceWidgetIntent: AppIntent {
static let title: LocalizedStringResource = "Next image"
static let openAppWhenRun = false
@Parameter(title: "Contact groups") var groupIds: [String]
init() { groupIds = [] }
init(groupIds: [Int64]) { self.groupIds = groupIds.map(String.init) }
func perform() async throws -> some IntentResult {
WidgetStorage.advance(groupIds.compactMap(Int64.init))
WidgetCenter.shared.reloadTimelines(ofKind: widgetKind)
return .result()
}
}
/// Why the widget has nothing to show, so the placeholder can say it.
enum EmptyReason: Equatable {
case notConfigured
@ -31,8 +15,8 @@ enum EmptyReason: Equatable {
case .notConfigured:
return "Hold to choose contact groups"
case .noMatchingImages(let total):
// The count separates "the app never delivered anything" from "images
// arrived but carry other groups than the ones this widget selects".
// The count separates "the app never delivered anything" from "an image
// is being held for other groups than the ones this widget selects".
return total == 0 ? "No images yet" : "\(total) shared, none in your groups"
case .fault(let fault):
return fault.summary
@ -43,18 +27,18 @@ enum EmptyReason: Equatable {
struct TwonlyEntry: TimelineEntry {
let date: Date
let image: ManifestImage?
let groupIds: [Int64]
let emptyReason: EmptyReason?
/// Longest edge this entry will ever be drawn at, in pixels. Decoding to the
/// size actually shown is what keeps the extension inside its memory budget.
let maxPixelSize: CGFloat
}
/// WidgetKit renders every timeline entry up front, so a timeline is a
/// multiplier on whatever one entry costs. Eight half-hour steps keep the
/// rotation going for four hours on a fraction of the memory a full day took.
private let timelineEntryCount = 8
private let timelineStepMinutes = 30
/// A widget shows the image that arrived last and nothing else, so its
/// timeline is a single entry. The app pushes a reload whenever the manifest
/// changes, which is what actually puts a new image on the home screen; this
/// interval only keeps the widget rebuilding while nothing arrives, because a
/// rebuild is the sole evidence that the widget is still placed.
private let timelineRefreshHours = 4
struct TwonlyProvider: AppIntentTimelineProvider {
/// The widget is never drawn larger than its container, so this is the most
@ -68,7 +52,6 @@ struct TwonlyProvider: AppIntentTimelineProvider {
TwonlyEntry(
date: .now,
image: nil,
groupIds: [],
emptyReason: .noMatchingImages(total: 0),
maxPixelSize: maxPixelSize(in: context)
)
@ -79,7 +62,6 @@ struct TwonlyProvider: AppIntentTimelineProvider {
return entry(
images: WidgetStorage.images(),
ids: ids,
index: WidgetStorage.index(for: ids),
date: .now,
maxPixelSize: maxPixelSize(in: context)
)
@ -88,48 +70,24 @@ struct TwonlyProvider: AppIntentTimelineProvider {
func timeline(for configuration: TwonlyWidgetIntent, in context: Context) async -> Timeline<TwonlyEntry> {
let ids = (configuration.groups ?? []).compactMap { Int64($0.id) }
WidgetStorage.persistSelection(ids)
// Read once: an extension that re-reads the manifest for every entry spends
// its whole budget on JSON.
let available = WidgetStorage.images()
// The rotation starts at whatever just arrived, so an image shared into
// this widget is on the home screen as soon as the timeline is rebuilt
// rather than whenever the rotation next comes back around to it.
let start = WidgetStorage.startIndex(
for: ids,
newestMediaId: newestMatching(in: available, ids: ids)?.mediaId
let entry = entry(
images: WidgetStorage.images(),
ids: ids,
date: .now,
maxPixelSize: maxPixelSize(in: context)
)
let pixels = maxPixelSize(in: context)
let entries = (0..<timelineEntryCount).map { offset in
entry(
images: available,
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) }
let next =
Calendar.current.date(byAdding: .hour, value: timelineRefreshHours, to: .now) ?? .now
widgetLog.debug("built 1 entry at \(entry.maxPixelSize, privacy: .public)px")
return Timeline(entries: [entry], policy: .after(next))
}
/// The one image this widget shows: the most recent one published for any of
/// its contact groups. The manifest is written newest first, so that is
/// simply the first match.
private func entry(
images: Result<[ManifestImage], WidgetStorage.ManifestFault>,
ids: [Int64],
index: Int,
date: Date,
maxPixelSize: CGFloat
) -> TwonlyEntry {
@ -139,7 +97,7 @@ struct TwonlyProvider: AppIntentTimelineProvider {
available = value
case .failure(let fault):
return TwonlyEntry(
date: date, image: nil, groupIds: ids,
date: date, image: nil,
emptyReason: .fault(fault), maxPixelSize: maxPixelSize)
}
@ -148,36 +106,31 @@ struct TwonlyProvider: AppIntentTimelineProvider {
guard !ids.isEmpty else {
widgetLog.notice("no contact group configured; nothing can match")
return TwonlyEntry(
date: date, image: nil, groupIds: ids,
date: date, image: nil,
emptyReason: .notConfigured, maxPixelSize: maxPixelSize)
}
let selected = Set(ids)
let now = Int64(date.timeIntervalSince1970)
let unexpired = available.filter { $0.expiresAt > now }
let matching = unexpired.filter { !selected.isDisjoint(with: $0.groupIds) }
guard !matching.isEmpty else {
// The two counts separate "everything aged out" from "the sender is in
// groups this widget did not select", which look identical on screen.
guard let newest = available.first(where: { !selected.isDisjoint(with: $0.groupIds) }) else {
// The offered groups are what separates "nobody has sent anything" from
// "the sender is in groups this widget did not select", which look
// identical on screen.
widgetLog.notice(
"""
no match: selected \(ids, privacy: .public), \
\(available.count, privacy: .public) images, \
\(unexpired.count, privacy: .public) unexpired, \
offered groups \(Set(available.flatMap(\.groupIds)).sorted(), privacy: .public)
""")
return TwonlyEntry(
date: date,
image: nil,
groupIds: ids,
emptyReason: .noMatchingImages(total: available.count),
maxPixelSize: maxPixelSize
)
}
return TwonlyEntry(
date: date,
image: matching[index % matching.count],
groupIds: ids,
image: newest,
emptyReason: nil,
maxPixelSize: maxPixelSize
)
@ -224,13 +177,10 @@ struct TwonlyWidgetView: View {
var body: some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
Button(intent: AdvanceWidgetIntent(groupIds: entry.groupIds)) {
Color.clear
}
.buttonStyle(.plain)
.accessibilityLabel("Show next image")
}
// Opens the containing app. The scheme is not registered anywhere and
// does not need to be: WidgetKit hands the URL to twonly itself, and the
// app ignores links it does not recognise.
.widgetURL(URL(string: "twonly://widget"))
.containerBackground(for: .widget) { brandBackground }
}

View file

@ -475,10 +475,12 @@ class RustApi {
required String groupId,
required String messageType,
required List<int> additionalData,
required bool hidden,
}) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendAdditionalData(
groupId: groupId,
messageType: messageType,
additionalData: additionalData,
hidden: hidden,
);
static Future<String> insertAndSendAskAboutUser({
@ -501,10 +503,12 @@ class RustApi {
required String groupId,
required String text,
String? quoteMessageId,
Uint8List? additionalMessageData,
}) => RustLib.instance.api.crateBridgeApiRustApiInsertAndSendText(
groupId: groupId,
text: text,
quoteMessageId: quoteMessageId,
additionalMessageData: additionalMessageData,
);
static Future<void> ipaPurchase({

260
lib/core/bridge/webxdc.dart Normal file
View 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

View file

@ -10,6 +10,7 @@ import 'bridge/callbacks.dart';
import 'bridge/groups.dart';
import 'bridge/logging.dart';
import 'bridge/user_config.dart';
import 'bridge/webxdc.dart';
import 'bridge/wrapper.dart';
import 'bridge/wrapper/app_database.dart';
import 'bridge/wrapper/backup.dart';
@ -125,6 +126,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
UserConfig dco_decode_box_autoadd_user_config(dynamic raw);
@protected
WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw);
@protected
double dco_decode_f_64(dynamic raw);
@ -234,6 +238,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
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
LogLevel dco_decode_log_level(dynamic raw);
@ -281,6 +291,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw);
@protected
WebxdcInstanceInfo? dco_decode_opt_box_autoadd_webxdc_instance_info(
dynamic raw,
);
@protected
List<String>? dco_decode_opt_list_String(dynamic raw);
@ -348,6 +363,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw);
@protected
int dco_decode_u_16(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@ -372,6 +390,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt dco_decode_usize(dynamic raw);
@protected
WebxdcInstanceInfo dco_decode_webxdc_instance_info(dynamic raw);
@protected
WebxdcResponse dco_decode_webxdc_response(dynamic raw);
@protected
WebxdcStoreApp dco_decode_webxdc_store_app(dynamic raw);
@protected
WebxdcUpdateEntry dco_decode_webxdc_update_entry(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@ -475,6 +505,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer);
@protected
WebxdcInstanceInfo sse_decode_box_autoadd_webxdc_instance_info(
SseDeserializer deserializer,
);
@protected
double sse_decode_f_64(SseDeserializer deserializer);
@ -606,6 +641,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
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
LogLevel sse_decode_log_level(SseDeserializer deserializer);
@ -661,6 +706,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
WebxdcInstanceInfo? sse_decode_opt_box_autoadd_webxdc_instance_info(
SseDeserializer deserializer,
);
@protected
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
@ -738,6 +788,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer);
@protected
int sse_decode_u_16(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@ -762,6 +815,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt sse_decode_usize(SseDeserializer deserializer);
@protected
WebxdcInstanceInfo sse_decode_webxdc_instance_info(
SseDeserializer deserializer,
);
@protected
WebxdcResponse sse_decode_webxdc_response(SseDeserializer deserializer);
@protected
WebxdcStoreApp sse_decode_webxdc_store_app(SseDeserializer deserializer);
@protected
WebxdcUpdateEntry sse_decode_webxdc_update_entry(
SseDeserializer deserializer,
);
@protected
void sse_encode_AnyhowException(
AnyhowException self,
@ -903,6 +972,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_webxdc_instance_info(
WebxdcInstanceInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_f_64(double self, SseSerializer serializer);
@ -1068,6 +1143,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
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
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
@ -1137,6 +1224,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_webxdc_instance_info(
WebxdcInstanceInfo? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
@ -1236,6 +1329,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_u_16(int self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@ -1259,6 +1355,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_usize(BigInt self, SseSerializer serializer);
@protected
void sse_encode_webxdc_instance_info(
WebxdcInstanceInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_webxdc_response(
WebxdcResponse self,
SseSerializer serializer,
);
@protected
void sse_encode_webxdc_store_app(
WebxdcStoreApp self,
SseSerializer serializer,
);
@protected
void sse_encode_webxdc_update_entry(
WebxdcUpdateEntry self,
SseSerializer serializer,
);
}
// Section: wire_class

View file

@ -13,6 +13,7 @@ import 'bridge/callbacks.dart';
import 'bridge/groups.dart';
import 'bridge/logging.dart';
import 'bridge/user_config.dart';
import 'bridge/webxdc.dart';
import 'bridge/wrapper.dart';
import 'bridge/wrapper/app_database.dart';
import 'bridge/wrapper/backup.dart';
@ -127,6 +128,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
UserConfig dco_decode_box_autoadd_user_config(dynamic raw);
@protected
WebxdcInstanceInfo dco_decode_box_autoadd_webxdc_instance_info(dynamic raw);
@protected
double dco_decode_f_64(dynamic raw);
@ -236,6 +240,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
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
LogLevel dco_decode_log_level(dynamic raw);
@ -283,6 +293,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
UserConfig? dco_decode_opt_box_autoadd_user_config(dynamic raw);
@protected
WebxdcInstanceInfo? dco_decode_opt_box_autoadd_webxdc_instance_info(
dynamic raw,
);
@protected
List<String>? dco_decode_opt_list_String(dynamic raw);
@ -350,6 +365,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TwonlySafeBackup dco_decode_twonly_safe_backup(dynamic raw);
@protected
int dco_decode_u_16(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@ -374,6 +392,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt dco_decode_usize(dynamic raw);
@protected
WebxdcInstanceInfo dco_decode_webxdc_instance_info(dynamic raw);
@protected
WebxdcResponse dco_decode_webxdc_response(dynamic raw);
@protected
WebxdcStoreApp dco_decode_webxdc_store_app(dynamic raw);
@protected
WebxdcUpdateEntry dco_decode_webxdc_update_entry(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@ -477,6 +507,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
UserConfig sse_decode_box_autoadd_user_config(SseDeserializer deserializer);
@protected
WebxdcInstanceInfo sse_decode_box_autoadd_webxdc_instance_info(
SseDeserializer deserializer,
);
@protected
double sse_decode_f_64(SseDeserializer deserializer);
@ -608,6 +643,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
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
LogLevel sse_decode_log_level(SseDeserializer deserializer);
@ -663,6 +708,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
WebxdcInstanceInfo? sse_decode_opt_box_autoadd_webxdc_instance_info(
SseDeserializer deserializer,
);
@protected
List<String>? sse_decode_opt_list_String(SseDeserializer deserializer);
@ -740,6 +790,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
TwonlySafeBackup sse_decode_twonly_safe_backup(SseDeserializer deserializer);
@protected
int sse_decode_u_16(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@ -764,6 +817,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BigInt sse_decode_usize(SseDeserializer deserializer);
@protected
WebxdcInstanceInfo sse_decode_webxdc_instance_info(
SseDeserializer deserializer,
);
@protected
WebxdcResponse sse_decode_webxdc_response(SseDeserializer deserializer);
@protected
WebxdcStoreApp sse_decode_webxdc_store_app(SseDeserializer deserializer);
@protected
WebxdcUpdateEntry sse_decode_webxdc_update_entry(
SseDeserializer deserializer,
);
@protected
void sse_encode_AnyhowException(
AnyhowException self,
@ -905,6 +974,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_webxdc_instance_info(
WebxdcInstanceInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_f_64(double self, SseSerializer serializer);
@ -1070,6 +1145,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
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
void sse_encode_log_level(LogLevel self, SseSerializer serializer);
@ -1139,6 +1226,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_webxdc_instance_info(
WebxdcInstanceInfo? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_list_String(List<String>? self, SseSerializer serializer);
@ -1238,6 +1331,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_u_16(int self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@ -1261,6 +1357,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_usize(BigInt self, SseSerializer serializer);
@protected
void sse_encode_webxdc_instance_info(
WebxdcInstanceInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_webxdc_response(
WebxdcResponse self,
SseSerializer serializer,
);
@protected
void sse_encode_webxdc_store_app(
WebxdcStoreApp self,
SseSerializer serializer,
);
@protected
void sse_encode_webxdc_update_entry(
WebxdcUpdateEntry self,
SseSerializer serializer,
);
}
// Section: wire_class

View file

@ -28,6 +28,7 @@ import 'package:twonly/src/services/migrations.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/services/webxdc/webxdc_host.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/startup_guard.dart';
import 'package:twonly/src/visual/themes/light.dart';
@ -190,6 +191,9 @@ Future<StartupResult> _startup(SettingsChangeProvider settings) async {
await notificationSetup;
NativeNotificationService.init();
// Claims the channel the native webview talks to. Nothing runs in it until
// a user starts an app from a chat.
WebxdcHost.initialize();
// The theme was read before the user was known, so re-read it now that it
// is. Repainting the splash costs nothing; the app UI is built after this.
@ -289,7 +293,7 @@ Future<void> postStartupTasks() async {
await twonlyDB.messagesDao.purgeMessageTable();
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
unawaited(MediaFileService.purgeTempFolder());
unawaited(HomeWidgetService.purgeExpiredMedia());
unawaited(HomeWidgetService.pruneSupersededMedia());
unawaited(HomeWidgetService.syncPermissions());
// 2. Service initializations

View file

@ -37,7 +37,6 @@ class Routes {
static const String settingsPrivacyUserDiscovery =
'/settings/privacy/user_discovery';
static const String settingsNotification = '/settings/notification';
static const String settingsWidgets = '/settings/widgets';
static const String settingsStorage = '/settings/storage_data';
static const String settingsStorageManage = '/settings/storage_data/manage';
static const String settingsStorageImport = '/settings/storage_data/import';

View file

@ -140,7 +140,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
// so ensuring that this message is not shown in the messages anymore
(messages.openedAt.isBiggerThanValue(deletionTime) |
messages.openedAt.isNull() |
messages.mediaStored.equals(true)) &
messages.mediaStored.equals(true) |
messages.type.equals(MessageType.webxdcApp.name)) &
(mediaFiles.downloadState
.equals(DownloadState.reuploadRequested.name)
.not() |
@ -208,7 +209,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
messages.groupId.equals(groupId) &
(messages.openedAt.isBiggerThanValue(deletionTime) |
messages.openedAt.isNull() |
messages.mediaStored.equals(true)) &
messages.mediaStored.equals(true) |
messages.type.equals(MessageType.webxdcApp.name)) &
(messages.isDeletedFromSender.equals(true) |
(messages.type.equals(MessageType.text.name).not() &
messages.type.equals(MessageType.media.name).not()) |
@ -256,7 +258,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
))) &
(messages.openedAt.isBiggerThanValue(deletionTime) |
messages.openedAt.isNull() |
messages.mediaStored.equals(true)) &
messages.mediaStored.equals(true) |
messages.type.equals(MessageType.webxdcApp.name)) &
(messages.isDeletedFromSender.equals(true) |
(messages.type.equals(MessageType.text.name).not() &
messages.type.equals(MessageType.media.name).not()) |
@ -310,6 +313,12 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
await (delete(messages)..where(
(m) =>
m.groupId.isIn(groupIds) &
// An app card owns a webxdc instance, and the instance owns
// its update log by foreign key. Sweeping the card away on
// the chat's timer would take an app's whole state with it
// without ever clearing the storage its origin holds, so
// apps leave only when somebody deletes them.
m.type.equals(MessageType.webxdcApp.name).not() &
((m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true)) |
m.mediaStored.equals(false)) &

View file

@ -3,7 +3,14 @@ import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
enum MessageType { media, text, contacts, restoreFlameCounter, askAboutUser }
enum MessageType {
media,
text,
contacts,
restoreFlameCounter,
askAboutUser,
webxdcApp,
}
@DataClassName('Message')
@TableIndex(

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

View file

@ -20,6 +20,7 @@ import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/tables/reactions.table.dart';
import 'package:twonly/src/database/tables/receipts.table.dart';
import 'package:twonly/src/database/tables/user_discovery.table.dart';
import 'package:twonly/src/database/tables/webxdc.table.dart';
import 'package:twonly/src/database/twonly.db.steps.dart';
import 'package:twonly/src/utils/log.dart';
@ -48,6 +49,9 @@ part 'twonly.db.g.dart';
UserDiscoveryShares,
ContactGroups,
ContactGroupMembers,
WebxdcApps,
WebxdcInstances,
WebxdcUpdates,
],
daos: [
MessagesDao,

File diff suppressed because it is too large Load diff

View file

@ -4676,144 +4676,6 @@ abstract class AppLocalizations {
/// **'Upgrade plan'**
String get fileLimitReachedUpgrade;
/// No description provided for @settingsWidgets.
///
/// In en, this message translates to:
/// **'Widgets'**
String get settingsWidgets;
/// No description provided for @widgetsTitle.
///
/// In en, this message translates to:
/// **'Widgets'**
String get widgetsTitle;
/// No description provided for @widgetsIntroTitle.
///
/// In en, this message translates to:
/// **'Photos on your home screen'**
String get widgetsIntroTitle;
/// No description provided for @widgetsIntroBody.
///
/// In en, this message translates to:
/// **'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.'**
String get widgetsIntroBody;
/// No description provided for @widgetsSetupIos.
///
/// In en, this message translates to:
/// **'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.'**
String get widgetsSetupIos;
/// No description provided for @widgetsSetupAndroid.
///
/// In en, this message translates to:
/// **'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.'**
String get widgetsSetupAndroid;
/// No description provided for @widgetsNoneTitle.
///
/// In en, this message translates to:
/// **'No widget added yet'**
String get widgetsNoneTitle;
/// No description provided for @widgetsPlacedTitle.
///
/// In en, this message translates to:
/// **'On your home screen'**
String get widgetsPlacedTitle;
/// No description provided for @widgetsAddAnother.
///
/// In en, this message translates to:
/// **'Add another widget'**
String get widgetsAddAnother;
/// No description provided for @widgetsNoGroups.
///
/// In en, this message translates to:
/// **'No contact group selected'**
String get widgetsNoGroups;
/// No description provided for @widgetsNoGroupsHint.
///
/// In en, this message translates to:
/// **'Nobody can send to this widget until you choose at least one contact group.'**
String get widgetsNoGroupsHint;
/// No description provided for @widgetsEditGroup.
///
/// In en, this message translates to:
/// **'Edit contact group'**
String get widgetsEditGroup;
/// No description provided for @widgetsChangeGroupsIos.
///
/// In en, this message translates to:
/// **'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.'**
String get widgetsChangeGroupsIos;
/// No description provided for @widgetsChangeGroupsAndroid.
///
/// In en, this message translates to:
/// **'To change the contact groups, remove the widget and add it again.'**
String get widgetsChangeGroupsAndroid;
/// No description provided for @widgetsCurrentImages.
///
/// In en, this message translates to:
/// **'Currently showing'**
String get widgetsCurrentImages;
/// No description provided for @widgetsNoImages.
///
/// In en, this message translates to:
/// **'No photos right now'**
String get widgetsNoImages;
/// No description provided for @widgetsNoImagesHint.
///
/// In en, this message translates to:
/// **'Photos your friends send to this widget will appear here.'**
String get widgetsNoImagesHint;
/// No description provided for @widgetsFrom.
///
/// In en, this message translates to:
/// **'From {sender}'**
String widgetsFrom(String sender);
/// No description provided for @widgetsExpiresIn.
///
/// In en, this message translates to:
/// **'Shown for {duration} more'**
String widgetsExpiresIn(String duration);
/// No description provided for @widgetsDeleteImage.
///
/// In en, this message translates to:
/// **'Delete photo'**
String get widgetsDeleteImage;
/// No description provided for @widgetsDeleteImageConfirm.
///
/// In en, this message translates to:
/// **'This removes the photo from every widget showing it. It cannot be undone.'**
String get widgetsDeleteImageConfirm;
/// No description provided for @widgetsDurationHours.
///
/// In en, this message translates to:
/// **'{hours} h'**
String widgetsDurationHours(int hours);
/// No description provided for @widgetsDurationMinutes.
///
/// In en, this message translates to:
/// **'{minutes} min'**
String widgetsDurationMinutes(int minutes);
/// No description provided for @contactGroupUsedByWidget.
///
/// In en, this message translates to:
@ -4832,35 +4694,59 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.} other{This contact group is used by {count} widgets on your home screen. Remove those widgets first, then you can delete the group.}}'**
String contactGroupDeleteBlockedByWidget(int count);
/// No description provided for @widgetsQueryFailed.
/// No description provided for @webxdcStoreMenu.
///
/// In en, this message translates to:
/// **'Could not read the widgets on your home screen, so this list may be out of date.'**
String get widgetsQueryFailed;
/// **'App'**
String get webxdcStoreMenu;
/// No description provided for @widgetsSizeSmall.
/// No description provided for @webxdcStoreTitle.
///
/// In en, this message translates to:
/// **'Small widget'**
String get widgetsSizeSmall;
/// **'Add an app'**
String get webxdcStoreTitle;
/// No description provided for @widgetsSizeMedium.
/// No description provided for @webxdcStoreEmpty.
///
/// In en, this message translates to:
/// **'Medium widget'**
String get widgetsSizeMedium;
/// **'No apps are available yet.'**
String get webxdcStoreEmpty;
/// No description provided for @widgetsSizeLarge.
/// No description provided for @webxdcStoreOffline.
///
/// In en, this message translates to:
/// **'Large widget'**
String get widgetsSizeLarge;
/// **'The app list could not be loaded. Check your connection and try again.'**
String get webxdcStoreOffline;
/// No description provided for @widgetsSizeUnknown.
/// No description provided for @webxdcStoreFailed.
///
/// In en, this message translates to:
/// **'Widget'**
String get widgetsSizeUnknown;
/// **'The app could not be added to this chat.'**
String get webxdcStoreFailed;
/// No description provided for @webxdcUnavailable.
///
/// In en, this message translates to:
/// **'This app could not be loaded.'**
String get webxdcUnavailable;
/// No description provided for @webxdcDeleteConfirm.
///
/// In en, this message translates to:
/// **'Everything this app saved on this device is deleted. Other members keep their own copy.'**
String get webxdcDeleteConfirm;
/// No description provided for @webxdcExternalLinkTitle.
///
/// In en, this message translates to:
/// **'Leave twonly?'**
String get webxdcExternalLinkTitle;
/// No description provided for @webxdcExternalLinkBody.
///
/// In en, this message translates to:
/// **'This link opens outside twonly, in your browser:'**
String get webxdcExternalLinkBody;
}
class _AppLocalizationsDelegate

View file

@ -2700,91 +2700,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get fileLimitReachedUpgrade => 'Tarif wechseln';
@override
String get settingsWidgets => 'Widgets';
@override
String get widgetsTitle => 'Widgets';
@override
String get widgetsIntroTitle => 'Fotos auf deinem Homebildschirm';
@override
String get widgetsIntroBody =>
'Freunde aus den von dir gewählten Kontaktgruppen können ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.';
@override
String get widgetsSetupIos =>
'Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Bearbeiten, dann auf Widget hinzufügen und wähle twonly. Halte anschließend das neue Widget gedrückt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwählen.';
@override
String get widgetsSetupAndroid =>
'Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dürfen.';
@override
String get widgetsNoneTitle => 'Noch kein Widget hinzugefügt';
@override
String get widgetsPlacedTitle => 'Auf deinem Homebildschirm';
@override
String get widgetsAddAnother => 'Weiteres Widget hinzufügen';
@override
String get widgetsNoGroups => 'Keine Kontaktgruppe ausgewählt';
@override
String get widgetsNoGroupsHint =>
'Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswählst.';
@override
String get widgetsEditGroup => 'Kontaktgruppe bearbeiten';
@override
String get widgetsChangeGroupsIos =>
'Um die Kontaktgruppen zu ändern, halte das Widget auf dem Homebildschirm gedrückt und tippe auf Widget bearbeiten.';
@override
String get widgetsChangeGroupsAndroid =>
'Um die Kontaktgruppen zu ändern, entferne das Widget und füge es erneut hinzu.';
@override
String get widgetsCurrentImages => 'Wird aktuell angezeigt';
@override
String get widgetsNoImages => 'Aktuell keine Fotos';
@override
String get widgetsNoImagesHint =>
'Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.';
@override
String widgetsFrom(String sender) {
return 'Von $sender';
}
@override
String widgetsExpiresIn(String duration) {
return 'Noch $duration sichtbar';
}
@override
String get widgetsDeleteImage => 'Foto löschen';
@override
String get widgetsDeleteImageConfirm =>
'Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rückgängig gemacht werden.';
@override
String widgetsDurationHours(int hours) {
return '$hours Std.';
}
@override
String widgetsDurationMinutes(int minutes) {
return '$minutes Min.';
}
@override
String get contactGroupUsedByWidget => 'Homebildschirm-Widget';
@ -2815,18 +2730,33 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get widgetsQueryFailed =>
'Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.';
String get webxdcStoreMenu => 'App';
@override
String get widgetsSizeSmall => 'Kleines Widget';
String get webxdcStoreTitle => 'App hinzufügen';
@override
String get widgetsSizeMedium => 'Mittleres Widget';
String get webxdcStoreEmpty => 'Es sind noch keine Apps verfügbar.';
@override
String get widgetsSizeLarge => 'Großes Widget';
String get webxdcStoreOffline =>
'Die App-Liste konnte nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.';
@override
String get widgetsSizeUnknown => 'Widget';
String get webxdcStoreFailed =>
'Die App konnte diesem Chat nicht hinzugefügt werden.';
@override
String get webxdcUnavailable => 'Diese App konnte nicht geladen werden.';
@override
String get webxdcDeleteConfirm =>
'Alles, was diese App auf diesem Gerät gespeichert hat, wird gelöscht. Andere Mitglieder behalten ihre eigene Kopie.';
@override
String get webxdcExternalLinkTitle => 'twonly verlassen?';
@override
String get webxdcExternalLinkBody =>
'Dieser Link öffnet sich außerhalb von twonly in deinem Browser:';
}

View file

@ -2675,91 +2675,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get fileLimitReachedUpgrade => 'Upgrade plan';
@override
String get settingsWidgets => 'Widgets';
@override
String get widgetsTitle => 'Widgets';
@override
String get widgetsIntroTitle => 'Photos on your home screen';
@override
String get widgetsIntroBody =>
'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.';
@override
String get widgetsSetupIos =>
'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.';
@override
String get widgetsSetupAndroid =>
'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.';
@override
String get widgetsNoneTitle => 'No widget added yet';
@override
String get widgetsPlacedTitle => 'On your home screen';
@override
String get widgetsAddAnother => 'Add another widget';
@override
String get widgetsNoGroups => 'No contact group selected';
@override
String get widgetsNoGroupsHint =>
'Nobody can send to this widget until you choose at least one contact group.';
@override
String get widgetsEditGroup => 'Edit contact group';
@override
String get widgetsChangeGroupsIos =>
'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.';
@override
String get widgetsChangeGroupsAndroid =>
'To change the contact groups, remove the widget and add it again.';
@override
String get widgetsCurrentImages => 'Currently showing';
@override
String get widgetsNoImages => 'No photos right now';
@override
String get widgetsNoImagesHint =>
'Photos your friends send to this widget will appear here.';
@override
String widgetsFrom(String sender) {
return 'From $sender';
}
@override
String widgetsExpiresIn(String duration) {
return 'Shown for $duration more';
}
@override
String get widgetsDeleteImage => 'Delete photo';
@override
String get widgetsDeleteImageConfirm =>
'This removes the photo from every widget showing it. It cannot be undone.';
@override
String widgetsDurationHours(int hours) {
return '$hours h';
}
@override
String widgetsDurationMinutes(int minutes) {
return '$minutes min';
}
@override
String get contactGroupUsedByWidget => 'Home screen widget';
@ -2788,18 +2703,32 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get widgetsQueryFailed =>
'Could not read the widgets on your home screen, so this list may be out of date.';
String get webxdcStoreMenu => 'App';
@override
String get widgetsSizeSmall => 'Small widget';
String get webxdcStoreTitle => 'Add an app';
@override
String get widgetsSizeMedium => 'Medium widget';
String get webxdcStoreEmpty => 'No apps are available yet.';
@override
String get widgetsSizeLarge => 'Large widget';
String get webxdcStoreOffline =>
'The app list could not be loaded. Check your connection and try again.';
@override
String get widgetsSizeUnknown => 'Widget';
String get webxdcStoreFailed => 'The app could not be added to this chat.';
@override
String get webxdcUnavailable => 'This app could not be loaded.';
@override
String get webxdcDeleteConfirm =>
'Everything this app saved on this device is deleted. Other members keep their own copy.';
@override
String get webxdcExternalLinkTitle => 'Leave twonly?';
@override
String get webxdcExternalLinkBody =>
'This link opens outside twonly, in your browser:';
}

View file

@ -1013,57 +1013,6 @@
"fileLimitReachedHint": "Nimm ein kürzeres Video auf und sende es erneut.",
"fileLimitReachedHintFree": "Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.",
"fileLimitReachedUpgrade": "Tarif wechseln",
"settingsWidgets": "Widgets",
"widgetsTitle": "Widgets",
"widgetsIntroTitle": "Fotos auf deinem Homebildschirm",
"widgetsIntroBody": "Freunde aus den von dir gewählten Kontaktgruppen können ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.",
"widgetsSetupIos": "Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Bearbeiten, dann auf Widget hinzufügen und wähle twonly. Halte anschließend das neue Widget gedrückt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwählen.",
"widgetsSetupAndroid": "Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dürfen.",
"widgetsNoneTitle": "Noch kein Widget hinzugefügt",
"widgetsPlacedTitle": "Auf deinem Homebildschirm",
"widgetsAddAnother": "Weiteres Widget hinzufügen",
"widgetsNoGroups": "Keine Kontaktgruppe ausgewählt",
"widgetsNoGroupsHint": "Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswählst.",
"widgetsEditGroup": "Kontaktgruppe bearbeiten",
"widgetsChangeGroupsIos": "Um die Kontaktgruppen zu ändern, halte das Widget auf dem Homebildschirm gedrückt und tippe auf Widget bearbeiten.",
"widgetsChangeGroupsAndroid": "Um die Kontaktgruppen zu ändern, entferne das Widget und füge es erneut hinzu.",
"widgetsCurrentImages": "Wird aktuell angezeigt",
"widgetsNoImages": "Aktuell keine Fotos",
"widgetsNoImagesHint": "Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.",
"widgetsFrom": "Von {sender}",
"@widgetsFrom": {
"placeholders": {
"sender": {
"type": "String"
}
}
},
"widgetsExpiresIn": "Noch {duration} sichtbar",
"@widgetsExpiresIn": {
"placeholders": {
"duration": {
"type": "String"
}
}
},
"widgetsDeleteImage": "Foto löschen",
"widgetsDeleteImageConfirm": "Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rückgängig gemacht werden.",
"widgetsDurationHours": "{hours} Std.",
"@widgetsDurationHours": {
"placeholders": {
"hours": {
"type": "int"
}
}
},
"widgetsDurationMinutes": "{minutes} Min.",
"@widgetsDurationMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"contactGroupUsedByWidget": "Homebildschirm-Widget",
"contactGroupUsedByWidgetSubtitle": "{count, plural, =1{Ein Widget auf deinem Homebildschirm zeigt Fotos aus dieser Gruppe.} other{{count} Widgets auf deinem Homebildschirm zeigen Fotos aus dieser Gruppe.}}",
"@contactGroupUsedByWidgetSubtitle": {
@ -1081,9 +1030,13 @@
}
}
},
"widgetsQueryFailed": "Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.",
"widgetsSizeSmall": "Kleines Widget",
"widgetsSizeMedium": "Mittleres Widget",
"widgetsSizeLarge": "Großes Widget",
"widgetsSizeUnknown": "Widget"
"webxdcStoreMenu": "App",
"webxdcStoreTitle": "App hinzufügen",
"webxdcStoreEmpty": "Es sind noch keine Apps verfügbar.",
"webxdcStoreOffline": "Die App-Liste konnte nicht geladen werden. Prüfe deine Verbindung und versuche es erneut.",
"webxdcStoreFailed": "Die App konnte diesem Chat nicht hinzugefügt werden.",
"webxdcUnavailable": "Diese App konnte nicht geladen werden.",
"webxdcDeleteConfirm": "Alles, was diese App auf diesem Gerät gespeichert hat, wird gelöscht. Andere Mitglieder behalten ihre eigene Kopie.",
"webxdcExternalLinkTitle": "twonly verlassen?",
"webxdcExternalLinkBody": "Dieser Link öffnet sich außerhalb von twonly in deinem Browser:"
}

View file

@ -1023,57 +1023,6 @@
"fileLimitReachedHint": "Record a shorter video and send it again.",
"fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.",
"fileLimitReachedUpgrade": "Upgrade plan",
"settingsWidgets": "Widgets",
"widgetsTitle": "Widgets",
"widgetsIntroTitle": "Photos on your home screen",
"widgetsIntroBody": "Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.",
"widgetsSetupIos": "Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.",
"widgetsSetupAndroid": "Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.",
"widgetsNoneTitle": "No widget added yet",
"widgetsPlacedTitle": "On your home screen",
"widgetsAddAnother": "Add another widget",
"widgetsNoGroups": "No contact group selected",
"widgetsNoGroupsHint": "Nobody can send to this widget until you choose at least one contact group.",
"widgetsEditGroup": "Edit contact group",
"widgetsChangeGroupsIos": "To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.",
"widgetsChangeGroupsAndroid": "To change the contact groups, remove the widget and add it again.",
"widgetsCurrentImages": "Currently showing",
"widgetsNoImages": "No photos right now",
"widgetsNoImagesHint": "Photos your friends send to this widget will appear here.",
"widgetsFrom": "From {sender}",
"@widgetsFrom": {
"placeholders": {
"sender": {
"type": "String"
}
}
},
"widgetsExpiresIn": "Shown for {duration} more",
"@widgetsExpiresIn": {
"placeholders": {
"duration": {
"type": "String"
}
}
},
"widgetsDeleteImage": "Delete photo",
"widgetsDeleteImageConfirm": "This removes the photo from every widget showing it. It cannot be undone.",
"widgetsDurationHours": "{hours} h",
"@widgetsDurationHours": {
"placeholders": {
"hours": {
"type": "int"
}
}
},
"widgetsDurationMinutes": "{minutes} min",
"@widgetsDurationMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"contactGroupUsedByWidget": "Home screen widget",
"contactGroupUsedByWidgetSubtitle": "{count, plural, =1{A widget on your home screen shows photos from this group.} other{{count} widgets on your home screen show photos from this group.}}",
"@contactGroupUsedByWidgetSubtitle": {
@ -1091,9 +1040,13 @@
}
}
},
"widgetsQueryFailed": "Could not read the widgets on your home screen, so this list may be out of date.",
"widgetsSizeSmall": "Small widget",
"widgetsSizeMedium": "Medium widget",
"widgetsSizeLarge": "Large widget",
"widgetsSizeUnknown": "Widget"
"webxdcStoreMenu": "App",
"webxdcStoreTitle": "Add an app",
"webxdcStoreEmpty": "No apps are available yet.",
"webxdcStoreOffline": "The app list could not be loaded. Check your connection and try again.",
"webxdcStoreFailed": "The app could not be added to this chat.",
"webxdcUnavailable": "This app could not be loaded.",
"webxdcDeleteConfirm": "Everything this app saved on this device is deleted. Other members keep their own copy.",
"webxdcExternalLinkTitle": "Leave twonly?",
"webxdcExternalLinkBody": "This link opens outside twonly, in your browser:"
}

View file

@ -106,6 +106,9 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
$core.Iterable<SharedContact>? contacts,
$fixnum.Int64? restoredFlameCounter,
$fixnum.Int64? askAboutUserId,
WebxdcApp? webxdcApp,
WebxdcUpdate? webxdcUpdate,
WebxdcOrigin? webxdcOrigin,
}) {
final result = create();
if (type != null) result.type = type;
@ -114,6 +117,9 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
if (restoredFlameCounter != null)
result.restoredFlameCounter = restoredFlameCounter;
if (askAboutUserId != null) result.askAboutUserId = askAboutUserId;
if (webxdcApp != null) result.webxdcApp = webxdcApp;
if (webxdcUpdate != null) result.webxdcUpdate = webxdcUpdate;
if (webxdcOrigin != null) result.webxdcOrigin = webxdcOrigin;
return result;
}
@ -136,6 +142,12 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
subBuilder: SharedContact.create)
..aInt64(4, _omitFieldNames ? '' : 'restoredFlameCounter')
..aInt64(5, _omitFieldNames ? '' : 'askAboutUserId')
..aOM<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;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -196,6 +208,305 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
$core.bool hasAskAboutUserId() => $_has(4);
@$pb.TagNumber(5)
void clearAskAboutUserId() => $_clearField(5);
@$pb.TagNumber(6)
WebxdcApp get webxdcApp => $_getN(5);
@$pb.TagNumber(6)
set webxdcApp(WebxdcApp value) => $_setField(6, value);
@$pb.TagNumber(6)
$core.bool hasWebxdcApp() => $_has(5);
@$pb.TagNumber(6)
void clearWebxdcApp() => $_clearField(6);
@$pb.TagNumber(6)
WebxdcApp ensureWebxdcApp() => $_ensure(5);
@$pb.TagNumber(7)
WebxdcUpdate get webxdcUpdate => $_getN(6);
@$pb.TagNumber(7)
set webxdcUpdate(WebxdcUpdate value) => $_setField(7, value);
@$pb.TagNumber(7)
$core.bool hasWebxdcUpdate() => $_has(6);
@$pb.TagNumber(7)
void clearWebxdcUpdate() => $_clearField(7);
@$pb.TagNumber(7)
WebxdcUpdate ensureWebxdcUpdate() => $_ensure(6);
@$pb.TagNumber(8)
WebxdcOrigin get webxdcOrigin => $_getN(7);
@$pb.TagNumber(8)
set webxdcOrigin(WebxdcOrigin value) => $_setField(8, value);
@$pb.TagNumber(8)
$core.bool hasWebxdcOrigin() => $_has(7);
@$pb.TagNumber(8)
void clearWebxdcOrigin() => $_clearField(8);
@$pb.TagNumber(8)
WebxdcOrigin ensureWebxdcOrigin() => $_ensure(7);
}
/// Attached to a message a webxdc app asked the user to send, so the chat can
/// say which app it came from.
///
/// `instance_id` is the app card in the chat the app runs in, which is not
/// necessarily the chat this message was sent to: the user picks the recipient.
/// `app_id` and `version` are carried as well so the receiver can name and
/// picture the app even when it has no instance of its own.
class WebxdcOrigin extends $pb.GeneratedMessage {
factory WebxdcOrigin({
$core.String? instanceId,
$core.String? appId,
$fixnum.Int64? version,
}) {
final result = create();
if (instanceId != null) result.instanceId = instanceId;
if (appId != null) result.appId = appId;
if (version != null) result.version = version;
return result;
}
WebxdcOrigin._();
factory WebxdcOrigin.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory WebxdcOrigin.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'WebxdcOrigin',
createEmptyInstance: create)
..aOS(1, _omitFieldNames ? '' : 'instanceId')
..aOS(2, _omitFieldNames ? '' : 'appId')
..aInt64(3, _omitFieldNames ? '' : 'version')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
WebxdcOrigin clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
WebxdcOrigin copyWith(void Function(WebxdcOrigin) updates) =>
super.copyWith((message) => updates(message as WebxdcOrigin))
as WebxdcOrigin;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static WebxdcOrigin create() => WebxdcOrigin._();
@$core.override
WebxdcOrigin createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static WebxdcOrigin getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<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 =

View file

@ -24,6 +24,12 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
2, _omitEnumNames ? '' : 'RESTORED_FLAME_COUNTER');
static const AdditionalMessageData_Type ASK_ABOUT_USER =
AdditionalMessageData_Type._(3, _omitEnumNames ? '' : 'ASK_ABOUT_USER');
static const AdditionalMessageData_Type WEBXDC_APP =
AdditionalMessageData_Type._(4, _omitEnumNames ? '' : 'WEBXDC_APP');
static const AdditionalMessageData_Type WEBXDC_UPDATE =
AdditionalMessageData_Type._(5, _omitEnumNames ? '' : 'WEBXDC_UPDATE');
static const AdditionalMessageData_Type WEBXDC_SENT =
AdditionalMessageData_Type._(6, _omitEnumNames ? '' : 'WEBXDC_SENT');
static const $core.List<AdditionalMessageData_Type> values =
<AdditionalMessageData_Type>[
@ -31,10 +37,13 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
CONTACTS,
RESTORED_FLAME_COUNTER,
ASK_ABOUT_USER,
WEBXDC_APP,
WEBXDC_UPDATE,
WEBXDC_SENT,
];
static final $core.List<AdditionalMessageData_Type?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 3);
$pb.ProtobufEnum.$_initByValueList(values, 6);
static AdditionalMessageData_Type? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];

View file

@ -76,12 +76,45 @@ const AdditionalMessageData$json = {
'10': 'askAboutUserId',
'17': true
},
{
'1': 'webxdc_app',
'3': 6,
'4': 1,
'5': 11,
'6': '.WebxdcApp',
'9': 3,
'10': 'webxdcApp',
'17': true
},
{
'1': 'webxdc_update',
'3': 7,
'4': 1,
'5': 11,
'6': '.WebxdcUpdate',
'9': 4,
'10': 'webxdcUpdate',
'17': true
},
{
'1': 'webxdc_origin',
'3': 8,
'4': 1,
'5': 11,
'6': '.WebxdcOrigin',
'9': 5,
'10': 'webxdcOrigin',
'17': true
},
],
'4': [AdditionalMessageData_Type$json],
'8': [
{'1': '_link'},
{'1': '_restored_flame_counter'},
{'1': '_ask_about_user_id'},
{'1': '_webxdc_app'},
{'1': '_webxdc_update'},
{'1': '_webxdc_origin'},
],
};
@ -93,6 +126,9 @@ const AdditionalMessageData_Type$json = {
{'1': 'CONTACTS', '2': 1},
{'1': 'RESTORED_FLAME_COUNTER', '2': 2},
{'1': 'ASK_ABOUT_USER', '2': 3},
{'1': 'WEBXDC_APP', '2': 4},
{'1': 'WEBXDC_UPDATE', '2': 5},
{'1': 'WEBXDC_SENT', '2': 6},
],
};
@ -102,6 +138,83 @@ final $typed_data.Uint8List additionalMessageDataDescriptor = $convert.base64Dec
'dlRGF0YS5UeXBlUgR0eXBlEhcKBGxpbmsYAiABKAlIAFIEbGlua4gBARIqCghjb250YWN0cxgD'
'IAMoCzIOLlNoYXJlZENvbnRhY3RSCGNvbnRhY3RzEjkKFnJlc3RvcmVkX2ZsYW1lX2NvdW50ZX'
'IYBCABKANIAVIUcmVzdG9yZWRGbGFtZUNvdW50ZXKIAQESLgoRYXNrX2Fib3V0X3VzZXJfaWQY'
'BSABKANIAlIOYXNrQWJvdXRVc2VySWSIAQEiTgoEVHlwZRIICgRMSU5LEAASDAoIQ09OVEFDVF'
'MQARIaChZSRVNUT1JFRF9GTEFNRV9DT1VOVEVSEAISEgoOQVNLX0FCT1VUX1VTRVIQA0IHCgVf'
'bGlua0IZChdfcmVzdG9yZWRfZmxhbWVfY291bnRlckIUChJfYXNrX2Fib3V0X3VzZXJfaWQ=');
'BSABKANIAlIOYXNrQWJvdXRVc2VySWSIAQESLgoKd2VieGRjX2FwcBgGIAEoCzIKLldlYnhkY0'
'FwcEgDUgl3ZWJ4ZGNBcHCIAQESNwoNd2VieGRjX3VwZGF0ZRgHIAEoCzINLldlYnhkY1VwZGF0'
'ZUgEUgx3ZWJ4ZGNVcGRhdGWIAQESNwoNd2VieGRjX29yaWdpbhgIIAEoCzINLldlYnhkY09yaW'
'dpbkgFUgx3ZWJ4ZGNPcmlnaW6IAQEiggEKBFR5cGUSCAoETElOSxAAEgwKCENPTlRBQ1RTEAES'
'GgoWUkVTVE9SRURfRkxBTUVfQ09VTlRFUhACEhIKDkFTS19BQk9VVF9VU0VSEAMSDgoKV0VCWE'
'RDX0FQUBAEEhEKDVdFQlhEQ19VUERBVEUQBRIPCgtXRUJYRENfU0VOVBAGQgcKBV9saW5rQhkK'
'F19yZXN0b3JlZF9mbGFtZV9jb3VudGVyQhQKEl9hc2tfYWJvdXRfdXNlcl9pZEINCgtfd2VieG'
'RjX2FwcEIQCg5fd2VieGRjX3VwZGF0ZUIQCg5fd2VieGRjX29yaWdpbg==');
@$core.Deprecated('Use webxdcOriginDescriptor instead')
const WebxdcOrigin$json = {
'1': 'WebxdcOrigin',
'2': [
{'1': 'instance_id', '3': 1, '4': 1, '5': 9, '10': 'instanceId'},
{'1': 'app_id', '3': 2, '4': 1, '5': 9, '10': 'appId'},
{'1': 'version', '3': 3, '4': 1, '5': 3, '10': 'version'},
],
};
/// Descriptor for `WebxdcOrigin`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List webxdcOriginDescriptor = $convert.base64Decode(
'CgxXZWJ4ZGNPcmlnaW4SHwoLaW5zdGFuY2VfaWQYASABKAlSCmluc3RhbmNlSWQSFQoGYXBwX2'
'lkGAIgASgJUgVhcHBJZBIYCgd2ZXJzaW9uGAMgASgDUgd2ZXJzaW9u');
@$core.Deprecated('Use webxdcAppDescriptor instead')
const WebxdcApp$json = {
'1': 'WebxdcApp',
'2': [
{'1': 'app_id', '3': 1, '4': 1, '5': 9, '10': 'appId'},
{'1': 'version', '3': 2, '4': 1, '5': 3, '10': 'version'},
],
};
/// Descriptor for `WebxdcApp`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List webxdcAppDescriptor = $convert.base64Decode(
'CglXZWJ4ZGNBcHASFQoGYXBwX2lkGAEgASgJUgVhcHBJZBIYCgd2ZXJzaW9uGAIgASgDUgd2ZX'
'JzaW9u');
@$core.Deprecated('Use webxdcUpdateDescriptor instead')
const WebxdcUpdate$json = {
'1': 'WebxdcUpdate',
'2': [
{'1': 'instance_id', '3': 1, '4': 1, '5': 9, '10': 'instanceId'},
{'1': 'payload', '3': 2, '4': 1, '5': 9, '10': 'payload'},
{'1': 'info', '3': 3, '4': 1, '5': 9, '9': 0, '10': 'info', '17': true},
{'1': 'href', '3': 4, '4': 1, '5': 9, '9': 1, '10': 'href', '17': true},
{
'1': 'summary',
'3': 5,
'4': 1,
'5': 9,
'9': 2,
'10': 'summary',
'17': true
},
{
'1': 'document',
'3': 6,
'4': 1,
'5': 9,
'9': 3,
'10': 'document',
'17': true
},
],
'8': [
{'1': '_info'},
{'1': '_href'},
{'1': '_summary'},
{'1': '_document'},
],
};
/// Descriptor for `WebxdcUpdate`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List webxdcUpdateDescriptor = $convert.base64Decode(
'CgxXZWJ4ZGNVcGRhdGUSHwoLaW5zdGFuY2VfaWQYASABKAlSCmluc3RhbmNlSWQSGAoHcGF5bG'
'9hZBgCIAEoCVIHcGF5bG9hZBIXCgRpbmZvGAMgASgJSABSBGluZm+IAQESFwoEaHJlZhgEIAEo'
'CUgBUgRocmVmiAEBEh0KB3N1bW1hcnkYBSABKAlIAlIHc3VtbWFyeYgBARIfCghkb2N1bWVudB'
'gGIAEoCUgDUghkb2N1bWVudIgBAUIHCgVfaW5mb0IHCgVfaHJlZkIKCghfc3VtbWFyeUILCglf'
'ZG9jdW1lbnQ=');

View file

@ -47,7 +47,6 @@ import 'package:twonly/src/visual/views/settings/profile/profile.view.dart';
import 'package:twonly/src/visual/views/settings/settings_main.view.dart';
import 'package:twonly/src/visual/views/settings/share_with_friends.view.dart';
import 'package:twonly/src/visual/views/settings/subscription/subscription.view.dart';
import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart';
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
@ -229,10 +228,6 @@ final routerProvider = GoRouter(
path: 'notification',
builder: (context, state) => const NotificationView(),
),
GoRoute(
path: 'widgets',
builder: (context, state) => const WidgetsSettingsView(),
),
GoRoute(
path: 'storage_data',
builder: (context, state) => const DataAndStorageView(),

View file

@ -13,41 +13,11 @@ class PlacedWidget {
required this.id,
required this.platform,
required this.contactGroupIds,
this.family,
});
final String id;
final String platform;
final List<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
@ -131,7 +101,10 @@ class HomeWidgetService {
/// Drops the cached reconcile so the next read asks the system again.
static void invalidate() => _cachedReport = null;
static Future<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 refresh();
}
@ -145,12 +118,6 @@ class HomeWidgetService {
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.
///
/// Neither platform notices the rewrite on its own: WidgetKit keeps the
@ -181,45 +148,29 @@ class HomeWidgetService {
/// the file whenever a widget is added or removed, so there the file is the
/// authority.
///
/// The returned `error` is set when iOS could not be asked; the widgets are
/// then whatever the file last recorded, which may name widgets that are
/// already gone.
static Future<({List<PlacedWidget> widgets, String? error})>
placedWidgetsResult() async {
if (!Platform.isIOS) {
return (widgets: await _widgetsFromFile(), error: null);
}
/// When iOS cannot be asked, this falls back to whatever the file last
/// recorded, which may name widgets that are already gone.
static Future<List<PlacedWidget>> placedWidgets() async {
if (!Platform.isIOS) return _widgetsFromFile();
final report = await reconcileReport();
if (report == null || report['error'] != null) {
return (
widgets: await _widgetsFromFile(),
error: '${report?['error'] ?? 'unknown'}',
);
}
if (report == null || report['error'] != null) return _widgetsFromFile();
final reported = ((report['widgets'] as List?) ?? const [])
.cast<Map<Object?, Object?>>()
.where((entry) => entry['mine'] == true && entry['live'] == true);
final seen = <String>{};
return (
widgets: [
for (final entry in reported)
if (seen.add('${entry['id']}'))
PlacedWidget(
id: '${entry['id']}',
platform: 'ios',
family: entry['family'] as String?,
contactGroupIds: ((entry['group_ids'] as List?) ?? const [])
.map((id) => (id as num).toInt())
.toList(),
),
],
error: null,
);
return [
for (final entry in reported)
if (seen.add('${entry['id']}'))
PlacedWidget(
id: '${entry['id']}',
platform: 'ios',
contactGroupIds: ((entry['group_ids'] as List?) ?? const [])
.map((id) => (id as num).toInt())
.toList(),
),
];
}
static Future<List<PlacedWidget>> placedWidgets() async =>
(await placedWidgetsResult()).widgets;
static Future<List<PlacedWidget>> _widgetsFromFile() async {
final file = File('${_root.path}/native-config.json');
if (!file.existsSync()) return const [];
@ -252,44 +203,4 @@ class HomeWidgetService {
lastSeen.toInt() * 1000,
).isAfter(oldestLive);
}
/// Every unexpired image Rust has published to the widgets, newest first.
static Future<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();
}
}

View file

@ -37,10 +37,11 @@ class NativeNotificationService {
/// Emits metadata for every native notification tapped while the app runs.
static Stream<NativeNotificationTap> get taps => _taps.stream;
/// Text messages (including quoted replies) open their conversation. Media
/// and every other notification kind stay on the chat overview.
/// Text messages (including quoted replies) and the announcements an app
/// writes into a chat open their conversation. Media and every other
/// notification kind stay on the chat overview.
static bool opensConversation(String? kind) =>
kind == 'text' || kind == 'response';
kind == 'text' || kind == 'response' || kind == 'webxdc';
static void init() {
_startBadgeSync();

View 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);
}
}

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

View file

@ -386,6 +386,19 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
bool reportOpened = false,
}) async {
final newestKnownAt = _animationState.newestKnownAt;
// Only a message the user just sent pulls the list back down. Deciding
// this from the newest known message alone would scroll on every unrelated
// update as well: reactions, media state, group actions and every page of
// older messages run through here too, and each of them would yank the
// reader back to the bottom whenever their own message ends the chat.
final lastMessage = newMessages.lastOrNull;
final wasSentByMe =
_animationState.hasReceivedFirstBatch &&
lastMessage != null &&
lastMessage.senderId == null &&
!_animationState.knownMessageIds.contains(lastMessage.messageId);
for (final msg in newMessages) {
// Only messages appended after the newest one already loaded are new to
// the user. Messages fetched by scrolling up are older and must not
@ -475,11 +488,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
}
}
final wasSentByMe =
_animationState.hasReceivedFirstBatch &&
newMessages.isNotEmpty &&
newMessages.last.senderId == null;
if (!mounted) return;
_data.chatItems = chatItems.reversed.toList();
_notifyMessageDataChanged();

View file

@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/bottom_sheets/webxdc_store.bottom_sheet.dart';
import 'package:twonly/src/visual/views/shared/select_contacts.view.dart';
class ShareAdditionalView extends StatefulWidget {
@ -62,6 +63,16 @@ class _ShareAdditionalViewState extends State<ShareAdditionalView> {
}
}
Future<void> openAppStore() async {
Navigator.pop(context);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => WebxdcStoreView(group: widget.group),
);
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
@ -95,27 +106,17 @@ class _ShareAdditionalViewState extends State<ShareAdditionalView> {
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 32,
children: [
GestureDetector(
_entry(
icon: FontAwesomeIcons.circleUser,
label: context.lang.shareContactsMenu,
onTap: openShareContactView,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: context.color.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
child: const FaIcon(FontAwesomeIcons.circleUser),
),
const SizedBox(height: 8),
Text(
context.lang.shareContactsMenu,
textAlign: TextAlign.center,
),
],
),
),
_entry(
icon: Icons.apps_rounded,
label: context.lang.webxdcStoreMenu,
onTap: openAppStore,
),
],
),
@ -125,4 +126,33 @@ class _ShareAdditionalViewState extends State<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),
],
),
);
}
}

View file

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

View file

@ -21,6 +21,8 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/c
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_text_entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_unknown.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_webxdc.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/chat_webxdc_message.entry.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_context_menu.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_reply_drag.dart';
@ -144,6 +146,16 @@ class _ChatListEntryState extends State<ChatListEntry> {
BubbleInfo info,
) {
if (widget.message.type == MessageType.text.name) {
// Only a message a webxdc app produced -- text handed to a chat, or an
// announcement an app made in one -- carries data alongside its text, so
// this is enough to route it without decoding anything here.
if (widget.message.additionalMessageData != null) {
return ChatWebxdcMessageEntry(
message: widget.message,
borderRadius: borderRadius,
info: info,
);
}
return ChatTextEntry(
message: widget.message,
borderRadius: borderRadius,
@ -198,6 +210,14 @@ class _ChatListEntryState extends State<ChatListEntry> {
);
}
if (widget.message.type == MessageType.webxdcApp.name) {
return ChatWebxdcEntry(
message: widget.message,
borderRadius: borderRadius,
info: info,
);
}
return const ChatUnknownEntry();
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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),
],
),
],
),
),
);
}
}

View file

@ -81,6 +81,14 @@ BubbleInfo getBubbleInfo(
return info;
}
/// The languages the reader prefers, most preferred first, for the parts of a
/// message that arrive translated -- a webxdc app's name, say. What the reader
/// asked for is decided from the resolved locale and never sent anywhere: every
/// translation is already on the device.
List<String> readerLanguages(BuildContext context) => [
Localizations.localeOf(context).toLanguageTag(),
];
/// Laying text out is expensive and `getBubbleInfo` runs for every visible
/// bubble on every rebuild, while the same message content is measured over and
/// over. Keep the last few hundred results around, in insertion order, so the

View file

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

View file

@ -13,6 +13,7 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dart'
as pb;
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/webxdc/webxdc.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
import 'package:twonly/src/visual/context_menu/context_menu.helper.dart';
@ -185,6 +186,14 @@ class MessageContextMenu extends StatelessWidget {
group,
galleryItems,
);
if (action == null) return;
if (message.type == MessageType.webxdcApp.name) {
// Removing the rows is only half of it. An app is free to keep
// its whole state in localStorage or IndexedDB, which no database
// delete reaches, so the instance goes first and takes its origin
// with it.
await WebxdcService.deleteInstance(message.messageId);
}
if (action == 'delete_for_all') {
await twonlyDB.messagesDao.handleMessageDeletion(
null,
@ -285,6 +294,18 @@ Future<String?> showDeleteMessageOptions(
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
// Deleting an app is not the same as deleting a message: the whole
// update log and everything the app saved on this device go with
// it, so the sheet says so rather than letting the usual wording
// stand in for it.
if (message.type == MessageType.webxdcApp.name) ...[
Text(
context.lang.webxdcDeleteConfirm,
textAlign: TextAlign.center,
style: TextStyle(color: context.color.onSurfaceVariant),
),
const SizedBox(height: 24),
],
if (isForAll) ...[
Center(
child: MyButton(

View file

@ -133,7 +133,10 @@ class _HomeWidgetDeveloperViewState extends State<HomeWidgetDeveloperView> {
final groups = (decoded['groups'] as List?) ?? [];
final images = (decoded['images'] as List?) ?? [];
lines.add('${groups.length} contact groups, ${images.length} images');
lines.add(
'${groups.length} contact groups, ${images.length} images '
'(at most one per contact group)',
);
// The group IDs are the whole matching rule: the widget shows an image only
// when its sender's groups intersect the groups the widget was configured
@ -141,14 +144,17 @@ class _HomeWidgetDeveloperViewState extends State<HomeWidgetDeveloperView> {
for (final group in groups.cast<Map<String, dynamic>>()) {
lines.add(' group ${group['id']}: ${group['name']}');
}
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
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();
lines.add(
' ${image['media_id']} from ${image['sender']} '
// One image per contact group, so these are the groups it is the
// current image for not the sender's full membership.
'groups=${image['group_ids']} '
'${expiresAt > now ? 'valid' : 'EXPIRED'} '
'received ${receivedAt.toLocal()} '
'${exists ? '' : 'FILE MISSING'}',
);
}

View file

@ -110,11 +110,6 @@ class SettingsMainView extends StatelessWidget {
text: context.lang.settingsNotification,
onTap: () => context.push(Routes.settingsNotification),
),
BetterListTile(
icon: Icons.widgets_rounded,
text: context.lang.settingsWidgets,
onTap: () => context.push(Routes.settingsWidgets),
),
BetterListTile(
icon: FontAwesomeIcons.chartPie,
iconSize: 15,

View file

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

View file

@ -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,
),
),
],
),
);
}
}

View file

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

View 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();
},
);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE webxdc_apps SET published = 0",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "3c6a9504fe958ab1dfd2330078d761fe69934fb2118cf48ff845a3524c7ffead"
}

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry\n FROM receipts WHERE contact_id = ? AND message_id = ?",
"query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry,\n MAX(ack_by_server_at) AS acknowledged\n FROM receipts WHERE contact_id = ? AND message_id = ?",
"describe": {
"columns": [
{
@ -14,6 +14,12 @@
"ordinal": 1,
"type_info": "Integer",
"origin": "Expression"
},
{
"name": "acknowledged",
"ordinal": 2,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@ -21,8 +27,9 @@
},
"nullable": [
false,
true,
true
]
},
"hash": "cf821c9b1530db447a1ba030c3be50c5eeeb78cb28fe74481d6493a6a0af4161"
"hash": "674895e43478d91c66caeb1cee349fd22ab22b783f8d985fe2ce92501503dc83"
}

View file

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

View file

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

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM webxdc_updates WHERE instance_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "8c0e68f56a7d625bfc87bb5991ac55a77dec6c9192bcd72c3f02858fa2b23657"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM webxdc_instances WHERE instance_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "cfa0b29edf334cdec34ade89ff33e9ad70f36f2616333a71d69bc5cc7a2781e3"
}

View file

@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ? WHERE receipt_id = ?",
"query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ?,\n wake_receiver = CASE WHEN ? THEN 0 ELSE wake_receiver END\n WHERE receipt_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 5
"Right": 6
},
"nullable": []
},
"hash": "fedeeeaee2ad31906726430055edac23359bb61ff80d53632c95b40837d504f3"
"hash": "cffb25fc9ca6a574dbafd9a4f661b64e9e55d162dd5b8b3b3791c82150e4a73f"
}

View file

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

View file

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

View file

@ -1,12 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ? WHERE receipt_id = ?",
"query": "UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,\n retry_count = ?, last_retry = ?,\n wake_receiver = CASE WHEN ? THEN 0 ELSE wake_receiver END\n WHERE receipt_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 5
"Right": 6
},
"nullable": []
},
"hash": "732496b1ad12c49c3aca6925d45abc4c801838e606d9bfd3f728f4e1dd91dd3a"
"hash": "e1154392dd25c96e3ac0e5dd3c5a2805fd8ebaaf5400b87b73b4439e43080c19"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "INSERT INTO messages(group_id, message_id, type, additional_message_data, created_at)\n VALUES (?, ?, ?, ?, ?)",
"query": "INSERT INTO messages(group_id, message_id, type, additional_message_data, created_at)\n VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "7ba6ad25a53db6d9ee725ad8dd06a58c119e4f445edf966b97016fb6d598f09a"
"hash": "e6a42d65c955aa13817fd01b4be533c53e52f20e6deca7054fd1161277a9e27e"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry\n FROM receipts WHERE contact_id = ? AND message_id = ?",
"query": "SELECT COUNT(*) AS \"count!: i64\", MAX(last_retry) AS last_retry,\n MAX(ack_by_server_at) AS acknowledged\n FROM receipts WHERE contact_id = ? AND message_id = ?",
"describe": {
"columns": [
{
@ -14,6 +14,12 @@
"ordinal": 1,
"type_info": "Integer",
"origin": "Expression"
},
{
"name": "acknowledged",
"ordinal": 2,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
@ -21,8 +27,9 @@
},
"nullable": [
false,
true,
true
]
},
"hash": "4ad8ea916d6113c45fef05150f2e619e06ac351ac0b7d0a9b340835ac7109fdf"
"hash": "eac6f09e21a49428007bb59568259c6163d5ba4daeced9f699aa6a9d9b15b1d3"
}

View file

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

View file

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

View file

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

View file

@ -145,6 +145,11 @@ fn client() -> Result<reqwest::Client> {
async fn success(response: reqwest::Response) -> Result<()> {
if response.status().is_success() {
Ok(())
} else if response.status() == reqwest::StatusCode::CONFLICT {
// The one status a caller can act on: the state moved while the
// request was being prepared, and re-applying the change to the new
// version is all that is needed.
Err(TwonlyError::GroupStateConflict)
} else {
Err(TwonlyError::Generic(format!(
"group server returned {}",

View file

@ -11,6 +11,7 @@ use crate::database::app::tables::Message;
use crate::database::app::tables::NewKeyVerification;
use crate::database::app::tables::{MessageType, NewMessage};
use crate::error::Result;
use crate::services::webxdc::WebxdcService;
use crate::utils::milliseconds_to_seconds;
use prost::Message as ProstMessage;
use sqlx::{Sqlite, Transaction};
@ -25,14 +26,46 @@ pub(crate) async fn handle_additional_data_message(
Message::check_message_owner(tr, &message.sender_message_id, from_user_id).await?;
let timestamp = milliseconds_to_seconds(message.timestamp);
if let Some(data) = message.additional_message_data.as_deref() {
// Additional metadata is optional. Keep accepting the containing
// message if it is malformed or contact verification cannot complete.
if let Err(error) = verify_shared_contacts(ctx, tr, from_user_id, data).await {
// Additional metadata is optional. Keep accepting the containing message if
// it is malformed or a handler cannot complete.
let data = match message.additional_message_data.as_deref() {
Some(bytes) => match proto::AdditionalMessageData::decode(bytes) {
Ok(data) => Some(data),
Err(error) => {
tracing::warn!("failed to decode additional message data: {error}");
None
}
},
None => None,
};
if let Some(data) = data.as_ref() {
if let Some(update) = data.webxdc_update.as_ref() {
// App state belongs in the instance's own log, which is ordered,
// gap free, and outside the reach of the chat's deletion timer.
WebxdcService::handle_incoming_update(
tr,
&message.sender_message_id,
group_id,
from_user_id,
update,
timestamp,
)
.await?;
} else if let Err(error) = verify_shared_contacts(ctx, tr, from_user_id, data).await {
tracing::warn!("failed to handle additional message data: {error}");
}
}
// A hidden message carries state a feature exchanges rather than something
// a person sent, so it leaves no trace in the chat: no row means it cannot
// be rendered, quoted, deleted, or swept up by the deletion timer, and the
// chat's last exchange is left alone so a running app cannot keep a streak
// alive on its own.
if message.hidden {
return Ok(());
}
NewMessage::builder()
.group_id(group_id)
.message_id(&message.sender_message_id)
@ -45,6 +78,12 @@ pub(crate) async fn handle_additional_data_message(
.insert(tr)
.await?;
if let Some(app) = data.as_ref().and_then(|data| data.webxdc_app.as_ref()) {
// Recorded only once the card exists: the instance is keyed by that
// message and cascades from it.
WebxdcService::handle_incoming_app(tr, &message.sender_message_id, group_id, app).await?;
}
Group::increase_last_message_exchange(tr, group_id, timestamp).await?;
Ok(())
@ -54,17 +93,15 @@ async fn verify_shared_contacts(
ctx: &Context,
tr: &mut Transaction<'_, Sqlite>,
from_user_id: i64,
bytes: &[u8],
data: &proto::AdditionalMessageData,
) -> Result<()> {
let data = proto::AdditionalMessageData::decode(bytes)?;
if data.r#type != proto::additional_message_data::Type::Contacts as i32 {
return Ok(());
}
let signal_database = ctx.rust_db.read().await.clone();
for contact in data.contacts {
for contact in &data.contacts {
if contact.public_identity_key.is_empty() {
tracing::info!("shared contact carries no public key, skipping verification");
continue;

View file

@ -15,27 +15,31 @@ use crate::utils::new_uuid_v4;
use prost::Message as ProstMessage;
use proto::encrypted_content::error_messages::Type;
use sqlx::{Sqlite, Transaction};
use std::sync::{Arc, LazyLock, Mutex, PoisonError};
use std::sync::{Arc, Mutex, PoisonError};
#[cfg(not(debug_assertions))]
use std::collections::HashMap;
#[cfg(not(debug_assertions))]
use std::sync::LazyLock;
#[cfg(not(debug_assertions))]
static ALREADY_QUEUED_RECEIPTS: LazyLock<Mutex<HashMap<String, std::time::Instant>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Coalesces overlapping flushes of the receipt queue.
/// Coalesces overlapping flushes of one account's receipt queue.
///
/// Every committed inbound message asks for a flush, and a flush scans the
/// whole queue, so one mailbox page would otherwise start as many identical
/// scans as it carries messages — all of them competing for the single
/// app-database connection. A request that arrives while a flush runs only
/// marks it dirty, so the running flush queries once more before it returns.
static QUEUED_RECEIPT_FLUSH: LazyLock<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)]
struct FlushClaim {
pub(crate) struct FlushClaim {
running: bool,
dirty: bool,
}
@ -43,24 +47,27 @@ struct FlushClaim {
/// Held for as long as a flush owns the claim. Dropping it without releasing
/// it — an error or a panic on the way out — frees the claim for the next
/// caller instead of blocking every later flush.
struct FlushGuard {
struct FlushGuard<'a> {
claim: &'a Mutex<FlushClaim>,
released: bool,
}
impl FlushGuard {
impl<'a> FlushGuard<'a> {
/// Registers a flush for this caller. Returns `None` when one is already
/// running, in which case it is marked dirty and will query once more.
fn claim() -> Option<Self> {
let mut claim = QUEUED_RECEIPT_FLUSH
.lock()
.unwrap_or_else(PoisonError::into_inner);
fn claim(state: &'a Mutex<FlushClaim>) -> Option<Self> {
let mut claim = state.lock().unwrap_or_else(PoisonError::into_inner);
if claim.running {
claim.dirty = true;
return None;
}
claim.running = true;
claim.dirty = false;
Some(Self { released: false })
drop(claim);
Some(Self {
claim: state,
released: false,
})
}
/// Ends the flush when nothing asked for another one while it ran,
@ -68,9 +75,7 @@ impl FlushGuard {
/// Both happen under one lock so a request cannot be dropped between the
/// last query and the release.
fn release_or_take_dirty(&mut self) -> bool {
let mut claim = QUEUED_RECEIPT_FLUSH
.lock()
.unwrap_or_else(PoisonError::into_inner);
let mut claim = self.claim.lock().unwrap_or_else(PoisonError::into_inner);
if claim.dirty {
claim.dirty = false;
return true;
@ -81,12 +86,12 @@ impl FlushGuard {
}
}
impl Drop for FlushGuard {
impl Drop for FlushGuard<'_> {
fn drop(&mut self) {
if self.released {
return;
}
QUEUED_RECEIPT_FLUSH
self.claim
.lock()
.unwrap_or_else(PoisonError::into_inner)
.running = false;
@ -101,7 +106,8 @@ pub(crate) async fn queue_encrypted_content(
) -> Result<String> {
Contact::ensure_exists(t, target_user_id).await?;
let wake_receiver = crate::services::notifications::should_wake_receiver(&content);
let wake_receiver =
crate::services::notifications::should_wake_receiver(t, target_user_id, &content).await?;
let message = proto::Message {
r#type: proto::message::Type::CiphertextV2 as i32,
@ -584,10 +590,20 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc<Context>, receipt_id: &str) ->
if receipt.contact_will_sends_receipt == 0 {
Receipt::delete(&mut *t, receipt_id).await?;
} else {
// The wake is spent the moment the server accepts the envelope, so it
// is cleared here alongside the acknowledgement that records it.
//
// This receipt outlives its delivery -- it is kept until the peer's own
// receipt comes back -- and every inbound message from that peer marks
// it for retry again. Without this, one message would push the peer
// once per retransmission: a chat busy enough to keep retrying, a game
// exchanging updates for instance, would alert them over and over for a
// message they were told about the first time and already have.
sqlx::query!(
r#"
UPDATE receipts SET
ack_by_server_at = CAST(strftime('%s', 'now') AS INTEGER),
wake_receiver = 0,
retry_count = retry_count + 1,
last_retry = CAST(strftime('%s', 'now') AS INTEGER),
mark_for_retry = NULL
@ -643,7 +659,7 @@ pub(crate) async fn release_deferred_receipts(
}
pub async fn retransmit_queued_receipts(ctx: &Arc<Context>) -> Result<()> {
let Some(mut guard) = FlushGuard::claim() else {
let Some(mut guard) = FlushGuard::claim(&ctx.queued_receipt_flush) else {
return Ok(());
};
@ -789,8 +805,9 @@ mod tests {
#[test]
fn a_second_flush_request_marks_the_running_one_dirty() {
let mut guard = FlushGuard::claim().expect("nothing else holds the claim");
assert!(FlushGuard::claim().is_none());
let state = Mutex::new(FlushClaim::default());
let mut guard = FlushGuard::claim(&state).expect("nothing else holds the claim");
assert!(FlushGuard::claim(&state).is_none());
// The running flush sees the dirty flag and keeps its claim.
assert!(guard.release_or_take_dirty());
@ -799,8 +816,19 @@ mod tests {
// Dropping a guard that never released frees the claim too, so an
// error on the way out cannot block every later flush.
let guard = FlushGuard::claim().expect("the claim was released");
let guard = FlushGuard::claim(&state).expect("the claim was released");
drop(guard);
assert!(FlushGuard::claim().is_some());
assert!(FlushGuard::claim(&state).is_some());
}
/// Two accounts in one process each flush their own queue. A claim held
/// for one must not turn the other's flush into a no-op.
#[test]
fn each_account_holds_its_own_flush_claim() {
let first = Mutex::new(FlushClaim::default());
let second = Mutex::new(FlushClaim::default());
let _held = FlushGuard::claim(&first).expect("nothing else holds the claim");
assert!(FlushGuard::claim(&second).is_some());
}
}

View file

@ -30,6 +30,7 @@ pub(crate) async fn handle_text_message(
.sender_id(from_user_id)
.content(&message.text)
.maybe_quotes_message_id(message.quote_message_id.as_deref())
.maybe_additional_message_data(message.additional_message_data.as_deref())
.ack_by_server(current_time().timestamp())
.build()
.insert(t)

View file

@ -10,10 +10,43 @@ use crate::context::Context;
use crate::error::Result;
use crate::user_config::UserConfig;
use prost::Message as _;
use sqlx::{Sqlite, Transaction};
use std::sync::Arc;
/// Brings user discovery up before an envelope asks it for a version.
///
/// Initializing it builds the share set, which writes through the app database
/// on a connection of its own. The pool has exactly one, so this has to happen
/// before the caller opens the transaction [`decorate_content`] then runs on.
pub(crate) async fn prepare_user_discovery(
ctx: &Context,
is_persisted_message: bool,
) -> Result<()> {
if !is_persisted_message {
return Ok(());
}
let Some(config) = UserConfig::load_from(ctx)? else {
return Ok(());
};
if config.is_user_discovery_enabled {
ctx.initialize_user_discovery_from_config().await?;
}
Ok(())
}
/// Fills in the metadata every outgoing envelope carries beside its payload.
///
/// The reads run on the caller's transaction rather than on the pool: the app
/// database has a single connection, so asking the pool for one here while the
/// caller holds that connection open waits for the caller to finish and
/// deadlocks until the acquire times out thirty seconds later -- with every
/// other database user in the process blocked behind it.
///
/// A caller that passes `is_persisted_message` must have called
/// [`prepare_user_discovery`] before opening `t`.
pub(crate) async fn decorate_content(
ctx: &Context,
t: &mut Transaction<'_, Sqlite>,
contact_id: i64,
content: &mut proto::EncryptedContent,
is_persisted_message: bool,
@ -23,20 +56,19 @@ pub(crate) async fn decorate_content(
};
content.sender_profile_counter = Some(config.avatar_counter);
let database = ctx.app_db.read().await.clone();
content.widget_sharing_allowed = Some(
sqlx::query_scalar!(
"SELECT widget_sharing_granted FROM contacts WHERE user_id = ?",
contact_id,
)
.fetch_optional(&database.pool)
.fetch_optional(&mut **t)
.await?
.unwrap_or(0)
!= 0,
);
if config.ask_for_friend_promotions {
let accepted = sqlx::query_scalar!("SELECT COUNT(*) FROM contacts WHERE accepted = 1")
.fetch_one(&database.pool)
.fetch_one(&mut **t)
.await?;
if accepted <= 5 {
content.ask_for_friend_promotions = Some(true);
@ -44,8 +76,6 @@ pub(crate) async fn decorate_content(
}
if config.is_user_discovery_enabled & is_persisted_message {
ctx.initialize_user_discovery_from_config().await?;
let database = ctx.app_db.read().await.clone();
let allowed = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM contacts WHERE user_id = ? AND accepted = 1
AND blocked = 0 AND media_send_counter >= ? AND user_discovery_excluded = 0
@ -54,7 +84,7 @@ pub(crate) async fn decorate_content(
config.required_send_images,
config.user_discovery_requires_manual_approval,
)
.fetch_one(&database.pool)
.fetch_one(&mut **t)
.await?;
if allowed != 0 {
content.sender_user_discovery_version =
@ -76,11 +106,13 @@ pub async fn send_c2c_message_to_contact(
) -> Result<Option<Vec<u8>>> {
let mut content = proto::EncryptedContent::decode(encrypted_content.as_slice())?;
decorate_content(ctx, contact_id, &mut content, message_id.is_some()).await?;
prepare_user_discovery(ctx, message_id.is_some()).await?;
let db_app = ctx.app_db.read().await.clone();
let mut t = db_app.pool.begin().await?;
decorate_content(ctx, &mut t, contact_id, &mut content, message_id.is_some()).await?;
if only_send_if_no_receipts_are_open {
let count = sqlx::query_scalar!(
"SELECT COUNT(*) FROM receipts WHERE contact_id = ?",
@ -95,9 +127,14 @@ pub async fn send_c2c_message_to_contact(
let mut retry_count = 0_i64;
let mut last_retry = None;
// A resend of a message the server already took has already pushed the
// recipient once. The replacement receipt must not ask for a second alert
// about the same message.
let mut already_woken = false;
if let Some(message_id) = &message_id {
let previous = sqlx::query!(
r#"SELECT COUNT(*) AS "count!: i64", MAX(last_retry) AS last_retry
r#"SELECT COUNT(*) AS "count!: i64", MAX(last_retry) AS last_retry,
MAX(ack_by_server_at) AS acknowledged
FROM receipts WHERE contact_id = ? AND message_id = ?"#,
contact_id,
message_id,
@ -106,6 +143,7 @@ pub async fn send_c2c_message_to_contact(
.await?;
retry_count = previous.count;
last_retry = previous.last_retry;
already_woken = previous.acknowledged.is_some();
sqlx::query!(
"DELETE FROM receipts WHERE contact_id = ? AND message_id = ?",
contact_id,
@ -128,11 +166,14 @@ pub async fn send_c2c_message_to_contact(
sqlx::query!(
r#"UPDATE receipts SET message_id = ?, will_be_retried_by_media_upload = ?,
retry_count = ?, last_retry = ? WHERE receipt_id = ?"#,
retry_count = ?, last_retry = ?,
wake_receiver = CASE WHEN ? THEN 0 ELSE wake_receiver END
WHERE receipt_id = ?"#,
message_id,
only_return_encrypted_data,
retry_count,
last_retry,
already_woken,
receipt_id
)
.execute(&mut *t)

View file

@ -111,3 +111,30 @@ message GroupState {
bytes encrypted_group_state = 2;
repeated AppendGroupState appended_group_states = 3;
}
// One published webxdc app, as the store advertises it. `bundle_sha256` is the
// hash of the .xdc the blob endpoint serves; the client verifies it before the
// zip is opened, and pins it per instance until the app is next started.
//
// An app that is unpublished simply stops appearing here. Clients keep the
// bundles they already downloaded, so an absence is not a signal to stop.
message WebxdcCatalogEntry {
string app_id = 1;
int64 version = 2;
string name = 3;
optional string source_code_url = 4;
string bundle_sha256 = 5;
int64 bundle_bytes = 6;
bytes icon = 8;
// One short line per language, keyed by language tag (`en`, `de`, `pt-br`).
// Every client gets every translation and picks its own, so asking for the
// catalog says nothing about what language a user reads.
map<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;
}

View file

@ -12,6 +12,9 @@ message AdditionalMessageData {
CONTACTS = 1;
RESTORED_FLAME_COUNTER = 2;
ASK_ABOUT_USER = 3;
WEBXDC_APP = 4;
WEBXDC_UPDATE = 5;
WEBXDC_SENT = 6;
}
Type type = 1;
@ -19,4 +22,39 @@ message AdditionalMessageData {
repeated SharedContact contacts = 3;
optional int64 restored_flame_counter = 4;
optional int64 ask_about_user_id = 5;
optional WebxdcApp webxdc_app = 6;
optional WebxdcUpdate webxdc_update = 7;
optional WebxdcOrigin webxdc_origin = 8;
}
// Attached to a message a webxdc app asked the user to send, so the chat can
// say which app it came from.
//
// `instance_id` is the app card in the chat the app runs in, which is not
// necessarily the chat this message was sent to: the user picks the recipient.
// `app_id` and `version` are carried as well so the receiver can name and
// picture the app even when it has no instance of its own.
message WebxdcOrigin {
string instance_id = 1;
string app_id = 2;
int64 version = 3;
}
// The app itself is never sent. Peers resolve the id and version against the
// twonly store and download the bundle from the API server, so a sender can
// only point at code that has already been published.
message WebxdcApp {
string app_id = 1;
int64 version = 2;
}
message WebxdcUpdate {
// The message id of the app card this update belongs to.
string instance_id = 1;
// JSON, as the app produced it. Never parsed by twonly.
string payload = 2;
optional string info = 3;
optional string href = 4;
optional string summary = 5;
optional string document = 6;
}

View file

@ -66,10 +66,13 @@ message EncryptedContent {
}
message TextMessage {
string sender_message_id = 1;
string text = 2;
int64 timestamp = 3;
optional string quote_message_id = 4;
string sender_message_id = 1;
string text = 2;
int64 timestamp = 3;
optional string quote_message_id = 4;
// Carries where the text came from when it was not typed by the sender --
// today, the webxdc app that produced it.
optional bytes additional_message_data = 5;
}
message AdditionalDataMessage {
@ -77,6 +80,14 @@ message EncryptedContent {
int64 timestamp = 2;
string type = 3;
optional bytes additional_message_data = 4;
// State a feature exchanges rather than something a person sent. A hidden
// message leaves no row in `messages`, so it never appears in a chat, and
// it neither raises a notification nor wakes the receiver.
//
// The one exception is a webxdc update carrying an `info`: the state stays
// hidden, but the announcement in it is written to be read and becomes a
// chat row of its own on arrival, which is worth waking for.
bool hidden = 5;
}
message Reaction {

View file

@ -916,10 +916,11 @@ impl RustApi {
group_id: String,
text: String,
quote_message_id: Option<String>,
additional_message_data: Option<Vec<u8>>,
) -> Result<String> {
let ctx = Context::get_static()?;
crate::services::messages::MessageService::new(ctx)
.insert_and_send_text(group_id, text, quote_message_id)
.insert_and_send_text(group_id, text, quote_message_id, additional_message_data)
.await
}
@ -927,10 +928,11 @@ impl RustApi {
group_id: String,
message_type: String,
additional_data: Vec<u8>,
hidden: bool,
) -> Result<String> {
let ctx = Context::get_static()?;
crate::services::messages::MessageService::new(ctx)
.insert_and_send_additional_data(group_id, message_type, additional_data)
.insert_and_send_additional_data(group_id, message_type, additional_data, hidden)
.await
}

View file

@ -9,6 +9,7 @@ pub mod callbacks;
pub mod groups;
pub mod logging;
pub mod user_config;
pub mod webxdc;
pub mod wrapper;
use crate::context::Context;

213
rust/src/bridge/webxdc.rs Normal file
View 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
}

View file

@ -59,6 +59,19 @@ pub struct Context {
/// bundle on the server (or has just published one). Per context rather
/// than process-wide so two accounts in one process check independently.
pub(crate) pqc_bundle_verified: AtomicBool,
/// Coalesces overlapping flushes of this account's receipt queue. Per
/// context for the same reason: a flush scans one app database, so a claim
/// held for one account must not silence another account's flush.
pub(crate) queued_receipt_flush:
std::sync::Mutex<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 {
@ -138,6 +151,9 @@ impl Context {
incoming_generation: AtomicU64::new(0),
incoming_committed: Notify::new(),
pqc_bundle_verified: AtomicBool::new(false),
queued_receipt_flush: std::sync::Mutex::default(),
media_preprocessing: Mutex::new(()),
media_retransmission: Mutex::new(()),
});
ApiRuntime::initialize(&ctx).await?;
ApiRuntime::connect(&ctx).await?;
@ -321,7 +337,10 @@ impl Context {
mailbox_drained: Notify::new(),
incoming_generation: AtomicU64::new(0),
incoming_committed: Notify::new(),
pqc_bundle_verified: AtomicBool::new(false),
pqc_bundle_verified: AtomicBool::new(false),
queued_receipt_flush: std::sync::Mutex::default(),
media_preprocessing: Mutex::new(()),
media_retransmission: Mutex::new(()),
});
if let Err(error) = ctx.initialize_user_discovery_from_config().await {
tracing::warn!("failed to initialize user discovery: {error}");
@ -364,7 +383,10 @@ impl Context {
mailbox_drained: Notify::new(),
incoming_generation: AtomicU64::new(0),
incoming_committed: Notify::new(),
pqc_bundle_verified: AtomicBool::new(false),
pqc_bundle_verified: AtomicBool::new(false),
queued_receipt_flush: std::sync::Mutex::default(),
media_preprocessing: Mutex::new(()),
media_retransmission: Mutex::new(()),
});
if let Err(error) = ctx.initialize_user_discovery_from_config().await {
tracing::warn!("failed to initialize user discovery: {error}");

View 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)
);

View 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.

View file

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

View file

@ -78,6 +78,12 @@ pub enum TwonlyError {
#[error("encrypted media is {bytes} bytes but the plan allows {limit}")]
MediaTooLarge { bytes: i64, limit: i64 },
/// The group server refused a state update because it was written against
/// a version it no longer holds. Somebody else changed the group first, so
/// the update has to be applied again to the version that won.
#[error("group state was updated by somebody else first")]
GroupStateConflict,
/// Content that will never be processable: it is malformed, or it uses a
/// feature this client does not implement. Callers must not retry it —
/// a reliable-mailbox row carrying such a message is acknowledged so the

Some files were not shown because too many files have changed in this diff Show more