diff --git a/android/app/build.gradle b/android/app/build.gradle
index f0777537..7bc8a4ff 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -86,4 +86,6 @@ dependencies {
implementation 'androidx.media3:media3-effect:1.11.0'
implementation 'androidx.media3:media3-common:1.11.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
+ // Material 3 components for the native widget configuration screen.
+ implementation 'com.google.android.material:material:1.13.0'
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b619df21..3e129247 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -106,6 +106,20 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt
index 6c208218..5785bc6c 100644
--- a/android/app/src/main/kotlin/eu/twonly/MainActivity.kt
+++ b/android/app/src/main/kotlin/eu/twonly/MainActivity.kt
@@ -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.widget.WidgetRuntimeChannel
class MainActivity : FlutterFragmentActivity() {
private val CHANNEL = "eu.twonly/photo_picker"
@@ -70,6 +71,7 @@ class MainActivity : FlutterFragmentActivity() {
NotificationTapChannel.configure(flutterEngine, applicationContext)
+ WidgetRuntimeChannel.configure(flutterEngine, applicationContext)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
@@ -104,6 +106,7 @@ class MainActivity : FlutterFragmentActivity() {
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
NotificationTapChannel.detach()
+ WidgetRuntimeChannel.detach()
super.cleanUpFlutterEngine(flutterEngine)
}
}
diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationBridge.kt b/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationBridge.kt
index 574a4ed5..9d269129 100644
--- a/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationBridge.kt
+++ b/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationBridge.kt
@@ -16,6 +16,9 @@ internal object NativeNotificationBridge {
@JvmStatic
external fun acknowledge(eventIdsJson: String): String
+ @JvmStatic
+ external fun finalizeWakeup(deadlineMs: Long): String
+
@JvmStatic
external fun storeFcmToken(
databaseDirectory: String,
diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt b/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt
index 3c1f0f0d..fd9f9f75 100644
--- a/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt
+++ b/android/app/src/main/kotlin/eu/twonly/notifications/NativeNotificationModels.kt
@@ -29,6 +29,7 @@ internal data class NativeNotificationBatch(
internal data class NativeNotificationResponse(
val ok: Boolean,
+ val widgetRefresh: Boolean,
val batch: NativeNotificationBatch?,
val fallback: NativeNotificationPresentation?,
) {
@@ -70,7 +71,12 @@ internal data class NativeNotificationResponse(
body = it.getString("body"),
)
}
- return NativeNotificationResponse(root.optBoolean("ok"), batch, fallback)
+ return NativeNotificationResponse(
+ root.optBoolean("ok"),
+ root.optBoolean("widget_refresh"),
+ batch,
+ fallback,
+ )
}
}
}
diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt b/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt
index c1eb00c6..4d99dd53 100644
--- a/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt
+++ b/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt
@@ -11,6 +11,7 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import eu.twonly.MainActivity
import eu.twonly.R
+import eu.twonly.widget.TwonlyWidgetProvider
import java.util.Locale
import org.json.JSONArray
@@ -26,16 +27,17 @@ class TwonlyNotificationWorker(
directory,
directory,
Locale.getDefault().toLanguageTag(),
- RUST_DEADLINE_MS,
+ PROCESS_DEADLINE_MS,
),
)
+ if (response.widgetRefresh) TwonlyWidgetProvider.refreshAll(applicationContext)
ensureNotificationChannel(applicationContext)
val batch = response.batch
if (!response.ok || batch == null) {
// Rust could not reach the mailbox. Show the generic alert so a
// high-priority wake-up still produces a notification, and retry.
response.fallback?.let(::showFallback)
- return if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.success()
+ return settle(retry = true)
}
val manager = NotificationManagerCompat.from(applicationContext)
@@ -52,17 +54,31 @@ class TwonlyNotificationWorker(
// An empty batch is the normal outcome of a duplicate wake-up or of
// traffic that is not user visible, so it must not retry. Only an
// undrained mailbox is worth another attempt.
- if (!batch.completed && runAttemptCount < MAX_RETRIES) {
- Result.retry()
- } else {
- Result.success()
- }
+ settle(retry = !batch.completed)
} catch (error: Throwable) {
Log.e(TAG, "Native notification processing failed", error)
if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
}
}
+ // Ends the attempt, running the deferred wake-up work first when no further
+ // attempt is coming. The notification is already on screen by then, so media
+ // downloads, widget upkeep, and the socket shutdown no longer sit between
+ // the message and the alert. A retry skips it and keeps the connection for
+ // the next attempt.
+ private fun settle(retry: Boolean): Result {
+ if (retry && runAttemptCount < MAX_RETRIES) return Result.retry()
+ try {
+ val finalized = NativeNotificationResponse.parse(
+ NativeNotificationBridge.finalizeWakeup(FINALIZE_DEADLINE_MS),
+ )
+ if (finalized.widgetRefresh) TwonlyWidgetProvider.refreshAll(applicationContext)
+ } catch (error: Throwable) {
+ Log.w(TAG, "Deferred notification maintenance failed", error)
+ }
+ return Result.success()
+ }
+
private fun showAddition(
manager: NotificationManagerCompat,
addition: NativeNotificationAddition,
@@ -122,6 +138,10 @@ class TwonlyNotificationWorker(
const val TAG = "TwonlyNotification"
const val FALLBACK_ID = 0x74776F
const val MAX_RETRIES = 2
- const val RUST_DEADLINE_MS = 25_000L
+
+ // Split from the old single 25s budget: the drain only has to produce
+ // the batch, and everything deferred behind it gets its own window.
+ const val PROCESS_DEADLINE_MS = 20_000L
+ const val FINALIZE_DEADLINE_MS = 25_000L
}
}
diff --git a/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt
new file mode 100644
index 00000000..441d9d0d
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt
@@ -0,0 +1,145 @@
+package eu.twonly.widget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.LinearLayout
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.checkbox.MaterialCheckBox
+import eu.twonly.R
+import java.io.File
+import org.json.JSONArray
+import org.json.JSONObject
+
+class TwonlyWidgetConfigureActivity : AppCompatActivity() {
+ private var widgetId = AppWidgetManager.INVALID_APPWIDGET_ID
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setResult(RESULT_CANCELED)
+ widgetId = intent?.getIntExtra(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID,
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+ if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish()
+ return
+ }
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ setContentView(R.layout.twonly_widget_configure)
+ applySystemBarInsets()
+
+ findViewById(R.id.toolbar).setNavigationOnClickListener { finish() }
+
+ val groupCard = findViewById(R.id.group_card)
+ val groupList = findViewById(R.id.group_list)
+ val emptyState = findViewById(R.id.empty_state)
+ val saveButton = findViewById(R.id.save_button)
+
+ val selected = TwonlyWidgetProvider.preferences(this)
+ .getStringSet(TwonlyWidgetProvider.groupsKey(widgetId), emptySet())
+ .orEmpty()
+ .toMutableSet()
+ val groups = runCatching {
+ JSONObject(File(filesDir, "widget/manifest.json").readText()).getJSONArray("groups")
+ }.getOrNull()
+
+ if (groups == null || groups.length() == 0) {
+ groupCard.visibility = View.GONE
+ emptyState.visibility = View.VISIBLE
+ saveButton.isEnabled = false
+ } else {
+ val inflater = LayoutInflater.from(this)
+ for (index in 0 until groups.length()) {
+ val group = groups.getJSONObject(index)
+ val id = group.getLong("id").toString()
+ val checkBox = inflater.inflate(
+ R.layout.twonly_widget_configure_group,
+ groupList,
+ false,
+ ) as MaterialCheckBox
+ checkBox.text = group.getString("name")
+ checkBox.isChecked = selected.contains(id)
+ checkBox.setOnCheckedChangeListener { _, checked ->
+ if (checked) selected.add(id) else selected.remove(id)
+ saveButton.isEnabled = selected.isNotEmpty()
+ }
+ groupList.addView(checkBox)
+ }
+ saveButton.isEnabled = selected.isNotEmpty()
+ }
+
+ saveButton.setOnClickListener {
+ TwonlyWidgetProvider.preferences(this)
+ .edit().putStringSet(TwonlyWidgetProvider.groupsKey(widgetId), selected).apply()
+ persistNativeConfiguration(this)
+ TwonlyWidgetProvider.update(
+ this,
+ AppWidgetManager.getInstance(this),
+ widgetId,
+ advance = false,
+ )
+ setResult(RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId))
+ finish()
+ }
+ }
+
+ /**
+ * The window draws edge to edge, so the system bars are kept clear by the
+ * views that touch them: the top app bar and the bottom action bar.
+ */
+ private fun applySystemBarInsets() {
+ val root = findViewById(R.id.root)
+ val appBar = findViewById(R.id.app_bar)
+ val actionBar = findViewById(R.id.action_bar)
+ val appBarPaddingTop = appBar.paddingTop
+ val actionBarPaddingBottom = actionBar.paddingBottom
+ ViewCompat.setOnApplyWindowInsetsListener(root) { target, windowInsets ->
+ val bars = windowInsets.getInsets(
+ WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
+ )
+ target.setPadding(bars.left, 0, bars.right, 0)
+ appBar.updatePaddingTop(appBarPaddingTop + bars.top)
+ actionBar.updatePaddingBottom(actionBarPaddingBottom + bars.bottom)
+ windowInsets
+ }
+ ViewCompat.requestApplyInsets(root)
+ }
+
+ private fun View.updatePaddingTop(top: Int) =
+ setPadding(paddingLeft, top, paddingRight, paddingBottom)
+
+ private fun View.updatePaddingBottom(bottom: Int) =
+ setPadding(paddingLeft, paddingTop, paddingRight, bottom)
+
+ companion object {
+ fun persistNativeConfiguration(context: Context) {
+ val manager = AppWidgetManager.getInstance(context)
+ val ids = manager.getAppWidgetIds(android.content.ComponentName(context, TwonlyWidgetProvider::class.java))
+ val widgets = JSONArray()
+ val preferences = TwonlyWidgetProvider.preferences(context)
+ ids.forEach { id ->
+ val groups = JSONArray()
+ preferences.getStringSet(TwonlyWidgetProvider.groupsKey(id), emptySet()).orEmpty()
+ .mapNotNull(String::toLongOrNull).forEach(groups::put)
+ widgets.put(JSONObject().put("id", "android:$id").put("platform", "android").put("group_ids", groups))
+ }
+ val directory = File(context.filesDir, "widget").apply { mkdirs() }
+ val temporary = File(directory, "native-config.json.tmp")
+ temporary.writeText(JSONObject().put("widgets", widgets).toString())
+ temporary.renameTo(File(directory, "native-config.json"))
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt
new file mode 100644
index 00000000..8d618335
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt
@@ -0,0 +1,123 @@
+package eu.twonly.widget
+
+import android.app.PendingIntent
+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
+import eu.twonly.R
+import java.io.File
+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)
+ }
+ }
+
+ 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()
+ }
+ 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) }
+ }
+
+ fun update(context: Context, manager: AppWidgetManager, widgetId: Int, advance: Boolean) {
+ 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 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)
+ }
+ }
+ }.getOrDefault(emptyList())
+
+ if (matching.isEmpty()) {
+ 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)
+ }
+ }
+
+ val intent = Intent(context, TwonlyWidgetProvider::class.java).apply {
+ action = ACTION_ADVANCE
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
+ }
+ views.setOnClickPendingIntent(
+ R.id.twonly_widget_root,
+ PendingIntent.getBroadcast(
+ context,
+ widgetId,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ manager.updateAppWidget(widgetId, views)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/eu/twonly/widget/WidgetRuntimeChannel.kt b/android/app/src/main/kotlin/eu/twonly/widget/WidgetRuntimeChannel.kt
new file mode 100644
index 00000000..718e228e
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/twonly/widget/WidgetRuntimeChannel.kt
@@ -0,0 +1,41 @@
+package eu.twonly.widget
+
+import android.content.Context
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
+
+/**
+ * Lets the running app redraw its home-screen widgets.
+ *
+ * Rust rewrites the widget manifest from inside this process, but an AppWidget
+ * only redraws when its provider is asked to, so nothing on the home screen
+ * changes until this runs. The notification worker covers pushes that arrive
+ * while the app is gone; this covers everything the running app changes.
+ *
+ * Shares its name with the iOS channel of the same purpose, so Dart talks to
+ * one channel on both platforms.
+ */
+object WidgetRuntimeChannel {
+ private const val CHANNEL = "eu.twonly/runtime_storage"
+
+ private var channel: MethodChannel? = null
+
+ fun configure(flutterEngine: FlutterEngine, context: Context) {
+ val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
+ channel.setMethodCallHandler { call, result ->
+ when (call.method) {
+ "reloadWidgets" -> {
+ TwonlyWidgetProvider.refreshAll(context.applicationContext)
+ result.success(null)
+ }
+ else -> result.notImplemented()
+ }
+ }
+ this.channel = channel
+ }
+
+ fun detach() {
+ channel?.setMethodCallHandler(null)
+ channel = null
+ }
+}
diff --git a/android/app/src/main/res/drawable/ic_close_24.xml b/android/app/src/main/res/drawable/ic_close_24.xml
new file mode 100644
index 00000000..1f251e80
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_close_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_group_24.xml b/android/app/src/main/res/drawable/ic_group_24.xml
new file mode 100644
index 00000000..47c80a53
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_group_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/layout/twonly_widget.xml b/android/app/src/main/res/layout/twonly_widget.xml
new file mode 100644
index 00000000..0a1359a4
--- /dev/null
+++ b/android/app/src/main/res/layout/twonly_widget.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/twonly_widget_configure.xml b/android/app/src/main/res/layout/twonly_widget_configure.xml
new file mode 100644
index 00000000..41ac323e
--- /dev/null
+++ b/android/app/src/main/res/layout/twonly_widget_configure.xml
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/twonly_widget_configure_group.xml b/android/app/src/main/res/layout/twonly_widget_configure_group.xml
new file mode 100644
index 00000000..a83a0756
--- /dev/null
+++ b/android/app/src/main/res/layout/twonly_widget_configure_group.xml
@@ -0,0 +1,18 @@
+
+
+
diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml
new file mode 100644
index 00000000..b2d09d4a
--- /dev/null
+++ b/android/app/src/main/res/values-de/strings.xml
@@ -0,0 +1,9 @@
+
+
+ Fotos, die dir deine Freunde senden
+ Kontaktgruppen auswählen
+ Wähle aus, wer Fotos an dieses Widget senden darf.
+ Öffne twonly einmal, um deine Kontaktgruppen zu laden.
+ Widget hinzufügen
+ Neuestes Foto in deinem Widget
+
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 00000000..1a94ead1
--- /dev/null
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,9 @@
+
+
+
+ #32BE80
+ #092016
+ #1C6947
+ #CFF2E2
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 24bb191a..7c1a7a20 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,4 +1,12 @@
#FF57CC99
-
\ No newline at end of file
+
+
+ #32BE80
+ #092016
+ #CFF2E2
+ #092016
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..95312f16
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,9 @@
+
+
+ Photos your friends send you
+ Choose contact groups
+ Choose who may send photos to this widget.
+ Open twonly once to load your contact groups.
+ Add widget
+ Latest photo in your widget
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..b3e6dc84
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/twonly_widget_info.xml b/android/app/src/main/res/xml/twonly_widget_info.xml
new file mode 100644
index 00000000..d5c1d62e
--- /dev/null
+++ b/android/app/src/main/res/xml/twonly_widget_info.xml
@@ -0,0 +1,11 @@
+
+
diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift
index ee6c148a..53902fc5 100644
--- a/ios/NotificationService/NotificationService.swift
+++ b/ios/NotificationService/NotificationService.swift
@@ -1,15 +1,28 @@
import Foundation
import Intents
import UserNotifications
+import WidgetKit
import rust_lib_twonly
private let runtimeAppGroup = "group.eu.twonly.runtime"
+/// Budget for the work deferred behind the alert. Behind a rendered alert it
+/// runs after the content handler has fired, so the system may reclaim the
+/// extension before it ends; a wake-up with nothing to render spends it up
+/// front instead, where the whole budget is actually available.
+private let finalizeDeadlineMs: UInt64 = 5_000
+
private struct NativeNotificationResponse: Decodable {
let ok: Bool
+ let widgetRefresh: Bool
let batch: NativeNotificationBatch?
let fallback: NativeNotificationPresentation?
let error: String?
+
+ enum CodingKeys: String, CodingKey {
+ case ok, batch, fallback, error
+ case widgetRefresh = "widget_refresh"
+ }
}
private struct NativeNotificationPresentation: Decodable {
@@ -90,7 +103,15 @@ final class NotificationService: UNNotificationServiceExtension {
)
return
}
+ if response.widgetRefresh {
+ WidgetCenter.shared.reloadAllTimelines()
+ }
guard let batch = response.batch, !batch.additions.isEmpty else {
+ // Nothing is waiting on screen for this wake-up — widget-only media is
+ // the ordinary case — so the deferred work runs while the extension is
+ // still guaranteed its time, rather than after the content handler has
+ // made it eligible for termination.
+ Self.finalizeRuntime()
self.deliverFallback(reason: "notification worker returned no messages")
return
}
@@ -148,6 +169,10 @@ final class NotificationService: UNNotificationServiceExtension {
stateLock.unlock()
Self.acknowledge(eventIds: eventIds)
self?.finish(with: content)
+ // The alert is on screen. Whatever time the system still grants this
+ // extension goes to the deferred work; being terminated part-way only
+ // defers it to the next wake-up or app launch.
+ Self.finalizeRuntime()
}
}
@@ -268,7 +293,7 @@ final class NotificationService: UNNotificationServiceExtension {
let pointer = runtimeDirectory.withCString { databaseDirectory in
runtimeDirectory.withCString { dataDirectory in
locale.withCString { locale in
- twonly_notification_process(databaseDirectory, dataDirectory, locale, 24_000)
+ twonly_notification_process(databaseDirectory, dataDirectory, locale, 22_000)
}
}
}
@@ -286,6 +311,24 @@ final class NotificationService: UNNotificationServiceExtension {
}
}
+ private static func finalizeRuntime() {
+ guard let pointer = twonly_notification_finalize(finalizeDeadlineMs) else { return }
+ defer { twonly_notification_string_free(pointer) }
+ let json = String(cString: pointer)
+ guard
+ let response = try? JSONDecoder().decode(
+ NativeNotificationResponse.self,
+ from: Data(json.utf8)
+ )
+ else { return }
+ if let error = response.error {
+ NSLog("Deferred Twonly notification maintenance failed: \(error)")
+ }
+ if response.widgetRefresh {
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+ }
+
private static func acknowledge(eventIds: [String]) {
guard !eventIds.isEmpty, let data = try? JSONEncoder().encode(eventIds),
let json = String(data: data, encoding: .utf8)
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index bca121e8..5c6133a6 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -22,6 +22,10 @@
D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D21FCEA42D9F2B750088701D /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25D4D1D2EF626E30029F805 /* StoreKit.framework */; };
D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */; };
+ D4A100032F81000100A10003 /* TwonlyWidgetShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */; };
+ D4A0000C2F80000100A0000C /* TwonlyWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D4A000012F80000100A00001 /* TwonlyWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ D4A000102F80000100A00010 /* logo.png in Resources */ = {isa = PBXBuildFile; fileRef = D4A0000F2F80000100A0000F /* logo.png */; };
D3A100022F70000100D1A001 /* DirectMediaTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */; };
D3A100062F70000100D1A006 /* BackgroundWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100052F70000100D1A005 /* BackgroundWork.swift */; };
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A100042F70000100D1A002 /* NativeImageCodec.swift */; };
@@ -52,6 +56,13 @@
remoteGlobalIDString = D25D4D6F2EFF41DB0029F805;
remoteInfo = ShareExtension;
};
+ D4A0000D2F80000100A0000D /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = D4A000042F80000100A00004;
+ remoteInfo = TwonlyWidget;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -71,6 +82,7 @@
dstPath = "";
dstSubfolderSpec = 13;
files = (
+ D4A0000C2F80000100A0000C /* TwonlyWidget.appex in Embed Foundation Extensions */,
D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */,
D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */,
);
@@ -113,6 +125,9 @@
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerRelease.entitlements; sourceTree = ""; };
D25D4D1D2EF626E30029F805 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
D25D4D702EFF41DB0029F805 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwonlyWidgetShared.swift; sourceTree = ""; };
+ D4A000012F80000100A00001 /* TwonlyWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TwonlyWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ D4A0000F2F80000100A0000F /* logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = logo.png; path = ../assets/images/logo.png; sourceTree = SOURCE_ROOT; };
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = ""; };
D3A100012F70000100D1A001 /* DirectMediaTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMediaTransfer.swift; sourceTree = ""; };
D3A100052F70000100D1A005 /* BackgroundWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWork.swift; sourceTree = ""; };
@@ -141,6 +156,13 @@
);
target = D25D4D6F2EFF41DB0029F805 /* ShareExtension */;
};
+ D4A000032F80000100A00003 /* Exceptions for "TwonlyWidget" folder in "TwonlyWidget" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = D4A000042F80000100A00004 /* TwonlyWidget */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -160,6 +182,22 @@
path = ShareExtension;
sourceTree = "";
};
+ D4A100042F81000100A10004 /* Shared */ = {
+ isa = PBXGroup;
+ children = (
+ D4A100012F81000100A10001 /* TwonlyWidgetShared.swift */,
+ );
+ path = Shared;
+ sourceTree = "";
+ };
+ D4A000022F80000100A00002 /* TwonlyWidget */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ D4A000032F80000100A00003 /* Exceptions for "TwonlyWidget" folder in "TwonlyWidget" target */,
+ );
+ path = TwonlyWidget;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -197,6 +235,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D4A000062F80000100A00006 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -227,6 +272,9 @@
97C146F01CF9000F007C117D /* Runner */,
D21FCEA52D9F2B750088701D /* NotificationService */,
D25D4D712EFF41DB0029F805 /* ShareExtension */,
+ D4A100042F81000100A10004 /* Shared */,
+ D4A000022F80000100A00002 /* TwonlyWidget */,
+ D4A0000F2F80000100A0000F /* logo.png */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */,
@@ -242,6 +290,7 @@
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
D21FCEA42D9F2B750088701D /* NotificationService.appex */,
D25D4D702EFF41DB0029F805 /* ShareExtension.appex */,
+ D4A000012F80000100A00001 /* TwonlyWidget.appex */,
);
name = Products;
sourceTree = "";
@@ -342,6 +391,7 @@
dependencies = (
D21FCEAA2D9F2B750088701D /* PBXTargetDependency */,
D25D4D792EFF41DB0029F805 /* PBXTargetDependency */,
+ D4A0000E2F80000100A0000E /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
@@ -393,6 +443,26 @@
productReference = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
+ D4A000042F80000100A00004 /* TwonlyWidget */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D4A000082F80000100A00008 /* Build configuration list for PBXNativeTarget "TwonlyWidget" */;
+ buildPhases = (
+ D4A000052F80000100A00005 /* Sources */,
+ D4A000062F80000100A00006 /* Frameworks */,
+ D4A000072F80000100A00007 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ D4A000022F80000100A00002 /* TwonlyWidget */,
+ );
+ name = TwonlyWidget;
+ productName = TwonlyWidget;
+ productReference = D4A000012F80000100A00001 /* TwonlyWidget.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -418,6 +488,9 @@
D25D4D6F2EFF41DB0029F805 = {
CreatedOnToolsVersion = 26.1.1;
};
+ D4A000042F80000100A00004 = {
+ CreatedOnToolsVersion = 16.2;
+ };
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -440,6 +513,7 @@
331C8080294A63A400263BE5 /* RunnerTests */,
D21FCEA32D9F2B750088701D /* NotificationService */,
D25D4D6F2EFF41DB0029F805 /* ShareExtension */,
+ D4A000042F80000100A00004 /* TwonlyWidget */,
);
};
/* End PBXProject section */
@@ -478,6 +552,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D4A000072F80000100A00007 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ D4A000102F80000100A00010 /* logo.png in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -654,6 +736,7 @@
D3A100032F70000100D1A002 /* NativeImageCodec.swift in Sources */,
D3A100052F70000100D1A003 /* NativeVideoCodec.swift in Sources */,
D3A100072F70000100D1A004 /* NativeGallery.swift in Sources */,
+ D4A100022F81000100A10002 /* TwonlyWidgetShared.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
@@ -673,6 +756,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D4A000052F80000100A00005 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ D4A100032F81000100A10003 /* TwonlyWidgetShared.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -691,6 +782,11 @@
target = D25D4D6F2EFF41DB0029F805 /* ShareExtension */;
targetProxy = D25D4D782EFF41DB0029F805 /* PBXContainerItemProxy */;
};
+ D4A0000E2F80000100A0000E /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = D4A000042F80000100A00004 /* TwonlyWidget */;
+ targetProxy = D4A0000D2F80000100A0000D /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@@ -788,7 +884,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.5;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -810,7 +906,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.example.connect.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -828,7 +924,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.example.connect.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -844,7 +940,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = com.example.connect.RunnerTests;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -987,7 +1083,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.0.5;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
@@ -1065,7 +1161,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1143,7 +1239,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.NotificationService;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1183,7 +1279,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -1269,7 +1365,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
- PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.testing.ShareExtension;
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -1281,6 +1377,92 @@
};
name = Profile;
};
+ D4A000092F80000100A00009 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CODE_SIGN_ENTITLEMENTS = TwonlyWidget/TwonlyWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = CN332ZUGRP;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = TwonlyWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = twonly;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.TwonlyWidget;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ D4A0000A2F80000100A0000A /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CODE_SIGN_ENTITLEMENTS = TwonlyWidget/TwonlyWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = CN332ZUGRP;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = TwonlyWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = twonly;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.TwonlyWidget;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ D4A0000B2F80000100A0000B /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CODE_SIGN_ENTITLEMENTS = TwonlyWidget/TwonlyWidget.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = CN332ZUGRP;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = TwonlyWidget/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = twonly;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.twonly.TwonlyWidget;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Profile;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1334,6 +1516,16 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ D4A000082F80000100A00008 /* Build configuration list for PBXNativeTarget "TwonlyWidget" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D4A000092F80000100A00009 /* Debug */,
+ D4A0000A2F80000100A0000A /* Release */,
+ D4A0000B2F80000100A0000B /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index c3fedb29..95d6e55f 100644
--- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -1,7 +1,7 @@
+ version = "1.7">
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/TwonlyWidget.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/TwonlyWidget.xcscheme
new file mode 100644
index 00000000..fc0a196d
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/TwonlyWidget.xcscheme
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 40421803..6a8a4eb1 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -1,8 +1,10 @@
+import AppIntents
import CryptoKit
import Flutter
import Foundation
import UIKit
import UserNotifications
+import WidgetKit
import flutter_sharing_intent
@main
@@ -222,6 +224,99 @@ class RuntimeStorageChannel {
binaryMessenger: messenger
)
channel.setMethodCallHandler { call, result in
+ // Rust rewrites the widget manifest from inside this process, and
+ // WidgetKit only re-reads it when a timeline reload is requested. The
+ // notification service extension covers pushes; this covers everything
+ // the running app changes.
+ if call.method == "reloadWidgets" {
+ WidgetCenter.shared.reloadAllTimelines()
+ result(nil)
+ return
+ }
+ // Only the app can ask which widgets are actually on the home screen. A
+ // widget extension is never told that its widget was removed, so without
+ // this the placement file keeps describing widgets that are long gone.
+ if call.method == "reconcileWidgets" {
+ // Widgets need iOS 17; on anything older there is nothing to reconcile.
+ guard #available(iOS 17.0, *) else {
+ result(["supported": false, "matched": 0, "widgets": []])
+ return
+ }
+ // `getCurrentConfigurations` answers on a background queue, but a
+ // FlutterResult must be delivered on the platform thread — replying off
+ // it is undefined and the reply can be dropped, leaving Dart awaiting a
+ // future that never completes.
+ let reply: (Any?) -> Void = { value in
+ DispatchQueue.main.async { result(value) }
+ }
+ // Ask every widget that is really on a home screen to rebuild, then give
+ // the extension a moment to answer. A widget the system merely still has
+ // a record of is never displayed, so it is never asked for a timeline
+ // and never stamps itself — which is what separates the two.
+ let askedAt = Int(Date().timeIntervalSince1970)
+ WidgetCenter.shared.reloadAllTimelines()
+ DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2) {
+ WidgetCenter.shared.getCurrentConfigurations { configurations in
+ switch configurations {
+ case .success(let placed):
+ var selections: [[Int64]] = []
+ var report: [[String: Any]] = []
+ // A widget that rebuilt just before the reload was asked for still
+ // counts; the window only has to exclude records that never rebuild.
+ let rebuilt = WidgetStorage.recentlyRebuiltIds(since: askedAt - 90)
+ for (index, info) in placed.enumerated() {
+ var entry: [String: Any] = [
+ "kind": info.kind,
+ "family": "\(info.family)",
+ "mine": info.kind == widgetKind,
+ ]
+ if info.kind == widgetKind {
+ if let intent = try? info.widgetConfigurationIntent(
+ of: TwonlyWidgetIntent.self)
+ {
+ let ids = (intent.groups ?? []).compactMap { Int64($0.id) }
+ let live = rebuilt.contains("ios:\(WidgetStorage.selectionKey(ids))")
+ entry["group_ids"] = ids
+ entry["id"] = "ios:\(index):\(WidgetStorage.selectionKey(ids))"
+ entry["live"] = live
+ if live { selections.append(ids) }
+ } else {
+ // The widget is placed even though its configuration will not
+ // decode. Recording it as unconfigured keeps it in the file:
+ // dropping it would report a removal that did not happen.
+ entry["configuration"] = "unreadable"
+ entry["group_ids"] = [Int64]()
+ entry["id"] = "ios:\(index)"
+ entry["live"] = false
+ }
+ }
+ report.append(entry)
+ }
+ WidgetStorage.replaceIosSelections(selections)
+ widgetLog.debug(
+ "WidgetKit reports \(placed.count, privacy: .public) widget(s); \(selections.count, privacy: .public) rebuilt a timeline and are counted as placed"
+ )
+ reply([
+ "supported": true,
+ "matched": selections.count,
+ "widgets": report,
+ ])
+ case .failure(let error):
+ // Leave the file alone: a failed query is not evidence of removal.
+ widgetLog.error(
+ "getCurrentConfigurations failed: \(error.localizedDescription, privacy: .public)"
+ )
+ reply(
+ FlutterError(
+ code: "widget_configurations_unavailable",
+ message: error.localizedDescription,
+ details: nil
+ ))
+ }
+ }
+ }
+ return
+ }
guard call.method == "runtimeSupportDirectory" else {
result(FlutterMethodNotImplemented)
return
diff --git a/ios/Shared/TwonlyWidgetShared.swift b/ios/Shared/TwonlyWidgetShared.swift
new file mode 100644
index 00000000..03531453
--- /dev/null
+++ b/ios/Shared/TwonlyWidgetShared.swift
@@ -0,0 +1,321 @@
+import AppIntents
+import Foundation
+import OSLog
+import WidgetKit
+
+// Compiled into BOTH the app and the widget extension.
+//
+// The extension owns the widget's appearance, but only the app can ask
+// WidgetKit which widgets are actually on the home screen
+// (`getCurrentConfigurations`), and reading their configuration requires the
+// very same intent and entity types the extension declares. Everything both
+// sides need therefore lives here rather than inside the extension.
+//
+// The app deploys further back than the widget extension does, so the
+// AppIntents declarations carry an explicit availability gate.
+
+let runtimeAppGroup = "group.eu.twonly.runtime"
+let widgetKind = "TwonlyWidget"
+
+/// How long a widget may go without rebuilding its timeline before it is taken
+/// to have been removed from the home screen. Timelines cover four hours, so a
+/// widget that is still placed refreshes far inside this.
+let staleWidgetSeconds: Double = 48 * 60 * 60
+
+/// A widget extension is launched by WidgetKit, off any debugger, at moments
+/// nobody is watching, and a failure just renders as an empty square. Every
+/// step that can silently produce "no image" logs here instead.
+///
+/// log stream --predicate 'subsystem == "eu.twonly.widget"' --level debug
+let widgetLog = Logger(subsystem: "eu.twonly.widget", category: "timeline")
+
+struct Manifest: Decodable {
+ let groups: [ManifestGroup]
+ let images: [ManifestImage]
+}
+
+struct ManifestGroup: Decodable {
+ let id: Int64
+ let name: String
+ let emoji: String?
+}
+
+struct ManifestImage: Decodable {
+ let mediaId: String
+ let path: String
+ let sender: String
+ let groupIds: [Int64]
+ let expiresAt: Int64
+
+ enum CodingKeys: String, CodingKey {
+ case path, sender
+ case mediaId = "media_id"
+ case groupIds = "group_ids"
+ case expiresAt = "expires_at"
+ }
+}
+
+enum WidgetStorage {
+ static var runtimeDirectory: URL? {
+ FileManager.default
+ .containerURL(forSecurityApplicationGroupIdentifier: runtimeAppGroup)?
+ .appendingPathComponent("runtime", isDirectory: true)
+ }
+
+ /// Every way reading the manifest can come up empty. The widget renders the
+ /// reason, because none of these are distinguishable from the home screen
+ /// otherwise: an unprovisioned App Group, a manifest the app never wrote and
+ /// a manifest with nothing in it all look like one blank widget.
+ enum ManifestFault: Error, Equatable {
+ case appGroupUnavailable
+ case missing
+ case unreadable(String)
+
+ var summary: String {
+ switch self {
+ case .appGroupUnavailable: return "No shared storage"
+ case .missing: return "Open twonly once"
+ case .unreadable(let detail): return "Unreadable data (\(detail))"
+ }
+ }
+ }
+
+ static func manifest() -> Result {
+ guard let url = runtimeDirectory?.appendingPathComponent("widget/manifest.json") else {
+ widgetLog.error("App Group \(runtimeAppGroup, privacy: .public) is not reachable")
+ return .failure(.appGroupUnavailable)
+ }
+ guard let data = try? Data(contentsOf: url) else {
+ let exists = FileManager.default.fileExists(atPath: url.path)
+ widgetLog.error(
+ "manifest unreadable at \(url.path, privacy: .public) (exists: \(exists, privacy: .public))")
+ return .failure(exists ? .unreadable("locked") : .missing)
+ }
+ do {
+ let manifest = try JSONDecoder().decode(Manifest.self, from: data)
+ widgetLog.debug(
+ """
+ manifest \(data.count, privacy: .public) bytes, \
+ \(manifest.groups.count, privacy: .public) groups, \
+ \(manifest.images.count, privacy: .public) images
+ """)
+ return .success(manifest)
+ } catch {
+ widgetLog.error("manifest decode failed: \(error, privacy: .public)")
+ return .failure(.unreadable("\(error)"))
+ }
+ }
+
+ static func images() -> Result<[ManifestImage], ManifestFault> {
+ manifest().map(\.images)
+ }
+
+ static func selectionKey(_ ids: [Int64]) -> String {
+ 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
+ /// one of them and has to merge into what the others wrote — replacing the
+ /// list would make every widget erase its neighbours. Nothing tells an
+ /// extension that a widget was removed either, so each entry carries the time
+ /// it was last rebuilt and readers drop the ones that stopped reporting.
+ /// Replaces every iOS entry with the widgets WidgetKit says are placed.
+ ///
+ /// The extension can only ever add itself, and is never told that its widget
+ /// was removed, so a removal is invisible from that side. The app can ask
+ /// outright, which makes this the only exact answer — call it before reading
+ /// the configuration back.
+ static func storedWidgets() -> [[String: Any]] {
+ guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true),
+ let data = try? Data(contentsOf: directory.appendingPathComponent("native-config.json")),
+ let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let stored = decoded["widgets"] as? [[String: Any]]
+ else { return [] }
+ return stored
+ }
+
+ /// Selections that have rebuilt a timeline at or after `since`.
+ ///
+ /// This is the only evidence available that a widget is really on a home
+ /// screen. WidgetKit keeps records of widgets it has known about and
+ /// `getCurrentConfigurations` hands them all back, so its answer alone cannot
+ /// tell a placed widget from one the system has simply not forgotten. Only a
+ /// widget that is actually being displayed is asked for a timeline, and
+ /// `persistSelection` — called from nowhere else — stamps the moment it was.
+ static func recentlyRebuiltIds(since: Int) -> Set {
+ var ids = Set()
+ for entry in storedWidgets() {
+ guard let id = entry["id"] as? String, let seen = entry["last_seen"] as? Int
+ else { continue }
+ if seen >= since { ids.insert(id) }
+ }
+ return ids
+ }
+
+ /// Rewrites the placement file from the widgets the app has established are
+ /// really placed. Each entry keeps the timestamp its widget wrote: stamping
+ /// `now` here would forge the very evidence the next reconcile depends on.
+ static func replaceIosSelections(_ selections: [[Int64]]) {
+ guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true)
+ else { return }
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+
+ var lastSeen: [String: Int] = [:]
+ for entry in storedWidgets() {
+ if let id = entry["id"] as? String, let seen = entry["last_seen"] as? Int {
+ lastSeen[id] = seen
+ }
+ }
+
+ // Two widgets configured with the same contact groups are interchangeable
+ // here: the id is the selection, and Rust only reads the union anyway.
+ var seen = Set()
+ var widgets: [[String: Any]] = []
+ for ids in selections {
+ let identifier = "ios:\(selectionKey(ids))"
+ guard seen.insert(identifier).inserted else { continue }
+ widgets.append([
+ "id": identifier,
+ "platform": "ios",
+ "group_ids": ids,
+ "last_seen": lastSeen[identifier] ?? 0,
+ ])
+ }
+ write(widgets: widgets, to: directory)
+ }
+
+ /// Overwrites the placement file.
+ ///
+ /// `Data.write(.atomic)` already writes to a neighbouring temporary file and
+ /// exchanges it, so this does not hand-roll that. The previous version did,
+ /// and swallowed a failed exchange: the destination still existed, so its
+ /// fallback never ran and the stale file survived a write that looked like it
+ /// had succeeded.
+ private static func write(widgets: [[String: Any]], to directory: URL) {
+ let destination = directory.appendingPathComponent("native-config.json")
+ do {
+ let data = try JSONSerialization.data(withJSONObject: ["widgets": widgets])
+ try data.write(
+ to: destination,
+ options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
+ )
+ widgetLog.debug(
+ "wrote \(widgets.count, privacy: .public) widget(s) to \(destination.path, privacy: .public)"
+ )
+ } catch {
+ widgetLog.error("could not write the placement file: \(error, privacy: .public)")
+ }
+ // A crash between the two steps of the old implementation could have left
+ // this behind, and it would otherwise sit in the App Group forever.
+ try? FileManager.default.removeItem(
+ at: directory.appendingPathComponent("native-config.json.tmp"))
+ }
+
+ static func persistSelection(_ ids: [Int64]) {
+ guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true)
+ else { return }
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try? (directory as NSURL).setResourceValue(
+ URLFileProtection.completeUntilFirstUserAuthentication,
+ forKey: .fileProtectionKey
+ )
+ let identifier = "ios:\(selectionKey(ids))"
+ let widget: [String: Any] = [
+ "id": identifier,
+ "platform": "ios",
+ "group_ids": ids,
+ "last_seen": Int(Date().timeIntervalSince1970),
+ ]
+
+ let destination = directory.appendingPathComponent("native-config.json")
+ var widgets: [[String: Any]] = []
+ if let existing = try? Data(contentsOf: destination),
+ let decoded = try? JSONSerialization.jsonObject(with: existing) as? [String: Any],
+ let stored = decoded["widgets"] as? [[String: Any]] {
+ // Android's entries are authoritative and carry no timestamp, so they are
+ // always kept; only this platform's own stale rows are dropped.
+ widgets = stored.filter { entry in
+ guard entry["id"] as? String != identifier else { return false }
+ guard let seen = entry["last_seen"] as? Int else { return true }
+ return Date().timeIntervalSince1970 - Double(seen) < staleWidgetSeconds
+ }
+ }
+ widgets.append(widget)
+
+ write(widgets: widgets, to: directory)
+ }
+}
+
+@available(iOS 17.0, *)
+struct ContactGroupEntity: AppEntity {
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact group")
+ static let defaultQuery = ContactGroupQuery()
+
+ let id: String
+ let name: String
+ let emoji: String?
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(title: "\(emoji.map { "\($0) " } ?? "")\(name)")
+ }
+}
+
+@available(iOS 17.0, *)
+struct ContactGroupQuery: EntityQuery {
+ func entities(for identifiers: [String]) async throws -> [ContactGroupEntity] {
+ let wanted = Set(identifiers)
+ return allEntities().filter { wanted.contains($0.id) }
+ }
+
+ private func allEntities() -> [ContactGroupEntity] {
+ let groups = (try? WidgetStorage.manifest().get())?.groups ?? []
+ return groups.map {
+ ContactGroupEntity(id: String($0.id), name: $0.name, emoji: $0.emoji)
+ }
+ }
+
+ func suggestedEntities() async throws -> [ContactGroupEntity] { allEntities() }
+}
+
+@available(iOS 17.0, *)
+struct TwonlyWidgetIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "twonly contact groups"
+ static let description = IntentDescription("Choose who may share images with this widget.")
+
+ @Parameter(title: "Contact groups")
+ var groups: [ContactGroupEntity]?
+
+ init() { groups = nil }
+}
+
diff --git a/ios/TwonlyWidget/Assets.xcassets/Contents.json b/ios/TwonlyWidget/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/ios/TwonlyWidget/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/TwonlyWidget/Assets.xcassets/logo.imageset/Contents.json b/ios/TwonlyWidget/Assets.xcassets/logo.imageset/Contents.json
new file mode 100644
index 00000000..cf642bf0
--- /dev/null
+++ b/ios/TwonlyWidget/Assets.xcassets/logo.imageset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "filename" : "logo.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/TwonlyWidget/Assets.xcassets/logo.imageset/logo.png b/ios/TwonlyWidget/Assets.xcassets/logo.imageset/logo.png
new file mode 100644
index 00000000..8b5e6084
Binary files /dev/null and b/ios/TwonlyWidget/Assets.xcassets/logo.imageset/logo.png differ
diff --git a/ios/TwonlyWidget/Info.plist b/ios/TwonlyWidget/Info.plist
new file mode 100644
index 00000000..ffffdd14
--- /dev/null
+++ b/ios/TwonlyWidget/Info.plist
@@ -0,0 +1,15 @@
+
+
+
+
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/ios/TwonlyWidget/TwonlyWidget.entitlements b/ios/TwonlyWidget/TwonlyWidget.entitlements
new file mode 100644
index 00000000..c9faa196
--- /dev/null
+++ b/ios/TwonlyWidget/TwonlyWidget.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.eu.twonly.runtime
+
+
+
diff --git a/ios/TwonlyWidget/TwonlyWidget.swift b/ios/TwonlyWidget/TwonlyWidget.swift
new file mode 100644
index 00000000..bb0f7c0b
--- /dev/null
+++ b/ios/TwonlyWidget/TwonlyWidget.swift
@@ -0,0 +1,339 @@
+import AppIntents
+import ImageIO
+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
+ case noMatchingImages(total: Int)
+ case fault(WidgetStorage.ManifestFault)
+
+ var message: String {
+ switch self {
+ 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".
+ return total == 0 ? "No images yet" : "\(total) shared, none in your groups"
+ case .fault(let fault):
+ return fault.summary
+ }
+ }
+}
+
+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
+
+struct TwonlyProvider: AppIntentTimelineProvider {
+ /// The widget is never drawn larger than its container, so this is the most
+ /// detail any image needs. `displaySize` is in points; 3x covers the densest
+ /// screen the extension can be asked to render for.
+ private func maxPixelSize(in context: Context) -> CGFloat {
+ max(context.displaySize.width, context.displaySize.height) * 3
+ }
+
+ func placeholder(in context: Context) -> TwonlyEntry {
+ TwonlyEntry(
+ date: .now,
+ image: nil,
+ groupIds: [],
+ emptyReason: .noMatchingImages(total: 0),
+ maxPixelSize: maxPixelSize(in: context)
+ )
+ }
+
+ func snapshot(for configuration: TwonlyWidgetIntent, in context: Context) async -> TwonlyEntry {
+ let ids = (configuration.groups ?? []).compactMap { Int64($0.id) }
+ return entry(
+ images: WidgetStorage.images(),
+ ids: ids,
+ index: WidgetStorage.index(for: ids),
+ date: .now,
+ maxPixelSize: maxPixelSize(in: context)
+ )
+ }
+
+ func timeline(for configuration: TwonlyWidgetIntent, in context: Context) async -> Timeline {
+ let ids = (configuration.groups ?? []).compactMap { Int64($0.id) }
+ WidgetStorage.persistSelection(ids)
+ // Read once: an extension that re-reads the manifest for every entry spends
+ // its whole budget on JSON.
+ let available = WidgetStorage.images()
+ // The rotation starts at whatever just arrived, so an image shared into
+ // this widget is on the home screen as soon as the timeline is rebuilt
+ // rather than whenever the rotation next comes back around to it.
+ let start = WidgetStorage.startIndex(
+ for: ids,
+ newestMediaId: newestMatching(in: available, ids: ids)?.mediaId
+ )
+ let pixels = maxPixelSize(in: context)
+ let entries = (0..,
+ ids: [Int64]
+ ) -> ManifestImage? {
+ guard !ids.isEmpty, let available = try? images.get() else { return nil }
+ let selected = Set(ids)
+ let now = Int64(Date().timeIntervalSince1970)
+ return available.first { $0.expiresAt > now && !selected.isDisjoint(with: $0.groupIds) }
+ }
+
+ private func entry(
+ images: Result<[ManifestImage], WidgetStorage.ManifestFault>,
+ ids: [Int64],
+ index: Int,
+ date: Date,
+ maxPixelSize: CGFloat
+ ) -> TwonlyEntry {
+ let available: [ManifestImage]
+ switch images {
+ case .success(let value):
+ available = value
+ case .failure(let fault):
+ return TwonlyEntry(
+ date: date, image: nil, groupIds: ids,
+ emptyReason: .fault(fault), maxPixelSize: maxPixelSize)
+ }
+
+ // An unconfigured widget selects nothing, and an empty set is disjoint from
+ // every image, so this would silently filter everything away.
+ guard !ids.isEmpty else {
+ widgetLog.notice("no contact group configured; nothing can match")
+ return TwonlyEntry(
+ date: date, image: nil, groupIds: ids,
+ 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.
+ 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,
+ emptyReason: nil,
+ maxPixelSize: maxPixelSize
+ )
+ }
+}
+
+/// The widget extension has its own bundle, so it carries its own copy of the
+/// logo in `TwonlyWidget/Assets.xcassets`; the Runner catalog is not visible
+/// here.
+private let placeholderLogo = UIImage(named: "logo")
+
+/// Decodes straight to the size the widget draws at.
+///
+/// `UIImage(contentsOfFile:)` expands the whole file into a bitmap first — a
+/// 1200px image costs about 5.8 MB that way, and WidgetKit renders every
+/// timeline entry, so a handful of them is enough to pass the extension's
+/// memory limit. Exceeding it is not an error the code can catch: the system
+/// kills the extension and the home screen keeps showing a blank square.
+/// ImageIO never materialises the full bitmap.
+private func downsampledImage(at path: String, maxPixelSize: CGFloat) -> UIImage? {
+ guard maxPixelSize > 0,
+ let source = CGImageSourceCreateWithURL(
+ URL(fileURLWithPath: path) as CFURL,
+ [kCGImageSourceShouldCache: false] as CFDictionary
+ )
+ else { return nil }
+
+ let options =
+ [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceShouldCacheImmediately: true,
+ kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
+ ] as CFDictionary
+ guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else {
+ return nil
+ }
+ return UIImage(cgImage: thumbnail)
+}
+
+struct TwonlyWidgetView: View {
+ let entry: TwonlyEntry
+
+ 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")
+ }
+ .containerBackground(for: .widget) { brandBackground }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if let image = entry.image,
+ let uiImage = downsampledImage(at: image.path, maxPixelSize: entry.maxPixelSize) {
+ photo(uiImage, sender: image.sender)
+ .onAppear {
+ widgetLog.debug(
+ "rendering \(image.mediaId, privacy: .public) at \(uiImage.size.debugDescription, privacy: .public)")
+ }
+ } else if let image = entry.image {
+ // The manifest named an image the extension cannot open: the file is
+ // gone, or its data protection class keeps it sealed out here.
+ placeholder(message: "Image could not be loaded")
+ .onAppear {
+ let manager = FileManager.default
+ widgetLog.error(
+ """
+ cannot open \(image.path, privacy: .public) \
+ (exists: \(manager.fileExists(atPath: image.path), privacy: .public), \
+ readable: \(manager.isReadableFile(atPath: image.path), privacy: .public))
+ """)
+ }
+ } else {
+ placeholder(message: (entry.emptyReason ?? .noMatchingImages(total: 0)).message)
+ }
+ }
+
+ /// A filled photo with the sender's name in the corner.
+ ///
+ /// `scaledToFill` reports a size larger than the space it was given, so an
+ /// image placed directly in a stack drives that stack's bounds past the edges
+ /// of the widget — and anything aligned to a corner goes off screen with it.
+ /// Overlaying the image on a flexible, zero-cost base keeps the layout the
+ /// size of the widget, so the name lands on the visible bottom-right corner.
+ private func photo(_ uiImage: UIImage, sender: String) -> some View {
+ Color.clear
+ .overlay {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ }
+ .clipped()
+ .overlay(alignment: .bottomTrailing) {
+ Text(sender)
+ .font(.caption2)
+ .foregroundStyle(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 3)
+ .background(.black.opacity(0.42), in: Capsule())
+ .padding(8)
+ }
+ }
+
+ /// Shown whenever there is nothing to display, always with the reason: a
+ /// blank widget is the one outcome that cannot be diagnosed from the home
+ /// screen.
+ private func placeholder(message: String) -> some View {
+ VStack(spacing: 8) {
+ Group {
+ if let placeholderLogo {
+ Image(uiImage: placeholderLogo).resizable().scaledToFit()
+ } else {
+ Image(systemName: "photo.on.rectangle.angled").resizable().scaledToFit()
+ }
+ }
+ .frame(maxWidth: 64, maxHeight: 64)
+ .foregroundStyle(.white)
+ .opacity(0.9)
+ Text(message)
+ .font(.caption2)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.white.opacity(0.85))
+ .lineLimit(3)
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ /// `defaultPrimaryColor` from the Flutter theme (0xFF32BE80).
+ private var brandBackground: some View {
+ Color(red: 50.0 / 255.0, green: 190.0 / 255.0, blue: 128.0 / 255.0)
+ }
+}
+
+@main
+struct TwonlyWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ TwonlyHomeWidget()
+ }
+}
+
+struct TwonlyHomeWidget: Widget {
+ var body: some WidgetConfiguration {
+ AppIntentConfiguration(kind: widgetKind, intent: TwonlyWidgetIntent.self, provider: TwonlyProvider()) {
+ TwonlyWidgetView(entry: $0)
+ }
+ .configurationDisplayName("twonly")
+ .description("Images your friends share with your widget.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ .contentMarginsDisabled()
+ }
+}
+
diff --git a/lib/app.dart b/lib/app.dart
index ce837b3d..4fdd460e 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -16,6 +16,7 @@ import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/model/json/onboarding_state.model.dart';
import 'package:twonly/src/providers/routing.provider.dart';
import 'package:twonly/src/providers/settings.provider.dart';
+import 'package:twonly/src/services/home_widget.service.dart';
import 'package:twonly/src/services/intent/links.intent.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/utils/keyvalue.dart';
@@ -73,6 +74,10 @@ class _AppState extends State with WidgetsBindingObserver {
unawaited(
rust_api.RustApi.setBackground(inBackground: true),
);
+ // Anything this session downloaded rewrote the widget manifest, but
+ // WidgetKit still shows the timeline it built earlier. Leaving the app is
+ // the moment the home screen becomes visible again.
+ unawaited(HomeWidgetService.refresh());
} else if (state == AppLifecycleState.detached) {
// Last chance before the engine goes away: hand anything still unsent to
// the OS, which delivers it once there is a network again whether or not
diff --git a/lib/core/bridge/api.dart b/lib/core/bridge/api.dart
index 6ed165da..eb104bc9 100644
--- a/lib/core/bridge/api.dart
+++ b/lib/core/bridge/api.dart
@@ -55,6 +55,11 @@ enum ApiEventKind {
appOutdated,
newDeviceRegistered,
loginTokenMigrated,
+
+ /// An image shared into a home-screen widget finished downloading. The
+ /// manifest is already rewritten; only the native widgets still have to be
+ /// told to redraw, which is something Flutter has to ask for.
+ widgetMediaReceived,
}
class FrbAdditionalAccount {
@@ -361,6 +366,12 @@ class RustApi {
static Future deleteMemory({required String mediaId}) =>
RustLib.instance.api.crateBridgeApiRustApiDeleteMemory(mediaId: mediaId);
+ /// Drops a single image a widget is showing, at the user's request.
+ static Future deleteWidgetMedia({required String mediaId}) => RustLib
+ .instance
+ .api
+ .crateBridgeApiRustApiDeleteWidgetMedia(mediaId: mediaId);
+
static Future disableMemoriesBackup() =>
RustLib.instance.api.crateBridgeApiRustApiDisableMemoriesBackup();
@@ -557,6 +568,19 @@ class RustApi {
static Future purgeMediaTempFolder() =>
RustLib.instance.api.crateBridgeApiRustApiPurgeMediaTempFolder();
+ static Future purgeWidgetMedia() =>
+ RustLib.instance.api.crateBridgeApiRustApiPurgeWidgetMedia();
+
+ /// Republishes the widget manifest.
+ ///
+ /// The contact groups a widget offers in its configuration UI are read from
+ /// that file, so it has to be rewritten whenever the groups change — not
+ /// only when images arrive. Cheaper than a full permission sync, which this
+ /// deliberately does not do: editing a group does not change which widgets
+ /// are placed.
+ static Future refreshWidgetManifest() =>
+ RustLib.instance.api.crateBridgeApiRustApiRefreshWidgetManifest();
+
static Future register({
required String username,
required PlatformInt64 proofOfWork,
@@ -569,6 +593,14 @@ class RustApi {
isIos: isIos,
);
+ static Future registerHomeWidget({
+ required String widgetId,
+ required String platform,
+ }) => RustLib.instance.api.crateBridgeApiRustApiRegisterHomeWidget(
+ widgetId: widgetId,
+ platform: platform,
+ );
+
static Future registerPasswordlessNotification({
required String notificationId,
required List downloadAuthToken,
@@ -696,10 +728,12 @@ class RustApi {
required String mediaId,
required List groupIds,
Uint8List? additionalMessageData,
+ required bool widgetOnly,
}) => RustLib.instance.api.crateBridgeApiRustApiSendMediaToGroups(
mediaId: mediaId,
groupIds: groupIds,
additionalMessageData: additionalMessageData,
+ widgetOnly: widgetOnly,
);
static Future sendQueuedMessage({required String receiptId}) => RustLib
@@ -728,6 +762,16 @@ class RustApi {
.api
.crateBridgeApiRustApiSetBackground(inBackground: inBackground);
+ static Future setHomeWidgetGroups({
+ required String widgetId,
+ required String platform,
+ required Int64List contactGroupIds,
+ }) => RustLib.instance.api.crateBridgeApiRustApiSetHomeWidgetGroups(
+ widgetId: widgetId,
+ platform: platform,
+ contactGroupIds: contactGroupIds,
+ );
+
static Future setLoginToken({required List token}) =>
RustLib.instance.api.crateBridgeApiRustApiSetLoginToken(token: token);
@@ -776,6 +820,9 @@ class RustApi {
encryptedMessage: encryptedMessage,
);
+ static Future syncWidgetPermissions() =>
+ RustLib.instance.api.crateBridgeApiRustApiSyncWidgetPermissions();
+
static Future toggleMediaRemoveAudio({required String mediaId}) =>
RustLib.instance.api.crateBridgeApiRustApiToggleMediaRemoveAudio(
mediaId: mediaId,
@@ -789,6 +836,11 @@ class RustApi {
expectedPublicKey: expectedPublicKey,
);
+ static Future unregisterHomeWidget({required String widgetId}) =>
+ RustLib.instance.api.crateBridgeApiRustApiUnregisterHomeWidget(
+ widgetId: widgetId,
+ );
+
static Future updateFcmToken({required String token}) =>
RustLib.instance.api.crateBridgeApiRustApiUpdateFcmToken(token: token);
diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart
index f3438700..0a48e51d 100644
--- a/lib/core/frb_generated.dart
+++ b/lib/core/frb_generated.dart
@@ -86,7 +86,7 @@ class RustLib extends BaseEntrypoint {
String get codegenVersion => '2.12.0';
@override
- int get rustContentHash => 1963960341;
+ int get rustContentHash => -1545547125;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -231,6 +231,10 @@ abstract class RustLibApi extends BaseApi {
Future crateBridgeApiRustApiDeleteMemory({required String mediaId});
+ Future crateBridgeApiRustApiDeleteWidgetMedia({
+ required String mediaId,
+ });
+
Future crateBridgeApiRustApiDisableMemoriesBackup();
Future crateBridgeApiRustApiDownloadDone({required List token});
@@ -345,6 +349,10 @@ abstract class RustLibApi extends BaseApi {
Future crateBridgeApiRustApiPurgeMediaTempFolder();
+ Future crateBridgeApiRustApiPurgeWidgetMedia();
+
+ Future crateBridgeApiRustApiRefreshWidgetManifest();
+
Future crateBridgeApiRustApiRegister({
required String username,
required PlatformInt64 proofOfWork,
@@ -352,6 +360,11 @@ abstract class RustLibApi extends BaseApi {
required bool isIos,
});
+ Future crateBridgeApiRustApiRegisterHomeWidget({
+ required String widgetId,
+ required String platform,
+ });
+
Future crateBridgeApiRustApiRegisterPasswordlessNotification({
required String notificationId,
required List downloadAuthToken,
@@ -431,6 +444,7 @@ abstract class RustLibApi extends BaseApi {
required String mediaId,
required List groupIds,
Uint8List? additionalMessageData,
+ required bool widgetOnly,
});
Future crateBridgeApiRustApiSendQueuedMessage({
@@ -449,6 +463,12 @@ abstract class RustLibApi extends BaseApi {
Future crateBridgeApiRustApiSetBackground({required bool inBackground});
+ Future crateBridgeApiRustApiSetHomeWidgetGroups({
+ required String widgetId,
+ required String platform,
+ required Int64List contactGroupIds,
+ });
+
Future crateBridgeApiRustApiSetLoginToken({required List token});
Future crateBridgeApiRustApiSetMediaDisplayLimit({
@@ -478,6 +498,8 @@ abstract class RustLibApi extends BaseApi {
required List encryptedMessage,
});
+ Future crateBridgeApiRustApiSyncWidgetPermissions();
+
Future crateBridgeApiRustApiToggleMediaRemoveAudio({
required String mediaId,
});
@@ -487,6 +509,10 @@ abstract class RustLibApi extends BaseApi {
required List expectedPublicKey,
});
+ Future crateBridgeApiRustApiUnregisterHomeWidget({
+ required String widgetId,
+ });
+
Future crateBridgeApiRustApiUpdateFcmToken({required String token});
Future crateBridgeApiRustApiUpdateSignedPreKey({
@@ -1982,6 +2008,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["mediaId"],
);
+ @override
+ Future crateBridgeApiRustApiDeleteWidgetMedia({
+ required String mediaId,
+ }) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_String(mediaId, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 40,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiDeleteWidgetMediaConstMeta,
+ argValues: [mediaId],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiDeleteWidgetMediaConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_delete_widget_media",
+ argNames: ["mediaId"],
+ );
+
@override
Future crateBridgeApiRustApiDisableMemoriesBackup() {
return handler.executeNormal(
@@ -1991,7 +2050,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 40,
+ funcId: 41,
port: port_,
);
},
@@ -2022,7 +2081,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 41,
+ funcId: 42,
port: port_,
);
},
@@ -2053,7 +2112,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 42,
+ funcId: 43,
port: port_,
);
},
@@ -2083,7 +2142,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 43,
+ funcId: 44,
port: port_,
);
},
@@ -2116,7 +2175,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 44,
+ funcId: 45,
port: port_,
);
},
@@ -2151,7 +2210,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 45,
+ funcId: 46,
port: port_,
);
},
@@ -2184,7 +2243,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 46,
+ funcId: 47,
port: port_,
);
},
@@ -2216,7 +2275,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 47,
+ funcId: 48,
port: port_,
);
},
@@ -2246,7 +2305,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 48,
+ funcId: 49,
port: port_,
);
},
@@ -2281,7 +2340,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 49,
+ funcId: 50,
port: port_,
);
},
@@ -2311,7 +2370,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 50,
+ funcId: 51,
port: port_,
);
},
@@ -2341,7 +2400,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 51,
+ funcId: 52,
port: port_,
);
},
@@ -2371,7 +2430,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 52,
+ funcId: 53,
port: port_,
);
},
@@ -2412,7 +2471,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 53,
+ funcId: 54,
port: port_,
);
},
@@ -2459,7 +2518,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 54,
+ funcId: 55,
port: port_,
);
},
@@ -2492,7 +2551,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 55,
+ funcId: 56,
port: port_,
);
},
@@ -2525,7 +2584,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 56,
+ funcId: 57,
port: port_,
);
},
@@ -2555,7 +2614,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 57,
+ funcId: 58,
port: port_,
);
},
@@ -2595,7 +2654,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 58,
+ funcId: 59,
port: port_,
);
},
@@ -2632,7 +2691,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 59,
+ funcId: 60,
port: port_,
);
},
@@ -2668,7 +2727,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 60,
+ funcId: 61,
port: port_,
);
},
@@ -2703,7 +2762,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 61,
+ funcId: 62,
port: port_,
);
},
@@ -2740,7 +2799,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 62,
+ funcId: 63,
port: port_,
);
},
@@ -2777,7 +2836,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 63,
+ funcId: 64,
port: port_,
);
},
@@ -2807,7 +2866,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 64,
+ funcId: 65,
port: port_,
);
},
@@ -2840,7 +2899,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 65,
+ funcId: 66,
port: port_,
);
},
@@ -2875,7 +2934,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 66,
+ funcId: 67,
port: port_,
);
},
@@ -2905,7 +2964,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 67,
+ funcId: 68,
port: port_,
);
},
@@ -2940,7 +2999,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 68,
+ funcId: 69,
port: port_,
);
},
@@ -2970,7 +3029,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 69,
+ funcId: 70,
port: port_,
);
},
@@ -3003,7 +3062,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 70,
+ funcId: 71,
port: port_,
);
},
@@ -3033,7 +3092,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 71,
+ funcId: 72,
port: port_,
);
},
@@ -3054,6 +3113,66 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [],
);
+ @override
+ Future crateBridgeApiRustApiPurgeWidgetMedia() {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 73,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiPurgeWidgetMediaConstMeta,
+ argValues: [],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiPurgeWidgetMediaConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_purge_widget_media",
+ argNames: [],
+ );
+
+ @override
+ Future crateBridgeApiRustApiRefreshWidgetManifest() {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 74,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiRefreshWidgetManifestConstMeta,
+ argValues: [],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiRefreshWidgetManifestConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_refresh_widget_manifest",
+ argNames: [],
+ );
+
@override
Future crateBridgeApiRustApiRegister({
required String username,
@@ -3072,7 +3191,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 72,
+ funcId: 75,
port: port_,
);
},
@@ -3093,6 +3212,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["username", "proofOfWork", "langCode", "isIos"],
);
+ @override
+ Future crateBridgeApiRustApiRegisterHomeWidget({
+ required String widgetId,
+ required String platform,
+ }) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_String(widgetId, serializer);
+ sse_encode_String(platform, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 76,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiRegisterHomeWidgetConstMeta,
+ argValues: [widgetId, platform],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiRegisterHomeWidgetConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_register_home_widget",
+ argNames: ["widgetId", "platform"],
+ );
+
@override
Future crateBridgeApiRustApiRegisterPasswordlessNotification({
required String notificationId,
@@ -3111,7 +3265,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 73,
+ funcId: 77,
port: port_,
);
},
@@ -3153,7 +3307,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 74,
+ funcId: 78,
port: port_,
);
},
@@ -3184,7 +3338,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 75,
+ funcId: 79,
port: port_,
);
},
@@ -3217,7 +3371,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 76,
+ funcId: 80,
port: port_,
);
},
@@ -3250,7 +3404,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 77,
+ funcId: 81,
port: port_,
);
},
@@ -3285,7 +3439,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 78,
+ funcId: 82,
port: port_,
);
},
@@ -3318,7 +3472,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 79,
+ funcId: 83,
port: port_,
);
},
@@ -3351,7 +3505,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 80,
+ funcId: 84,
port: port_,
);
},
@@ -3384,7 +3538,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 81,
+ funcId: 85,
port: port_,
);
},
@@ -3421,7 +3575,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 82,
+ funcId: 86,
port: port_,
);
},
@@ -3451,7 +3605,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 83,
+ funcId: 87,
port: port_,
);
},
@@ -3481,7 +3635,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 84,
+ funcId: 88,
port: port_,
);
},
@@ -3511,7 +3665,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 85,
+ funcId: 89,
port: port_,
);
},
@@ -3544,7 +3698,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 86,
+ funcId: 90,
port: port_,
);
},
@@ -3575,7 +3729,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 87,
+ funcId: 91,
port: port_,
);
},
@@ -3608,7 +3762,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 88,
+ funcId: 92,
port: port_,
);
},
@@ -3654,7 +3808,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 89,
+ funcId: 93,
port: port_,
);
},
@@ -3710,7 +3864,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 90,
+ funcId: 94,
port: port_,
);
},
@@ -3742,6 +3896,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
required String mediaId,
required List groupIds,
Uint8List? additionalMessageData,
+ required bool widgetOnly,
}) {
return handler.executeNormal(
NormalTask(
@@ -3753,10 +3908,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
additionalMessageData,
serializer,
);
+ sse_encode_bool(widgetOnly, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 91,
+ funcId: 95,
port: port_,
);
},
@@ -3765,7 +3921,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeErrorData: sse_decode_AnyhowException,
),
constMeta: kCrateBridgeApiRustApiSendMediaToGroupsConstMeta,
- argValues: [mediaId, groupIds, additionalMessageData],
+ argValues: [mediaId, groupIds, additionalMessageData, widgetOnly],
apiImpl: this,
),
);
@@ -3774,7 +3930,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateBridgeApiRustApiSendMediaToGroupsConstMeta =>
const TaskConstMeta(
debugName: "rust_api_send_media_to_groups",
- argNames: ["mediaId", "groupIds", "additionalMessageData"],
+ argNames: [
+ "mediaId",
+ "groupIds",
+ "additionalMessageData",
+ "widgetOnly",
+ ],
);
@override
@@ -3789,7 +3950,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 92,
+ funcId: 96,
port: port_,
);
},
@@ -3824,7 +3985,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 93,
+ funcId: 97,
port: port_,
);
},
@@ -3859,7 +4020,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 94,
+ funcId: 98,
port: port_,
);
},
@@ -3892,7 +4053,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 95,
+ funcId: 99,
port: port_,
);
},
@@ -3913,6 +4074,43 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["inBackground"],
);
+ @override
+ Future crateBridgeApiRustApiSetHomeWidgetGroups({
+ required String widgetId,
+ required String platform,
+ required Int64List contactGroupIds,
+ }) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_String(widgetId, serializer);
+ sse_encode_String(platform, serializer);
+ sse_encode_list_prim_i_64_strict(contactGroupIds, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 100,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiSetHomeWidgetGroupsConstMeta,
+ argValues: [widgetId, platform, contactGroupIds],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiSetHomeWidgetGroupsConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_set_home_widget_groups",
+ argNames: ["widgetId", "platform", "contactGroupIds"],
+ );
+
@override
Future crateBridgeApiRustApiSetLoginToken({required List token}) {
return handler.executeNormal(
@@ -3923,7 +4121,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 96,
+ funcId: 101,
port: port_,
);
},
@@ -3961,7 +4159,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 97,
+ funcId: 102,
port: port_,
);
},
@@ -3996,7 +4194,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 98,
+ funcId: 103,
port: port_,
);
},
@@ -4035,7 +4233,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 99,
+ funcId: 104,
port: port_,
);
},
@@ -4068,7 +4266,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 100,
+ funcId: 105,
port: port_,
);
},
@@ -4099,7 +4297,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 101,
+ funcId: 106,
port: port_,
);
},
@@ -4134,7 +4332,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 102,
+ funcId: 107,
port: port_,
);
},
@@ -4155,6 +4353,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["notificationId", "encryptedMessage"],
);
+ @override
+ Future crateBridgeApiRustApiSyncWidgetPermissions() {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 108,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiSyncWidgetPermissionsConstMeta,
+ argValues: [],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiSyncWidgetPermissionsConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_sync_widget_permissions",
+ argNames: [],
+ );
+
@override
Future crateBridgeApiRustApiToggleMediaRemoveAudio({
required String mediaId,
@@ -4167,7 +4395,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 103,
+ funcId: 109,
port: port_,
);
},
@@ -4202,7 +4430,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 104,
+ funcId: 110,
port: port_,
);
},
@@ -4223,6 +4451,39 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["contactId", "expectedPublicKey"],
);
+ @override
+ Future crateBridgeApiRustApiUnregisterHomeWidget({
+ required String widgetId,
+ }) {
+ return handler.executeNormal(
+ NormalTask(
+ callFfi: (port_) {
+ final serializer = SseSerializer(generalizedFrbRustBinding);
+ sse_encode_String(widgetId, serializer);
+ pdeCallFfi(
+ generalizedFrbRustBinding,
+ serializer,
+ funcId: 111,
+ port: port_,
+ );
+ },
+ codec: SseCodec(
+ decodeSuccessData: sse_decode_unit,
+ decodeErrorData: sse_decode_AnyhowException,
+ ),
+ constMeta: kCrateBridgeApiRustApiUnregisterHomeWidgetConstMeta,
+ argValues: [widgetId],
+ apiImpl: this,
+ ),
+ );
+ }
+
+ TaskConstMeta get kCrateBridgeApiRustApiUnregisterHomeWidgetConstMeta =>
+ const TaskConstMeta(
+ debugName: "rust_api_unregister_home_widget",
+ argNames: ["widgetId"],
+ );
+
@override
Future crateBridgeApiRustApiUpdateFcmToken({required String token}) {
return handler.executeNormal(
@@ -4233,7 +4494,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 105,
+ funcId: 112,
port: port_,
);
},
@@ -4270,7 +4531,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 106,
+ funcId: 113,
port: port_,
);
},
@@ -4322,7 +4583,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 107,
+ funcId: 114,
port: port_,
);
},
@@ -4375,7 +4636,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 108,
+ funcId: 115,
port: port_,
);
},
@@ -4415,7 +4676,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 109,
+ funcId: 116,
port: port_,
);
},
@@ -4448,7 +4709,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 110,
+ funcId: 117,
port: port_,
);
},
@@ -4481,7 +4742,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 111,
+ funcId: 118,
port: port_,
);
},
@@ -4518,7 +4779,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 112,
+ funcId: 119,
port: port_,
);
},
@@ -4550,7 +4811,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 113,
+ funcId: 120,
port: port_,
);
},
@@ -4583,7 +4844,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 114,
+ funcId: 121,
port: port_,
);
},
@@ -4618,7 +4879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 115,
+ funcId: 122,
port: port_,
);
},
@@ -4650,7 +4911,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 116,
+ funcId: 123,
port: port_,
);
},
@@ -4688,7 +4949,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 117,
+ funcId: 124,
port: port_,
);
},
@@ -4721,7 +4982,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 118,
+ funcId: 125,
port: port_,
);
},
@@ -4759,7 +5020,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 119,
+ funcId: 126,
port: port_,
);
},
@@ -4796,7 +5057,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 120,
+ funcId: 127,
port: port_,
);
},
@@ -4833,7 +5094,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 121,
+ funcId: 128,
port: port_,
);
},
@@ -4871,7 +5132,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 122,
+ funcId: 129,
port: port_,
);
},
@@ -4909,7 +5170,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 123,
+ funcId: 130,
port: port_,
);
},
@@ -4942,7 +5203,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 124,
+ funcId: 131,
port: port_,
);
},
@@ -4974,7 +5235,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 125,
+ funcId: 132,
port: port_,
);
},
@@ -5009,7 +5270,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 126,
+ funcId: 133,
port: port_,
);
},
@@ -5046,7 +5307,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 127,
+ funcId: 134,
port: port_,
);
},
@@ -5079,7 +5340,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 128,
+ funcId: 135,
port: port_,
);
},
@@ -5111,7 +5372,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 129,
+ funcId: 136,
port: port_,
);
},
@@ -5146,7 +5407,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 130,
+ funcId: 137,
port: port_,
);
},
@@ -5185,7 +5446,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 131,
+ funcId: 138,
port: port_,
);
},
@@ -5222,7 +5483,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 132,
+ funcId: 139,
port: port_,
);
},
@@ -5252,7 +5513,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 133,
+ funcId: 140,
port: port_,
);
},
@@ -5284,7 +5545,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 134,
+ funcId: 141,
port: port_,
);
},
@@ -5319,7 +5580,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 135,
+ funcId: 142,
port: port_,
);
},
@@ -5351,7 +5612,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 136,
+ funcId: 143,
port: port_,
);
},
@@ -5389,7 +5650,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 137,
+ funcId: 144,
port: port_,
);
},
@@ -5428,7 +5689,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 138,
+ funcId: 145,
port: port_,
);
},
@@ -5463,7 +5724,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 139,
+ funcId: 146,
port: port_,
);
},
@@ -5498,7 +5759,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 140,
+ funcId: 147,
port: port_,
);
},
@@ -5533,7 +5794,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 141,
+ funcId: 148,
port: port_,
);
},
@@ -5566,7 +5827,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 142,
+ funcId: 149,
)!;
},
codec: SseCodec(
@@ -5606,7 +5867,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 143,
+ funcId: 150,
port: port_,
);
},
@@ -5651,7 +5912,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 144,
+ funcId: 151,
port: port_,
);
},
@@ -5681,7 +5942,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 145,
+ funcId: 152,
port: port_,
);
},
@@ -5714,7 +5975,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 146,
+ funcId: 153,
port: port_,
);
},
@@ -5749,7 +6010,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 147,
+ funcId: 154,
port: port_,
);
},
@@ -5788,7 +6049,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi(
generalizedFrbRustBinding,
serializer,
- funcId: 148,
+ funcId: 155,
)!;
},
codec: SseCodec(
diff --git a/lib/main.dart b/lib/main.dart
index 8a659648..6580ef72 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -21,6 +21,7 @@ import 'package:twonly/src/providers/image_editor.provider.dart';
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/services/backup.service.dart';
+import 'package:twonly/src/services/home_widget.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories.service.dart';
import 'package:twonly/src/services/migrations.service.dart';
@@ -288,6 +289,8 @@ Future postStartupTasks() async {
await twonlyDB.messagesDao.purgeMessageTable();
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
unawaited(MediaFileService.purgeTempFolder());
+ unawaited(HomeWidgetService.purgeExpiredMedia());
+ unawaited(HomeWidgetService.syncPermissions());
// 2. Service initializations
unawaitedRustCall(
diff --git a/lib/src/constants/keyvalue.keys.dart b/lib/src/constants/keyvalue.keys.dart
index 9f3bee56..ab4f16e9 100644
--- a/lib/src/constants/keyvalue.keys.dart
+++ b/lib/src/constants/keyvalue.keys.dart
@@ -2,4 +2,6 @@ class KeyValueKeys {
static const String currentBackupState = 'current_backup_state';
static const String backupRecoveryState = 'backup_recovery_state';
static const String onboardingState = 'onboarding_state';
+ static const String shareImageWidgetExplainer =
+ 'share_image_widget_explainer';
}
diff --git a/lib/src/constants/routes.keys.dart b/lib/src/constants/routes.keys.dart
index 3113d38a..c9035292 100644
--- a/lib/src/constants/routes.keys.dart
+++ b/lib/src/constants/routes.keys.dart
@@ -37,6 +37,7 @@ class Routes {
static const String settingsPrivacyUserDiscovery =
'/settings/privacy/user_discovery';
static const String settingsNotification = '/settings/notification';
+ static const String settingsWidgets = '/settings/widgets';
static const String settingsStorage = '/settings/storage_data';
static const String settingsStorageManage = '/settings/storage_data/manage';
static const String settingsStorageImport = '/settings/storage_data/import';
diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart
index dec08fab..93ff62d6 100644
--- a/lib/src/database/daos/groups.dao.dart
+++ b/lib/src/database/daos/groups.dao.dart
@@ -21,7 +21,7 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin {
// of this object.
// ignore: matching_super_parameters
GroupsDao(super.db);
-Future deleteGroup(String groupId) async {
+ Future deleteGroup(String groupId) async {
await (delete(groups)..where((t) => t.groupId.equals(groupId))).go();
}
@@ -49,6 +49,7 @@ Future deleteGroup(String groupId) async {
groupMembers,
)..where((t) => t.groupId.equals(groupId))).get();
}
+
Future createNewGroup(GroupsCompanion group) async {
return _insertGroup(group);
}
@@ -82,7 +83,8 @@ Future deleteGroup(String groupId) async {
..orderBy([(t) => OrderingTerm.asc(t.actionAt)]))
.watch();
}
-Future createNewDirectChat(
+
+ Future createNewDirectChat(
int contactId,
GroupsCompanion group,
) async {
@@ -175,6 +177,26 @@ Future createNewDirectChat(
.watch();
}
+ Stream> watchGroupsAllowedForWidgetShare() {
+ final query =
+ select(groups).join([
+ innerJoin(
+ groupMembers,
+ groupMembers.groupId.equalsExp(groups.groupId),
+ ),
+ innerJoin(
+ contacts,
+ contacts.userId.equalsExp(groupMembers.contactId),
+ ),
+ ])..where(
+ groups.isDirectChat.equals(true) &
+ groups.leftGroup.equals(false) &
+ groups.deletedContent.equals(false) &
+ contacts.widgetSharingAllowed.equals(true),
+ );
+ return query.map((row) => row.readTable(groups)).watch();
+ }
+
Stream> watchContactGroupMember(int contactId) {
return (select(groupMembers)..where(
(g) => g.contactId.equals(contactId),
@@ -193,7 +215,8 @@ Future createNewDirectChat(
groups,
)..where((t) => t.groupId.equals(groupId))).watchSingleOrNull();
}
-Stream> watchGroupsForChatList() {
+
+ Stream> watchGroupsForChatList() {
return (select(groups)
..where((t) => t.deletedContent.equals(false))
..orderBy([(t) => OrderingTerm.desc(t.lastMessageExchange)]))
@@ -231,7 +254,8 @@ Stream> watchGroupsForChatList() {
Future> getAllGroups() {
return select(groups).get();
}
-Future getDirectChat(int userId) async {
+
+ Future getDirectChat(int userId) async {
final query =
((select(groups)..where((t) => t.isDirectChat.equals(true))).join([
leftOuterJoin(
@@ -242,7 +266,8 @@ Future getDirectChat(int userId) async {
return query.map((row) => row.readTable(groups)).getSingleOrNull();
}
-Stream watchSumTotalMediaCounter() {
+
+ Stream watchSumTotalMediaCounter() {
final query = selectOnly(groups)
..addColumns([groups.totalMediaCounter.sum()]);
return query.watch().map((rows) {
diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart
index 551ef8be..6b8a5b71 100644
--- a/lib/src/database/daos/mediafiles.dao.dart
+++ b/lib/src/database/daos/mediafiles.dao.dart
@@ -88,7 +88,8 @@ class MediaFilesDao extends DatabaseAccessor
mediaFiles,
)..where((t) => t.mediaId.equals(mediaId))).watchSingleOrNull();
}
-Future> getAllMediaFilesPendingDownload() async {
+
+ Future> getAllMediaFilesPendingDownload() async {
return (select(mediaFiles)..where(
(t) =>
t.downloadState.equals(DownloadState.pending.name) |
@@ -144,7 +145,8 @@ Future> getAllMediaFilesPendingDownload() async {
]);
return query.map((row) => row.readTable(mediaFiles)).watch();
}
-Stream> watchMediaFilesByIds(Set mediaIds) {
+
+ Stream> watchMediaFilesByIds(Set mediaIds) {
if (mediaIds.isEmpty) return Stream.value(const []);
return (select(
mediaFiles,
@@ -152,13 +154,14 @@ Stream> watchMediaFilesByIds(Set mediaIds) {
}
Stream> watchMediaFilesForGroup(String groupId) {
- final query = select(mediaFiles).join([
- innerJoin(
- db.messages,
- db.messages.mediaId.equalsExp(mediaFiles.mediaId),
- useColumns: false,
- ),
- ])..where(db.messages.groupId.equals(groupId));
+ final query =
+ select(mediaFiles).join([
+ innerJoin(
+ db.messages,
+ db.messages.mediaId.equalsExp(mediaFiles.mediaId),
+ useColumns: false,
+ ),
+ ])..where(db.messages.groupId.equals(groupId));
return query.map((row) => row.readTable(mediaFiles)).watch();
}
@@ -201,6 +204,7 @@ Stream> watchMediaFilesByIds(Set mediaIds) {
])..where(
mediaFiles.storedFileHash.equals(hash) &
db.messages.senderId.equals(senderId) &
+ db.messages.isWidgetMedia.equals(false) &
db.messages.openedAt.isNull(),
);
diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart
index fabbdcc7..3d71806d 100644
--- a/lib/src/database/daos/messages.dao.dart
+++ b/lib/src/database/daos/messages.dao.dart
@@ -41,6 +41,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin {
])
..where(
messages.openedAt.isNull() &
+ messages.isWidgetMedia.equals(false) &
messages.groupId.equals(groupId) &
messages.isDeletedFromSender.equals(false) &
(messages.mediaId.isNull() |
@@ -63,6 +64,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin {
])
..where(
messages.openedAt.isNull() &
+ messages.isWidgetMedia.equals(false) &
messages.isDeletedFromSender.equals(false) &
(messages.mediaId.isNull() |
mediaFiles.downloadState.isNull() |
@@ -89,6 +91,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin {
.not()) &
mediaFiles.type.equals(MediaType.audio.name).not() &
messages.openedAt.isNull() &
+ messages.isWidgetMedia.equals(false) &
messages.groupId.equals(groupId) &
messages.mediaId.isNotNull() &
messages.senderId.isNotNull() &
@@ -107,6 +110,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin {
),
])..where(
messages.openedAt.isNull() &
+ messages.isWidgetMedia.equals(false) &
messages.mediaId.isNotNull() &
messages.type.equals(MessageType.media.name) &
mediaFiles.downloadState.equals(DownloadState.ready.name) &
@@ -285,7 +289,8 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin {
.map((row) => (row.readTable(groupMembers), row.readTable(contacts)))
.watch();
}
-Future purgeMessageTable() async {
+
+ Future purgeMessageTable() async {
final allGroups = await select(groups).get();
final groupedByTime = >{};
@@ -622,7 +627,8 @@ Future purgeMessageTable() async {
return null;
}
}
-Future deleteMessagesById(String messageId) {
+
+ Future deleteMessagesById(String messageId) {
return (delete(messages)..where((t) => t.messageId.equals(messageId))).go();
}
@@ -665,7 +671,8 @@ Future deleteMessagesById(String messageId) {
))
.watch();
}
-Stream> watchMessageHistory(String messageId) {
+
+ Stream> watchMessageHistory(String messageId) {
return (select(messageHistories)
..where((t) => t.messageId.equals(messageId))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
diff --git a/lib/src/database/tables/contacts.table.dart b/lib/src/database/tables/contacts.table.dart
index 0dda2ddb..e83a4812 100644
--- a/lib/src/database/tables/contacts.table.dart
+++ b/lib/src/database/tables/contacts.table.dart
@@ -48,6 +48,11 @@ class Contacts extends Table {
BoolColumn get askForFriendPromotions => boolean().nullable()();
+ BoolColumn get widgetSharingAllowed =>
+ boolean().withDefault(const Constant(false))();
+ BoolColumn get widgetSharingGranted =>
+ boolean().withDefault(const Constant(false))();
+
IntColumn get mediaSendCounter => integer().withDefault(const Constant(0))();
IntColumn get mediaReceivedCounter =>
integer().withDefault(const Constant(0))();
diff --git a/lib/src/database/tables/mediafiles.table.dart b/lib/src/database/tables/mediafiles.table.dart
index f6517813..f4e212a7 100644
--- a/lib/src/database/tables/mediafiles.table.dart
+++ b/lib/src/database/tables/mediafiles.table.dart
@@ -59,6 +59,8 @@ class MediaFiles extends Table {
BoolColumn get stored => boolean().withDefault(const Constant(false))();
BoolColumn get isDraftMedia => boolean().withDefault(const Constant(false))();
+ BoolColumn get isWidgetMedia =>
+ boolean().withDefault(const Constant(false))();
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
BoolColumn get hasCropAnalyzed =>
boolean().withDefault(const Constant(false))();
diff --git a/lib/src/database/tables/messages.table.dart b/lib/src/database/tables/messages.table.dart
index 9729bcca..f1ed06bd 100644
--- a/lib/src/database/tables/messages.table.dart
+++ b/lib/src/database/tables/messages.table.dart
@@ -40,6 +40,8 @@ class Messages extends Table {
BoolColumn get isDeletedFromSender =>
boolean().withDefault(const Constant(false))();
+ BoolColumn get isWidgetMedia =>
+ boolean().withDefault(const Constant(false))();
DateTimeColumn get openedAt => dateTime().nullable()();
DateTimeColumn get openedByAll => dateTime().nullable()();
diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart
index d44a5a43..a8f21b27 100644
--- a/lib/src/database/twonly.db.g.dart
+++ b/lib/src/database/twonly.db.g.dart
@@ -309,6 +309,34 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
'CHECK ("ask_for_friend_promotions" IN (0, 1))',
),
);
+ static const VerificationMeta _widgetSharingAllowedMeta =
+ const VerificationMeta('widgetSharingAllowed');
+ @override
+ late final GeneratedColumn widgetSharingAllowed = GeneratedColumn(
+ 'widget_sharing_allowed',
+ aliasedName,
+ false,
+ type: DriftSqlType.bool,
+ requiredDuringInsert: false,
+ defaultConstraints: GeneratedColumn.constraintIsAlways(
+ 'CHECK ("widget_sharing_allowed" IN (0, 1))',
+ ),
+ defaultValue: const Constant(false),
+ );
+ static const VerificationMeta _widgetSharingGrantedMeta =
+ const VerificationMeta('widgetSharingGranted');
+ @override
+ late final GeneratedColumn widgetSharingGranted = GeneratedColumn(
+ 'widget_sharing_granted',
+ aliasedName,
+ false,
+ type: DriftSqlType.bool,
+ requiredDuringInsert: false,
+ defaultConstraints: GeneratedColumn.constraintIsAlways(
+ 'CHECK ("widget_sharing_granted" IN (0, 1))',
+ ),
+ defaultValue: const Constant(false),
+ );
static const VerificationMeta _mediaSendCounterMeta = const VerificationMeta(
'mediaSendCounter',
);
@@ -358,6 +386,8 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
recoveryContactsLastHeartbeat,
recoveryContactsThreshold,
askForFriendPromotions,
+ widgetSharingAllowed,
+ widgetSharingGranted,
mediaSendCounter,
mediaReceivedCounter,
];
@@ -558,6 +588,24 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
),
);
}
+ if (data.containsKey('widget_sharing_allowed')) {
+ context.handle(
+ _widgetSharingAllowedMeta,
+ widgetSharingAllowed.isAcceptableOrUnknown(
+ data['widget_sharing_allowed']!,
+ _widgetSharingAllowedMeta,
+ ),
+ );
+ }
+ if (data.containsKey('widget_sharing_granted')) {
+ context.handle(
+ _widgetSharingGrantedMeta,
+ widgetSharingGranted.isAcceptableOrUnknown(
+ data['widget_sharing_granted']!,
+ _widgetSharingGrantedMeta,
+ ),
+ );
+ }
if (data.containsKey('media_send_counter')) {
context.handle(
_mediaSendCounterMeta,
@@ -683,6 +731,14 @@ class $ContactsTable extends Contacts with TableInfo<$ContactsTable, Contact> {
DriftSqlType.bool,
data['${effectivePrefix}ask_for_friend_promotions'],
),
+ widgetSharingAllowed: attachedDatabase.typeMapping.read(
+ DriftSqlType.bool,
+ data['${effectivePrefix}widget_sharing_allowed'],
+ )!,
+ widgetSharingGranted: attachedDatabase.typeMapping.read(
+ DriftSqlType.bool,
+ data['${effectivePrefix}widget_sharing_granted'],
+ )!,
mediaSendCounter: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}media_send_counter'],
@@ -730,6 +786,8 @@ class Contact extends DataClass implements Insertable {
final DateTime? recoveryContactsLastHeartbeat;
final int? recoveryContactsThreshold;
final bool? askForFriendPromotions;
+ final bool widgetSharingAllowed;
+ final bool widgetSharingGranted;
final int mediaSendCounter;
final int mediaReceivedCounter;
const Contact({
@@ -757,6 +815,8 @@ class Contact extends DataClass implements Insertable {
this.recoveryContactsLastHeartbeat,
this.recoveryContactsThreshold,
this.askForFriendPromotions,
+ required this.widgetSharingAllowed,
+ required this.widgetSharingGranted,
required this.mediaSendCounter,
required this.mediaReceivedCounter,
});
@@ -823,6 +883,8 @@ class Contact extends DataClass implements Insertable {
if (!nullToAbsent || askForFriendPromotions != null) {
map['ask_for_friend_promotions'] = Variable(askForFriendPromotions);
}
+ map['widget_sharing_allowed'] = Variable(widgetSharingAllowed);
+ map['widget_sharing_granted'] = Variable(widgetSharingGranted);
map['media_send_counter'] = Variable(mediaSendCounter);
map['media_received_counter'] = Variable(mediaReceivedCounter);
return map;
@@ -880,6 +942,8 @@ class Contact extends DataClass implements Insertable {
askForFriendPromotions: askForFriendPromotions == null && nullToAbsent
? const Value.absent()
: Value(askForFriendPromotions),
+ widgetSharingAllowed: Value(widgetSharingAllowed),
+ widgetSharingGranted: Value(widgetSharingGranted),
mediaSendCounter: Value(mediaSendCounter),
mediaReceivedCounter: Value(mediaReceivedCounter),
);
@@ -941,6 +1005,12 @@ class Contact extends DataClass implements Insertable {
askForFriendPromotions: serializer.fromJson(
json['askForFriendPromotions'],
),
+ widgetSharingAllowed: serializer.fromJson(
+ json['widgetSharingAllowed'],
+ ),
+ widgetSharingGranted: serializer.fromJson(
+ json['widgetSharingGranted'],
+ ),
mediaSendCounter: serializer.fromJson(json['mediaSendCounter']),
mediaReceivedCounter: serializer.fromJson(
json['mediaReceivedCounter'],
@@ -993,6 +1063,8 @@ class Contact extends DataClass implements Insertable {
'askForFriendPromotions': serializer.toJson(
askForFriendPromotions,
),
+ 'widgetSharingAllowed': serializer.toJson(widgetSharingAllowed),
+ 'widgetSharingGranted': serializer.toJson(widgetSharingGranted),
'mediaSendCounter': serializer.toJson(mediaSendCounter),
'mediaReceivedCounter': serializer.toJson(mediaReceivedCounter),
};
@@ -1023,6 +1095,8 @@ class Contact extends DataClass implements Insertable {
Value recoveryContactsLastHeartbeat = const Value.absent(),
Value recoveryContactsThreshold = const Value.absent(),
Value askForFriendPromotions = const Value.absent(),
+ bool? widgetSharingAllowed,
+ bool? widgetSharingGranted,
int? mediaSendCounter,
int? mediaReceivedCounter,
}) => Contact(
@@ -1069,6 +1143,8 @@ class Contact extends DataClass implements Insertable {
askForFriendPromotions: askForFriendPromotions.present
? askForFriendPromotions.value
: this.askForFriendPromotions,
+ widgetSharingAllowed: widgetSharingAllowed ?? this.widgetSharingAllowed,
+ widgetSharingGranted: widgetSharingGranted ?? this.widgetSharingGranted,
mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter,
mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter,
);
@@ -1130,6 +1206,12 @@ class Contact extends DataClass implements Insertable {
askForFriendPromotions: data.askForFriendPromotions.present
? data.askForFriendPromotions.value
: this.askForFriendPromotions,
+ widgetSharingAllowed: data.widgetSharingAllowed.present
+ ? data.widgetSharingAllowed.value
+ : this.widgetSharingAllowed,
+ widgetSharingGranted: data.widgetSharingGranted.present
+ ? data.widgetSharingGranted.value
+ : this.widgetSharingGranted,
mediaSendCounter: data.mediaSendCounter.present
? data.mediaSendCounter.value
: this.mediaSendCounter,
@@ -1168,6 +1250,8 @@ class Contact extends DataClass implements Insertable {
)
..write('recoveryContactsThreshold: $recoveryContactsThreshold, ')
..write('askForFriendPromotions: $askForFriendPromotions, ')
+ ..write('widgetSharingAllowed: $widgetSharingAllowed, ')
+ ..write('widgetSharingGranted: $widgetSharingGranted, ')
..write('mediaSendCounter: $mediaSendCounter, ')
..write('mediaReceivedCounter: $mediaReceivedCounter')
..write(')'))
@@ -1200,6 +1284,8 @@ class Contact extends DataClass implements Insertable {
recoveryContactsLastHeartbeat,
recoveryContactsThreshold,
askForFriendPromotions,
+ widgetSharingAllowed,
+ widgetSharingGranted,
mediaSendCounter,
mediaReceivedCounter,
]);
@@ -1245,6 +1331,8 @@ class Contact extends DataClass implements Insertable {
this.recoveryContactsLastHeartbeat &&
other.recoveryContactsThreshold == this.recoveryContactsThreshold &&
other.askForFriendPromotions == this.askForFriendPromotions &&
+ other.widgetSharingAllowed == this.widgetSharingAllowed &&
+ other.widgetSharingGranted == this.widgetSharingGranted &&
other.mediaSendCounter == this.mediaSendCounter &&
other.mediaReceivedCounter == this.mediaReceivedCounter);
}
@@ -1274,6 +1362,8 @@ class ContactsCompanion extends UpdateCompanion {
final Value recoveryContactsLastHeartbeat;
final Value recoveryContactsThreshold;
final Value askForFriendPromotions;
+ final Value widgetSharingAllowed;
+ final Value widgetSharingGranted;
final Value mediaSendCounter;
final Value mediaReceivedCounter;
const ContactsCompanion({
@@ -1301,6 +1391,8 @@ class ContactsCompanion extends UpdateCompanion {
this.recoveryContactsLastHeartbeat = const Value.absent(),
this.recoveryContactsThreshold = const Value.absent(),
this.askForFriendPromotions = const Value.absent(),
+ this.widgetSharingAllowed = const Value.absent(),
+ this.widgetSharingGranted = const Value.absent(),
this.mediaSendCounter = const Value.absent(),
this.mediaReceivedCounter = const Value.absent(),
});
@@ -1329,6 +1421,8 @@ class ContactsCompanion extends UpdateCompanion {
this.recoveryContactsLastHeartbeat = const Value.absent(),
this.recoveryContactsThreshold = const Value.absent(),
this.askForFriendPromotions = const Value.absent(),
+ this.widgetSharingAllowed = const Value.absent(),
+ this.widgetSharingGranted = const Value.absent(),
this.mediaSendCounter = const Value.absent(),
this.mediaReceivedCounter = const Value.absent(),
}) : username = Value(username);
@@ -1357,6 +1451,8 @@ class ContactsCompanion extends UpdateCompanion {
Expression? recoveryContactsLastHeartbeat,
Expression? recoveryContactsThreshold,
Expression? askForFriendPromotions,
+ Expression? widgetSharingAllowed,
+ Expression? widgetSharingGranted,
Expression? mediaSendCounter,
Expression? mediaReceivedCounter,
}) {
@@ -1397,6 +1493,10 @@ class ContactsCompanion extends UpdateCompanion {
'recovery_contacts_threshold': recoveryContactsThreshold,
if (askForFriendPromotions != null)
'ask_for_friend_promotions': askForFriendPromotions,
+ if (widgetSharingAllowed != null)
+ 'widget_sharing_allowed': widgetSharingAllowed,
+ if (widgetSharingGranted != null)
+ 'widget_sharing_granted': widgetSharingGranted,
if (mediaSendCounter != null) 'media_send_counter': mediaSendCounter,
if (mediaReceivedCounter != null)
'media_received_counter': mediaReceivedCounter,
@@ -1428,6 +1528,8 @@ class ContactsCompanion extends UpdateCompanion {
Value? recoveryContactsLastHeartbeat,
Value? recoveryContactsThreshold,
Value? askForFriendPromotions,
+ Value? widgetSharingAllowed,
+ Value? widgetSharingGranted,
Value? mediaSendCounter,
Value? mediaReceivedCounter,
}) {
@@ -1464,6 +1566,8 @@ class ContactsCompanion extends UpdateCompanion {
recoveryContactsThreshold ?? this.recoveryContactsThreshold,
askForFriendPromotions:
askForFriendPromotions ?? this.askForFriendPromotions,
+ widgetSharingAllowed: widgetSharingAllowed ?? this.widgetSharingAllowed,
+ widgetSharingGranted: widgetSharingGranted ?? this.widgetSharingGranted,
mediaSendCounter: mediaSendCounter ?? this.mediaSendCounter,
mediaReceivedCounter: mediaReceivedCounter ?? this.mediaReceivedCounter,
);
@@ -1568,6 +1672,16 @@ class ContactsCompanion extends UpdateCompanion {
askForFriendPromotions.value,
);
}
+ if (widgetSharingAllowed.present) {
+ map['widget_sharing_allowed'] = Variable(
+ widgetSharingAllowed.value,
+ );
+ }
+ if (widgetSharingGranted.present) {
+ map['widget_sharing_granted'] = Variable(
+ widgetSharingGranted.value,
+ );
+ }
if (mediaSendCounter.present) {
map['media_send_counter'] = Variable(mediaSendCounter.value);
}
@@ -1606,6 +1720,8 @@ class ContactsCompanion extends UpdateCompanion {
)
..write('recoveryContactsThreshold: $recoveryContactsThreshold, ')
..write('askForFriendPromotions: $askForFriendPromotions, ')
+ ..write('widgetSharingAllowed: $widgetSharingAllowed, ')
+ ..write('widgetSharingGranted: $widgetSharingGranted, ')
..write('mediaSendCounter: $mediaSendCounter, ')
..write('mediaReceivedCounter: $mediaReceivedCounter')
..write(')'))
@@ -3223,6 +3339,21 @@ class $MediaFilesTable extends MediaFiles
),
defaultValue: const Constant(false),
);
+ static const VerificationMeta _isWidgetMediaMeta = const VerificationMeta(
+ 'isWidgetMedia',
+ );
+ @override
+ late final GeneratedColumn isWidgetMedia = GeneratedColumn(
+ 'is_widget_media',
+ aliasedName,
+ false,
+ type: DriftSqlType.bool,
+ requiredDuringInsert: false,
+ defaultConstraints: GeneratedColumn.constraintIsAlways(
+ 'CHECK ("is_widget_media" IN (0, 1))',
+ ),
+ defaultValue: const Constant(false),
+ );
static const VerificationMeta _isFavoriteMeta = const VerificationMeta(
'isFavorite',
);
@@ -3439,6 +3570,7 @@ class $MediaFilesTable extends MediaFiles
requiresAuthentication,
stored,
isDraftMedia,
+ isWidgetMedia,
isFavorite,
hasCropAnalyzed,
preProgressingProcess,
@@ -3507,6 +3639,15 @@ class $MediaFilesTable extends MediaFiles
),
);
}
+ if (data.containsKey('is_widget_media')) {
+ context.handle(
+ _isWidgetMediaMeta,
+ isWidgetMedia.isAcceptableOrUnknown(
+ data['is_widget_media']!,
+ _isWidgetMediaMeta,
+ ),
+ );
+ }
if (data.containsKey('is_favorite')) {
context.handle(
_isFavoriteMeta,
@@ -3695,6 +3836,10 @@ class $MediaFilesTable extends MediaFiles
DriftSqlType.bool,
data['${effectivePrefix}is_draft_media'],
)!,
+ isWidgetMedia: attachedDatabase.typeMapping.read(
+ DriftSqlType.bool,
+ data['${effectivePrefix}is_widget_media'],
+ )!,
isFavorite: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}is_favorite'],
@@ -3806,6 +3951,7 @@ class MediaFile extends DataClass implements Insertable {
final bool requiresAuthentication;
final bool stored;
final bool isDraftMedia;
+ final bool isWidgetMedia;
final bool isFavorite;
final bool hasCropAnalyzed;
final int? preProgressingProcess;
@@ -3838,6 +3984,7 @@ class MediaFile extends DataClass implements Insertable {
required this.requiresAuthentication,
required this.stored,
required this.isDraftMedia,
+ required this.isWidgetMedia,
required this.isFavorite,
required this.hasCropAnalyzed,
this.preProgressingProcess,
@@ -3886,6 +4033,7 @@ class MediaFile extends DataClass implements Insertable {
map['requires_authentication'] = Variable(requiresAuthentication);
map['stored'] = Variable(stored);
map['is_draft_media'] = Variable(isDraftMedia);
+ map['is_widget_media'] = Variable(isWidgetMedia);
map['is_favorite'] = Variable(isFavorite);
map['has_crop_analyzed'] = Variable(hasCropAnalyzed);
if (!nullToAbsent || preProgressingProcess != null) {
@@ -3955,6 +4103,7 @@ class MediaFile extends DataClass implements Insertable {
requiresAuthentication: Value(requiresAuthentication),
stored: Value(stored),
isDraftMedia: Value(isDraftMedia),
+ isWidgetMedia: Value(isWidgetMedia),
isFavorite: Value(isFavorite),
hasCropAnalyzed: Value(hasCropAnalyzed),
preProgressingProcess: preProgressingProcess == null && nullToAbsent
@@ -4027,6 +4176,7 @@ class MediaFile extends DataClass implements Insertable {
),
stored: serializer.fromJson(json['stored']),
isDraftMedia: serializer.fromJson(json['isDraftMedia']),
+ isWidgetMedia: serializer.fromJson(json['isWidgetMedia']),
isFavorite: serializer.fromJson(json['isFavorite']),
hasCropAnalyzed: serializer.fromJson(json['hasCropAnalyzed']),
preProgressingProcess: serializer.fromJson(
@@ -4073,6 +4223,7 @@ class MediaFile extends DataClass implements Insertable {
'requiresAuthentication': serializer.toJson(requiresAuthentication),
'stored': serializer.toJson(stored),
'isDraftMedia': serializer.toJson(isDraftMedia),
+ 'isWidgetMedia': serializer.toJson(isWidgetMedia),
'isFavorite': serializer.toJson(isFavorite),
'hasCropAnalyzed': serializer.toJson(hasCropAnalyzed),
'preProgressingProcess': serializer.toJson(preProgressingProcess),
@@ -4105,6 +4256,7 @@ class MediaFile extends DataClass implements Insertable {
bool? requiresAuthentication,
bool? stored,
bool? isDraftMedia,
+ bool? isWidgetMedia,
bool? isFavorite,
bool? hasCropAnalyzed,
Value preProgressingProcess = const Value.absent(),
@@ -4135,6 +4287,7 @@ class MediaFile extends DataClass implements Insertable {
requiresAuthentication ?? this.requiresAuthentication,
stored: stored ?? this.stored,
isDraftMedia: isDraftMedia ?? this.isDraftMedia,
+ isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia,
isFavorite: isFavorite ?? this.isFavorite,
hasCropAnalyzed: hasCropAnalyzed ?? this.hasCropAnalyzed,
preProgressingProcess: preProgressingProcess.present
@@ -4192,6 +4345,9 @@ class MediaFile extends DataClass implements Insertable {
isDraftMedia: data.isDraftMedia.present
? data.isDraftMedia.value
: this.isDraftMedia,
+ isWidgetMedia: data.isWidgetMedia.present
+ ? data.isWidgetMedia.value
+ : this.isWidgetMedia,
isFavorite: data.isFavorite.present
? data.isFavorite.value
: this.isFavorite,
@@ -4254,6 +4410,7 @@ class MediaFile extends DataClass implements Insertable {
..write('requiresAuthentication: $requiresAuthentication, ')
..write('stored: $stored, ')
..write('isDraftMedia: $isDraftMedia, ')
+ ..write('isWidgetMedia: $isWidgetMedia, ')
..write('isFavorite: $isFavorite, ')
..write('hasCropAnalyzed: $hasCropAnalyzed, ')
..write('preProgressingProcess: $preProgressingProcess, ')
@@ -4286,6 +4443,7 @@ class MediaFile extends DataClass implements Insertable {
requiresAuthentication,
stored,
isDraftMedia,
+ isWidgetMedia,
isFavorite,
hasCropAnalyzed,
preProgressingProcess,
@@ -4317,6 +4475,7 @@ class MediaFile extends DataClass implements Insertable {
other.requiresAuthentication == this.requiresAuthentication &&
other.stored == this.stored &&
other.isDraftMedia == this.isDraftMedia &&
+ other.isWidgetMedia == this.isWidgetMedia &&
other.isFavorite == this.isFavorite &&
other.hasCropAnalyzed == this.hasCropAnalyzed &&
other.preProgressingProcess == this.preProgressingProcess &&
@@ -4352,6 +4511,7 @@ class MediaFilesCompanion extends UpdateCompanion {
final Value requiresAuthentication;
final Value stored;
final Value isDraftMedia;
+ final Value isWidgetMedia;
final Value isFavorite;
final Value hasCropAnalyzed;
final Value preProgressingProcess;
@@ -4380,6 +4540,7 @@ class MediaFilesCompanion extends UpdateCompanion {
this.requiresAuthentication = const Value.absent(),
this.stored = const Value.absent(),
this.isDraftMedia = const Value.absent(),
+ this.isWidgetMedia = const Value.absent(),
this.isFavorite = const Value.absent(),
this.hasCropAnalyzed = const Value.absent(),
this.preProgressingProcess = const Value.absent(),
@@ -4409,6 +4570,7 @@ class MediaFilesCompanion extends UpdateCompanion {
this.requiresAuthentication = const Value.absent(),
this.stored = const Value.absent(),
this.isDraftMedia = const Value.absent(),
+ this.isWidgetMedia = const Value.absent(),
this.isFavorite = const Value.absent(),
this.hasCropAnalyzed = const Value.absent(),
this.preProgressingProcess = const Value.absent(),
@@ -4439,6 +4601,7 @@ class MediaFilesCompanion extends UpdateCompanion {
Expression? requiresAuthentication,
Expression? stored,
Expression? isDraftMedia,
+ Expression? isWidgetMedia,
Expression? isFavorite,
Expression? hasCropAnalyzed,
Expression