From a44e6347c947dc67c7a828302b10d5a9d0ae149b Mon Sep 17 00:00:00 2001 From: otsmr Date: Wed, 2 Sep 2026 22:56:25 +0200 Subject: [PATCH] fixing multiple issues within the widget --- android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 14 + .../src/main/kotlin/eu/twonly/MainActivity.kt | 3 + .../notifications/NativeNotificationBridge.kt | 3 + .../notifications/NativeNotificationModels.kt | 8 +- .../notifications/TwonlyNotificationWorker.kt | 36 +- .../widget/TwonlyWidgetConfigureActivity.kt | 145 +++++ .../eu/twonly/widget/TwonlyWidgetProvider.kt | 123 +++++ .../eu/twonly/widget/WidgetRuntimeChannel.kt | 41 ++ .../app/src/main/res/drawable/ic_close_24.xml | 10 + .../app/src/main/res/drawable/ic_group_24.xml | 10 + .../app/src/main/res/layout/twonly_widget.xml | 26 + .../res/layout/twonly_widget_configure.xml | 118 +++++ .../layout/twonly_widget_configure_group.xml | 18 + .../app/src/main/res/values-de/strings.xml | 9 + .../app/src/main/res/values-night/colors.xml | 9 + android/app/src/main/res/values/colors.xml | 10 +- android/app/src/main/res/values/strings.xml | 9 + android/app/src/main/res/values/themes.xml | 14 + .../src/main/res/xml/twonly_widget_info.xml | 11 + .../NotificationService.swift | 45 +- ios/Runner.xcodeproj/project.pbxproj | 210 +++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- .../xcschemes/TwonlyWidget.xcscheme | 107 ++++ ios/Runner/AppDelegate.swift | 95 ++++ ios/Shared/TwonlyWidgetShared.swift | 321 +++++++++++ .../Assets.xcassets/Contents.json | 6 + .../logo.imageset/Contents.json | 13 + .../Assets.xcassets/logo.imageset/logo.png | Bin 0 -> 18028 bytes ios/TwonlyWidget/Info.plist | 15 + ios/TwonlyWidget/TwonlyWidget.entitlements | 10 + ios/TwonlyWidget/TwonlyWidget.swift | 339 ++++++++++++ lib/app.dart | 5 + lib/core/bridge/api.dart | 52 ++ lib/core/frb_generated.dart | 485 +++++++++++++---- lib/main.dart | 3 + lib/src/constants/keyvalue.keys.dart | 2 + lib/src/constants/routes.keys.dart | 1 + lib/src/database/daos/groups.dao.dart | 35 +- lib/src/database/daos/mediafiles.dao.dart | 22 +- lib/src/database/daos/messages.dao.dart | 13 +- lib/src/database/tables/contacts.table.dart | 5 + lib/src/database/tables/mediafiles.table.dart | 2 + lib/src/database/tables/messages.table.dart | 2 + lib/src/database/twonly.db.g.dart | 308 +++++++++++ .../generated/app_localizations.dart | 216 ++++++++ .../generated/app_localizations_de.dart | 149 +++++- .../generated/app_localizations_en.dart | 144 +++++ lib/src/localization/translations/de.arb | 94 +++- lib/src/localization/translations/en.arb | 92 +++- lib/src/providers/routing.provider.dart | 11 +- lib/src/services/api/api.service.dart | 6 + lib/src/services/home_widget.service.dart | 295 +++++++++++ .../context_menu/group.context_menu.dart | 5 +- lib/src/visual/elements/my_card.element.dart | 112 ++++ lib/src/visual/themes/colors.dart | 2 + .../camera_preview.dart | 2 +- .../share_image_contact_selection.view.dart | 31 +- .../views/camera/share_image_editor.view.dart | 167 +++++- .../editor_side_toolbar.dart | 65 ++- .../widget_share_explainer.dart | 83 +++ .../group_list_item.comp.dart | 10 +- .../views/chats/chat_messages.view.dart | 14 +- .../entries/chat_media_entry.dart | 9 +- .../in_chat_media_viewer.dart | 3 +- .../message_input.dart | 4 +- .../message_send_state_icon.dart | 24 +- .../visual/views/chats/media_viewer.view.dart | 11 +- .../visual/views/contact/contact.view.dart | 26 +- .../mutual_groups_expansion_tile.comp.dart | 5 +- .../contact/contact_group_settings.view.dart | 263 +++++---- .../views/groups/group_member.context.dart | 1 + .../onboarding/recover_password.view.dart | 1 + .../backup/components/recovery_card.comp.dart | 72 +-- .../settings/developer/developer.view.dart | 7 + .../developer/home_widget_developer.view.dart | 260 +++++++++ .../views/settings/settings_main.view.dart | 5 + .../settings/widgets/widget_detail.view.dart | 185 +++++++ .../widgets/widget_setup_guide.comp.dart | 69 +++ .../views/settings/widgets/widgets.view.dart | 161 ++++++ ...06e00ef02c0b9923d0dac6b5719223482822e.json | 12 + ...42d4cab0069c385c3efe8be764c6eacd8e112.json | 12 - ...19774559c6c41add3171b8b47244dcaa98018.json | 26 + ...3b4a6a2c01f1d16b011267fce8a4363fa031.json} | 6 +- ...94b984ec433c5012edcadbd1928f70409111.json} | 4 +- ...7e89b1fc0586c286e985a5853af7bfc38cb31.json | 12 + ...d880b10ee6472c469c2c4a7781a91dec0c68.json} | 4 +- ...f153e1d599a485e63efe48be297a696bb8cf.json} | 6 +- ...9d705f5bd2ecc7b6ab2f88c51df90197577c6.json | 12 + ...43636345cf9372c0d4005df9d179f1004a619.json | 12 - ...e1a0378f6c85fc006f8362df8d11381933d1.json} | 6 +- ...779b5fe4f003a16ba01ee2834f028fabf213f.json | 24 + ...c529bb8b04517c14c6c4742c16b43aaa10ea1.json | 12 + ...e9fd3f3d617907841e30bfeaba38eff12a52.json} | 4 +- rust/src/api/messages/incoming.rs | 94 +++- rust/src/api/messages/incoming/media.rs | 45 +- rust/src/api/messages/incoming/messages.rs | 113 +++- rust/src/api/messages/outgoing.rs | 12 +- rust/src/api/proto/client/messages.proto | 2 + rust/src/api/runtime/helpers.rs | 2 + rust/src/bridge/api.rs | 60 ++- .../app/migrations/0013_home_widgets.sql | 27 + rust/src/database/app/mod.rs | 2 +- rust/src/database/app/tables/contact.rs | 23 +- rust/src/frb_generated.rs | 498 ++++++++++++++---- rust/src/native/notifications.rs | 76 ++- rust/src/services/direct_media_upload.rs | 354 ++++++++++++- rust/src/services/home_widget.rs | 429 +++++++++++++++ rust/src/services/media_upload.rs | 69 ++- rust/src/services/mediafiles.rs | 206 ++++++-- rust/src/services/mod.rs | 1 + rust/src/services/notifications.rs | 75 ++- rust/tests/api/media.rs | 2 + .../ios/Classes/TwonlyNotifications.h | 5 + test/visual/chat_contacts_entry_test.dart | 1 + 115 files changed, 6929 insertions(+), 640 deletions(-) create mode 100644 android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetConfigureActivity.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/widget/TwonlyWidgetProvider.kt create mode 100644 android/app/src/main/kotlin/eu/twonly/widget/WidgetRuntimeChannel.kt create mode 100644 android/app/src/main/res/drawable/ic_close_24.xml create mode 100644 android/app/src/main/res/drawable/ic_group_24.xml create mode 100644 android/app/src/main/res/layout/twonly_widget.xml create mode 100644 android/app/src/main/res/layout/twonly_widget_configure.xml create mode 100644 android/app/src/main/res/layout/twonly_widget_configure_group.xml create mode 100644 android/app/src/main/res/values-de/strings.xml create mode 100644 android/app/src/main/res/values-night/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/twonly_widget_info.xml create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/TwonlyWidget.xcscheme create mode 100644 ios/Shared/TwonlyWidgetShared.swift create mode 100644 ios/TwonlyWidget/Assets.xcassets/Contents.json create mode 100644 ios/TwonlyWidget/Assets.xcassets/logo.imageset/Contents.json create mode 100644 ios/TwonlyWidget/Assets.xcassets/logo.imageset/logo.png create mode 100644 ios/TwonlyWidget/Info.plist create mode 100644 ios/TwonlyWidget/TwonlyWidget.entitlements create mode 100644 ios/TwonlyWidget/TwonlyWidget.swift create mode 100644 lib/src/services/home_widget.service.dart create mode 100644 lib/src/visual/elements/my_card.element.dart create mode 100644 lib/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart create mode 100644 lib/src/visual/views/settings/developer/home_widget_developer.view.dart create mode 100644 lib/src/visual/views/settings/widgets/widget_detail.view.dart create mode 100644 lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart create mode 100644 lib/src/visual/views/settings/widgets/widgets.view.dart create mode 100644 rust/.sqlx/query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json delete mode 100644 rust/.sqlx/query-26099393d47834ce775cee6533f42d4cab0069c385c3efe8be764c6eacd8e112.json create mode 100644 rust/.sqlx/query-38975397d77e89bc70bdbead5a919774559c6c41add3171b8b47244dcaa98018.json rename rust/.sqlx/{query-90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991.json => query-43114af54b919556644128c208ad3b4a6a2c01f1d16b011267fce8a4363fa031.json} (73%) rename rust/.sqlx/{query-b84d4a6bba4e8eb084bbb6b6ffdff05df10bc93c4ff9bfba248f376439e268a1.json => query-44d1e907c77acd1d5cb5762c778494b984ec433c5012edcadbd1928f70409111.json} (59%) create mode 100644 rust/.sqlx/query-463452781891c7f94456ca4869f7e89b1fc0586c286e985a5853af7bfc38cb31.json rename rust/.sqlx/{query-2cc4a73378cf37ff6e3dcbd761fec56686a8f9820a3bb2c489e94fb036de0786.json => query-5bca89eb7f5df3a6190d8b343bf4d880b10ee6472c469c2c4a7781a91dec0c68.json} (55%) rename rust/.sqlx/{query-d50a6ee6ff69408981408585348040d91c2e430c936b91bd02a3f7f562edf658.json => query-783995c916cafa69d2aed5bdcbb9f153e1d599a485e63efe48be297a696bb8cf.json} (66%) create mode 100644 rust/.sqlx/query-785424639a0aa856ab9e037a7949d705f5bd2ecc7b6ab2f88c51df90197577c6.json delete mode 100644 rust/.sqlx/query-9e596b383a0af665ed0645792af43636345cf9372c0d4005df9d179f1004a619.json rename rust/.sqlx/{query-2b86ca80f38f47f115c8c8e6ce79343574e6ca435946c303dd8f35e68bfc57ef.json => query-a34ec4535e12998589d315b60e1ce1a0378f6c85fc006f8362df8d11381933d1.json} (67%) create mode 100644 rust/.sqlx/query-c86a99f1f94fc29cfb5e270f1c8c529bb8b04517c14c6c4742c16b43aaa10ea1.json rename rust/.sqlx/{query-e569dd744fb4a5e49cfbe14c61005d14f8c0f82a8640269ec29d5e9edabb106b.json => query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json} (75%) create mode 100644 rust/src/database/app/migrations/0013_home_widgets.sql create mode 100644 rust/src/services/home_widget.rs 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 0000000000000000000000000000000000000000..8b5e60841733d4b82d76f0eba36e15126f6b08cc GIT binary patch literal 18028 zcmch~w5`1!~`Nv7{3+8fDO$o|vIWY-A+>pA8;w=x;*#TO&KN2sIZB4&pW+Q)_#x@Fd zFm=5=`{U(HE+1aF{&>^m+?k^YN9>=gVhU^V3UCC9o*RvYaPJUvSD*bdr{4Py21hu; zQ`Eud;4c)t!8J5woJ|P6y7&Jgt^LB-nO!lya5R+gc)slb!f`m9aOh$Esr^;gnCKz? z5t)m75V+Ci!B|L2dEgv*19Jx$=8jR>4(zfhL@5v;a4o~_0Ke7o=1-q({j)1{wX;4^NmVxJcY(<}i<^2i&r31+dT6ynd zhkgKqiFv{vCja9!x6>{3e;OtvVEMm{_e;;5hTPCyr}hcv{)B~&l}K+~-WQJ)cfR`z zT}T#dt8)Mq34`S;F+aoYLA`jir!1gz!YMBH6gQ+5;BIih8DYSgsPfUHWUd0(R;YfA zB+QAj+~VL9K7c;s0!Q3Fa$?Fr;&0|+4p{dDuujD%gm0e#P4x7T^?Mn<1G&5fa$y*H zbD&ZefH03wg{yPpu%eGNwNIG7fFB?cD?M{Qoh0U62s!_=-iVh`eb^2p~>bH~s);JRELL7@0WW?*C+Xkk;cu z)^SBFnjPm0YZK3SR;YI%V;Eu;iHA74kU*7P8Hn&A+N{ge^pDSSL*H^E1`nK39B@X> z>f%@SE%|>r?8F0kjl0gYPjTRa;aY3KF>yeP@jf_On3Lc53K_~0B5lNTHT)VDuM12o zGiCdLtv`U)-$Qno9GKRB40{L@?oV2rGyhGMYH6ki~;ubJGbSSaSB?Z&lzsQ=|* zA!^dS9*r)EUls6{(2AQs3mKtQT^0WWWD^0hE%>+tB~<|?{BPJm4c}4l(AFUh5dd_( zn&z#{0r`VKZ!mXefv)f2^1mG3VuUJKFBw?6hU}Ypn>wDxoZq1N!2P}j9JwKsYId-J z7TAEl=Vr5)Kl;N1wnKXypog0GIf2FBg-1}NbS2lN2M+Ip=t6Q*;iur=)b_dae~#oR zAfSWQ1Q!^FLP+KU0{`iqxETBh!njA10mxlgh#L|y@}k&hgdXrb`Z?v!WP^T&9hBVr0r_R!Am9+*Q?L9<;v3%tny z0$s3=CU<@e7AiKVwm#flT6@j6e$She;G=V|;pSVvQ$oP9@2AD)IHMX>G1Q2o^biHJ zvi^bOP6DN6Xb4x_hlK(`k5Vu=_dv}q1EoFrn&Z(v&*`tAp+mP0negm;qtC3QdHwC)y5Atm<6h_n*RU7FzzVaud`U?INgX24A%Dbz`G0c0ha4I)t=r* z35ET;k1X2!9I(KT0qi0$xFWKAK-lJvYbaV11bZu!XHOyA1-Meraq&%h?&sQQ=$gSr z6Tmq9B^dQuLVN|P6R7ZXSLfpcGKOegAI6$a$8D3j$wORTUY-s~^4l7!D&N{1G7Foi zpWfZ|tJ+-}atSjPr4C~i;DG8~qbpgPM-3ZqN zTX4ez%nFoV8!_ASrM}Syy8r&c1lbn9(a6flIhc8V@_pHDW&~5r&&l}32K@UoMR8z$ zxI=Kgtzcbs`V*ID(P}_NPZ|~i0fYRn8e<4UR^MMfyamFsjVaj;!(i2-DldEuPN^Js zy$i(FOipH*SUtSFHm0}c7vxKHKPPkPQS`;zE~H=~5BjFRWEt|BcZvGELsSz_XZ9(= z@*N$Xg+aI=q)(}&=0Xtu;=9bQ8?bymxo5!;t`3>K@h%YxFz;<2n<<833LL%2r0t>% zR<0_X3B)g-Zn^-$#IENozXps*rZHF%k~o*-=s&nSU+Jq@qyQfP6gYln1Nw#bhh_O~ zKjY`9wk4b*=^hq>a&O)rJ%vt5z!LRy)3~TkA)-VV%3(Cvr5TP`ya#SJY^d^AvzZrc zSk#w#?J%K!+i%xl{#)V6e0?<3FJvhLXhkp-uhz9FQodnVLuONQG8b$YflQnFydIvW z(*UC;#BV;9XRID^=n6{qt)&sH?F6tLG!izPr^GB8p@=HK_8zB+r%CYy7KA$mzplV& z{xUax!fM3q5(`|XDZeL?39{%`n+K*q6gFRJRUvV6BjS!m1EQ|_xS49;H(8?v-9J|w zZini^=fhRIHzJwwQo0?CfSxTi{8X~~M;$b(pW3ou7X%I~a@qt3>VXSo%(GK6FOLhH zeE}USyeJLQdffQ+-Hj<$#^J|UdJh%5VFx5WC=@e+Kz(H)>%fXz**0@ov=^&3p=fh^ z=cIX+>zU`k2HqblRRW8P;GDa0M_3Vocv=sk89^vl+tLEa;|YGJwOjmBf~db^ZLz)3 z+U9_q-|r~}&f*^nJ>OY?0>$u}Bb{ix-#ywX9-6PxWqbbdGtj-h+qO@D4rl@cNKeR; z;6vmZu2YS&K$?fha$*Mf^vXk@eCfY(;e_Dw$9+`uCMx0PKWo@nVDNRYXiNOiMK~7? z^{p_J3By4?KzxE#!>6k%!y*FM@9NoTKwE`RQx%r{FlLzBep&F)7uyNWfEBV|esWRVTY-Dk? zCe}(VBGHLi?AF9))8=WEh2PcNq4K|%93cR7zgg*;Rm3lS`=B;JWHG@jNE&4$zp zXU!yz@!duytUg|w`Qtf&Sfcj~gA2azy?i0H{#VPF? zcZkn#j_ACrezw-Ko7#69DJ!UF?hVUFGtlACS8-{$c?QK;^f9rNK>yB7n+HBi9v<)1 zYEQ$rVhxK1`5Pm?(_nLi^5o#u$icu$VId#=$fE0S(|wIkOl)J{adA){>eC&i!jGEWWLN6cuZ(8TArV{mSCz@o;ne+liw%J$S&7CUvV? zt+cJ@8(*#3Qh+31SFC;qZXPZ@c^an-rt##;v1fS5_VdJeoFLWBOqf?#bB6a`9#B)i zE~_;J#f%lSG13y+5Qa1;zq8^B4njKgvYTZ^dRuNV9v=nrw35NjI*3 zj(}p`8 z0Jgq@v8|P(-Ij2v=%}|YEw1ut`q$4}uJnymXs%niG9^OKnnnj-cQGM!qGr7^LKE{i zDa?|O@)#M&L&4ckwHQ1Y@~5Kx1W~xjs~(O}kWxxLemosbszBh0r`xM_k(qKD8anl> zW+9`Lap@!nnT-Wyp4kk&z8$}=xO%)rf*O}9bmHus2f_`-WGL5juGdoHBU|yc54#)_ z9j&iQ@c9<9l&eWO={o9=eZz-y@NOiHU*y+^ucgnTa1U)u>`tlbE#};v&veUuhQJ%I zwD;A(RUMgvggWhQl*}(QoS@S+__=mWs@_32j3V?$lG<~wY2Y>GJM9mWV;#eGcopP0`tASvp> zJJj~@nq*I>vVlOnYP#a8E;<5dr+I5H0Q0>-uf{9#P8~8~hF`cfd6$be8}4<;$N8{I zugRWdZeK=|jM6*RChANjh$N(JdtUXHW!O$$a6`i*;#&FZLl{24s*Cb@`|P z9#dakQ#I zR5#?$BL?^@R$|tiwXgGpFIJo{4Khd#zs#VGCea$aE;m`M%qst~`Dy$~9>ZS?+`0h7 zBJh@HhWXf9fyxun8SirrlBRe~_j1qflYD-M>_yxXk6W-|n4tOrgZg|a8r%}RX2?^< zTb{{j)c4A3yGLn25NCP1FK&J=dImR#Q7dz|q0uBe?E5G4h;mp}&(;;MX0ao*P0D)? zjVBo0PV0F{wnB=R@Yh>Kh*YS~MG^l9_P49cIuCA+<(*RU!)0V-C^#1N&9AH7MW`cW z12)sO^)X?oQI`mzdUBk(E=U9TYI)%^%7$ z!ceflBdvSP^gEyQa#gD2g6(d9d(s$uoPsdxwggWb+lp_Cf}~&rJa#CQzxY&)4-+Sn zUzG4`J8hnV33}Bkw|7nGAC4tT)(_lJV5W!DUJLV?r|{WW^4qPNt!UkO3C>jNv>AO% zh`=gv1F-cD+ZFTY)k!m`+>=&m@e)78Cz>)B0TB4+CDZE&lN=qmbx!gcNgnMY^7mHn zfDp_{GLlZ86)1KqmW#`fv{}2mGuFJjv$FW}mmNNp@Ku}@Vq_J)-;Knl1$E4R1Z?M- zKRW@A0hfpa^J@{+7kghp9p-|TS{{5B;%R3LTUwS`&?w4f^{h`lysw@^*q=r z`rTl_V6whG$?)2#J4A)2^7QR@@6tJ*yaV;?tyw;DqX3Be)UZ?>?kQ}viiS*2H zQbzhsV72u*p$lsKTG&}|AERoJO$#U7MN0Pj!jFr7+q~@NmF3DMW+01C&g=ea_Ekzw z#(KWzyKtX=wE-+aZXuTgumy|Xlncwgbb`2ABi$p*|d!l zIR?*x^Oagz(qa{==6-BxT&$9J(}k`X%bH;LxwpCw`*B$}C@HoHnwK@ItVJR4b6*b= zFQcK098o|>NeW$M{Sl={ayC0wpY?QTiDzBxn26wzGJ=+Qo?EOuD_kkRdh;Un_H8}x zKGh=UH&KWDF?6g3bRYtd(k8o%3+-M&J$8Iy_lxdB_RH#|b0^)I;0peid57mQg_%Z^ zYa1M@hTI54q^$HAK6_C}3vEVvu1P_t2@g)vHXkFpR}J}JHTaZkBEkvCa7&cyX6M?7 z3Y;GBb*!c_Klxp~8HIgC;Bd>u*9>FU)hdUY%flpxE399}ub6(g)MuIC{kJorqE=8ctolrz#0N;s#}%b^3=TYq?mfY+D>@FFMgR+o&N zb9!E~a$E8!C3@)$(WkdV9Oe}9(m?~oRQ_f@Cp0p-;wD$2kd(F0u|I!|Co1CiF7vsN z2(-&xYFr>|x86f)_8n8sFFO}nHn%H%&tK{^tFEusb5Lr|_m_D^th+o?@<--sVJr}E z#jy!J8E~*j9Qz$*8qk?n)HfTQtRPvmJ?`#rM874|5Us!S*#G7s4P{4VTRZ)Z23TJQ0868bC(a{4i3eavz| z@7tR?q74*-7uLpG)xH~HV;B9Zm3V;8gcju7U{}x6|Erz4(Q2D+04;xOxy^WA#*t|| zjkSa2T|CFm4rzVbtE%YO_i%~yu!m^;z`13wWV3{=6WfP8pkRE+4(=gWKX;#Zu|RD&efE#TI$2ZG)^y%e!>#nV~MvY zUJY0W;a_AWW=wsu;Ey1>ajy)Xn#^46WKF2B`=qf6CkRH0$hZ99xtBiIN$X6%cAJNI z-)flsg!fec{kGksnutpw;f8TCYISf&@K7ZDRD^@^iWhAA?sKE`9$!PP3KW$KS7jWt zhfG`6?J#k68ScqRxp}40m)(N4IRuNY#_tqn%BHV zXttJoslF`nS43sr=zj4lT);G z9V+*cNWyS0h&PpAGFuh%rorCPcym$>anwvI;?Po-Lx!o($-Y+9HxNuSvJ(oWs0BYW zEC+Qe_Z}brU90t*kPo~-Ow-{4Bu#dyF;4>9PGLAz)qRj(OO^FE9SIQcm0hI$K&8C= z_RY@|F#}$iaHPKf9f{y#x{5#4Ie(QI? zX$mgLGDB2mc8nD<8v+)&hmwTkj_^aPon5PzxUZ63jTRA$mRaihLh0FYe3c)sFW5T2 zyk}=Ixb8YERJ*IX>V~stoQvO~!3I6O?JV$wE6e1pS`4j2yY_`T(FGr7C@<-w%xW_0 zj+?9F5T8yTO{l<%sGhb)UDtD>O- zUu%`WRUag?(?qQh_`?_E*EKe>u6&=q74qP0R~l4&tHk249w}5ens+6}t5##k@Ty(fXEY|<5qjrlkPi(t9;mz}>RsdX$ZLta9`>!|9WU$18;mE}fB zr?r70Gt`Vb1FsW#u69V->{?dm=^B1R#>ykwkn96K|BnvsT|=U3oWsVp+ANwryx#x0qI`?V!~E`Mj6mY>Atn)uHVpmrslsm+CQl`=17Y*=rH!2kF^|0}WnyU5pYG zV!festz8XQN%QyTlf{+Pp|GKC(twSQagkP3qhlpi+lgbRxwoFZly^^1TA?nu%L66F zhaO)OEM6>%EX;w zX8a>-&q$Z?(friJt5l75mpI_{a6&kx!%=PXyH0st&iyyWE)HYZuhyh2-lnd}F*s#> z{3zs}(2s%da4B3*9y&7f(z3&8{3c8BwQVNog09eQGuFfYALKP}ztdvS7Y6S&tT_|| zIS&SXB>p@eZ^yOwxMqNRamLJXD@N9dQo5|gI%g`wOx@RkJg4iXh8i12n`HD4Y5{Bu zk5Hz?mC3Z!iNrHiRo>p7K5sW7E(m?gQs3Y_?YrbHwB{<=29NNs9n3!(Jngzv)b*sS zC~;(1w5ubuLcCyL9Nf6Y{=9`ImF?Ql^pLHY`7>`{?G{wMtcOPluj*PTbbaA>9m&n5T^Q8FTBWn;xMxfaQ0ckt|FMsu`k;-|>=gbL+fh zxXX-}*1Nn24sMNd9506m?fv-(ywAA$BCDEObw|QgH7cvkdhk959@>1s;c@Z$ldmwR z{;8oS=c`P_uCbkp@-A>9LaU5gw%&9#vykb<7WOIJtsp_{S%PvS@YqsJ+ zPwq=BWbh%5qyFWB)vw2?cS)P%E5wzlR}(^wH@kR81*f0FoT`cAKQXEC%B#TF%rKv5 z^B=}Qi+JTzxz>eH@wC1k97$}O&zo_6ntg0Xc4PM3s|ST=pugFD`KF>?xPBjw~5g^?AiJ=P+_O=uPCB^c8X}N`mt;6$i=krmYc)e zqbE1A^waD`-{yvQ?+lUS)Awljr2wTRDw{D(z@@2;7uqJE#BY@W9D zZ3wBN6Sxmub6?UZZ7`IFMA&wsiVICDVA;i0Q1RWaDzo*?QEM%d@3|;Tk>~2QzL`)= zr__Mp^iR9ax5IfpCJ`+ZeY@aAERJp-@iN!g_`}Hg;dx7>m<9g&m-D9~(@PwiGx1;e z=q}z&l%e~U^@IXQ9F{#5FXHjSSa8dd4*OfaBxYIlR;KQ+lieff-CzDDUy?chvbK-L zKjg=+UPZ__YtSsba8mVr$mE0J`O{GP=3v1!nAn2nt@99HZhcpl%?~eA)sMU0;K3-& zgXK!8t#heYtWw>iqTeTr8tDw0sV5X_C0%d7ekt~9p!L^Bvi>%MjMLhN=v=C4Elc7D z2Ja_`LFuKjrb zFLRgEOWvH8kG`4nNxrn0FSj=JjoilC#qowz9BJ(wpz+vvzS@t{yUs^lSBOR1eE;k@Tf@;k^&?isABTK-E`tOv<%;sIz#*2z0cW+nq%Z)~(tvxTsZW#+Q zK(D$94fjR2Onw@9n8BQWj)=N_G?oWWWk<6JE&v@aLBCT_U@1$gNMx!Rdajf9R}mED zJoJ^y{2GzP89rM}d0J00{P0G>Yz|bYy@g@_U8@%Ld|P+r)23N$0hL93UdUsXj&tXyi}GW=!zblFyXyTL*9*ep2LL2H*H zdj;3&>@a6<4)v@aUi|I`Cotsr_OYP2@3+2!uWs`F9yg-)*I%C56b5g15)3RQHt7`~ zRuaOg?YMTfHGi93v-Ay&S*W{K>taN*dtCBB2b@B?TK#H!Z-@-f)1HDk)z4hYS66C{ zNWJQri}GLNqDuduN$qn6K)ZN}razxkS7?lG_GgOWws#G?bq_N>Fe+Iy)ph(T!&*Vc z=g6vp2dkT)bjaA|E|b~zRrd?*ax0Y;p5L~(um6Ea^{vT+>%OjZMx3I3qh`ac4Q7q& zd1vQg##V>HY{nh8rays_ZO>u(-%FUf1gjykA#e*-?l9b? zz*?WUbbeU#;=L;%#dvoKbF`%|(fUtrb9?9gZX0_h4e*w#8!n%C=F{C9BXZYfh01r? zl^6%F)FSbM<2Tdmt5o4@u{vfXnN+Lf0iZUHndR&(71&ZK&V-lL6oj*e+IwK#SvKn4 zMp(PVv2m0i`YrqKX+y5`re)p8qi3O`Pky{S2MszFi6#38yNbM%@(-+HQETRMcqs-2 zzxR%GIn(PcsL-N6Jpc1}!pzW`|K+p4@wTq|Q1*@b>uN8ER=XzRHRY_rORv>E9z6qD zXI?|g4y)Hnp2m0ibLvXnV_!XHUaEk1+MDlF2ZY_uG4F(HVMPnCAA>TgtaAVw5;jcd z<-O8_bAJ59^~fiKI)^-~GB4wafGK!M*2t!WhO}sUC3MAhHJO@WWdXxHbyHakhSZ*5X-h;chy*$8Pa9L7khT_QPRT4_4-(*1>?*IiPFx$f)9=If4dX?2k>PP z?)Ph(Ml!o^_Al@6JC3<5kP$tLRB}z?RoalM4pA$^CQAo;4h9p$~xgP(U!u)fW!f;-D*H?q{v|&|Y*1t5`)@WF}Z6vc1mI|G9 zjWA-{&o~EJhp|(h(4m#PnKdhuf5MU^{I9QeJJN#p{JN(JTUK9mPj=E}^(=dEYW=!& zemY1|FE8g;K~trNUBl{|kRhQYog?2)ZJjk(KBUY7fooSZ*Q?yQ@^EdEjP`UvJfCS$ z?BX#gXXucN{OKeY>+1#G+ksVC0ju+XJ8r2QLr%ERnb+)FB#madyX{8;>}_-$({HzC zfo$;hHe13woxgUE{F3eOcDWBE|Dnl`hmNJ7a!Q|XDY099d8;e;w(yk{q~)0w+bQ&Q z{qk89qz$z%uDFe55CZ6>YNu(QHuh}Tqa~O;oU# z`wq$&tTKN+s^lwEui(&p%&zz51JOy~2?gPGy=`ncuk40qsqF@_{C5eCSj5<1A=7@Sz}CF&8{lNh)>HO7MR&-kjA-)Qx_R`4Y3O zBsfjmMwyXpXF#E%*~CB(jTcMG*>=Xahbtbg!p%=~oR4QhVQckaML!sv#$0Vt)D!2X zP9#mq+ddE10nf|A+I`tDr`1eVT)P>k5-xs?81Ix%koDQZ@SEV)T~<%TP5Bjetezz5 zfw^K_FHHkY&=gG-X)fc5{b8&}6-`|=>=i*}=nP z*nFD@p+(0N^ta)si%sU{&z9aKJSQj(iMUB{cqw1wa*pm|#>ng3$xbsnus2=7>qpX%>fdfb~^zH2kW3`&!4V9dv$$>i-cVK#- zwKPsvV`}X$MtQv2agIoDO|3Wij0CszaDrzUy|-1l6fKq~y`@Zx3H(+fT=nSXq{i&7 zZH8x7RLJ7cV_L0?mRx?cq)d`QS-HRmkiC9()pqVk z{9MqqH#TqxCIGD_9*s5>^;tTyE=9WeQF_L>cqig>xwgltKSPb+^61CaI5DV{fhyUB zdc4#0hRZzrnPJTQ#ZMeKHnPW*<}1HlMh!-hTohHzMeyw(TSBHw^DR}ACxia#r)Fk@j9Ef&ZyOYqQ`20^f|lm5~FS) zw>98cbb7jS<6$$2oOD02R{0kHS%1KjuOFU;&8su85_eiqtGpVrBdgCT#{rJn;V4E=B!a{L0M{AZ<^1@hex84olf-XPX1 zZI^5-%&l#5F$mdwnQzQYqa`woN6UACO7vIV;AY(ve`>&tbc1NkOv6BFX8UOc(5mH;1)S|_(Y!8&R>3} zOpHItg<3yB%}#-;CV688FPM9fmX>u+p1R*sBFy0y>csS{ zO)?G2pN|sv3RtUpu|U~rI;=eEs3qKFM(tY>xABQj1ET;FXWKXY)PVhgs+&DyqE&Gx z=i%2uG3WpfhDuCks z+FqG}Bm4TNr+g2M`(lxQ2dC*@wp1%I8(0Y>aSH3$Qp|#S*;N+KHox}xJlb_^$+<;( z_3_~=y!EAgggEEs)y1>W`V;^ldkkkndk2xv0gk4`Y7gV0*p`I5UVA|39@Yz`lgrbc6A_bcf zNrLyGen06zn;WP@H-bMW5=X=wCS7Hp0KuhcN0i2rtfqB$Qb+i9*1j&C|LFB3*ZYpa z+pr^)!xxfuvVr>5e07Fh|{d8Uq^moWg30MP{Qm8Z<- z&y}K!H)R}EEKB9GK;d4|a4hB5m^GD7bisioeB|Z#<09x$OFp{0w2(E7!i6$zY~FF? zS|vRK?GG-%%U+rr2#?$H#p=Pzn!`NkXmJa+=kwKShRB7&L{RZ&U~kIgacHJn@oDpq z`Cy$pl_Ixt^^}=+4ApHz@c&e~)8v%`<)Fa;_gk%8t?f^2J=7AW1@g08d3;`7xp$(AHJ+YyZ?>Azp=)ws}A)9YzldrDjX- zs#Ybwi$Yxl{xSZ>H{17k3j$qdregMe@oY&|y^kTDNcrb#mAKGUJ3HVVRj17+C~a3# zCQkTX6wbm$1S;*84K34|a3-E~0?*y5)`|u7`0EM^FVFc$p>V3>lc{$qXmRKMj--n4 zlh)>*w{#;4(5%!=RbR3cC2_nFI5S^ajVTG85lMS-F~4VpMfT-B0sfoU{XtC?NSPk7 zPH3#r$WgMu^R5N*ZdPX7?t&|3Khg7P8nr}t&Q0-WmefECoMPkUD8nuODDybmp3C9a zqsaK_?`9t1tsJRrjhX>)){Nfue*BjVdT)KUU@52e#ncXl%Oy*yJJ+%z)}oe{-SoD!I)1-E|MTc^ ztC>VW!rxN}zjH>?opR-0xOPil;zbsYJttc4x}$4PS}=TZ5cO{L6Zawo}zA zUTE3+^0eIsLHN*#59>jCO6}QNUt+jkDg1dQh^X`ubrtcEtjk{gA_e=R$o&DB^k*mQ zvI@K)!6>d>F~2rMwnZ!JHIEbfOhA4b&^slp*QQo`+i5J-7?rSGy!NwG zV}6V8(9Yu0EW#Spp1t@P=I_6~oLgUZVL4j*FAINC6r1yEH4XN+kjLBIS6s)FCY)cH zv{Su~X3m;zj7{~MbfzAajCfK+U%YLWjus$_ml)dw;@9BJBe_s9LHNk>gj29z>GQ># zdCj)8en0io{jxR^oiTc-B108a5XQe`Y`seVOS1DDr)%&o;0LCdckcRx=E5txNK0eE z$5+Q&0_3NBUB9LIDiBg}zAN*|h+2mZJ;m+Qe;guy&GlVhruMR?wFJ#$gCOMz)p}f~ z4^z;SYsxflZ(I9WFe6BaY^;lq?7trW(aT-6yApvq0m{ZBo-%_%HG==sOJnYsS8^i* zE|kJWG{sbP5~6VCLD_dP2pX~ z)0|bC#%%f{u5rHNNeALD5UKV0Lg=nB{^H9{vsU=U*W7&5ZlyB6qCT<2BnOhxY5xdX zAlejxP_c12>Jb42y27gda+F#(_!9fin)6^JyC&!&|0Wbe9xXZjrku!Vp~+>~z`T2R z^rUXvUbV9b0q$TWOG~MikUO* z!w@J?$O0AUIVx2J;i)G)k4!FL274y~YUI;6EmO%DeGon>?p@ze=xTH~0n{dgRw1iA zLf{T9j+-nKOQyd1kQPXsWo)k2XIJ!b0rLHLmycJwsANhQNdAJ|I(Uev%HR z>$C_z+a{O6T25yBk((*-vwTn-(gcx|l(bVi;8^8$-#GOJB6~|UGq{pmvc2~((_mFk zE?1VkS{Oo%&NeUI%)`4ac>{2Fz=p#k5ywjDrIb7y&IMMTnK`ThBrgGXrbvmlb`U&~ z!ptLvX6Su5TtDm92VOancgS`6+#k^WPz=+pqAC`8L`Uo4EWi626U?r0Hy_Tuk8L|H z<-7zp9~rTnDWwNJFp^n*XByKxKzS>uYKJb$q@z#)Iz6o?Ix~m%ngY<=fPKfx(UE2R zh~V5@-Kw^+pO*lWjDZk9jY=5~%iRNgbYQIPDFLPH2#&$mB)?>ULYmK0^`0~r2S?8(2SnB1G&Ztn*n^bU%{OW`ky|QlIbew1 zii)RA&3w2nOFo6sF!8oB%t@)5<3SacyLf1d5G6n0O)g>oi)so(xm=!kZvnLQTubtsSv=k2@sfMyS2u{oCIV$niw zrz`kOg0e{UJa{|-ROACMQEWAE_1VU06!5tW<9Y8W-5kX-l#<<#C)O6S0=S>RmBwwM zjG<&2VK&O^LGn6}LB%uT7trzU9N+a9(VkU=@yMea4Xx%BWa<=v87JpNQBaptKf2Au zjKR&%&%P>O+w|R>2U0@`63#L!(xw{CBM@lDyXxfXvDxi_y!qo{q$QVDAs-`WMCIv> z_ljK1&r8Ff{`PYQ%CSIgPTHmMK?U_`46k-CJvyBy3ZohQdYYHB!*Kop*Cuz&6h z?7sNR&jK`DbeQ->%`B*BhJu4mx6&DaC$UUtvxIQ8i z`B;c$HBposYRSWq3#0d1V}keYaX7-rqyjdHfwN?S8HF5q6o-Rz)*b%p_C6ojVNd(S zv42z0DG?x{uh-2Y&nHj&ABOR5(1b6m@6-3XdoY37FyP+6h*#?Euaft)Lm8YfNNu@% z{3=MPb&pi=(4+CQQfLNOa-*sVpf0zZ?O&tOab!7tlfjphzATVJ$NBk)CZ4{%mL|~8 z@F18H7}FU$P!3PV0v`twhPa24w*PkAH#K(Ye<<$@-z8jnyifRn;j}nz3}I{@CA2<5 z!2+=+L&1AJB?weDKR1-C?{bolUJuW9?H|eH?&Cq^L$&F%*}ngP6H@-8+hULY8Ni-Z znkj{Udl5^p8BS&`hhS~$%6lCC?>ndebH5e2TkF94?$H5yKmMcrlLc5Ls}O0)$yd;} zE;E{=VA8noGv(#|)IT?Uucr@f1QYe_^~&vwgNhwzpC$a0qXGF9;4?8;6Es<83D|WH zO;G=*F0cQ>uEhtvV?cXdfQ)xhq{o5kir}waK<(@X8^^vf5IR^l3;^o%I{2ISzIpw2im3rOo_Kr`Je^Q3Phs|ogA4TG^%NVoZ2*M31C&$Dmg=CD^DEFHi%vz+12k)aY24{NtU{($4Jm7=K<;SY1Rwfp z6&|d44PtNO>vr^8l)=ZxH=hV^i&=cSrJ+E53*2Z5>2?^Pt})WzP5&t)26RIU2nyV9 zkh@o$;WaB#c51x&;lOQcd&ZQ*PkXVlwn3k32led{zISWq}4P zF(+r&gZAq-Wsp3+$<*9u7`Z>|e|wnl5r5MR!(f*WCyY+srzN*ee=W7w`sU;TpkD-R zW+(H14>ti}ZKCdb$iSnLZLb%(iBT%~c+31H%;)d~O*GV`cW(QjMbH=|E~4<>#D5F9 zpZ`yWX;2=0COBTU8U}DpM6Pz<*E}SU9$fVzf=r|UpSkKM1I1A~H7HDP0#o_I*g&fLPc}f%OPw0m_aNcD;7M@T51`W~NH-NRg(_bC_2JzMum=HL zqg-S?ki-9uo@?vj>x&zrR!8s%@J8VFFHnF~6gbNQ-D~~_gs=iN*c(oX!ctkeO#krD z-EB)1TGgJ5pkjnF7GH!X#h+ZU~Z{X*DXy+&NGiFhk*y4!}$piJH0_g|1 zggsdD0-*i9>*xRD;b59h(u0sU_ud^&-+9+?U+f41jznh+{{F2omz}V^CpZvmaUj?t z#r_4`=a=zMmE&&8O}&RmSH4#rc-^PKht<)rxk(9C+8R4I-QiCOCwGkFjewi+`EUNw z=@Ml4$H4yEG0@CPw#4VAga=TV0Qc+5*-z#7Tg-1DG$`=`#Ty{}>`i(s0D8v%+b}|! zCJlwLS?R5N9tb$S{o!y1;yuf-f>bincyI + + + + 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? preProgressingProcess, @@ -4469,6 +4632,7 @@ class MediaFilesCompanion extends UpdateCompanion { 'requires_authentication': requiresAuthentication, if (stored != null) 'stored': stored, if (isDraftMedia != null) 'is_draft_media': isDraftMedia, + if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia, if (isFavorite != null) 'is_favorite': isFavorite, if (hasCropAnalyzed != null) 'has_crop_analyzed': hasCropAnalyzed, if (preProgressingProcess != null) @@ -4503,6 +4667,7 @@ class MediaFilesCompanion extends UpdateCompanion { Value? requiresAuthentication, Value? stored, Value? isDraftMedia, + Value? isWidgetMedia, Value? isFavorite, Value? hasCropAnalyzed, Value? preProgressingProcess, @@ -4533,6 +4698,7 @@ class MediaFilesCompanion extends UpdateCompanion { requiresAuthentication ?? this.requiresAuthentication, stored: stored ?? this.stored, isDraftMedia: isDraftMedia ?? this.isDraftMedia, + isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia, isFavorite: isFavorite ?? this.isFavorite, hasCropAnalyzed: hasCropAnalyzed ?? this.hasCropAnalyzed, preProgressingProcess: @@ -4596,6 +4762,9 @@ class MediaFilesCompanion extends UpdateCompanion { if (isDraftMedia.present) { map['is_draft_media'] = Variable(isDraftMedia.value); } + if (isWidgetMedia.present) { + map['is_widget_media'] = Variable(isWidgetMedia.value); + } if (isFavorite.present) { map['is_favorite'] = Variable(isFavorite.value); } @@ -4673,6 +4842,7 @@ class MediaFilesCompanion extends UpdateCompanion { ..write('requiresAuthentication: $requiresAuthentication, ') ..write('stored: $stored, ') ..write('isDraftMedia: $isDraftMedia, ') + ..write('isWidgetMedia: $isWidgetMedia, ') ..write('isFavorite: $isFavorite, ') ..write('hasCropAnalyzed: $hasCropAnalyzed, ') ..write('preProgressingProcess: $preProgressingProcess, ') @@ -4852,6 +5022,21 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { ), 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 _openedAtMeta = const VerificationMeta( 'openedAt', ); @@ -4933,6 +5118,7 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { downloadToken, quotesMessageId, isDeletedFromSender, + isWidgetMedia, openedAt, openedByAll, createdAt, @@ -5048,6 +5234,15 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { ), ); } + if (data.containsKey('is_widget_media')) { + context.handle( + _isWidgetMediaMeta, + isWidgetMedia.isAcceptableOrUnknown( + data['is_widget_media']!, + _isWidgetMediaMeta, + ), + ); + } if (data.containsKey('opened_at')) { context.handle( _openedAtMeta, @@ -5147,6 +5342,10 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { DriftSqlType.bool, data['${effectivePrefix}is_deleted_from_sender'], )!, + isWidgetMedia: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}is_widget_media'], + )!, openedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}opened_at'], @@ -5193,6 +5392,7 @@ class Message extends DataClass implements Insertable { final Uint8List? downloadToken; final String? quotesMessageId; final bool isDeletedFromSender; + final bool isWidgetMedia; final DateTime? openedAt; final DateTime? openedByAll; final DateTime createdAt; @@ -5212,6 +5412,7 @@ class Message extends DataClass implements Insertable { this.downloadToken, this.quotesMessageId, required this.isDeletedFromSender, + required this.isWidgetMedia, this.openedAt, this.openedByAll, required this.createdAt, @@ -5248,6 +5449,7 @@ class Message extends DataClass implements Insertable { map['quotes_message_id'] = Variable(quotesMessageId); } map['is_deleted_from_sender'] = Variable(isDeletedFromSender); + map['is_widget_media'] = Variable(isWidgetMedia); if (!nullToAbsent || openedAt != null) { map['opened_at'] = Variable(openedAt); } @@ -5293,6 +5495,7 @@ class Message extends DataClass implements Insertable { ? const Value.absent() : Value(quotesMessageId), isDeletedFromSender: Value(isDeletedFromSender), + isWidgetMedia: Value(isWidgetMedia), openedAt: openedAt == null && nullToAbsent ? const Value.absent() : Value(openedAt), @@ -5334,6 +5537,7 @@ class Message extends DataClass implements Insertable { isDeletedFromSender: serializer.fromJson( json['isDeletedFromSender'], ), + isWidgetMedia: serializer.fromJson(json['isWidgetMedia']), openedAt: serializer.fromJson(json['openedAt']), openedByAll: serializer.fromJson(json['openedByAll']), createdAt: serializer.fromJson(json['createdAt']), @@ -5360,6 +5564,7 @@ class Message extends DataClass implements Insertable { 'downloadToken': serializer.toJson(downloadToken), 'quotesMessageId': serializer.toJson(quotesMessageId), 'isDeletedFromSender': serializer.toJson(isDeletedFromSender), + 'isWidgetMedia': serializer.toJson(isWidgetMedia), 'openedAt': serializer.toJson(openedAt), 'openedByAll': serializer.toJson(openedByAll), 'createdAt': serializer.toJson(createdAt), @@ -5382,6 +5587,7 @@ class Message extends DataClass implements Insertable { Value downloadToken = const Value.absent(), Value quotesMessageId = const Value.absent(), bool? isDeletedFromSender, + bool? isWidgetMedia, Value openedAt = const Value.absent(), Value openedByAll = const Value.absent(), DateTime? createdAt, @@ -5407,6 +5613,7 @@ class Message extends DataClass implements Insertable { ? quotesMessageId.value : this.quotesMessageId, isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender, + isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia, openedAt: openedAt.present ? openedAt.value : this.openedAt, openedByAll: openedByAll.present ? openedByAll.value : this.openedByAll, createdAt: createdAt ?? this.createdAt, @@ -5440,6 +5647,9 @@ class Message extends DataClass implements Insertable { isDeletedFromSender: data.isDeletedFromSender.present ? data.isDeletedFromSender.value : this.isDeletedFromSender, + isWidgetMedia: data.isWidgetMedia.present + ? data.isWidgetMedia.value + : this.isWidgetMedia, openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, openedByAll: data.openedByAll.present ? data.openedByAll.value @@ -5470,6 +5680,7 @@ class Message extends DataClass implements Insertable { ..write('downloadToken: $downloadToken, ') ..write('quotesMessageId: $quotesMessageId, ') ..write('isDeletedFromSender: $isDeletedFromSender, ') + ..write('isWidgetMedia: $isWidgetMedia, ') ..write('openedAt: $openedAt, ') ..write('openedByAll: $openedByAll, ') ..write('createdAt: $createdAt, ') @@ -5494,6 +5705,7 @@ class Message extends DataClass implements Insertable { $driftBlobEquality.hash(downloadToken), quotesMessageId, isDeletedFromSender, + isWidgetMedia, openedAt, openedByAll, createdAt, @@ -5520,6 +5732,7 @@ class Message extends DataClass implements Insertable { $driftBlobEquality.equals(other.downloadToken, this.downloadToken) && other.quotesMessageId == this.quotesMessageId && other.isDeletedFromSender == this.isDeletedFromSender && + other.isWidgetMedia == this.isWidgetMedia && other.openedAt == this.openedAt && other.openedByAll == this.openedByAll && other.createdAt == this.createdAt && @@ -5541,6 +5754,7 @@ class MessagesCompanion extends UpdateCompanion { final Value downloadToken; final Value quotesMessageId; final Value isDeletedFromSender; + final Value isWidgetMedia; final Value openedAt; final Value openedByAll; final Value createdAt; @@ -5561,6 +5775,7 @@ class MessagesCompanion extends UpdateCompanion { this.downloadToken = const Value.absent(), this.quotesMessageId = const Value.absent(), this.isDeletedFromSender = const Value.absent(), + this.isWidgetMedia = const Value.absent(), this.openedAt = const Value.absent(), this.openedByAll = const Value.absent(), this.createdAt = const Value.absent(), @@ -5582,6 +5797,7 @@ class MessagesCompanion extends UpdateCompanion { this.downloadToken = const Value.absent(), this.quotesMessageId = const Value.absent(), this.isDeletedFromSender = const Value.absent(), + this.isWidgetMedia = const Value.absent(), this.openedAt = const Value.absent(), this.openedByAll = const Value.absent(), this.createdAt = const Value.absent(), @@ -5605,6 +5821,7 @@ class MessagesCompanion extends UpdateCompanion { Expression? downloadToken, Expression? quotesMessageId, Expression? isDeletedFromSender, + Expression? isWidgetMedia, Expression? openedAt, Expression? openedByAll, Expression? createdAt, @@ -5628,6 +5845,7 @@ class MessagesCompanion extends UpdateCompanion { if (quotesMessageId != null) 'quotes_message_id': quotesMessageId, if (isDeletedFromSender != null) 'is_deleted_from_sender': isDeletedFromSender, + if (isWidgetMedia != null) 'is_widget_media': isWidgetMedia, if (openedAt != null) 'opened_at': openedAt, if (openedByAll != null) 'opened_by_all': openedByAll, if (createdAt != null) 'created_at': createdAt, @@ -5651,6 +5869,7 @@ class MessagesCompanion extends UpdateCompanion { Value? downloadToken, Value? quotesMessageId, Value? isDeletedFromSender, + Value? isWidgetMedia, Value? openedAt, Value? openedByAll, Value? createdAt, @@ -5673,6 +5892,7 @@ class MessagesCompanion extends UpdateCompanion { downloadToken: downloadToken ?? this.downloadToken, quotesMessageId: quotesMessageId ?? this.quotesMessageId, isDeletedFromSender: isDeletedFromSender ?? this.isDeletedFromSender, + isWidgetMedia: isWidgetMedia ?? this.isWidgetMedia, openedAt: openedAt ?? this.openedAt, openedByAll: openedByAll ?? this.openedByAll, createdAt: createdAt ?? this.createdAt, @@ -5724,6 +5944,9 @@ class MessagesCompanion extends UpdateCompanion { if (isDeletedFromSender.present) { map['is_deleted_from_sender'] = Variable(isDeletedFromSender.value); } + if (isWidgetMedia.present) { + map['is_widget_media'] = Variable(isWidgetMedia.value); + } if (openedAt.present) { map['opened_at'] = Variable(openedAt.value); } @@ -5763,6 +5986,7 @@ class MessagesCompanion extends UpdateCompanion { ..write('downloadToken: $downloadToken, ') ..write('quotesMessageId: $quotesMessageId, ') ..write('isDeletedFromSender: $isDeletedFromSender, ') + ..write('isWidgetMedia: $isWidgetMedia, ') ..write('openedAt: $openedAt, ') ..write('openedByAll: $openedByAll, ') ..write('createdAt: $createdAt, ') @@ -12592,6 +12816,8 @@ typedef $$ContactsTableCreateCompanionBuilder = Value recoveryContactsLastHeartbeat, Value recoveryContactsThreshold, Value askForFriendPromotions, + Value widgetSharingAllowed, + Value widgetSharingGranted, Value mediaSendCounter, Value mediaReceivedCounter, }); @@ -12621,6 +12847,8 @@ typedef $$ContactsTableUpdateCompanionBuilder = Value recoveryContactsLastHeartbeat, Value recoveryContactsThreshold, Value askForFriendPromotions, + Value widgetSharingAllowed, + Value widgetSharingGranted, Value mediaSendCounter, Value mediaReceivedCounter, }); @@ -13030,6 +13258,16 @@ class $$ContactsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get widgetSharingAllowed => $composableBuilder( + column: $table.widgetSharingAllowed, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get widgetSharingGranted => $composableBuilder( + column: $table.widgetSharingGranted, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get mediaSendCounter => $composableBuilder( column: $table.mediaSendCounter, builder: (column) => ColumnFilters(column), @@ -13480,6 +13718,16 @@ class $$ContactsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get widgetSharingAllowed => $composableBuilder( + column: $table.widgetSharingAllowed, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get widgetSharingGranted => $composableBuilder( + column: $table.widgetSharingGranted, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get mediaSendCounter => $composableBuilder( column: $table.mediaSendCounter, builder: (column) => ColumnOrderings(column), @@ -13607,6 +13855,16 @@ class $$ContactsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get widgetSharingAllowed => $composableBuilder( + column: $table.widgetSharingAllowed, + builder: (column) => column, + ); + + GeneratedColumn get widgetSharingGranted => $composableBuilder( + column: $table.widgetSharingGranted, + builder: (column) => column, + ); + GeneratedColumn get mediaSendCounter => $composableBuilder( column: $table.mediaSendCounter, builder: (column) => column, @@ -13999,6 +14257,8 @@ class $$ContactsTableTableManager const Value.absent(), Value recoveryContactsThreshold = const Value.absent(), Value askForFriendPromotions = const Value.absent(), + Value widgetSharingAllowed = const Value.absent(), + Value widgetSharingGranted = const Value.absent(), Value mediaSendCounter = const Value.absent(), Value mediaReceivedCounter = const Value.absent(), }) => ContactsCompanion( @@ -14026,6 +14286,8 @@ class $$ContactsTableTableManager recoveryContactsLastHeartbeat: recoveryContactsLastHeartbeat, recoveryContactsThreshold: recoveryContactsThreshold, askForFriendPromotions: askForFriendPromotions, + widgetSharingAllowed: widgetSharingAllowed, + widgetSharingGranted: widgetSharingGranted, mediaSendCounter: mediaSendCounter, mediaReceivedCounter: mediaReceivedCounter, ), @@ -14057,6 +14319,8 @@ class $$ContactsTableTableManager const Value.absent(), Value recoveryContactsThreshold = const Value.absent(), Value askForFriendPromotions = const Value.absent(), + Value widgetSharingAllowed = const Value.absent(), + Value widgetSharingGranted = const Value.absent(), Value mediaSendCounter = const Value.absent(), Value mediaReceivedCounter = const Value.absent(), }) => ContactsCompanion.insert( @@ -14084,6 +14348,8 @@ class $$ContactsTableTableManager recoveryContactsLastHeartbeat: recoveryContactsLastHeartbeat, recoveryContactsThreshold: recoveryContactsThreshold, askForFriendPromotions: askForFriendPromotions, + widgetSharingAllowed: widgetSharingAllowed, + widgetSharingGranted: widgetSharingGranted, mediaSendCounter: mediaSendCounter, mediaReceivedCounter: mediaReceivedCounter, ), @@ -15446,6 +15712,7 @@ typedef $$MediaFilesTableCreateCompanionBuilder = Value requiresAuthentication, Value stored, Value isDraftMedia, + Value isWidgetMedia, Value isFavorite, Value hasCropAnalyzed, Value preProgressingProcess, @@ -15476,6 +15743,7 @@ typedef $$MediaFilesTableUpdateCompanionBuilder = Value requiresAuthentication, Value stored, Value isDraftMedia, + Value isWidgetMedia, Value isFavorite, Value hasCropAnalyzed, Value preProgressingProcess, @@ -15577,6 +15845,11 @@ class $$MediaFilesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get isWidgetMedia => $composableBuilder( + column: $table.isWidgetMedia, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get isFavorite => $composableBuilder( column: $table.isFavorite, builder: (column) => ColumnFilters(column), @@ -15743,6 +16016,11 @@ class $$MediaFilesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get isWidgetMedia => $composableBuilder( + column: $table.isWidgetMedia, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get isFavorite => $composableBuilder( column: $table.isFavorite, builder: (column) => ColumnOrderings(column), @@ -15878,6 +16156,11 @@ class $$MediaFilesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get isWidgetMedia => $composableBuilder( + column: $table.isWidgetMedia, + builder: (column) => column, + ); + GeneratedColumn get isFavorite => $composableBuilder( column: $table.isFavorite, builder: (column) => column, @@ -16023,6 +16306,7 @@ class $$MediaFilesTableTableManager Value requiresAuthentication = const Value.absent(), Value stored = const Value.absent(), Value isDraftMedia = const Value.absent(), + Value isWidgetMedia = const Value.absent(), Value isFavorite = const Value.absent(), Value hasCropAnalyzed = const Value.absent(), Value preProgressingProcess = const Value.absent(), @@ -16051,6 +16335,7 @@ class $$MediaFilesTableTableManager requiresAuthentication: requiresAuthentication, stored: stored, isDraftMedia: isDraftMedia, + isWidgetMedia: isWidgetMedia, isFavorite: isFavorite, hasCropAnalyzed: hasCropAnalyzed, preProgressingProcess: preProgressingProcess, @@ -16081,6 +16366,7 @@ class $$MediaFilesTableTableManager Value requiresAuthentication = const Value.absent(), Value stored = const Value.absent(), Value isDraftMedia = const Value.absent(), + Value isWidgetMedia = const Value.absent(), Value isFavorite = const Value.absent(), Value hasCropAnalyzed = const Value.absent(), Value preProgressingProcess = const Value.absent(), @@ -16109,6 +16395,7 @@ class $$MediaFilesTableTableManager requiresAuthentication: requiresAuthentication, stored: stored, isDraftMedia: isDraftMedia, + isWidgetMedia: isWidgetMedia, isFavorite: isFavorite, hasCropAnalyzed: hasCropAnalyzed, preProgressingProcess: preProgressingProcess, @@ -16200,6 +16487,7 @@ typedef $$MessagesTableCreateCompanionBuilder = Value downloadToken, Value quotesMessageId, Value isDeletedFromSender, + Value isWidgetMedia, Value openedAt, Value openedByAll, Value createdAt, @@ -16222,6 +16510,7 @@ typedef $$MessagesTableUpdateCompanionBuilder = Value downloadToken, Value quotesMessageId, Value isDeletedFromSender, + Value isWidgetMedia, Value openedAt, Value openedByAll, Value createdAt, @@ -16420,6 +16709,11 @@ class $$MessagesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get isWidgetMedia => $composableBuilder( + column: $table.isWidgetMedia, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnFilters(column), @@ -16674,6 +16968,11 @@ class $$MessagesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get isWidgetMedia => $composableBuilder( + column: $table.isWidgetMedia, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnOrderings(column), @@ -16822,6 +17121,11 @@ class $$MessagesTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get isWidgetMedia => $composableBuilder( + column: $table.isWidgetMedia, + builder: (column) => column, + ); + GeneratedColumn get openedAt => $composableBuilder(column: $table.openedAt, builder: (column) => column); @@ -17064,6 +17368,7 @@ class $$MessagesTableTableManager Value downloadToken = const Value.absent(), Value quotesMessageId = const Value.absent(), Value isDeletedFromSender = const Value.absent(), + Value isWidgetMedia = const Value.absent(), Value openedAt = const Value.absent(), Value openedByAll = const Value.absent(), Value createdAt = const Value.absent(), @@ -17084,6 +17389,7 @@ class $$MessagesTableTableManager downloadToken: downloadToken, quotesMessageId: quotesMessageId, isDeletedFromSender: isDeletedFromSender, + isWidgetMedia: isWidgetMedia, openedAt: openedAt, openedByAll: openedByAll, createdAt: createdAt, @@ -17106,6 +17412,7 @@ class $$MessagesTableTableManager Value downloadToken = const Value.absent(), Value quotesMessageId = const Value.absent(), Value isDeletedFromSender = const Value.absent(), + Value isWidgetMedia = const Value.absent(), Value openedAt = const Value.absent(), Value openedByAll = const Value.absent(), Value createdAt = const Value.absent(), @@ -17126,6 +17433,7 @@ class $$MessagesTableTableManager downloadToken: downloadToken, quotesMessageId: quotesMessageId, isDeletedFromSender: isDeletedFromSender, + isWidgetMedia: isWidgetMedia, openedAt: openedAt, openedByAll: openedByAll, createdAt: createdAt, diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index e18934ae..df74c536 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -278,6 +278,30 @@ abstract class AppLocalizations { /// **'Show archived users'** String get shareImageShowArchived; + /// No description provided for @shareImageSendToWidget. + /// + /// In en, this message translates to: + /// **'Send to home-screen widget'** + String get shareImageSendToWidget; + + /// No description provided for @shareImageWidgetExplainerTitle. + /// + /// In en, this message translates to: + /// **'Send to a widget'** + String get shareImageWidgetExplainerTitle; + + /// No description provided for @shareImageWidgetExplainerBody. + /// + /// In en, this message translates to: + /// **'Sends the photo straight to your friends\' home-screen widgets. Only friends who allow them can be selected.'** + String get shareImageWidgetExplainerBody; + + /// No description provided for @shareImageWidgetExplainerDismiss. + /// + /// In en, this message translates to: + /// **'Got it'** + String get shareImageWidgetExplainerDismiss; + /// No description provided for @searchUsernameInput. /// /// In en, this message translates to: @@ -392,6 +416,12 @@ abstract class AppLocalizations { /// **'Sent'** String get messageSendState_Send; + /// No description provided for @messageSendState_Delivered. + /// + /// In en, this message translates to: + /// **'Received'** + String get messageSendState_Delivered; + /// No description provided for @messageSendState_Sending. /// /// In en, this message translates to: @@ -4591,6 +4621,192 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Upgrade plan'** String get fileLimitReachedUpgrade; + + /// No description provided for @settingsWidgets. + /// + /// In en, this message translates to: + /// **'Widgets'** + String get settingsWidgets; + + /// No description provided for @widgetsTitle. + /// + /// In en, this message translates to: + /// **'Widgets'** + String get widgetsTitle; + + /// No description provided for @widgetsIntroTitle. + /// + /// In en, this message translates to: + /// **'Photos on your home screen'** + String get widgetsIntroTitle; + + /// No description provided for @widgetsIntroBody. + /// + /// In en, this message translates to: + /// **'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.'** + String get widgetsIntroBody; + + /// No description provided for @widgetsSetupIos. + /// + /// In en, this message translates to: + /// **'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.'** + String get widgetsSetupIos; + + /// No description provided for @widgetsSetupAndroid. + /// + /// In en, this message translates to: + /// **'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.'** + String get widgetsSetupAndroid; + + /// No description provided for @widgetsNoneTitle. + /// + /// In en, this message translates to: + /// **'No widget added yet'** + String get widgetsNoneTitle; + + /// No description provided for @widgetsPlacedTitle. + /// + /// In en, this message translates to: + /// **'On your home screen'** + String get widgetsPlacedTitle; + + /// No description provided for @widgetsAddAnother. + /// + /// In en, this message translates to: + /// **'Add another widget'** + String get widgetsAddAnother; + + /// No description provided for @widgetsNoGroups. + /// + /// In en, this message translates to: + /// **'No contact group selected'** + String get widgetsNoGroups; + + /// No description provided for @widgetsNoGroupsHint. + /// + /// In en, this message translates to: + /// **'Nobody can send to this widget until you choose at least one contact group.'** + String get widgetsNoGroupsHint; + + /// No description provided for @widgetsEditGroup. + /// + /// In en, this message translates to: + /// **'Edit contact group'** + String get widgetsEditGroup; + + /// No description provided for @widgetsChangeGroupsIos. + /// + /// In en, this message translates to: + /// **'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.'** + String get widgetsChangeGroupsIos; + + /// No description provided for @widgetsChangeGroupsAndroid. + /// + /// In en, this message translates to: + /// **'To change the contact groups, remove the widget and add it again.'** + String get widgetsChangeGroupsAndroid; + + /// No description provided for @widgetsCurrentImages. + /// + /// In en, this message translates to: + /// **'Currently showing'** + String get widgetsCurrentImages; + + /// No description provided for @widgetsNoImages. + /// + /// In en, this message translates to: + /// **'No photos right now'** + String get widgetsNoImages; + + /// No description provided for @widgetsNoImagesHint. + /// + /// In en, this message translates to: + /// **'Photos your friends send to this widget will appear here.'** + String get widgetsNoImagesHint; + + /// No description provided for @widgetsFrom. + /// + /// In en, this message translates to: + /// **'From {sender}'** + String widgetsFrom(String sender); + + /// No description provided for @widgetsExpiresIn. + /// + /// In en, this message translates to: + /// **'Shown for {duration} more'** + String widgetsExpiresIn(String duration); + + /// No description provided for @widgetsDeleteImage. + /// + /// In en, this message translates to: + /// **'Delete photo'** + String get widgetsDeleteImage; + + /// No description provided for @widgetsDeleteImageConfirm. + /// + /// In en, this message translates to: + /// **'This removes the photo from every widget showing it. It cannot be undone.'** + String get widgetsDeleteImageConfirm; + + /// No description provided for @widgetsDurationHours. + /// + /// In en, this message translates to: + /// **'{hours} h'** + String widgetsDurationHours(int hours); + + /// No description provided for @widgetsDurationMinutes. + /// + /// In en, this message translates to: + /// **'{minutes} min'** + String widgetsDurationMinutes(int minutes); + + /// No description provided for @contactGroupUsedByWidget. + /// + /// In en, this message translates to: + /// **'Home screen widget'** + String get contactGroupUsedByWidget; + + /// No description provided for @contactGroupUsedByWidgetSubtitle. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{A widget on your home screen shows photos from this group.} other{{count} widgets on your home screen show photos from this group.}}'** + String contactGroupUsedByWidgetSubtitle(int count); + + /// No description provided for @contactGroupDeleteBlockedByWidget. + /// + /// In en, this message translates to: + /// **'{count, plural, =1{This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.} other{This contact group is used by {count} widgets on your home screen. Remove those widgets first, then you can delete the group.}}'** + String contactGroupDeleteBlockedByWidget(int count); + + /// No description provided for @widgetsQueryFailed. + /// + /// In en, this message translates to: + /// **'Could not read the widgets on your home screen, so this list may be out of date.'** + String get widgetsQueryFailed; + + /// No description provided for @widgetsSizeSmall. + /// + /// In en, this message translates to: + /// **'Small widget'** + String get widgetsSizeSmall; + + /// No description provided for @widgetsSizeMedium. + /// + /// In en, this message translates to: + /// **'Medium widget'** + String get widgetsSizeMedium; + + /// No description provided for @widgetsSizeLarge. + /// + /// In en, this message translates to: + /// **'Large widget'** + String get widgetsSizeLarge; + + /// No description provided for @widgetsSizeUnknown. + /// + /// In en, this message translates to: + /// **'Widget'** + String get widgetsSizeUnknown; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index ff55b8a0..a15aeadc 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -104,6 +104,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get shareImageShowArchived => 'Archivierte Benutzer anzeigen'; + @override + String get shareImageSendToWidget => 'An Startbildschirm-Widget senden'; + + @override + String get shareImageWidgetExplainerTitle => 'An ein Widget senden'; + + @override + String get shareImageWidgetExplainerBody => + 'Sendet das Foto direkt an die Startbildschirm-Widgets deiner Freunde. Nur Freunde, die das erlauben, sind auswählbar.'; + + @override + String get shareImageWidgetExplainerDismiss => 'Alles klar'; + @override String get searchUsernameInput => 'Benutzername'; @@ -162,6 +175,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get messageSendState_Send => 'Gesendet'; + @override + String get messageSendState_Delivered => 'Empfangen'; + @override String get messageSendState_Sending => 'Wird gesendet'; @@ -2196,8 +2212,7 @@ class AppLocalizationsDe extends AppLocalizations { String get passwordlessRecoveryEnableBtn => 'Vertraute Freunde aktivieren'; @override - String get passwordlessRecoveryRecoverBtn => - 'Mithilfe von vertrauten Freunden wiederherstellen'; + String get passwordlessRecoveryRecoverBtn => 'Über Freunde wiederherstellen'; @override String get passwordlessRecoveryModifyBtn => 'Vertraute Freunde bearbeiten'; @@ -2653,4 +2668,134 @@ class AppLocalizationsDe extends AppLocalizations { @override String get fileLimitReachedUpgrade => 'Tarif wechseln'; + + @override + String get settingsWidgets => 'Widgets'; + + @override + String get widgetsTitle => 'Widgets'; + + @override + String get widgetsIntroTitle => 'Fotos auf deinem Homebildschirm'; + + @override + String get widgetsIntroBody => + 'Freunde aus den von dir gewählten Kontaktgruppen können ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.'; + + @override + String get widgetsSetupIos => + 'Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Bearbeiten, dann auf Widget hinzufügen und wähle twonly. Halte anschließend das neue Widget gedrückt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwählen.'; + + @override + String get widgetsSetupAndroid => + 'Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dürfen.'; + + @override + String get widgetsNoneTitle => 'Noch kein Widget hinzugefügt'; + + @override + String get widgetsPlacedTitle => 'Auf deinem Homebildschirm'; + + @override + String get widgetsAddAnother => 'Weiteres Widget hinzufügen'; + + @override + String get widgetsNoGroups => 'Keine Kontaktgruppe ausgewählt'; + + @override + String get widgetsNoGroupsHint => + 'Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswählst.'; + + @override + String get widgetsEditGroup => 'Kontaktgruppe bearbeiten'; + + @override + String get widgetsChangeGroupsIos => + 'Um die Kontaktgruppen zu ändern, halte das Widget auf dem Homebildschirm gedrückt und tippe auf Widget bearbeiten.'; + + @override + String get widgetsChangeGroupsAndroid => + 'Um die Kontaktgruppen zu ändern, entferne das Widget und füge es erneut hinzu.'; + + @override + String get widgetsCurrentImages => 'Wird aktuell angezeigt'; + + @override + String get widgetsNoImages => 'Aktuell keine Fotos'; + + @override + String get widgetsNoImagesHint => + 'Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.'; + + @override + String widgetsFrom(String sender) { + return 'Von $sender'; + } + + @override + String widgetsExpiresIn(String duration) { + return 'Noch $duration sichtbar'; + } + + @override + String get widgetsDeleteImage => 'Foto löschen'; + + @override + String get widgetsDeleteImageConfirm => + 'Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rückgängig gemacht werden.'; + + @override + String widgetsDurationHours(int hours) { + return '$hours Std.'; + } + + @override + String widgetsDurationMinutes(int minutes) { + return '$minutes Min.'; + } + + @override + String get contactGroupUsedByWidget => 'Homebildschirm-Widget'; + + @override + String contactGroupUsedByWidgetSubtitle(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + '$count Widgets auf deinem Homebildschirm zeigen Fotos aus dieser Gruppe.', + one: + 'Ein Widget auf deinem Homebildschirm zeigt Fotos aus dieser Gruppe.', + ); + return '$_temp0'; + } + + @override + String contactGroupDeleteBlockedByWidget(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'Diese Kontaktgruppe wird von $count Widgets auf deinem Homebildschirm verwendet. Entferne zuerst diese Widgets, danach kannst du die Gruppe löschen.', + one: + 'Diese Kontaktgruppe wird von einem Widget auf deinem Homebildschirm verwendet. Entferne zuerst dieses Widget, danach kannst du die Gruppe löschen.', + ); + return '$_temp0'; + } + + @override + String get widgetsQueryFailed => + 'Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.'; + + @override + String get widgetsSizeSmall => 'Kleines Widget'; + + @override + String get widgetsSizeMedium => 'Mittleres Widget'; + + @override + String get widgetsSizeLarge => 'Großes Widget'; + + @override + String get widgetsSizeUnknown => 'Widget'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 9b7567a9..d2c93972 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -103,6 +103,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get shareImageShowArchived => 'Show archived users'; + @override + String get shareImageSendToWidget => 'Send to home-screen widget'; + + @override + String get shareImageWidgetExplainerTitle => 'Send to a widget'; + + @override + String get shareImageWidgetExplainerBody => + 'Sends the photo straight to your friends\' home-screen widgets. Only friends who allow them can be selected.'; + + @override + String get shareImageWidgetExplainerDismiss => 'Got it'; + @override String get searchUsernameInput => 'Username'; @@ -160,6 +173,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messageSendState_Send => 'Sent'; + @override + String get messageSendState_Delivered => 'Received'; + @override String get messageSendState_Sending => 'Sending'; @@ -2628,4 +2644,132 @@ class AppLocalizationsEn extends AppLocalizations { @override String get fileLimitReachedUpgrade => 'Upgrade plan'; + + @override + String get settingsWidgets => 'Widgets'; + + @override + String get widgetsTitle => 'Widgets'; + + @override + String get widgetsIntroTitle => 'Photos on your home screen'; + + @override + String get widgetsIntroBody => + 'Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.'; + + @override + String get widgetsSetupIos => + 'Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.'; + + @override + String get widgetsSetupAndroid => + 'Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.'; + + @override + String get widgetsNoneTitle => 'No widget added yet'; + + @override + String get widgetsPlacedTitle => 'On your home screen'; + + @override + String get widgetsAddAnother => 'Add another widget'; + + @override + String get widgetsNoGroups => 'No contact group selected'; + + @override + String get widgetsNoGroupsHint => + 'Nobody can send to this widget until you choose at least one contact group.'; + + @override + String get widgetsEditGroup => 'Edit contact group'; + + @override + String get widgetsChangeGroupsIos => + 'To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.'; + + @override + String get widgetsChangeGroupsAndroid => + 'To change the contact groups, remove the widget and add it again.'; + + @override + String get widgetsCurrentImages => 'Currently showing'; + + @override + String get widgetsNoImages => 'No photos right now'; + + @override + String get widgetsNoImagesHint => + 'Photos your friends send to this widget will appear here.'; + + @override + String widgetsFrom(String sender) { + return 'From $sender'; + } + + @override + String widgetsExpiresIn(String duration) { + return 'Shown for $duration more'; + } + + @override + String get widgetsDeleteImage => 'Delete photo'; + + @override + String get widgetsDeleteImageConfirm => + 'This removes the photo from every widget showing it. It cannot be undone.'; + + @override + String widgetsDurationHours(int hours) { + return '$hours h'; + } + + @override + String widgetsDurationMinutes(int minutes) { + return '$minutes min'; + } + + @override + String get contactGroupUsedByWidget => 'Home screen widget'; + + @override + String contactGroupUsedByWidgetSubtitle(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count widgets on your home screen show photos from this group.', + one: 'A widget on your home screen shows photos from this group.', + ); + return '$_temp0'; + } + + @override + String contactGroupDeleteBlockedByWidget(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: + 'This contact group is used by $count widgets on your home screen. Remove those widgets first, then you can delete the group.', + one: + 'This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.', + ); + return '$_temp0'; + } + + @override + String get widgetsQueryFailed => + 'Could not read the widgets on your home screen, so this list may be out of date.'; + + @override + String get widgetsSizeSmall => 'Small widget'; + + @override + String get widgetsSizeMedium => 'Medium widget'; + + @override + String get widgetsSizeLarge => 'Large widget'; + + @override + String get widgetsSizeUnknown => 'Widget'; } diff --git a/lib/src/localization/translations/de.arb b/lib/src/localization/translations/de.arb index 6c248def..097d5b61 100644 --- a/lib/src/localization/translations/de.arb +++ b/lib/src/localization/translations/de.arb @@ -27,6 +27,10 @@ "shareImageAllUsers": "Alle Kontakte", "shareImageSearchAllContacts": "Alle Kontakte durchsuchen", "shareImageShowArchived": "Archivierte Benutzer anzeigen", + "shareImageSendToWidget": "An Startbildschirm-Widget senden", + "shareImageWidgetExplainerTitle": "An ein Widget senden", + "shareImageWidgetExplainerBody": "Sendet das Foto direkt an die Startbildschirm-Widgets deiner Freunde. Nur Freunde, die das erlauben, sind auswählbar.", + "shareImageWidgetExplainerDismiss": "Alles klar", "startNewChatSearchHint": "Name, Benutzername oder Gruppenname", "searchUsernameInput": "Benutzername", "addFriendTitle": "Freunde hinzufügen", @@ -58,6 +62,7 @@ "messageSendState_Received": "Empfangen", "messageSendState_Opened": "Geöffnet", "messageSendState_Send": "Gesendet", + "messageSendState_Delivered": "Empfangen", "messageSendState_Sending": "Wird gesendet", "messageSendState_TapToLoad": "Tippe zum Laden", "messageSendState_Loading": "Herunterladen", @@ -796,7 +801,7 @@ "passwordlessRecoveryEnterPin": "Bitte gib eine PIN ein.", "passwordlessRecoveryEnterEmail": "Bitte gib eine E-Mail-Adresse ein.", "passwordlessRecoveryEnableBtn": "Vertraute Freunde aktivieren", - "passwordlessRecoveryRecoverBtn": "Mithilfe von vertrauten Freunden wiederherstellen", + "passwordlessRecoveryRecoverBtn": "Über Freunde wiederherstellen", "passwordlessRecoveryModifyBtn": "Vertraute Freunde bearbeiten", "passwordlessRecoveryStatusEnabled": "Aktiviert • {count} Freunde", "@passwordlessRecoveryStatusEnabled": { @@ -980,17 +985,96 @@ "fileLimitReachedDetail": "Diese Datei ist {size} groß, dein Tarif erlaubt aber höchstens {limit} pro Sendung.", "@fileLimitReachedDetail": { "placeholders": { - "size": { "type": "String" }, - "limit": { "type": "String" } + "size": { + "type": "String" + }, + "limit": { + "type": "String" + } } }, "fileLimitReachedDetailNoSize": "Dein Tarif erlaubt höchstens {limit} pro Sendung.", "@fileLimitReachedDetailNoSize": { "placeholders": { - "limit": { "type": "String" } + "limit": { + "type": "String" + } } }, "fileLimitReachedHint": "Nimm ein kürzeres Video auf und sende es erneut.", "fileLimitReachedHintFree": "Nimm ein kürzeres Video auf oder wechsle den Tarif, um größere Dateien zu senden.", - "fileLimitReachedUpgrade": "Tarif wechseln" + "fileLimitReachedUpgrade": "Tarif wechseln", + "settingsWidgets": "Widgets", + "widgetsTitle": "Widgets", + "widgetsIntroTitle": "Fotos auf deinem Homebildschirm", + "widgetsIntroBody": "Freunde aus den von dir gewählten Kontaktgruppen können ein Foto direkt an ein Widget auf deinem Homebildschirm senden. Fotos verschwinden nach 24 Stunden von selbst.", + "widgetsSetupIos": "Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Bearbeiten, dann auf Widget hinzufügen und wähle twonly. Halte anschließend das neue Widget gedrückt und tippe auf Widget bearbeiten, um die Kontaktgruppen auszuwählen.", + "widgetsSetupAndroid": "Halte eine freie Stelle auf dem Homebildschirm gedrückt, tippe auf Widgets und ziehe das twonly-Widget an seinen Platz. Du wirst gefragt, welche Kontaktgruppen daran senden dürfen.", + "widgetsNoneTitle": "Noch kein Widget hinzugefügt", + "widgetsPlacedTitle": "Auf deinem Homebildschirm", + "widgetsAddAnother": "Weiteres Widget hinzufügen", + "widgetsNoGroups": "Keine Kontaktgruppe ausgewählt", + "widgetsNoGroupsHint": "Niemand kann an dieses Widget senden, solange du keine Kontaktgruppe auswählst.", + "widgetsEditGroup": "Kontaktgruppe bearbeiten", + "widgetsChangeGroupsIos": "Um die Kontaktgruppen zu ändern, halte das Widget auf dem Homebildschirm gedrückt und tippe auf Widget bearbeiten.", + "widgetsChangeGroupsAndroid": "Um die Kontaktgruppen zu ändern, entferne das Widget und füge es erneut hinzu.", + "widgetsCurrentImages": "Wird aktuell angezeigt", + "widgetsNoImages": "Aktuell keine Fotos", + "widgetsNoImagesHint": "Fotos, die dir deine Freunde an dieses Widget senden, erscheinen hier.", + "widgetsFrom": "Von {sender}", + "@widgetsFrom": { + "placeholders": { + "sender": { + "type": "String" + } + } + }, + "widgetsExpiresIn": "Noch {duration} sichtbar", + "@widgetsExpiresIn": { + "placeholders": { + "duration": { + "type": "String" + } + } + }, + "widgetsDeleteImage": "Foto löschen", + "widgetsDeleteImageConfirm": "Damit wird das Foto von allen Widgets entfernt, die es anzeigen. Das kann nicht rückgängig gemacht werden.", + "widgetsDurationHours": "{hours} Std.", + "@widgetsDurationHours": { + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "widgetsDurationMinutes": "{minutes} Min.", + "@widgetsDurationMinutes": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "contactGroupUsedByWidget": "Homebildschirm-Widget", + "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{Ein Widget auf deinem Homebildschirm zeigt Fotos aus dieser Gruppe.} other{{count} Widgets auf deinem Homebildschirm zeigen Fotos aus dieser Gruppe.}}", + "@contactGroupUsedByWidgetSubtitle": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "contactGroupDeleteBlockedByWidget": "{count, plural, =1{Diese Kontaktgruppe wird von einem Widget auf deinem Homebildschirm verwendet. Entferne zuerst dieses Widget, danach kannst du die Gruppe löschen.} other{Diese Kontaktgruppe wird von {count} Widgets auf deinem Homebildschirm verwendet. Entferne zuerst diese Widgets, danach kannst du die Gruppe löschen.}}", + "@contactGroupDeleteBlockedByWidget": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "widgetsQueryFailed": "Die Widgets auf deinem Homebildschirm konnten nicht gelesen werden, diese Liste ist möglicherweise veraltet.", + "widgetsSizeSmall": "Kleines Widget", + "widgetsSizeMedium": "Mittleres Widget", + "widgetsSizeLarge": "Großes Widget", + "widgetsSizeUnknown": "Widget" } diff --git a/lib/src/localization/translations/en.arb b/lib/src/localization/translations/en.arb index 2d01e265..afa668c4 100644 --- a/lib/src/localization/translations/en.arb +++ b/lib/src/localization/translations/en.arb @@ -30,6 +30,10 @@ "startNewChatNewContact": "New Contact", "shareImageAllUsers": "All contacts", "shareImageShowArchived": "Show archived users", + "shareImageSendToWidget": "Send to home-screen widget", + "shareImageWidgetExplainerTitle": "Send to a widget", + "shareImageWidgetExplainerBody": "Sends the photo straight to your friends' home-screen widgets. Only friends who allow them can be selected.", + "shareImageWidgetExplainerDismiss": "Got it", "searchUsernameInput": "Username", "addFriendTitle": "Add friends", "searchUserNamePending": "Request pending", @@ -49,6 +53,7 @@ "messageSendState_Received": "Received", "messageSendState_Opened": "Opened", "messageSendState_Send": "Sent", + "messageSendState_Delivered": "Received", "messageSendState_Sending": "Sending", "messageSendState_TapToLoad": "Tap to load", "messageSendState_Loading": "Downloading", @@ -990,17 +995,96 @@ "fileLimitReachedDetail": "This file is {size}, but your plan allows at most {limit} per send.", "@fileLimitReachedDetail": { "placeholders": { - "size": { "type": "String" }, - "limit": { "type": "String" } + "size": { + "type": "String" + }, + "limit": { + "type": "String" + } } }, "fileLimitReachedDetailNoSize": "Your plan allows at most {limit} per send.", "@fileLimitReachedDetailNoSize": { "placeholders": { - "limit": { "type": "String" } + "limit": { + "type": "String" + } } }, "fileLimitReachedHint": "Record a shorter video and send it again.", "fileLimitReachedHintFree": "Record a shorter video, or upgrade your plan to send larger files.", - "fileLimitReachedUpgrade": "Upgrade plan" + "fileLimitReachedUpgrade": "Upgrade plan", + "settingsWidgets": "Widgets", + "widgetsTitle": "Widgets", + "widgetsIntroTitle": "Photos on your home screen", + "widgetsIntroBody": "Friends in the contact groups you choose can send a photo straight to a widget on your home screen. Photos disappear on their own after 24 hours.", + "widgetsSetupIos": "Touch and hold an empty area of the home screen, tap Edit, then Add Widget and pick twonly. Then touch and hold the new widget and tap Edit Widget to choose which contact groups may send to it.", + "widgetsSetupAndroid": "Touch and hold an empty area of the home screen, tap Widgets, then drag the twonly widget into place. You will be asked which contact groups may send to it.", + "widgetsNoneTitle": "No widget added yet", + "widgetsPlacedTitle": "On your home screen", + "widgetsAddAnother": "Add another widget", + "widgetsNoGroups": "No contact group selected", + "widgetsNoGroupsHint": "Nobody can send to this widget until you choose at least one contact group.", + "widgetsEditGroup": "Edit contact group", + "widgetsChangeGroupsIos": "To change the contact groups, touch and hold the widget on your home screen and tap Edit Widget.", + "widgetsChangeGroupsAndroid": "To change the contact groups, remove the widget and add it again.", + "widgetsCurrentImages": "Currently showing", + "widgetsNoImages": "No photos right now", + "widgetsNoImagesHint": "Photos your friends send to this widget will appear here.", + "widgetsFrom": "From {sender}", + "@widgetsFrom": { + "placeholders": { + "sender": { + "type": "String" + } + } + }, + "widgetsExpiresIn": "Shown for {duration} more", + "@widgetsExpiresIn": { + "placeholders": { + "duration": { + "type": "String" + } + } + }, + "widgetsDeleteImage": "Delete photo", + "widgetsDeleteImageConfirm": "This removes the photo from every widget showing it. It cannot be undone.", + "widgetsDurationHours": "{hours} h", + "@widgetsDurationHours": { + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "widgetsDurationMinutes": "{minutes} min", + "@widgetsDurationMinutes": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "contactGroupUsedByWidget": "Home screen widget", + "contactGroupUsedByWidgetSubtitle": "{count, plural, =1{A widget on your home screen shows photos from this group.} other{{count} widgets on your home screen show photos from this group.}}", + "@contactGroupUsedByWidgetSubtitle": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "contactGroupDeleteBlockedByWidget": "{count, plural, =1{This contact group is used by a widget on your home screen. Remove that widget first, then you can delete the group.} other{This contact group is used by {count} widgets on your home screen. Remove those widgets first, then you can delete the group.}}", + "@contactGroupDeleteBlockedByWidget": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "widgetsQueryFailed": "Could not read the widgets on your home screen, so this list may be out of date.", + "widgetsSizeSmall": "Small widget", + "widgetsSizeMedium": "Medium widget", + "widgetsSizeLarge": "Large widget", + "widgetsSizeUnknown": "Widget" } diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 0f53018e..ea0b5b3b 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -47,6 +47,7 @@ import 'package:twonly/src/visual/views/settings/profile/profile.view.dart'; import 'package:twonly/src/visual/views/settings/settings_main.view.dart'; import 'package:twonly/src/visual/views/settings/share_with_friends.view.dart'; import 'package:twonly/src/visual/views/settings/subscription/subscription.view.dart'; +import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart'; final GlobalKey rootNavigatorKey = GlobalKey(); @@ -100,7 +101,11 @@ final routerProvider = GoRouter( path: 'messages/:groupId', builder: (context, state) { final groupId = state.pathParameters['groupId']!; - return ChatMessagesView(groupId); + final extra = state.extra; + return ChatMessagesView( + groupId, + initialGroup: extra is Group ? extra : null, + ); }, ), ], @@ -224,6 +229,10 @@ final routerProvider = GoRouter( path: 'notification', builder: (context, state) => const NotificationView(), ), + GoRoute( + path: 'widgets', + builder: (context, state) => const WidgetsSettingsView(), + ), GoRoute( path: 'storage_data', builder: (context, state) => const DataAndStorageView(), diff --git a/lib/src/services/api/api.service.dart b/lib/src/services/api/api.service.dart index ef219f0b..c221de8d 100644 --- a/lib/src/services/api/api.service.dart +++ b/lib/src/services/api/api.service.dart @@ -4,6 +4,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:twonly/core/bridge/api.dart' as rust_api; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/services/home_widget.service.dart'; import 'package:twonly/src/services/memories/memories_cloud.service.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/utils/log.dart'; @@ -43,6 +44,11 @@ class ApiService { event.kind == ApiEventKind.newDeviceRegistered) { permanentRejection = event.kind; } + if (event.kind == ApiEventKind.widgetMediaReceived) { + // Rust has already rewritten the manifest; the home screen still shows + // what the widgets drew before it arrived. + unawaited(HomeWidgetService.refresh()); + } } // Function is called after the user is authenticated at the server diff --git a/lib/src/services/home_widget.service.dart b/lib/src/services/home_widget.service.dart new file mode 100644 index 00000000..332faa15 --- /dev/null +++ b/lib/src/services/home_widget.service.dart @@ -0,0 +1,295 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:twonly/core/bridge/api.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/utils/log.dart'; + +/// One widget the user has placed on their home screen. +class PlacedWidget { + const PlacedWidget({ + required this.id, + required this.platform, + required this.contactGroupIds, + this.family, + }); + + final String id; + final String platform; + final List contactGroupIds; + + /// The widget's size, as WidgetKit names it (`systemSmall` and so on). Null + /// on Android, which does not report one. + final String? family; + + /// A widget with no contact group can never show anything: nobody is allowed + /// to share with it, so nothing is ever delivered. + bool get isConfigured => contactGroupIds.isNotEmpty; +} + +/// An image a widget is currently rotating through. +class WidgetImage { + const WidgetImage({ + required this.mediaId, + required this.path, + required this.sender, + required this.contactGroupIds, + required this.expiresAt, + }); + + final String mediaId; + final String path; + final String sender; + final List contactGroupIds; + final DateTime expiresAt; + + Duration get remaining => expiresAt.difference(DateTime.now()); + bool get isExpired => remaining.isNegative; + File get file => File(path); +} + +/// Keeps native widget placement and Rust's derived sharing permissions in +/// sync. Native configuration UIs also persist their selection in the shared +/// widget configuration file, which Rust imports during every sync. +class HomeWidgetService { + const HomeWidgetService._(); + + static const _runtimeChannel = MethodChannel('eu.twonly/runtime_storage'); + + static String get platform => Platform.isIOS ? 'ios' : 'android'; + + static Future register(String widgetId) async { + await RustApi.registerHomeWidget(widgetId: widgetId, platform: platform); + await refresh(); + } + + static Future unregister(String widgetId) async { + await RustApi.unregisterHomeWidget(widgetId: widgetId); + await refresh(); + } + + static Future setGroups( + String widgetId, + List contactGroupIds, + ) async { + await RustApi.setHomeWidgetGroups( + widgetId: widgetId, + platform: platform, + contactGroupIds: Int64List.fromList(contactGroupIds), + ); + await refresh(); + } + + static Future syncPermissions() async { + // Must run first: Rust derives who may share from the placement file, so a + // widget removed from the home screen has to disappear from it before that + // import reads it. + await reconcile(); + await RustApi.syncWidgetPermissions(); + await refresh(); + } + + /// Rewrites the placement file from the widgets WidgetKit says are placed. + /// + /// A WidgetKit extension is never told that its widget was removed, so its + /// own entry would otherwise linger. Only the app can ask, and only on iOS — + /// Android's provider already enumerates its widgets whenever one changes. + static Future reconcile() async => reconcileReport(); + + /// Reconciles, and reports what WidgetKit answered. + /// + /// Returns null when the platform has nothing to reconcile, and rethrows + /// nothing: a failed query leaves the placement file alone, because failing + /// to ask is not evidence that a widget was removed. The diagnostics screen + /// renders the report; ordinary callers use [reconcile]. + static Future?> reconcileReport() async { + if (!Platform.isIOS) return null; + // Reconciling waits on the widget extension, so a screen that syncs and + // then lists would otherwise pay for it twice in a row. + if (_cachedReport case final cached?) { + if (DateTime.now().difference(cached.at) < _reportCacheFor) { + return cached.report; + } + } + try { + final report = await _runtimeChannel.invokeMapMethod( + 'reconcileWidgets', + ); + _cachedReport = (at: DateTime.now(), report: report); + return report; + } catch (error) { + Log.error('Could not reconcile the placed widgets: $error'); + return {'error': '$error'}; + } + } + + static ({DateTime at, Map? report})? _cachedReport; + static const _reportCacheFor = Duration(seconds: 10); + + /// Drops the cached reconcile so the next read asks the system again. + static void invalidate() => _cachedReport = null; + + static Future purgeExpiredMedia() async { + await RustApi.purgeWidgetMedia(); + await refresh(); + } + + /// Republishes the manifest so the widgets see the current contact groups. + /// + /// A widget's configuration UI lists the groups from that file, so a group + /// that was just created is invisible to it until this runs. + static Future refreshManifest() async { + await RustApi.refreshWidgetManifest(); + await refresh(); + } + + /// Removes one image from every widget showing it, and from disk. + static Future deleteImage(String mediaId) async { + await RustApi.deleteWidgetMedia(mediaId: mediaId); + await refresh(); + } + + /// Asks the placed widgets to redraw from the manifest Rust just wrote. + /// + /// Neither platform notices the rewrite on its own: WidgetKit keeps the + /// timeline it built from the older manifest, and an AppWidget only redraws + /// when its provider is asked to. Until this runs, an image that has already + /// arrived is nowhere on the home screen. + static Future refresh() async { + try { + await _runtimeChannel.invokeMethod('reloadWidgets'); + } catch (error) { + Log.error('Could not reload the home widget: $error'); + } + } + + static Directory get _root => + Directory('${AppEnvironment.supportDir}/widget'); + + /// Mirrors `staleWidgetSeconds` in the iOS widget and `STALE_WIDGET_SECONDS` + /// in Rust: an iOS entry that has not refreshed within this belongs to a + /// widget that is no longer on the home screen. + static const _staleWidget = Duration(hours: 48); + + /// The widgets currently on the home screen. + /// + /// On iOS this is WidgetKit's own answer rather than the placement file: + /// the file is *derived* from this, so reading it back could only ever repeat + /// a stale write. Android has no equivalent query, and its provider rewrites + /// the file whenever a widget is added or removed, so there the file is the + /// authority. + /// + /// The returned `error` is set when iOS could not be asked; the widgets are + /// then whatever the file last recorded, which may name widgets that are + /// already gone. + static Future<({List widgets, String? error})> + placedWidgetsResult() async { + if (!Platform.isIOS) { + return (widgets: await _widgetsFromFile(), error: null); + } + final report = await reconcileReport(); + if (report == null || report['error'] != null) { + return ( + widgets: await _widgetsFromFile(), + error: '${report?['error'] ?? 'unknown'}', + ); + } + final reported = ((report['widgets'] as List?) ?? const []) + .cast>() + .where((entry) => entry['mine'] == true && entry['live'] == true); + final seen = {}; + return ( + widgets: [ + for (final entry in reported) + if (seen.add('${entry['id']}')) + PlacedWidget( + id: '${entry['id']}', + platform: 'ios', + family: entry['family'] as String?, + contactGroupIds: ((entry['group_ids'] as List?) ?? const []) + .map((id) => (id as num).toInt()) + .toList(), + ), + ], + error: null, + ); + } + + static Future> placedWidgets() async => + (await placedWidgetsResult()).widgets; + + static Future> _widgetsFromFile() async { + final file = File('${_root.path}/native-config.json'); + if (!file.existsSync()) return const []; + try { + final decoded = jsonDecode(await file.readAsString()); + final widgets = ((decoded as Map)['widgets'] as List?) + ?.cast>(); + final oldestLive = DateTime.now().subtract(_staleWidget); + return [ + for (final widget in widgets ?? const >[]) + if (_isLive(widget['last_seen'], oldestLive)) + PlacedWidget( + id: '${widget['id']}', + platform: '${widget['platform']}', + contactGroupIds: ((widget['group_ids'] as List?) ?? const []) + .map((id) => (id as num).toInt()) + .toList(), + ), + ]; + } catch (error) { + Log.error('Could not read the widget configuration: $error'); + return const []; + } + } + + /// An entry with no timestamp is Android's, which is always authoritative. + static bool _isLive(Object? lastSeen, DateTime oldestLive) { + if (lastSeen is! num) return true; + return DateTime.fromMillisecondsSinceEpoch( + lastSeen.toInt() * 1000, + ).isAfter(oldestLive); + } + + /// Every unexpired image Rust has published to the widgets, newest first. + static Future> images() async { + final file = File('${_root.path}/manifest.json'); + if (!file.existsSync()) return const []; + try { + final decoded = + jsonDecode(await file.readAsString()) as Map; + final images = ((decoded['images'] as List?) ?? const []) + .cast>(); + final parsed = [ + for (final image in images) + WidgetImage( + mediaId: '${image['mediaId'] ?? image['media_id']}', + path: '${image['path']}', + sender: '${image['sender']}', + contactGroupIds: ((image['group_ids'] as List?) ?? const []) + .map((id) => (id as num).toInt()) + .toList(), + expiresAt: DateTime.fromMillisecondsSinceEpoch( + ((image['expires_at'] as num?)?.toInt() ?? 0) * 1000, + ), + ), + ]..sort((a, b) => b.expiresAt.compareTo(a.expiresAt)); + return parsed.where((image) => !image.isExpired).toList(); + } catch (error) { + Log.error('Could not read the widget manifest: $error'); + return const []; + } + } + + /// The images a single widget rotates through: only senders whose contact + /// groups overlap the ones that widget selected. + static Future> imagesFor(PlacedWidget widget) async { + final selected = widget.contactGroupIds.toSet(); + final all = await images(); + return all + .where((image) => image.contactGroupIds.any(selected.contains)) + .toList(); + } +} diff --git a/lib/src/visual/context_menu/group.context_menu.dart b/lib/src/visual/context_menu/group.context_menu.dart index 0aa69a29..76449ada 100644 --- a/lib/src/visual/context_menu/group.context_menu.dart +++ b/lib/src/visual/context_menu/group.context_menu.dart @@ -46,7 +46,10 @@ class GroupContextMenu extends StatelessWidget { ContextMenuItem( title: context.lang.contextMenuOpenChat, onTap: () => - navigator.context.push(Routes.chatsMessages(group.groupId)), + navigator.context.push( + Routes.chatsMessages(group.groupId), + extra: group, + ), icon: FontAwesomeIcons.comments, ), if (!group.archived) diff --git a/lib/src/visual/elements/my_card.element.dart b/lib/src/visual/elements/my_card.element.dart new file mode 100644 index 00000000..ba065ffa --- /dev/null +++ b/lib/src/visual/elements/my_card.element.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/src/utils/misc.dart'; + +/// The app's standard tappable card: a tinted circular icon, a title with +/// supporting text, and a chevron. +/// +/// Grew out of the backup recovery options, which are the reference for how +/// this kind of row should look; every other place that needs the same shape +/// uses this rather than rebuilding it, so they stay in step. +class MyCard extends StatelessWidget { + const MyCard({ + required this.icon, + required this.title, + super.key, + this.subtitle, + this.onTap, + this.accentColor, + this.trailing, + this.titleColor, + }); + + /// `IconData` or `FaIconData`, matching the rest of the app's icon handling. + final dynamic icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + + /// Tints the icon and its circle. Defaults to the theme's primary colour; + /// pass an error colour to mark the card's subject as needing attention. + final Color? accentColor; + + /// Replaces the chevron. A card without `onTap` shows nothing here unless + /// this is given. + final Widget? trailing; + + /// Overrides the title colour, for cards whose title *is* the warning. + final Color? titleColor; + + static const _radius = 16.0; + + @override + Widget build(BuildContext context) { + final accent = accentColor ?? context.color.primary; + final content = Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: icon is IconData + ? Icon(icon as IconData, color: accent, size: 24) + : FaIcon(icon as FaIconData?, color: accent, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: titleColor, + ), + ), + if (subtitle case final subtitle?) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (trailing case final trailing?) ...[ + const SizedBox(width: 3), + trailing, + ] else if (onTap != null) ...[ + const SizedBox(width: 3), + Icon( + Icons.chevron_right_rounded, + color: context.color.onSurfaceVariant, + ), + ], + ], + ), + ); + + return Card( + elevation: 0, + color: context.color.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_radius), + ), + child: onTap == null + ? content + : InkWell( + borderRadius: BorderRadius.circular(_radius), + onTap: onTap, + child: content, + ), + ); + } +} diff --git a/lib/src/visual/themes/colors.dart b/lib/src/visual/themes/colors.dart index c3d88865..704a7586 100644 --- a/lib/src/visual/themes/colors.dart +++ b/lib/src/visual/themes/colors.dart @@ -20,6 +20,8 @@ Color getMessageColorFromType( color = Colors.orange; } else if (message.type == MessageType.text.name) { color = Colors.blueAccent; + } else if (message.isWidgetMedia) { + color = const Color.fromARGB(255, 155, 89, 182); } else if (mediaFile != null) { if (mediaFile.requiresAuthentication) { color = context.color.primary; diff --git a/lib/src/visual/views/camera/camera_preview_components/camera_preview.dart b/lib/src/visual/views/camera/camera_preview_components/camera_preview.dart index 486bb7bf..ce544029 100644 --- a/lib/src/visual/views/camera/camera_preview_components/camera_preview.dart +++ b/lib/src/visual/views/camera/camera_preview_components/camera_preview.dart @@ -19,7 +19,7 @@ class MainCameraPreview extends StatelessWidget { return Container(); } return Positioned.fill( - child: MediaViewSizingHelper.cameraEditor( + child: MediaViewSizingHelper( bottomNavigation: const SizedBox.shrink(), child: Stack( children: [ diff --git a/lib/src/visual/views/camera/share_image_contact_selection.view.dart b/lib/src/visual/views/camera/share_image_contact_selection.view.dart index f19457fb..d922d6eb 100644 --- a/lib/src/visual/views/camera/share_image_contact_selection.view.dart +++ b/lib/src/visual/views/camera/share_image_contact_selection.view.dart @@ -30,6 +30,7 @@ class ShareImageView extends StatefulWidget { required this.mediaStoreFuture, required this.mediaFileService, required this.additionalData, + required this.sendToWidget, super.key, }); final HashSet selectedGroupIds; @@ -37,6 +38,7 @@ class ShareImageView extends StatefulWidget { final Future? mediaStoreFuture; final MediaFileService mediaFileService; final AdditionalMessageData? additionalData; + final bool sendToWidget; @override State createState() => _ShareImageView(); @@ -47,7 +49,6 @@ class _ShareImageView extends State { List _otherUsers = []; List _bestFriends = []; List _pinnedContacts = []; - bool sendingImage = false; bool mediaStoreFutureReady = false; ScreenshotImageHelper? _screenshotImage; @@ -60,9 +61,19 @@ class _ShareImageView extends State { void initState() { super.initState(); - allGroupSub = twonlyDB.groupsDao.watchGroupsForShareImage().listen(( - allGroups, - ) async { + final groups = widget.sendToWidget + ? twonlyDB.groupsDao.watchGroupsAllowedForWidgetShare() + : twonlyDB.groupsDao.watchGroupsForShareImage(); + allGroupSub = groups.listen((allGroups) async { + if (!mounted) return; + if (widget.sendToWidget) { + final allowedGroupIds = allGroups.map((group) => group.groupId).toSet(); + for (final groupId in widget.selectedGroupIds.toList()) { + if (!allowedGroupIds.contains(groupId)) { + widget.updateSelectedGroupIds(groupId, false); + } + } + } setState(() { _allGroups = allGroups; }); @@ -188,10 +199,11 @@ class _ShareImageView extends State { ), ), const SizedBox(height: 10), - ContactGroupShortcutRow( - selectedGroupIds: widget.selectedGroupIds, - updateSelectedGroupIds: updateSelectedGroupIds, - ), + if (!widget.sendToWidget) + ContactGroupShortcutRow( + selectedGroupIds: widget.selectedGroupIds, + updateSelectedGroupIds: updateSelectedGroupIds, + ), if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10), BestFriendsSelector( groups: _pinnedContacts, @@ -199,6 +211,7 @@ class _ShareImageView extends State { updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImagePinnedContacts, showSelectAll: + !widget.sendToWidget && !widget.mediaFileService.mediaFile.requiresAuthentication, ), const SizedBox(height: 10), @@ -208,6 +221,7 @@ class _ShareImageView extends State { updateSelectedGroupIds: updateSelectedGroupIds, title: context.lang.shareImageBestFriends, showSelectAll: + !widget.sendToWidget && !widget.mediaFileService.mediaFile.requiresAuthentication, ), const SizedBox(height: 10), @@ -316,6 +330,7 @@ class _ShareImageView extends State { groupIds: widget.selectedGroupIds.toList(), additionalMessageData: widget.additionalData ?.writeToBuffer(), + widgetOnly: widget.sendToWidget, ), 'sendMediaToGroups', ); diff --git a/lib/src/visual/views/camera/share_image_editor.view.dart b/lib/src/visual/views/camera/share_image_editor.view.dart index fb9803a8..7d59863a 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -1,13 +1,16 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:math' as math; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart'; import 'package:twonly/src/visual/helpers/screenshot.helper.dart'; @@ -23,6 +26,7 @@ import 'package:twonly/src/visual/views/camera/share_image_editor_components/edi import 'package:twonly/src/visual/views/camera/share_image_editor_components/editor_top_toolbar.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/image_item.dart'; import 'package:twonly/src/visual/views/camera/share_image_editor_components/video_trimmer.dart'; +import 'package:twonly/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart'; import 'package:video_player/video_player.dart'; /// Lets the user edit a just taken (or shared) photo/video/gif and send it. @@ -82,6 +86,25 @@ class _ShareImageEditorView extends State { ImageItem currentImage = ImageItem(); ScreenshotController screenshotController = ScreenshotController(); Timer? _imageLoadingTimer; + late final StreamSubscription> _widgetGroupsSubscription; + final GlobalKey _editorStackKey = GlobalKey(); + final GlobalKey _widgetActionKey = GlobalKey(); + Offset? _widgetActionCenter; + bool _widgetActionMeasurementScheduled = false; + + bool _widgetRecipientAvailable = false; + bool _sendToWidget = false; + bool _updatingWidgetMode = false; + bool _widgetExplainerPreferenceLoaded = false; + bool _widgetExplainerDismissed = false; + bool _previousMediaSettingsCaptured = false; + int? _displayLimitBeforeWidget; + bool _requiresAuthBeforeWidget = false; + + bool get _showWidgetExplainer => + _widgetRecipientAvailable && + _widgetExplainerPreferenceLoaded && + !_widgetExplainerDismissed; MediaFileService get mediaService => widget.mediaFileService; MediaFile get media => widget.mediaFileService.mediaFile; @@ -113,6 +136,11 @@ class _ShareImageEditorView extends State { selectedGroupIds.add(widget.sendToGroup!.groupId); } + _widgetGroupsSubscription = twonlyDB.groupsDao + .watchGroupsAllowedForWidgetShare() + .listen(_updateWidgetRecipientAvailability); + unawaited(_loadWidgetExplainerPreference()); + if (media.type == MediaType.image || media.type == MediaType.gif) { _loadInitialImage(); } @@ -133,9 +161,69 @@ class _ShareImageEditorView extends State { ), ); _imageLoadingTimer?.cancel(); + unawaited(_widgetGroupsSubscription.cancel()); super.dispose(); } + Future _loadWidgetExplainerPreference() async { + final preference = await KeyValueStore.get( + KeyValueKeys.shareImageWidgetExplainer, + ); + if (!mounted) return; + setState(() { + _widgetExplainerDismissed = preference?['dismissed'] == true; + _widgetExplainerPreferenceLoaded = true; + }); + } + + Future _dismissWidgetExplainer() async { + setState(() => _widgetExplainerDismissed = true); + await KeyValueStore.put( + KeyValueKeys.shareImageWidgetExplainer, + const {'dismissed': true}, + ); + } + + void _updateWidgetRecipientAvailability(List widgetGroups) { + final fixedGroupId = widget.sendToGroup?.groupId; + final recipientAvailable = + media.type == MediaType.image && + (fixedGroupId == null + ? widgetGroups.isNotEmpty + : widgetGroups.any((group) => group.groupId == fixedGroupId)); + + if (mounted && recipientAvailable != _widgetRecipientAvailable) { + setState(() => _widgetRecipientAvailable = recipientAvailable); + } + if (!recipientAvailable && _sendToWidget && !_updatingWidgetMode) { + unawaited(_setSendToWidget(false)); + } + } + + void _scheduleWidgetActionMeasurement() { + if (_widgetActionMeasurementScheduled) return; + _widgetActionMeasurementScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _widgetActionMeasurementScheduled = false; + if (!mounted || !_showWidgetExplainer) return; + + final actionBox = + _widgetActionKey.currentContext?.findRenderObject() as RenderBox?; + final stackBox = + _editorStackKey.currentContext?.findRenderObject() as RenderBox?; + if (actionBox == null || stackBox == null || !actionBox.hasSize) return; + + final topLeft = actionBox.localToGlobal( + Offset.zero, + ancestor: stackBox, + ); + final center = topLeft + actionBox.size.center(Offset.zero); + if (_widgetActionCenter != center) { + setState(() => _widgetActionCenter = center); + } + }); + } + // --------------------------------------------------------------------------- // loading the media // --------------------------------------------------------------------------- @@ -286,6 +374,51 @@ class _ShareImageEditorView extends State { if (mounted) setState(() {}); } + Future _setSendToWidget(bool enabled) async { + if (_updatingWidgetMode || (enabled && !_widgetRecipientAvailable)) return; + + if (widget.sendToGroup == null) { + selectedGroupIds.clear(); + } + + if (enabled) { + _displayLimitBeforeWidget = media.displayLimitInMilliseconds; + _requiresAuthBeforeWidget = media.requiresAuthentication; + _previousMediaSettingsCaptured = true; + } + + setState(() { + _sendToWidget = enabled; + _updatingWidgetMode = true; + }); + + try { + if (enabled) { + if (media.displayLimitInMilliseconds != null) { + await mediaService.setDisplayLimit(null); + } + if (media.requiresAuthentication) { + await mediaService.setRequiresAuth(false); + } + } else if (_previousMediaSettingsCaptured) { + if (media.displayLimitInMilliseconds != _displayLimitBeforeWidget) { + await mediaService.setDisplayLimit(_displayLimitBeforeWidget); + } + if (media.requiresAuthentication != _requiresAuthBeforeWidget) { + await mediaService.setRequiresAuth(_requiresAuthBeforeWidget); + } + _previousMediaSettingsCaptured = false; + } + } finally { + if (mounted) { + setState(() => _updatingWidgetMode = false); + if (_sendToWidget && !_widgetRecipientAvailable) { + unawaited(_setSendToWidget(false)); + } + } + } + } + Future _editDisplayTime() async { await showDisplayTimePicker( context, @@ -352,6 +485,7 @@ class _ShareImageEditorView extends State { mediaStoreFuture: mediaStoreFuture, mediaFileService: mediaService, additionalData: getAdditionalData(), + sendToWidget: _sendToWidget, ), ), ); @@ -381,6 +515,7 @@ class _ShareImageEditorView extends State { mediaId: mediaService.mediaFile.mediaId, groupIds: [widget.sendToGroup!.groupId], additionalMessageData: getAdditionalData()?.writeToBuffer(), + widgetOnly: _sendToWidget, ); if (mounted) { @@ -409,6 +544,13 @@ class _ShareImageEditorView extends State { @override Widget build(BuildContext context) { pixelRatio = MediaQuery.of(context).devicePixelRatio; + if (_showWidgetExplainer) _scheduleWidgetActionMeasurement(); + + final double widgetExplainerWidth = math.min( + 340, + MediaQuery.sizeOf(context).width - 82, + ); + final widgetActionCenter = _widgetActionCenter; return PopScope( canPop: false, @@ -422,6 +564,7 @@ class _ShareImageEditorView extends State { : Colors.white.withAlpha(0), resizeToAvoidBottomInset: false, body: Stack( + key: _editorStackKey, fit: StackFit.expand, children: [ GestureDetector( @@ -438,7 +581,7 @@ class _ShareImageEditorView extends State { mediaService: mediaService, sendToGroup: widget.sendToGroup, isLoadingImage: loadingImage, - isSending: sendingOrLoadingImage, + isSending: sendingOrLoadingImage || _updatingWidgetMode, storeImageAsOriginal: storeImageAsOriginal, onAddMoreRecipients: pushShareImageView, onSend: () async { @@ -484,6 +627,21 @@ class _ShareImageEditorView extends State { ), ), ), + if (_showWidgetExplainer && widgetActionCenter != null) + Positioned( + left: math.max( + 12, + widgetActionCenter.dx + 24 - widgetExplainerWidth, + ), + top: widgetActionCenter.dy, + width: widgetExplainerWidth, + child: FractionalTranslation( + translation: const Offset(0, -0.5), + child: WidgetShareExplainer( + onDismiss: _dismissWidgetExplainer, + ), + ), + ), Positioned( right: 6, top: 100, @@ -498,6 +656,13 @@ class _ShareImageEditorView extends State { onEditDisplayTime: _editDisplayTime, onToggleAudio: _toggleAudio, onToggleRequiresAuth: _toggleRequiresAuth, + sendToWidget: _sendToWidget, + showWidgetOption: _widgetRecipientAvailable, + highlightWidgetOption: _showWidgetExplainer, + isUpdatingWidgetMode: _updatingWidgetMode, + widgetActionKey: _widgetActionKey, + onToggleSendToWidget: () => + _setSendToWidget(!_sendToWidget), canTrim: _canTrim, trimmerVisible: _trimmerVisible, onToggleTrimmer: () => diff --git a/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart b/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart index 762da209..1885df15 100644 --- a/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart +++ b/lib/src/visual/views/camera/share_image_editor_components/editor_side_toolbar.dart @@ -23,6 +23,12 @@ class EditorSideToolbar extends StatelessWidget { required this.canTrim, required this.trimmerVisible, required this.onToggleTrimmer, + required this.sendToWidget, + required this.showWidgetOption, + required this.highlightWidgetOption, + required this.isUpdatingWidgetMode, + required this.widgetActionKey, + required this.onToggleSendToWidget, super.key, }); @@ -40,6 +46,12 @@ class EditorSideToolbar extends StatelessWidget { final bool canTrim; final bool trimmerVisible; final VoidCallback onToggleTrimmer; + final bool sendToWidget; + final bool showWidgetOption; + final bool highlightWidgetOption; + final bool isUpdatingWidgetMode; + final GlobalKey widgetActionKey; + final VoidCallback onToggleSendToWidget; MediaFile get media => mediaService.mediaFile; @@ -103,15 +115,32 @@ class EditorSideToolbar extends StatelessWidget { onChanged(); }, ), - const SizedBox(height: 8), - NotificationBadgeComp( - count: _displayTimeLabel, - child: ActionButton( - _displayTimeIcon, - tooltipText: context.lang.protectAsARealTwonly, - onPressed: onEditDisplayTime, + if (showWidgetOption) ...[ + const SizedBox(height: 8), + SizedBox( + key: widgetActionKey, + child: ActionButton( + sendToWidget ? Icons.widgets_rounded : Icons.widgets_outlined, + tooltipText: context.lang.shareImageSendToWidget, + color: sendToWidget || highlightWidgetOption + ? Theme.of(context).colorScheme.primary + : Colors.white, + disable: isUpdatingWidgetMode, + onPressed: onToggleSendToWidget, + ), ), - ), + ], + if (!sendToWidget) ...[ + const SizedBox(height: 8), + NotificationBadgeComp( + count: _displayTimeLabel, + child: ActionButton( + _displayTimeIcon, + tooltipText: context.lang.protectAsARealTwonly, + onPressed: onEditDisplayTime, + ), + ), + ], if (canTrim) ...[ const SizedBox(height: 8), ActionButton( @@ -148,15 +177,17 @@ class EditorSideToolbar extends StatelessWidget { }, ), ], - const SizedBox(height: 8), - ActionButton( - FontAwesomeIcons.shieldHeart, - tooltipText: context.lang.protectAsARealTwonly, - color: media.requiresAuthentication - ? Theme.of(context).colorScheme.primary - : Colors.white, - onPressed: onToggleRequiresAuth, - ), + if (!sendToWidget) ...[ + const SizedBox(height: 8), + ActionButton( + FontAwesomeIcons.shieldHeart, + tooltipText: context.lang.protectAsARealTwonly, + color: media.requiresAuthentication + ? Theme.of(context).colorScheme.primary + : Colors.white, + onPressed: onToggleRequiresAuth, + ), + ], ], ); } diff --git a/lib/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart b/lib/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart new file mode 100644 index 00000000..b7cd4b78 --- /dev/null +++ b/lib/src/visual/views/camera/share_image_editor_components/widget_share_explainer.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; + +/// A one-time coach mark for the widget-send option in the editor toolbar. +class WidgetShareExplainer extends StatelessWidget { + const WidgetShareExplainer({required this.onDismiss, super.key}); + + final VoidCallback onDismiss; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Material( + color: colors.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 10, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.lang.shareImageWidgetExplainerTitle, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + context.lang.shareImageWidgetExplainerBody, + style: Theme.of(context).textTheme.bodyMedium, + ), + Align( + alignment: Alignment.centerRight, + child: MyButton( + variant: MyButtonVariant.text, + onPressed: onDismiss, + child: Text( + context.lang.shareImageWidgetExplainerDismiss, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 40), + ], + ), + ), + Positioned.fill( + child: IgnorePointer( + child: Align( + alignment: Alignment.centerRight, + child: Transform.translate( + offset: const Offset(-1.5, 0), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + border: Border.all(color: colors.primary, width: 2), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart index ea01f69d..f8bea14e 100644 --- a/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart +++ b/lib/src/visual/views/chats/chat_list_components/group_list_item.comp.dart @@ -237,7 +237,7 @@ class _UserListItem extends State { } final msgs = _previewMessages - .where((x) => x.type == MessageType.media.name) + .where((x) => x.type == MessageType.media.name && !x.isWidgetMedia) .toList(); if (msgs.isNotEmpty && msgs.first.type == MessageType.media.name && @@ -291,7 +291,7 @@ class _UserListItem extends State { if (_hasNonOpenedMediaFile) { final msgs = _previewMessages - .where((x) => x.type == MessageType.media.name) + .where((x) => x.type == MessageType.media.name && !x.isWidgetMedia) .toList(); final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById( msgs.first.mediaId!, @@ -313,7 +313,10 @@ class _UserListItem extends State { } } if (!mounted) return; - await context.push(Routes.chatsMessages(widget.group.groupId)); + await context.push( + Routes.chatsMessages(widget.group.groupId), + extra: widget.group, + ); } @override @@ -434,6 +437,7 @@ class _UserListItem extends State { if (_hasNonOpenedMediaFile) { context.push( Routes.chatsMessages(widget.group.groupId), + extra: widget.group, ); } else { context.push( diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 4afd5b4d..b2b6fccf 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -81,10 +81,14 @@ class _ChatSubscriptions { } class ChatMessagesView extends StatefulWidget { - const ChatMessagesView(this.groupId, {super.key}); + const ChatMessagesView(this.groupId, {this.initialGroup, super.key}); final String groupId; + /// Handed over by the caller when it already holds the row, so the first + /// frame can draw the real chat instead of waiting for the group stream. + final Group? initialGroup; + @override State createState() => _ChatMessagesViewState(); } @@ -119,6 +123,7 @@ class _ChatMessagesViewState extends State @override void initState() { super.initState(); + _group = widget.initialGroup; textFieldFocus = FocusNode(); WidgetsBinding.instance.addObserver(this); itemPositionsListener.itemPositions.addListener(_loadOlderWhenNeeded); @@ -576,7 +581,12 @@ class _ChatMessagesViewState extends State @override Widget build(BuildContext context) { - if (_group == null) return Container(); + if (_group == null) { + // Drawing nothing leaves the pushed route black until the group row + // arrives, which is plainly visible whenever the database is busy. The + // empty app bar keeps the back button reachable during that wait. + return Scaffold(appBar: AppBar(), body: const SizedBox.shrink()); + } final group = _group!; return GestureDetector( onTap: () => FocusScope.of(context).unfocus(), diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart index e22498e4..07955caa 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_media_entry.dart @@ -78,7 +78,9 @@ class _ChatMediaEntryState extends State { } Future initAsync() async { - if (widget.message.senderId == null || widget.message.mediaStored) { + if (widget.message.senderId == null || + widget.message.mediaStored || + widget.message.isWidgetMedia) { return; } if (widget.mediaService.mediaFile.requiresAuthentication || @@ -95,7 +97,9 @@ class _ChatMediaEntryState extends State { } Future onDoubleTap() async { - if (widget.message.openedAt == null || widget.message.mediaStored) { + if (widget.message.isWidgetMedia || + widget.message.openedAt == null || + widget.message.mediaStored) { return; } if (widget.mediaService.canBeOpenedAgain && @@ -120,6 +124,7 @@ class _ChatMediaEntryState extends State { } Future onTap() async { + if (widget.message.isWidgetMedia) return; if ((widget.mediaService.mediaFile.downloadState == DownloadState.ready) && widget.message.openedAt == null) { if (!mounted) return; diff --git a/lib/src/visual/views/chats/chat_messages_components/in_chat_media_viewer.dart b/lib/src/visual/views/chats/chat_messages_components/in_chat_media_viewer.dart index 0cb0f387..1884dfd7 100644 --- a/lib/src/visual/views/chats/chat_messages_components/in_chat_media_viewer.dart +++ b/lib/src/visual/views/chats/chat_messages_components/in_chat_media_viewer.dart @@ -137,7 +137,8 @@ class _InChatMediaViewerState extends State { @override Widget build(BuildContext context) { - if (!widget.message.mediaStored || + if (widget.message.isWidgetMedia || + !widget.message.mediaStored || !widget.mediaService.imagePreviewAvailable) { return Container( constraints: const BoxConstraints( diff --git a/lib/src/visual/views/chats/chat_messages_components/message_input.dart b/lib/src/visual/views/chats/chat_messages_components/message_input.dart index 22881ccc..84ced648 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_input.dart @@ -165,7 +165,8 @@ class _MessageInputState extends State { // The periodic announcement is what keeps the indicator alive, but at its // cadence the first keystroke would take seconds to reach the other side. // That one is announced directly and the timer carries it from there. - final wasIdle = _lastTextChangeTime == null || + final wasIdle = + _lastTextChangeTime == null || now.difference(_lastTextChangeTime!) > typingIndicatorInterval; _lastTextChangeTime = now; if (wasIdle && @@ -257,6 +258,7 @@ class _MessageInputState extends State { await RustApi.sendMediaToGroups( mediaId: mediaFileService.mediaFile.mediaId, groupIds: [widget.group.groupId], + widgetOnly: false, ); } diff --git a/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart b/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart index 6560a095..4f458031 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart @@ -12,6 +12,7 @@ import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/animate_icon.comp.dart'; import 'package:twonly/src/visual/themes/colors.dart'; +import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/file_limit_reached.dialog.dart'; enum MessageSendState { @@ -95,6 +96,12 @@ class _MessageSendStateIconState extends State { var hasLoader = false; GestureTapCallback? onTap; + /// Only the chat view knows which of the sent messages the receivers have + /// acknowledged; elsewhere the set stays empty and 'sent' is the last state. + final ackedMessageIds = + ChatMessageActionScope.maybeOf(context)?.ackedMessageIds ?? + const {}; + for (final message in widget.messages) { if (icons.length == 2) break; if (kindsAlreadyShown.contains(message.type)) continue; @@ -125,6 +132,13 @@ class _MessageSendStateIconState extends State { } } text = context.lang.messageSendState_Received; + if (message.isWidgetMedia && + mediaFile != null && + mediaFile.downloadState == DownloadState.downloading) { + text = context.lang.messageSendState_Loading; + icon = getLoaderIcon(color); + hasLoader = true; + } if (widget.canBeReopened) { textWidget = Text( context.lang.doubleClickToReopen, @@ -153,7 +167,9 @@ class _MessageSendStateIconState extends State { size: 12, color: color, ); - text = context.lang.messageSendState_Send; + text = ackedMessageIds.contains(message.messageId) + ? context.lang.messageSendState_Delivered + : context.lang.messageSendState_Send; case MessageSendState.sending: icon = getLoaderIcon(color); text = context.lang.messageSendState_Sending; @@ -193,6 +209,12 @@ class _MessageSendStateIconState extends State { hasLoader = true; } + // Only the icon marks a widget media apart; the state text stays the + // same as for any other media so the two read alike. + if (message.isWidgetMedia && !hasLoader) { + icon = Icon(Icons.widgets_rounded, size: 12, color: color); + } + if (message.mediaStored && message.openedAt != null) { icon = FaIcon(FontAwesomeIcons.floppyDisk, size: 12, color: color); text = context.lang.messageStoredInGallery; diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index 7c3b7309..9dcdecae 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -84,7 +84,8 @@ class _MediaViewerViewState extends State { super.initState(); _currentMediaSender = widget.group.groupName; - if (widget.initialMessage != null) { + if (widget.initialMessage != null && + !widget.initialMessage!.isWidgetMedia) { allMediaFiles = [widget.initialMessage!]; } @@ -137,6 +138,9 @@ class _MediaViewerViewState extends State { .listen((messages) async { await _messageUpdateLock.protect(() async { for (final msg in messages) { + if (msg.isWidgetMedia) { + continue; + } if (_alreadyOpenedMediaIds.contains(msg.mediaId)) { continue; } @@ -210,7 +214,10 @@ class _MediaViewerViewState extends State { if (group != null && group.draftMessage != null && group.draftMessage != '') { - context.replace(Routes.chatsMessages(group.groupId)); + context.replace( + Routes.chatsMessages(group.groupId), + extra: group, + ); } else { Navigator.pop(context); } diff --git a/lib/src/visual/views/contact/contact.view.dart b/lib/src/visual/views/contact/contact.view.dart index 53033dca..f2d147cf 100644 --- a/lib/src/visual/views/contact/contact.view.dart +++ b/lib/src/visual/views/contact/contact.view.dart @@ -204,7 +204,10 @@ class _ContactViewState extends State { contact.userId, ); if (group != null && context.mounted) { - await context.push(Routes.chatsMessages(group.groupId)); + await context.push( + Routes.chatsMessages(group.groupId), + extra: group, + ); } }, ), @@ -316,12 +319,21 @@ class _ContactViewState extends State { Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Center( - child: Text( - 'Encryption: ${switch (contact.signalVersion) { - SignalVersion.v1 => 'Signal Protocol (v1)', - SignalVersion.v2 => 'PQXDH (v2)', - }}', - style: const TextStyle(color: Colors.grey), + child: Column( + children: [ + Text( + 'Encryption: ${switch (contact.signalVersion) { + SignalVersion.v1 => 'Signal Protocol (v1)', + SignalVersion.v2 => 'PQXDH (v2)', + }}', + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 4), + Text( + 'Can send widgets: ${contact.widgetSharingAllowed ? 'Yes' : 'No'}', + style: const TextStyle(color: Colors.grey), + ), + ], ), ), ), diff --git a/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart b/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart index 219dca47..5b1e6585 100644 --- a/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart +++ b/lib/src/visual/views/contact/contact_components/mutual_groups_expansion_tile.comp.dart @@ -102,7 +102,10 @@ class _MutualGroupsExpansionTileCompState ), ), onTap: () { - context.push(Routes.chatsMessages(group.groupId)); + context.push( + Routes.chatsMessages(group.groupId), + extra: group, + ); }, ); }).toList(), diff --git a/lib/src/visual/views/contact/contact_group_settings.view.dart b/lib/src/visual/views/contact/contact_group_settings.view.dart index 1c4f6eed..a21371c3 100644 --- a/lib/src/visual/views/contact/contact_group_settings.view.dart +++ b/lib/src/visual/views/contact/contact_group_settings.view.dart @@ -5,6 +5,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/home_widget.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/contact_groups.comp.dart'; @@ -78,6 +79,11 @@ class _ContactGroupSettingsViewState extends State { final Set _selectedGroupIds = {}; final List> _subscriptions = []; + /// Widgets on the home screen configured with this contact group. Its + /// members are what makes those widgets able to receive anything, so the + /// group cannot be deleted while any of them is still placed. + int _widgetCount = 0; + bool get _isEditing => widget.contactGroup != null; @override @@ -91,6 +97,7 @@ class _ContactGroupSettingsViewState extends State { contactGroup?.showAsShortcut ?? widget.initialShowAsShortcut; _showAsLabel = contactGroup?.showAsLabel ?? !widget.initialShowAsShortcut; _emoji = contactGroup?.emoji; + if (contactGroup != null) unawaited(_loadWidgetUsage(contactGroup.id)); _subscriptions.add( twonlyDB.contactsDao.watchAllAcceptedContacts().listen((contacts) { @@ -154,14 +161,18 @@ class _ContactGroupSettingsViewState extends State { } List<_MemberEntry> get _members { - final entries = <_MemberEntry>[ - for (final contact in _contacts) - _MemberEntry(name: getContactDisplayName(contact), contact: contact), - for (final group in _groups) - _MemberEntry(name: group.groupName, group: group), - ]..sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); + final entries = + <_MemberEntry>[ + for (final contact in _contacts) + _MemberEntry( + name: getContactDisplayName(contact), + contact: contact, + ), + for (final group in _groups) + _MemberEntry(name: group.groupName, group: group), + ]..sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); final filter = _memberFilter.trim().toLowerCase(); if (filter.isEmpty) return entries; return entries @@ -232,15 +243,49 @@ class _ContactGroupSettingsViewState extends State { userIds: _selectedUserIds, groupIds: _selectedGroupIds, ); + // A widget's configuration UI lists the groups from the manifest, so a + // group that was just created or renamed is invisible to it until the + // manifest is republished. + unawaited(HomeWidgetService.refreshManifest()); if (mounted) Navigator.pop(context, id); } finally { if (mounted) setState(() => _saving = false); } } + Future _loadWidgetUsage(int contactGroupId) async { + final widgets = await HomeWidgetService.placedWidgets(); + final count = widgets + .where((placed) => placed.contactGroupIds.contains(contactGroupId)) + .length; + if (!mounted) return; + setState(() => _widgetCount = count); + } + Future _delete() async { final contactGroup = widget.contactGroup; if (contactGroup == null) return; + if (_widgetCount > 0) { + // Deleting would silently strip the widget of everyone allowed to send to + // it, leaving a placed widget that can never fill up again. Neither + // platform lets the app take the widget down, so the user has to. + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.lang.deleteContactGroup), + content: Text( + context.lang.contactGroupDeleteBlockedByWidget(_widgetCount), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.lang.ok), + ), + ], + ), + ); + return; + } final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( @@ -260,6 +305,7 @@ class _ContactGroupSettingsViewState extends State { ); if (confirmed != true) return; await twonlyDB.contactGroupsDao.deleteContactGroup(contactGroup.id); + unawaited(HomeWidgetService.refreshManifest()); if (mounted) Navigator.pop(context); } @@ -343,7 +389,9 @@ class _ContactGroupSettingsViewState extends State { width: painter.width.clamp(16, 240) + 8, child: TextField( controller: _nameController, - autofocus: true, + // Only for a brand-new group: opening an existing one to change its + // colour or members should not throw up the keyboard. + autofocus: widget.contactGroup == null, maxLength: 24, textAlign: TextAlign.center, textCapitalization: TextCapitalization.words, @@ -406,100 +454,121 @@ class _ContactGroupSettingsViewState extends State { ], ), ), - body: ListView( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 100), - children: [ - _nameEditor(), - const SizedBox(height: 20), - Text( - context.lang.contactGroupBackgroundColor, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 10), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - _colorButton( - noContactGroupBackgroundColor, - !hasBackground, - () => setState( - () => _backgroundColor = noContactGroupBackgroundColor, - ), - tooltip: context.lang.contactGroupNoBackground, - ), - for (final color in _backgroundColors) + body: GestureDetector( + // The name and filter fields sit in a long scrolling page, so tapping + // anywhere that is not a control should put the keyboard away. + behavior: HitTestBehavior.translucent, + onTap: () => FocusScope.of(context).unfocus(), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 100), + children: [ + _nameEditor(), + const SizedBox(height: 20), + Text( + context.lang.contactGroupBackgroundColor, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ _colorButton( - color, - _backgroundColor == color, - () => setState(() => _backgroundColor = color), + noContactGroupBackgroundColor, + !hasBackground, + () => setState( + () => _backgroundColor = noContactGroupBackgroundColor, + ), + tooltip: context.lang.contactGroupNoBackground, ), - _customColorButton(background: true), - ], - ), - const SizedBox(height: 20), - Text( - context.lang.contactGroupTextColor, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 10), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - for (final color in _textColors) - _colorButton( - color, - _textColor == color, - () => setState(() => _textColor = color), - ), - _customColorButton(background: false), - ], - ), - const Divider(height: 40), - Text( - context.lang.contactGroupFeatures, - style: Theme.of(context).textTheme.titleMedium, - ), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: Text(context.lang.contactGroupShowAsLabel), - subtitle: Text(context.lang.contactGroupShowAsLabelSubtitle), - value: _showAsLabel, - onChanged: (value) => setState(() => _showAsLabel = value), - ), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: Text(context.lang.contactGroupShowAsShortcut), - subtitle: Text(context.lang.contactGroupShowAsShortcutSubtitle), - value: _showAsShortcut, - onChanged: (value) => setState(() => _showAsShortcut = value), - ), - if (_showAsShortcut) - ListTile( + for (final color in _backgroundColors) + _colorButton( + color, + _backgroundColor == color, + () => setState(() => _backgroundColor = color), + ), + _customColorButton(background: true), + ], + ), + const SizedBox(height: 20), + Text( + context.lang.contactGroupTextColor, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (final color in _textColors) + _colorButton( + color, + _textColor == color, + () => setState(() => _textColor = color), + ), + _customColorButton(background: false), + ], + ), + const Divider(height: 40), + Text( + context.lang.contactGroupFeatures, + style: Theme.of(context).textTheme.titleMedium, + ), + SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, - title: Text(context.lang.selectEmoji), - trailing: Text( - _emoji ?? '+', - style: const TextStyle(fontSize: 24), + title: Text(context.lang.contactGroupShowAsLabel), + subtitle: Text(context.lang.contactGroupShowAsLabelSubtitle), + value: _showAsLabel, + onChanged: (value) => setState(() => _showAsLabel = value), + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: Text(context.lang.contactGroupShowAsShortcut), + subtitle: Text(context.lang.contactGroupShowAsShortcutSubtitle), + value: _showAsShortcut, + onChanged: (value) => setState(() => _showAsShortcut = value), + ), + if (_showAsShortcut) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.lang.selectEmoji), + trailing: Text( + _emoji ?? '+', + style: const TextStyle(fontSize: 24), + ), + onTap: _selectEmoji, ), - onTap: _selectEmoji, + if (_widgetCount > 0) + // Stated rather than offered as a switch: which groups a widget + // draws from is chosen on the home screen, not here. + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.lang.contactGroupUsedByWidget), + subtitle: Text( + context.lang.contactGroupUsedByWidgetSubtitle(_widgetCount), + ), + trailing: FaIcon( + FontAwesomeIcons.image, + size: 18, + color: context.color.primary, + ), + ), + const Divider(height: 40), + Text( + context.lang.contactGroupMembers, + style: Theme.of(context).textTheme.titleMedium, ), - const Divider(height: 40), - Text( - context.lang.contactGroupMembers, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - TextField( - onChanged: (value) => setState(() => _memberFilter = value), - decoration: getInputDecoration( - context, - context.lang.shareImageSearchAllContacts, + const SizedBox(height: 12), + TextField( + onChanged: (value) => setState(() => _memberFilter = value), + decoration: getInputDecoration( + context, + context.lang.shareImageSearchAllContacts, + ), ), - ), - for (final entry in members) _memberTile(entry), - ], + for (final entry in members) _memberTile(entry), + ], + ), ), ); } diff --git a/lib/src/visual/views/groups/group_member.context.dart b/lib/src/visual/views/groups/group_member.context.dart index 579d4451..a3d4ff51 100644 --- a/lib/src/visual/views/groups/group_member.context.dart +++ b/lib/src/visual/views/groups/group_member.context.dart @@ -136,6 +136,7 @@ class GroupMemberContextMenu extends StatelessWidget { if (!navigator.mounted) return; await navigator.context.push( Routes.chatsMessages(directChat.groupId), + extra: directChat, ); }, icon: FontAwesomeIcons.message, diff --git a/lib/src/visual/views/onboarding/recover_password.view.dart b/lib/src/visual/views/onboarding/recover_password.view.dart index 84acc7ff..b0304ef8 100644 --- a/lib/src/visual/views/onboarding/recover_password.view.dart +++ b/lib/src/visual/views/onboarding/recover_password.view.dart @@ -188,6 +188,7 @@ class _BackupRecoveryViewState extends State { }, child: Text( context.lang.passwordlessRecoveryRecoverBtn, + textAlign: TextAlign.center, ), ), const Spacer(), diff --git a/lib/src/visual/views/settings/backup/components/recovery_card.comp.dart b/lib/src/visual/views/settings/backup/components/recovery_card.comp.dart index abf29333..cd53efad 100644 --- a/lib/src/visual/views/settings/backup/components/recovery_card.comp.dart +++ b/lib/src/visual/views/settings/backup/components/recovery_card.comp.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_card.element.dart'; +/// A recovery option, tinted red until it is actually set up. class RecoveryCard extends StatelessWidget { const RecoveryCard({ required this.icon, @@ -19,69 +20,12 @@ class RecoveryCard extends StatelessWidget { @override Widget build(BuildContext context) { - final effectiveColor = isEnabled ? context.color.primary : Colors.red; - - return Card( - elevation: 0, - color: context.color.surfaceContainer, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: icon is IconData - ? Icon( - icon as IconData, - color: effectiveColor, - size: 24, - ) - : FaIcon( - icon as FaIconData?, - color: effectiveColor, - size: 24, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: context.color.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 3), - Icon( - Icons.chevron_right_rounded, - color: context.color.onSurfaceVariant, - ), - ], - ), - ), - ), + return MyCard( + icon: icon, + title: title, + subtitle: subtitle, + onTap: onTap, + accentColor: isEnabled ? context.color.primary : Colors.red, ); } } diff --git a/lib/src/visual/views/settings/developer/developer.view.dart b/lib/src/visual/views/settings/developer/developer.view.dart index 43cc071c..f97ff891 100644 --- a/lib/src/visual/views/settings/developer/developer.view.dart +++ b/lib/src/visual/views/settings/developer/developer.view.dart @@ -25,6 +25,7 @@ import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/views/onboarding/setup.view.dart'; +import 'package:twonly/src/visual/views/settings/developer/home_widget_developer.view.dart'; import 'package:twonly/src/visual/views/settings/developer/user_discovery_developer.view.dart'; class DeveloperSettingsView extends StatefulWidget { @@ -220,6 +221,7 @@ class _DeveloperSettingsViewState extends State { stored: true, requiresAuthentication: false, isDraftMedia: false, + isWidgetMedia: false, isFavorite: false, hasCropAnalyzed: false, hasThumbnail: false, @@ -413,6 +415,11 @@ class _DeveloperSettingsViewState extends State { onTap: () => context.navPush(const UserDiscoveryDeveloperView()), ), + ListTile( + title: const Text('Home Widget State'), + subtitle: const Text('What the widget reads'), + onTap: () => context.navPush(const HomeWidgetDeveloperView()), + ), ListTile( title: const Text('Share local database'), onTap: () async { diff --git a/lib/src/visual/views/settings/developer/home_widget_developer.view.dart b/lib/src/visual/views/settings/developer/home_widget_developer.view.dart new file mode 100644 index 00000000..d5f97dff --- /dev/null +++ b/lib/src/visual/views/settings/developer/home_widget_developer.view.dart @@ -0,0 +1,260 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/services/home_widget.service.dart'; +import 'package:twonly/src/visual/components/snackbar.dart'; + +/// Shows the files the home widget reads, from the app side of the App Group. +/// +/// The widget extension is a separate process that cannot be inspected from +/// here, and it renders a blank square for every failure. This screen answers +/// the half of the question the app can answer on its own: whether Rust wrote a +/// manifest at all, what is in it, and whether the images it points at exist. +/// Whether the extension can *reach* those same files is what the widget's own +/// `os_log` output ("eu.twonly.widget") reports. +class HomeWidgetDeveloperView extends StatefulWidget { + const HomeWidgetDeveloperView({super.key}); + + @override + State createState() => + _HomeWidgetDeveloperViewState(); +} + +class _HomeWidgetDeveloperViewState extends State { + String _report = 'Loading…'; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Directory get _widgetDirectory => + Directory('${AppEnvironment.supportDir}/widget'); + + Future _load() async { + final report = await _buildReport(); + if (!mounted) return; + setState(() => _report = report); + } + + Future _buildReport() async { + final lines = [ + 'platform: ${Platform.operatingSystem}', + 'support dir: ${AppEnvironment.supportDir}', + '', + ]; + + final root = _widgetDirectory; + lines + ..addAll(await _reconcileSection()) + ..add(''); + if (!root.existsSync()) { + lines + ..add('${root.path} does not exist.') + ..add('Rust has never written widget state on this install.'); + return lines.join('\n'); + } + + lines + ..addAll(await _manifestSection(File('${root.path}/manifest.json'))) + ..add('') + ..addAll(_nativeConfigSection(File('${root.path}/native-config.json'))) + ..add('') + ..addAll(_imagesSection(Directory('${root.path}/images'))); + return lines.join('\n'); + } + + /// What WidgetKit itself says is on the home screen. This is the only + /// authoritative answer: the placement file below is merely derived from it, + /// so when the two disagree the fault is between these two sections. + Future> _reconcileSection() async { + HomeWidgetService.invalidate(); + final report = await HomeWidgetService.reconcileReport(); + if (report == null) { + return ['-- WidgetKit --', 'Not applicable on this platform.']; + } + if (report['error'] case final error?) { + return [ + '-- WidgetKit --', + 'QUERY FAILED: $error', + 'The placement file was left untouched.', + ]; + } + final widgets = (report['widgets'] as List?) ?? const []; + final summary = + '${widgets.length} widget(s) installed, ' + '${report['matched']} confirmed placed'; + final lines = [ + '-- WidgetKit --', + 'supported: ${report['supported']}', + summary, + ]; + for (final widget in widgets.cast>()) { + lines.add( + ' kind=${widget['kind']} family=${widget['family']} ' + 'mine=${widget['mine']} live=${widget['live'] ?? '-'} ' + 'groups=${widget['group_ids'] ?? '-'}' + '${widget['configuration'] == null ? '' : ' UNREADABLE'}', + ); + } + if (widgets.isEmpty) { + lines.add(' (none — the placement file should now be empty)'); + } + return lines; + } + + Future> _manifestSection(File manifest) async { + if (!manifest.existsSync()) { + return [ + '-- manifest.json --', + 'MISSING. The widget shows "Open twonly once" in this state.', + ]; + } + + final raw = await manifest.readAsString(); + final lines = [ + '-- manifest.json --', + '${raw.length} bytes, modified ${manifest.statSync().modified.toLocal()}', + ]; + + final Map decoded; + try { + decoded = jsonDecode(raw) as Map; + } catch (error) { + return lines + ..add('UNPARSEABLE: $error') + ..add(raw); + } + + final groups = (decoded['groups'] as List?) ?? []; + final images = (decoded['images'] as List?) ?? []; + lines.add('${groups.length} contact groups, ${images.length} images'); + + // The group IDs are the whole matching rule: the widget shows an image only + // when its sender's groups intersect the groups the widget was configured + // with, so a mismatch here is the difference between a picture and a blank. + for (final group in groups.cast>()) { + lines.add(' group ${group['id']}: ${group['name']}'); + } + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + for (final image in images.cast>()) { + final expiresAt = (image['expires_at'] as num?)?.toInt() ?? 0; + final exists = File('${image['path']}').existsSync(); + lines.add( + ' ${image['media_id']} from ${image['sender']} ' + 'groups=${image['group_ids']} ' + '${expiresAt > now ? 'valid' : 'EXPIRED'} ' + '${exists ? '' : 'FILE MISSING'}', + ); + } + return lines; + } + + static const _missingNativeConfig = + 'MISSING. On iOS the widget writes this the first time WidgetKit builds ' + 'its timeline; until then Rust grants nobody permission to share with ' + 'the widget.'; + + List _nativeConfigSection(File config) { + if (!config.existsSync()) { + return ['-- native-config.json --', _missingNativeConfig]; + } + return [ + '-- native-config.json --', + 'modified ${config.statSync().modified.toLocal()}', + config.readAsStringSync(), + ]; + } + + List _imagesSection(Directory images) { + if (!images.existsSync()) { + return ['-- images/ --', 'MISSING']; + } + final files = images.listSync().whereType().toList(); + return [ + '-- images/ --', + if (files.isEmpty) '(empty)', + for (final file in files) + ' ${file.uri.pathSegments.last} ${file.lengthSync()} bytes', + ]; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Home Widget'), + actions: [ + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Copy report', + onPressed: () async { + await Clipboard.setData(ClipboardData(text: _report)); + if (!context.mounted) return; + showSnackbar( + context, + 'Report copied.', + level: SnackbarLevel.info, + ); + }, + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Reload', + onPressed: _load, + ), + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + spacing: 8, + children: [ + Expanded( + child: FilledButton.tonal( + onPressed: () async { + await HomeWidgetService.syncPermissions(); + await _load(); + }, + child: const Text('Rewrite manifest'), + ), + ), + const Expanded( + child: _ReloadWidgetButton(), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: SelectableText( + _report, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ), + ), + ], + ), + ); + } +} + +class _ReloadWidgetButton extends StatelessWidget { + const _ReloadWidgetButton(); + + @override + Widget build(BuildContext context) { + return const FilledButton.tonal( + onPressed: HomeWidgetService.refresh, + child: Text('Reload widget'), + ); + } +} diff --git a/lib/src/visual/views/settings/settings_main.view.dart b/lib/src/visual/views/settings/settings_main.view.dart index 4ea798e3..53f7ca77 100644 --- a/lib/src/visual/views/settings/settings_main.view.dart +++ b/lib/src/visual/views/settings/settings_main.view.dart @@ -110,6 +110,11 @@ class SettingsMainView extends StatelessWidget { text: context.lang.settingsNotification, onTap: () => context.push(Routes.settingsNotification), ), + BetterListTile( + icon: Icons.widgets_rounded, + text: context.lang.settingsWidgets, + onTap: () => context.push(Routes.settingsWidgets), + ), BetterListTile( icon: FontAwesomeIcons.chartPie, iconSize: 15, diff --git a/lib/src/visual/views/settings/widgets/widget_detail.view.dart b/lib/src/visual/views/settings/widgets/widget_detail.view.dart new file mode 100644 index 00000000..58d0d2b7 --- /dev/null +++ b/lib/src/visual/views/settings/widgets/widget_detail.view.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/home_widget.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/alert.dialog.dart'; +import 'package:twonly/src/visual/views/contact/contact_group_settings.view.dart'; +import 'package:twonly/src/visual/views/settings/widgets/widgets.view.dart'; + +/// One placed widget: who may send to it, and what it is showing right now. +class WidgetDetailView extends StatefulWidget { + const WidgetDetailView({required this.widget, super.key}); + + final PlacedWidget widget; + + @override + State createState() => _WidgetDetailViewState(); +} + +class _WidgetDetailViewState extends State { + List? _images; + List _groups = const []; + Timer? _ticker; + + @override + void initState() { + super.initState(); + unawaited(_load()); + // Every entry shows how much of its 24 hours is left, so the screen has to + // keep counting down while it is open. + _ticker = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + Future _load() async { + final images = await HomeWidgetService.imagesFor(widget.widget); + final all = await twonlyDB.contactGroupsDao.watchAllContactGroups().first; + final selected = widget.widget.contactGroupIds.toSet(); + if (!mounted) return; + setState(() { + _images = images; + _groups = all.where((group) => selected.contains(group.id)).toList(); + }); + } + + Future _delete(WidgetImage image) async { + final confirmed = await showAlertDialog( + context, + context.lang.widgetsDeleteImage, + context.lang.widgetsDeleteImageConfirm, + customOk: context.lang.widgetsDeleteImage, + ); + if (!confirmed) return; + await HomeWidgetService.deleteImage(image.mediaId); + await _load(); + } + + /// Coarse on purpose: the exact second an image disappears is noise, and the + /// user only needs to know whether it is here for a while or nearly gone. + String _remaining(WidgetImage image) { + final remaining = image.remaining; + if (remaining.inHours >= 1) { + return context.lang.widgetsDurationHours(remaining.inHours); + } + return context.lang.widgetsDurationMinutes( + remaining.inMinutes.clamp(1, 59), + ); + } + + @override + Widget build(BuildContext context) { + final images = _images; + return Scaffold( + appBar: AppBar(title: Text(context.lang.widgetsTitle)), + body: ListView( + padding: const EdgeInsets.only(top: 8, bottom: 32), + children: [ + if (_groups.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + context.lang.widgetsNoGroupsHint, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ) + else + for (final group in _groups) + ListTile( + leading: group.emoji == null + ? const Icon(Icons.group_outlined) + : Text( + group.emoji!, + style: const TextStyle(fontSize: 20), + ), + title: Text(group.name), + subtitle: Text(context.lang.widgetsEditGroup), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await context.navPush( + ContactGroupSettingsView(contactGroup: group), + ); + await _load(); + }, + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 0), + child: Text( + changeGroupsHint(context), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const Divider(height: 32), + _sectionTitle(context.lang.widgetsCurrentImages), + if (images == null) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ), + ) + else if (images.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + context.lang.widgetsNoImagesHint, + style: Theme.of(context).textTheme.bodyMedium, + ), + ) + else + for (final image in images) _imageTile(image), + ], + ), + ); + } + + Widget _sectionTitle(String text) => Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Text( + text, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + + Widget _imageTile(WidgetImage image) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + image.file, + width: 56, + height: 56, + fit: BoxFit.cover, + // The file is deleted the moment the user confirms, and Flutter keeps + // decoded frames in a cache keyed by path, so a stale entry would + // outlive the file it came from. + cacheWidth: 168, + errorBuilder: (_, _, _) => const SizedBox( + width: 56, + height: 56, + child: Icon(Icons.broken_image_outlined), + ), + ), + ), + title: Text(context.lang.widgetsFrom(image.sender)), + subtitle: Text(context.lang.widgetsExpiresIn(_remaining(image))), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: context.lang.widgetsDeleteImage, + onPressed: () => _delete(image), + ), + ); + } +} diff --git a/lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart b/lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart new file mode 100644 index 00000000..4ebe59ad --- /dev/null +++ b/lib/src/visual/views/settings/widgets/widget_setup_guide.comp.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:twonly/src/utils/misc.dart'; + +/// Explains how to put a twonly widget on the home screen. +/// +/// A widget can only be placed by the user, from the home screen itself — +/// neither iOS nor Android exposes an API for an app to add or configure one on +/// the user's behalf — so instructions are the most the app can offer. +class WidgetSetupGuide extends StatelessWidget { + const WidgetSetupGuide({required this.showIntro, super.key}); + + /// Whether to lead with what the feature is. Shown when the user has no + /// widget yet; skipped when this sits under a list they can already see. + final bool showIntro; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showIntro) ...[ + Center( + child: Icon( + Icons.widgets_outlined, + size: 56, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Text( + context.lang.widgetsIntroTitle, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + context.lang.widgetsIntroBody, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 24), + ], + Text( + showIntro + ? context.lang.widgetsNoneTitle + : context.lang.widgetsAddAnother, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + Platform.isIOS + ? context.lang.widgetsSetupIos + : context.lang.widgetsSetupAndroid, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodySmall?.color, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/visual/views/settings/widgets/widgets.view.dart b/lib/src/visual/views/settings/widgets/widgets.view.dart new file mode 100644 index 00000000..47e25dd6 --- /dev/null +++ b/lib/src/visual/views/settings/widgets/widgets.view.dart @@ -0,0 +1,161 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/services/home_widget.service.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/elements/my_card.element.dart'; +import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart'; +import 'package:twonly/src/visual/views/settings/widgets/widget_detail.view.dart'; +import 'package:twonly/src/visual/views/settings/widgets/widget_setup_guide.comp.dart'; + +/// Lists the widgets on the home screen and who may send to each of them. +/// +/// Neither platform lets an app place or reconfigure a widget on the user's +/// behalf, so this screen explains the placement and then reflects the result. +/// What it *can* offer directly is the part that lives in the app: which +/// contacts are in each group. +class WidgetsSettingsView extends StatefulWidget { + const WidgetsSettingsView({super.key}); + + @override + State createState() => _WidgetsSettingsViewState(); +} + +class _WidgetsSettingsViewState extends State { + List? _widgets; + String? _queryError; + Map _groups = const {}; + Map _imageCounts = const {}; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + // The manifest and the placement file are rewritten by Rust and by the + // native widgets, so this screen always re-reads rather than caching. + HomeWidgetService.invalidate(); + await HomeWidgetService.syncPermissions(); + final result = await HomeWidgetService.placedWidgetsResult(); + final widgets = result.widgets; + final groups = await twonlyDB.contactGroupsDao + .watchAllContactGroups() + .first; + final counts = {}; + for (final widget in widgets) { + counts[widget.id] = (await HomeWidgetService.imagesFor(widget)).length; + } + if (!mounted) return; + setState(() { + _widgets = widgets; + _queryError = result.error; + _groups = {for (final group in groups) group.id: group}; + _imageCounts = counts; + }); + } + + @override + Widget build(BuildContext context) { + final widgets = _widgets; + return Scaffold( + appBar: AppBar(title: Text(context.lang.widgetsTitle)), + body: widgets == null + ? const Center(child: ThreeRotatingDots(size: 40)) + : RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + // Without this the list would quietly present a stale file as + // the state of the home screen. + if (_queryError != null) + Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 8), + child: Text( + context.lang.widgetsQueryFailed, + style: TextStyle(color: context.color.error), + ), + ), + if (widgets.isEmpty) + const WidgetSetupGuide(showIntro: true) + else ...[ + for (final widget in widgets) _tile(widget), + const Divider(height: 32), + const WidgetSetupGuide(showIntro: false), + ], + ], + ), + ), + ); + } + + /// Several widgets can share one contact group, so the size is what tells + /// two cards apart. + String _size(PlacedWidget widget) => switch (widget.family) { + 'systemSmall' => context.lang.widgetsSizeSmall, + 'systemMedium' => context.lang.widgetsSizeMedium, + 'systemLarge' => context.lang.widgetsSizeLarge, + _ => context.lang.widgetsSizeUnknown, + }; + + Widget _tile(PlacedWidget widget) { + final selected = [ + for (final id in widget.contactGroupIds) ?_groups[id], + ]; + final count = _imageCounts[widget.id] ?? 0; + final unconfigured = selected.isEmpty; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: MyCard( + icon: FontAwesomeIcons.image, + accentColor: unconfigured ? context.color.error : null, + titleColor: unconfigured ? context.color.error : null, + title: unconfigured + ? context.lang.widgetsNoGroups + : selected + .map( + (g) => '${g.emoji == null ? '' : '${g.emoji} '}${g.name}', + ) + .join(', '), + subtitle: unconfigured + ? context.lang.widgetsNoGroupsHint + : '${_size(widget)} · ' + '${count == 0 ? context.lang.widgetsNoImages : context.lang.widgetsCurrentImages}', + trailing: count == 0 + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Not a warning, just a count: the badge's default error red + // reads as something being wrong. + Badge( + backgroundColor: context.color.primary, + textColor: context.color.onPrimary, + label: Text('$count'), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right_rounded, + color: context.color.onSurfaceVariant, + ), + ], + ), + onTap: () async { + await context.navPush(WidgetDetailView(widget: widget)); + await _load(); + }, + ), + ); + } +} + +/// Platform wording for how the contact groups of a placed widget are changed. +String changeGroupsHint(BuildContext context) => Platform.isIOS + ? context.lang.widgetsChangeGroupsIos + : context.lang.widgetsChangeGroupsAndroid; diff --git a/rust/.sqlx/query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json b/rust/.sqlx/query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json new file mode 100644 index 00000000..f0473359 --- /dev/null +++ b/rust/.sqlx/query-013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n is_widget_media,\n opened_at,\n opened_by_all,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "013966ba4d977a6d396259f9f6706e00ef02c0b9923d0dac6b5719223482822e" +} diff --git a/rust/.sqlx/query-26099393d47834ce775cee6533f42d4cab0069c385c3efe8be764c6eacd8e112.json b/rust/.sqlx/query-26099393d47834ce775cee6533f42d4cab0069c385c3efe8be764c6eacd8e112.json deleted file mode 100644 index 1eb0eb1e..00000000 --- a/rust/.sqlx/query-26099393d47834ce775cee6533f42d4cab0069c385c3efe8be764c6eacd8e112.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n media_id,\n additional_message_data,\n quotes_message_id,\n created_at\n ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "26099393d47834ce775cee6533f42d4cab0069c385c3efe8be764c6eacd8e112" -} diff --git a/rust/.sqlx/query-38975397d77e89bc70bdbead5a919774559c6c41add3171b8b47244dcaa98018.json b/rust/.sqlx/query-38975397d77e89bc70bdbead5a919774559c6c41add3171b8b47244dcaa98018.json new file mode 100644 index 00000000..a52a1120 --- /dev/null +++ b/rust/.sqlx/query-38975397d77e89bc70bdbead5a919774559c6c41add3171b8b47244dcaa98018.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT widget_sharing_granted FROM contacts WHERE user_id = ?", + "describe": { + "columns": [ + { + "name": "widget_sharing_granted", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "widget_sharing_granted" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "38975397d77e89bc70bdbead5a919774559c6c41add3171b8b47244dcaa98018" +} diff --git a/rust/.sqlx/query-90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991.json b/rust/.sqlx/query-43114af54b919556644128c208ad3b4a6a2c01f1d16b011267fce8a4363fa031.json similarity index 73% rename from rust/.sqlx/query-90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991.json rename to rust/.sqlx/query-43114af54b919556644128c208ad3b4a6a2c01f1d16b011267fce8a4363fa031.json index 05e97e3f..3c2d000d 100644 --- a/rust/.sqlx/query-90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991.json +++ b/rust/.sqlx/query-43114af54b919556644128c208ad3b4a6a2c01f1d16b011267fce8a4363fa031.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n UPDATE contacts SET\n username = COALESCE(?, username),\n display_name = CASE WHEN ? THEN ? ELSE display_name END,\n avatar_svg_compressed = CASE WHEN ? THEN ? ELSE avatar_svg_compressed END,\n sender_profile_counter = COALESCE(?, sender_profile_counter),\n signal_version = COALESCE(?, signal_version),\n accepted = COALESCE(?, accepted),\n requested = COALESCE(?, requested),\n requested_by_user = COALESCE(?, requested_by_user),\n deleted_by_user = COALESCE(?, deleted_by_user),\n blocked = COALESCE(?, blocked)\n WHERE user_id = ?\n ", + "query": "\n UPDATE contacts SET\n username = COALESCE(?, username),\n display_name = CASE WHEN ? THEN ? ELSE display_name END,\n avatar_svg_compressed = CASE WHEN ? THEN ? ELSE avatar_svg_compressed END,\n sender_profile_counter = COALESCE(?, sender_profile_counter),\n signal_version = COALESCE(?, signal_version),\n accepted = COALESCE(?, accepted),\n requested = COALESCE(?, requested),\n requested_by_user = COALESCE(?, requested_by_user),\n deleted_by_user = COALESCE(?, deleted_by_user),\n blocked = COALESCE(?, blocked),\n widget_sharing_allowed = COALESCE(?, widget_sharing_allowed)\n WHERE user_id = ?\n ", "describe": { "columns": [], "parameters": { - "Right": 13 + "Right": 14 }, "nullable": [] }, - "hash": "90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991" + "hash": "43114af54b919556644128c208ad3b4a6a2c01f1d16b011267fce8a4363fa031" } diff --git a/rust/.sqlx/query-b84d4a6bba4e8eb084bbb6b6ffdff05df10bc93c4ff9bfba248f376439e268a1.json b/rust/.sqlx/query-44d1e907c77acd1d5cb5762c778494b984ec433c5012edcadbd1928f70409111.json similarity index 59% rename from rust/.sqlx/query-b84d4a6bba4e8eb084bbb6b6ffdff05df10bc93c4ff9bfba248f376439e268a1.json rename to rust/.sqlx/query-44d1e907c77acd1d5cb5762c778494b984ec433c5012edcadbd1928f70409111.json index 1df89b90..0bf204a1 100644 --- a/rust/.sqlx/query-b84d4a6bba4e8eb084bbb6b6ffdff05df10bc93c4ff9bfba248f376439e268a1.json +++ b/rust/.sqlx/query-44d1e907c77acd1d5cb5762c778494b984ec433c5012edcadbd1928f70409111.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT media_id FROM media_files WHERE download_state = 'pending'", + "query": "SELECT media_id FROM media_files\n WHERE download_state IN ('pending', 'downloading')\n ORDER BY is_widget_media DESC, created_at DESC", "describe": { "columns": [ { @@ -22,5 +22,5 @@ false ] }, - "hash": "b84d4a6bba4e8eb084bbb6b6ffdff05df10bc93c4ff9bfba248f376439e268a1" + "hash": "44d1e907c77acd1d5cb5762c778494b984ec433c5012edcadbd1928f70409111" } diff --git a/rust/.sqlx/query-463452781891c7f94456ca4869f7e89b1fc0586c286e985a5853af7bfc38cb31.json b/rust/.sqlx/query-463452781891c7f94456ca4869f7e89b1fc0586c286e985a5853af7bfc38cb31.json new file mode 100644 index 00000000..e486bafa --- /dev/null +++ b/rust/.sqlx/query-463452781891c7f94456ca4869f7e89b1fc0586c286e985a5853af7bfc38cb31.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE contacts SET requested = 1, deleted_by_user = 1 WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "463452781891c7f94456ca4869f7e89b1fc0586c286e985a5853af7bfc38cb31" +} diff --git a/rust/.sqlx/query-2cc4a73378cf37ff6e3dcbd761fec56686a8f9820a3bb2c489e94fb036de0786.json b/rust/.sqlx/query-5bca89eb7f5df3a6190d8b343bf4d880b10ee6472c469c2c4a7781a91dec0c68.json similarity index 55% rename from rust/.sqlx/query-2cc4a73378cf37ff6e3dcbd761fec56686a8f9820a3bb2c489e94fb036de0786.json rename to rust/.sqlx/query-5bca89eb7f5df3a6190d8b343bf4d880b10ee6472c469c2c4a7781a91dec0c68.json index cfdc6b73..de62e6d7 100644 --- a/rust/.sqlx/query-2cc4a73378cf37ff6e3dcbd761fec56686a8f9820a3bb2c489e94fb036de0786.json +++ b/rust/.sqlx/query-5bca89eb7f5df3a6190d8b343bf4d880b10ee6472c469c2c4a7781a91dec0c68.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE media_files SET download_state = 'reuploadRequested' WHERE media_id = ?", + "query": "UPDATE media_files SET download_state = 'reuploadRequested'\n WHERE media_id = ? AND download_state IS NOT 'ready'", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "2cc4a73378cf37ff6e3dcbd761fec56686a8f9820a3bb2c489e94fb036de0786" + "hash": "5bca89eb7f5df3a6190d8b343bf4d880b10ee6472c469c2c4a7781a91dec0c68" } diff --git a/rust/.sqlx/query-d50a6ee6ff69408981408585348040d91c2e430c936b91bd02a3f7f562edf658.json b/rust/.sqlx/query-783995c916cafa69d2aed5bdcbb9f153e1d599a485e63efe48be297a696bb8cf.json similarity index 66% rename from rust/.sqlx/query-d50a6ee6ff69408981408585348040d91c2e430c936b91bd02a3f7f562edf658.json rename to rust/.sqlx/query-783995c916cafa69d2aed5bdcbb9f153e1d599a485e63efe48be297a696bb8cf.json index f05ca172..5f1825cc 100644 --- a/rust/.sqlx/query-d50a6ee6ff69408981408585348040d91c2e430c936b91bd02a3f7f562edf658.json +++ b/rust/.sqlx/query-783995c916cafa69d2aed5bdcbb9f153e1d599a485e63efe48be297a696bb8cf.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n INSERT INTO media_files(\n media_id,\n type,\n download_state,\n requires_authentication,\n display_limit_in_milliseconds,\n download_token,\n encryption_key,\n encryption_mac,\n encryption_nonce,\n created_at\n ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)\n ", + "query": "\n INSERT INTO media_files(\n media_id,\n type,\n download_state,\n requires_authentication,\n display_limit_in_milliseconds,\n download_token,\n encryption_key,\n encryption_mac,\n encryption_nonce,\n is_widget_media,\n created_at\n ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)\n ", "describe": { "columns": [], "parameters": { - "Right": 9 + "Right": 10 }, "nullable": [] }, - "hash": "d50a6ee6ff69408981408585348040d91c2e430c936b91bd02a3f7f562edf658" + "hash": "783995c916cafa69d2aed5bdcbb9f153e1d599a485e63efe48be297a696bb8cf" } diff --git a/rust/.sqlx/query-785424639a0aa856ab9e037a7949d705f5bd2ecc7b6ab2f88c51df90197577c6.json b/rust/.sqlx/query-785424639a0aa856ab9e037a7949d705f5bd2ecc7b6ab2f88c51df90197577c6.json new file mode 100644 index 00000000..e787f459 --- /dev/null +++ b/rust/.sqlx/query-785424639a0aa856ab9e037a7949d705f5bd2ecc7b6ab2f88c51df90197577c6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE media_files SET download_state = 'pending'\n WHERE media_id = ? AND download_state = 'downloading'", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "785424639a0aa856ab9e037a7949d705f5bd2ecc7b6ab2f88c51df90197577c6" +} diff --git a/rust/.sqlx/query-9e596b383a0af665ed0645792af43636345cf9372c0d4005df9d179f1004a619.json b/rust/.sqlx/query-9e596b383a0af665ed0645792af43636345cf9372c0d4005df9d179f1004a619.json deleted file mode 100644 index bb6d4f8e..00000000 --- a/rust/.sqlx/query-9e596b383a0af665ed0645792af43636345cf9372c0d4005df9d179f1004a619.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE media_files SET download_state = 'pending' WHERE download_state = 'downloading'", - "describe": { - "columns": [], - "parameters": { - "Right": 0 - }, - "nullable": [] - }, - "hash": "9e596b383a0af665ed0645792af43636345cf9372c0d4005df9d179f1004a619" -} diff --git a/rust/.sqlx/query-2b86ca80f38f47f115c8c8e6ce79343574e6ca435946c303dd8f35e68bfc57ef.json b/rust/.sqlx/query-a34ec4535e12998589d315b60e1ce1a0378f6c85fc006f8362df8d11381933d1.json similarity index 67% rename from rust/.sqlx/query-2b86ca80f38f47f115c8c8e6ce79343574e6ca435946c303dd8f35e68bfc57ef.json rename to rust/.sqlx/query-a34ec4535e12998589d315b60e1ce1a0378f6c85fc006f8362df8d11381933d1.json index 20ac15a0..90ca4228 100644 --- a/rust/.sqlx/query-2b86ca80f38f47f115c8c8e6ce79343574e6ca435946c303dd8f35e68bfc57ef.json +++ b/rust/.sqlx/query-a34ec4535e12998589d315b60e1ce1a0378f6c85fc006f8362df8d11381933d1.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE media_files SET type = ?, download_state = 'pending',\n requires_authentication = ?, display_limit_in_milliseconds = ?,\n download_token = ?, encryption_key = ?, encryption_mac = ?,\n encryption_nonce = ?, created_at = ? WHERE media_id = ?", + "query": "UPDATE media_files SET type = ?, download_state = 'pending',\n requires_authentication = ?, display_limit_in_milliseconds = ?,\n download_token = ?, encryption_key = ?, encryption_mac = ?,\n encryption_nonce = ?, is_widget_media = ?, created_at = ? WHERE media_id = ?", "describe": { "columns": [], "parameters": { - "Right": 9 + "Right": 10 }, "nullable": [] }, - "hash": "2b86ca80f38f47f115c8c8e6ce79343574e6ca435946c303dd8f35e68bfc57ef" + "hash": "a34ec4535e12998589d315b60e1ce1a0378f6c85fc006f8362df8d11381933d1" } diff --git a/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json b/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json index 7a8784e3..e7819962 100644 --- a/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json +++ b/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json @@ -299,6 +299,28 @@ "name": "requested_by_user" } } + }, + { + "name": "widget_sharing_allowed", + "ordinal": 27, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "widget_sharing_allowed" + } + } + }, + { + "name": "widget_sharing_granted", + "ordinal": 28, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "widget_sharing_granted" + } + } } ], "parameters": { @@ -331,6 +353,8 @@ true, false, false, + false, + false, false ] }, diff --git a/rust/.sqlx/query-c86a99f1f94fc29cfb5e270f1c8c529bb8b04517c14c6c4742c16b43aaa10ea1.json b/rust/.sqlx/query-c86a99f1f94fc29cfb5e270f1c8c529bb8b04517c14c6c4742c16b43aaa10ea1.json new file mode 100644 index 00000000..7543b29e --- /dev/null +++ b/rust/.sqlx/query-c86a99f1f94fc29cfb5e270f1c8c529bb8b04517c14c6c4742c16b43aaa10ea1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE contacts SET widget_sharing_allowed = ? WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "c86a99f1f94fc29cfb5e270f1c8c529bb8b04517c14c6c4742c16b43aaa10ea1" +} diff --git a/rust/.sqlx/query-e569dd744fb4a5e49cfbe14c61005d14f8c0f82a8640269ec29d5e9edabb106b.json b/rust/.sqlx/query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json similarity index 75% rename from rust/.sqlx/query-e569dd744fb4a5e49cfbe14c61005d14f8c0f82a8640269ec29d5e9edabb106b.json rename to rust/.sqlx/query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json index 5d39b48e..d3c1795b 100644 --- a/rust/.sqlx/query-e569dd744fb4a5e49cfbe14c61005d14f8c0f82a8640269ec29d5e9edabb106b.json +++ b/rust/.sqlx/query-f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND opened_at IS NULL AND sender_id IS NOT NULL", + "query": "SELECT message_id, sender_id AS \"sender_id!: i64\" FROM messages\n WHERE media_id = ? AND sender_id IS NOT NULL\n AND (opened_at IS NULL OR is_widget_media = 1)", "describe": { "columns": [ { @@ -34,5 +34,5 @@ true ] }, - "hash": "e569dd744fb4a5e49cfbe14c61005d14f8c0f82a8640269ec29d5e9edabb106b" + "hash": "f96e23fc46fe9d566e332d17edece9fd3f3d617907841e30bfeaba38eff12a52" } diff --git a/rust/src/api/messages/incoming.rs b/rust/src/api/messages/incoming.rs index 56ddfbc6..c33a1734 100644 --- a/rust/src/api/messages/incoming.rs +++ b/rust/src/api/messages/incoming.rs @@ -34,7 +34,62 @@ use prost::Message as _; use proto::message::Type; use server_to_client::v0::Kind; use sqlx::{Sqlite, Transaction}; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex, PoisonError, Weak}; + +/// Serialises the handling of one receipt ID against itself. +/// +/// The server resends a mailbox page whose acknowledgement timed out, so a +/// message can arrive again while the first copy is still being handled. Both +/// copies would open their own transaction on the single app-database +/// connection, and the second could only ever find the claim the first is +/// about to write. Waiting here keeps the duplicate off that connection until +/// the first copy has committed, after which it takes the ordinary +/// already-claimed path. +static IN_FLIGHT_RECEIPTS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// The registry entry for one in-flight receipt. It keeps the only strong +/// reference held by a handler that is not waiting, so dropping it once no +/// other copy is queued takes the entry out of the registry again. +struct InFlightReceipt { + receipt_id: String, + lock: Arc>, +} + +impl InFlightReceipt { + fn claim(receipt_id: &str) -> Self { + let mut in_flight = IN_FLIGHT_RECEIPTS + .lock() + .unwrap_or_else(PoisonError::into_inner); + let lock = in_flight + .get(receipt_id) + .and_then(Weak::upgrade) + .unwrap_or_else(|| { + let lock = Arc::new(tokio::sync::Mutex::new(())); + in_flight.insert(receipt_id.to_owned(), Arc::downgrade(&lock)); + lock + }); + Self { + receipt_id: receipt_id.to_owned(), + lock, + } + } +} + +impl Drop for InFlightReceipt { + fn drop(&mut self) { + let mut in_flight = IN_FLIGHT_RECEIPTS + .lock() + .unwrap_or_else(PoisonError::into_inner); + // Every other copy of this receipt holds a strong reference of its + // own, and both taking one and removing the entry happen under this + // lock, so being the last holder means nothing is queued behind us. + if Arc::strong_count(&self.lock) == 1 { + in_flight.remove(&self.receipt_id); + } + } +} pub(crate) async fn handle_server_message( ctx: &Arc, @@ -256,6 +311,13 @@ pub(crate) async fn handle_decoded_server_message( )); } + // A redelivery of a message still being handled waits here instead of + // racing the copy in flight for the single app-database connection. The + // registry entry outlives the lock guard, so the receipt stays registered + // for as long as anything is queued on it. + let in_flight = InFlightReceipt::claim(&message.receipt_id); + let _in_flight = in_flight.lock.lock().await; + let message_type = Type::try_from(message.r#type)?; let is_encrypted_message = matches!( message_type, @@ -486,6 +548,10 @@ async fn handle_encrypted_inner( Contact::update_ask_for_friend_promotions(t, from_user_id).await?; } + if let Some(allowed) = content.widget_sharing_allowed { + Contact::update_widget_sharing_allowed(t, from_user_id, allowed).await?; + } + let type_kind = content_type_kind(&content); tracing::Span::current().record("kind", type_kind); @@ -653,3 +719,29 @@ async fn handle_encrypted_inner( "client2client content in receipt {receipt_id} is not implemented in Rust" ))) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn copies_of_one_receipt_share_a_lock_until_the_last_one_goes() { + let receipt_id = "in-flight-registry-test"; + + let first = InFlightReceipt::claim(receipt_id); + let second = InFlightReceipt::claim(receipt_id); + assert!(Arc::ptr_eq(&first.lock, &second.lock)); + + // One copy leaving must not unregister a receipt another still holds. + drop(second); + let third = InFlightReceipt::claim(receipt_id); + assert!(Arc::ptr_eq(&third.lock, &first.lock)); + + drop(third); + drop(first); + assert!(!IN_FLIGHT_RECEIPTS + .lock() + .unwrap_or_else(PoisonError::into_inner) + .contains_key(receipt_id)); + } +} diff --git a/rust/src/api/messages/incoming/media.rs b/rust/src/api/messages/incoming/media.rs index 81c0c9de..fb3ae3e2 100644 --- a/rust/src/api/messages/incoming/media.rs +++ b/rust/src/api/messages/incoming/media.rs @@ -59,6 +59,25 @@ pub(crate) async fn handle_media( media: encrypted_content::Media, ) -> Result<()> { let media_type = MediaType::try_from(media.r#type)?; + let widget_only = media.widget_only == Some(true); + + if widget_only { + let granted = sqlx::query_scalar::<_, i64>( + "SELECT widget_sharing_granted FROM contacts WHERE user_id = ?", + ) + .bind(from_user_id) + .fetch_optional(&mut **t) + .await? + .unwrap_or(0) + != 0; + if !granted + || media_type != MediaType::Image + || media.display_limit_in_milliseconds.is_some() + { + tracing::warn!(from_user_id, "dropping unauthorized widget media"); + return Ok(()); + } + } if media_type == MediaType::Reupload { let media_id = sqlx::query_scalar!( @@ -140,7 +159,7 @@ pub(crate) async fn handle_media( r#"UPDATE media_files SET type = ?, download_state = 'pending', requires_authentication = ?, display_limit_in_milliseconds = ?, download_token = ?, encryption_key = ?, encryption_mac = ?, - encryption_nonce = ?, created_at = ? WHERE media_id = ?"#, + encryption_nonce = ?, is_widget_media = ?, created_at = ? WHERE media_id = ?"#, media_type, media.requires_authentication, media.display_limit_in_milliseconds, @@ -148,6 +167,7 @@ pub(crate) async fn handle_media( media.encryption_key, media.encryption_mac, media.encryption_nonce, + widget_only, timestamp, media_id, ) @@ -172,8 +192,9 @@ pub(crate) async fn handle_media( encryption_key, encryption_mac, encryption_nonce, + is_widget_media, created_at - ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?) "#, media_id, media_type, @@ -183,10 +204,16 @@ pub(crate) async fn handle_media( media.encryption_key, media.encryption_mac, media.encryption_nonce, + widget_only, timestamp, ) .execute(&mut **t) .await?; + let (opened_at, opened_by_all) = if widget_only { + (Some(timestamp), Some(timestamp)) + } else { + (None, None) + }; sqlx::query!( r#" INSERT INTO messages( @@ -197,8 +224,11 @@ pub(crate) async fn handle_media( media_id, additional_message_data, quotes_message_id, + is_widget_media, + opened_at, + opened_by_all, created_at - ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?) + ) VALUES (?, ?, ?, 'media', ?, ?, ?, ?, ?, ?, ?) "#, group_id, media.sender_message_id, @@ -206,13 +236,18 @@ pub(crate) async fn handle_media( media_id, media.additional_message_data, media.quote_message_id, + widget_only, + opened_at, + opened_by_all, timestamp, ) .execute(&mut **t) .await?; - Group::increase_last_message_exchange(t, group_id, timestamp).await?; - Group::record_media_exchange(t, group_id, true, timestamp).await?; + if !widget_only { + Group::increase_last_message_exchange(t, group_id, timestamp).await?; + Group::record_media_exchange(t, group_id, true, timestamp).await?; + } spawn_media_download(ctx, media_id); diff --git a/rust/src/api/messages/incoming/messages.rs b/rust/src/api/messages/incoming/messages.rs index e466399b..f4e2c196 100644 --- a/rust/src/api/messages/incoming/messages.rs +++ b/rust/src/api/messages/incoming/messages.rs @@ -15,14 +15,83 @@ use crate::utils::new_uuid_v4; use prost::Message as ProstMessage; use proto::encrypted_content::error_messages::Type; use sqlx::{Sqlite, Transaction}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock, Mutex, PoisonError}; #[cfg(not(debug_assertions))] -use std::{collections::HashMap, sync::LazyLock}; +use std::collections::HashMap; #[cfg(not(debug_assertions))] -static ALREADY_QUEUED_RECEIPTS: LazyLock>> = - LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +static ALREADY_QUEUED_RECEIPTS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Coalesces overlapping flushes of the receipt queue. +/// +/// Every committed inbound message asks for a flush, and a flush scans the +/// whole queue, so one mailbox page would otherwise start as many identical +/// scans as it carries messages — all of them competing for the single +/// app-database connection. A request that arrives while a flush runs only +/// marks it dirty, so the running flush queries once more before it returns. +static QUEUED_RECEIPT_FLUSH: LazyLock> = + LazyLock::new(|| Mutex::new(FlushClaim::default())); + +#[derive(Default)] +struct FlushClaim { + running: bool, + dirty: bool, +} + +/// Held for as long as a flush owns the claim. Dropping it without releasing +/// it — an error or a panic on the way out — frees the claim for the next +/// caller instead of blocking every later flush. +struct FlushGuard { + released: bool, +} + +impl FlushGuard { + /// Registers a flush for this caller. Returns `None` when one is already + /// running, in which case it is marked dirty and will query once more. + fn claim() -> Option { + let mut claim = QUEUED_RECEIPT_FLUSH + .lock() + .unwrap_or_else(PoisonError::into_inner); + if claim.running { + claim.dirty = true; + return None; + } + claim.running = true; + claim.dirty = false; + Some(Self { released: false }) + } + + /// Ends the flush when nothing asked for another one while it ran, + /// otherwise clears the dirty flag and keeps the claim for one more pass. + /// Both happen under one lock so a request cannot be dropped between the + /// last query and the release. + fn release_or_take_dirty(&mut self) -> bool { + let mut claim = QUEUED_RECEIPT_FLUSH + .lock() + .unwrap_or_else(PoisonError::into_inner); + if claim.dirty { + claim.dirty = false; + return true; + } + claim.running = false; + self.released = true; + false + } +} + +impl Drop for FlushGuard { + fn drop(&mut self) { + if self.released { + return; + } + QUEUED_RECEIPT_FLUSH + .lock() + .unwrap_or_else(PoisonError::into_inner) + .running = false; + } +} pub(crate) async fn queue_encrypted_content( t: &mut Transaction<'_, Sqlite>, @@ -574,6 +643,20 @@ pub(crate) async fn release_deferred_receipts( } pub async fn retransmit_queued_receipts(ctx: &Arc) -> Result<()> { + let Some(mut guard) = FlushGuard::claim() else { + return Ok(()); + }; + + loop { + flush_queued_receipts(ctx).await?; + + if !guard.release_or_take_dirty() { + return Ok(()); + } + } +} + +async fn flush_queued_receipts(ctx: &Arc) -> Result<()> { let database = ctx.app_db.read().await.clone(); let receipt_ids = sqlx::query_scalar!( r#" @@ -699,3 +782,25 @@ pub async fn handle_plaintext_content( } Ok(None) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_second_flush_request_marks_the_running_one_dirty() { + let mut guard = FlushGuard::claim().expect("nothing else holds the claim"); + assert!(FlushGuard::claim().is_none()); + + // The running flush sees the dirty flag and keeps its claim. + assert!(guard.release_or_take_dirty()); + // Nothing arrived during the second pass, so the claim is released. + assert!(!guard.release_or_take_dirty()); + + // Dropping a guard that never released frees the claim too, so an + // error on the way out cannot block every later flush. + let guard = FlushGuard::claim().expect("the claim was released"); + drop(guard); + assert!(FlushGuard::claim().is_some()); + } +} diff --git a/rust/src/api/messages/outgoing.rs b/rust/src/api/messages/outgoing.rs index 7ed135d3..dc42ae25 100644 --- a/rust/src/api/messages/outgoing.rs +++ b/rust/src/api/messages/outgoing.rs @@ -23,8 +23,18 @@ pub(crate) async fn decorate_content( }; content.sender_profile_counter = Some(config.avatar_counter); + let database = ctx.app_db.read().await.clone(); + content.widget_sharing_allowed = Some( + sqlx::query_scalar!( + "SELECT widget_sharing_granted FROM contacts WHERE user_id = ?", + contact_id, + ) + .fetch_optional(&database.pool) + .await? + .unwrap_or(0) + != 0, + ); if config.ask_for_friend_promotions { - let database = ctx.app_db.read().await.clone(); let accepted = sqlx::query_scalar!("SELECT COUNT(*) FROM contacts WHERE accepted = 1") .fetch_one(&database.pool) .await?; diff --git a/rust/src/api/proto/client/messages.proto b/rust/src/api/proto/client/messages.proto index 2eaa0d94..0cb43858 100644 --- a/rust/src/api/proto/client/messages.proto +++ b/rust/src/api/proto/client/messages.proto @@ -10,6 +10,7 @@ message EncryptedContent { optional bytes sender_user_discovery_version = 21; optional bool ask_for_friend_promotions = 25; reserved 28; + optional bool widget_sharing_allowed = 29; optional MessageUpdate message_update = 5; optional Media media = 6; @@ -119,6 +120,7 @@ message EncryptedContent { optional bytes encryption_nonce = 10; optional bytes additional_message_data = 11; + optional bool widget_only = 12; } message MediaUpdate { diff --git a/rust/src/api/runtime/helpers.rs b/rust/src/api/runtime/helpers.rs index 15ac59ed..302bf272 100644 --- a/rust/src/api/runtime/helpers.rs +++ b/rust/src/api/runtime/helpers.rs @@ -78,12 +78,14 @@ pub(crate) fn schedule_post_authentication(ctx: &Arc, in_background: bo if let Err(error) = messages::retransmit_queued_receipts(&ctx).await { tracing::warn!("failed to retransmit queued receipts: {error}"); } + // A preparation that a terminated process left half-finished is only // resumed by a sweep like this one; a mid-session reconnect is just as // good a moment for it as a cold start, and far more frequent. if let Err(error) = MediaUploadService::new(&ctx).finish_started_uploads().await { tracing::warn!("failed to finish started media uploads: {error}"); } + if let Err(error) = MediaFileService::new(&ctx).download_pending().await { tracing::warn!("failed to download pending media: {error}"); } diff --git a/rust/src/bridge/api.rs b/rust/src/bridge/api.rs index 66a796f6..a33a6a0d 100644 --- a/rust/src/bridge/api.rs +++ b/rust/src/bridge/api.rs @@ -90,6 +90,10 @@ pub 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, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -210,13 +214,67 @@ impl RustApi { media_id: String, group_ids: Vec, additional_message_data: Option>, + widget_only: bool, ) -> Result<()> { let ctx = Context::get_static()?; MediaUploadService::new(ctx) - .insert_into_messages(media_id, group_ids, additional_message_data) + .insert_into_messages(media_id, group_ids, additional_message_data, widget_only) .await } + pub async fn register_home_widget(widget_id: String, platform: String) -> Result<()> { + let ctx = Context::get_static()?; + crate::services::home_widget::register_widget(ctx, &widget_id, &platform).await + } + + pub async fn unregister_home_widget(widget_id: String) -> Result<()> { + let ctx = Context::get_static()?; + crate::services::home_widget::unregister_widget(ctx, &widget_id).await + } + + pub async fn set_home_widget_groups( + widget_id: String, + platform: String, + contact_group_ids: Vec, + ) -> Result<()> { + let ctx = Context::get_static()?; + crate::services::home_widget::set_widget_groups( + ctx, + &widget_id, + &platform, + &contact_group_ids, + ) + .await + } + + pub async fn sync_widget_permissions() -> Result<()> { + let ctx = Context::get_static()?; + crate::services::home_widget::sync_permissions(ctx).await + } + + pub async fn purge_widget_media() -> Result<()> { + let ctx = Context::get_static()?; + crate::services::home_widget::purge_widget_media(ctx).await + } + + /// 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. + pub async fn refresh_widget_manifest() -> Result<()> { + let ctx = Context::get_static()?; + crate::services::home_widget::refresh_manifest(ctx).await + } + + /// Drops a single image a widget is showing, at the user's request. + pub async fn delete_widget_media(media_id: String) -> Result<()> { + let ctx = Context::get_static()?; + crate::services::home_widget::delete_media(ctx, &media_id).await + } + /// Retries the media sends whose receipts are still marked for retry. pub async fn reupload_pending_media() -> Result<()> { let ctx = Context::get_static()?; diff --git a/rust/src/database/app/migrations/0013_home_widgets.sql b/rust/src/database/app/migrations/0013_home_widgets.sql new file mode 100644 index 00000000..88eb1a97 --- /dev/null +++ b/rust/src/database/app/migrations/0013_home_widgets.sql @@ -0,0 +1,27 @@ +-- Home-screen widget placement is device-local and deliberately excluded from +-- application backups. +CREATE TABLE home_widgets ( + widget_id TEXT NOT NULL PRIMARY KEY, + platform TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s', CURRENT_TIMESTAMP) AS INTEGER)) +); + +CREATE TABLE home_widget_groups ( + widget_id TEXT NOT NULL REFERENCES home_widgets(widget_id) ON DELETE CASCADE, + contact_group_id INTEGER NOT NULL REFERENCES contact_groups(id) ON DELETE CASCADE, + PRIMARY KEY (widget_id, contact_group_id) +); + +-- What a peer has granted us, and what we most recently announced to them. +ALTER TABLE contacts ADD COLUMN widget_sharing_allowed INTEGER NOT NULL DEFAULT 0 + CHECK (widget_sharing_allowed IN (0, 1)); +ALTER TABLE contacts ADD COLUMN widget_sharing_granted INTEGER NOT NULL DEFAULT 0 + CHECK (widget_sharing_granted IN (0, 1)); + +-- Widget-only media is downloaded normally but never appears in chat. +ALTER TABLE media_files ADD COLUMN is_widget_media INTEGER NOT NULL DEFAULT 0 + CHECK (is_widget_media IN (0, 1)); +ALTER TABLE messages ADD COLUMN is_widget_media INTEGER NOT NULL DEFAULT 0 + CHECK (is_widget_media IN (0, 1)); +CREATE INDEX idx_messages_widget_media ON messages(is_widget_media, created_at) + WHERE is_widget_media = 1; diff --git a/rust/src/database/app/mod.rs b/rust/src/database/app/mod.rs index 76e24845..d57eb100 100644 --- a/rust/src/database/app/mod.rs +++ b/rust/src/database/app/mod.rs @@ -16,7 +16,7 @@ mod legacy_import; pub mod tables; pub const APP_DATABASE_FILE: &str = "app_db.sqlite"; -pub const APP_SCHEMA_VERSION: i64 = 7; +pub const APP_SCHEMA_VERSION: i64 = 8; /// User-owned application tables in the current Rust schema. Rust-only outbox /// tables are deliberately absent because they are reconstructed locally. diff --git a/rust/src/database/app/tables/contact.rs b/rust/src/database/app/tables/contact.rs index e5849b92..64be6246 100644 --- a/rust/src/database/app/tables/contact.rs +++ b/rust/src/database/app/tables/contact.rs @@ -39,6 +39,8 @@ pub struct Contact { pub ask_for_friend_promotions: Option, pub media_send_counter: i64, pub media_received_counter: i64, + pub widget_sharing_allowed: i64, + pub widget_sharing_granted: i64, } #[derive(bon::Builder)] @@ -59,6 +61,8 @@ pub struct UpdateContact { deleted_by_user: Option, #[builder(with = |value: bool| value as i64)] blocked: Option, + #[builder(with = |value: bool| value as i64)] + widget_sharing_allowed: Option, } impl UpdateContact { @@ -95,7 +99,8 @@ impl Contact { requested = COALESCE(?, requested), requested_by_user = COALESCE(?, requested_by_user), deleted_by_user = COALESCE(?, deleted_by_user), - blocked = COALESCE(?, blocked) + blocked = COALESCE(?, blocked), + widget_sharing_allowed = COALESCE(?, widget_sharing_allowed) WHERE user_id = ? "#, contact.username, @@ -110,6 +115,7 @@ impl Contact { contact.requested_by_user, contact.deleted_by_user, contact.blocked, + contact.widget_sharing_allowed, contact.user_id, ) .execute(&mut **t) @@ -185,6 +191,21 @@ impl Contact { Ok(()) } + pub async fn update_widget_sharing_allowed( + t: &mut Transaction<'_, Sqlite>, + user_id: i64, + allowed: bool, + ) -> Result<()> { + sqlx::query!( + "UPDATE contacts SET widget_sharing_allowed = ? WHERE user_id = ?", + allowed, + user_id, + ) + .execute(&mut **t) + .await?; + Ok(()) + } + pub async fn is_user_discovery_allowed( context: &Context, t: &mut Transaction<'_, Sqlite>, diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index baf69c6b..f00027bd 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1963960341; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1545547125; // Section: executor @@ -1438,6 +1438,43 @@ fn wire__crate__bridge__api__rust_api_delete_memory_impl( }, ) } +fn wire__crate__bridge__api__rust_api_delete_widget_media_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_delete_widget_media", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_media_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::delete_widget_media(api_media_id).await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_disable_memories_backup_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2670,6 +2707,77 @@ fn wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl( }, ) } +fn wire__crate__bridge__api__rust_api_purge_widget_media_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_purge_widget_media", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::api::RustApi::purge_widget_media().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_refresh_widget_manifest", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::refresh_widget_manifest().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_register_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -2715,6 +2823,47 @@ fn wire__crate__bridge__api__rust_api_register_impl( }, ) } +fn wire__crate__bridge__api__rust_api_register_home_widget_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_register_home_widget", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_widget_id = ::sse_decode(&mut deserializer); + let api_platform = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::api::RustApi::register_home_widget( + api_widget_id, + api_platform, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_register_passwordless_notification_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3450,6 +3599,7 @@ fn wire__crate__bridge__api__rust_api_send_media_to_groups_impl( let api_media_id = ::sse_decode(&mut deserializer); let api_group_ids = >::sse_decode(&mut deserializer); let api_additional_message_data = >>::sse_decode(&mut deserializer); + let api_widget_only = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( @@ -3458,6 +3608,7 @@ fn wire__crate__bridge__api__rust_api_send_media_to_groups_impl( api_media_id, api_group_ids, api_additional_message_data, + api_widget_only, ) .await?; Ok(output_ok) @@ -3621,6 +3772,49 @@ fn wire__crate__bridge__api__rust_api_set_background_impl( }, ) } +fn wire__crate__bridge__api__rust_api_set_home_widget_groups_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_set_home_widget_groups", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_widget_id = ::sse_decode(&mut deserializer); + let api_platform = ::sse_decode(&mut deserializer); + let api_contact_group_ids = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = crate::bridge::api::RustApi::set_home_widget_groups( + api_widget_id, + api_platform, + api_contact_group_ids, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_set_login_token_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3900,6 +4094,42 @@ fn wire__crate__bridge__api__rust_api_submit_recovery_share_impl( }, ) } +fn wire__crate__bridge__api__rust_api_sync_widget_permissions_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_sync_widget_permissions", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::sync_widget_permissions().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3981,6 +4211,44 @@ fn wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl( }, ) } +fn wire__crate__bridge__api__rust_api_unregister_home_widget_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_unregister_home_widget", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_widget_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::unregister_home_widget(api_widget_id) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_update_fcm_token_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -5568,6 +5836,7 @@ impl SseDecode for crate::bridge::api::ApiEventKind { 4 => crate::bridge::api::ApiEventKind::AppOutdated, 5 => crate::bridge::api::ApiEventKind::NewDeviceRegistered, 6 => crate::bridge::api::ApiEventKind::LoginTokenMigrated, + 7 => crate::bridge::api::ApiEventKind::WidgetMediaReceived, _ => unreachable!("Invalid variant for ApiEventKind: {}", inner), }; } @@ -6643,113 +6912,120 @@ fn pde_ffi_dispatcher_primary_impl( 36 => wire__crate__bridge__api__rust_api_current_user_avatar_path_impl(port, ptr, rust_vec_len, data_len), 38 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), 39 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), -40 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), -41 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), -42 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), -43 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), -44 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len), -45 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), -46 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), -47 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len), -48 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), -49 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), -50 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), -51 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), -52 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), -53 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -54 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), -55 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), -56 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), -57 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len), -58 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len), -59 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), -60 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), -61 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), -62 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), -63 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), -64 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), -65 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len), -66 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len), -67 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), -68 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), -69 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), -70 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len), -71 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), -72 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), -73 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -74 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -75 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), -76 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), -77 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), -78 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), -79 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), -80 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), -81 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), -82 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), -83 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), -84 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), -85 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), -86 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), -87 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), -88 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), -89 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), -90 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), -91 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), -92 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), -93 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), -94 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), -95 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), -96 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), -97 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), -98 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), -99 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), -100 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), -101 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), -102 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), -103 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), -104 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), -105 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), -106 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), -107 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), -108 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), -109 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), -110 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), -111 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), -112 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), -113 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), -114 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), -115 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), -116 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), -117 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -118 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), -119 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -120 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), -121 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -122 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -123 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -124 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), -125 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), -126 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), -127 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -128 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), -129 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), -130 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), -131 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), -132 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), -133 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), -134 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), -135 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), -136 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), -137 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -138 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -139 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -140 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), -141 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), -143 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), -144 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), -145 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), -146 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), -147 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), +40 => wire__crate__bridge__api__rust_api_delete_widget_media_impl(port, ptr, rust_vec_len, data_len), +41 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), +42 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), +43 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), +44 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), +45 => wire__crate__bridge__api__rust_api_ensure_avatar_png_impl(port, ptr, rust_vec_len, data_len), +46 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), +47 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), +48 => wire__crate__bridge__api__rust_api_finish_started_media_uploads_impl(port, ptr, rust_vec_len, data_len), +49 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), +50 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), +51 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), +52 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), +53 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), +54 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +55 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), +56 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), +57 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), +58 => wire__crate__bridge__api__rust_api_hand_outbox_to_os_impl(port, ptr, rust_vec_len, data_len), +59 => wire__crate__bridge__api__rust_api_initialize_media_upload_impl(port, ptr, rust_vec_len, data_len), +60 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), +61 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), +62 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), +63 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), +64 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), +65 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), +66 => wire__crate__bridge__api__rust_api_media_size_limit_report_impl(port, ptr, rust_vec_len, data_len), +67 => wire__crate__bridge__api__rust_api_media_step_finished_impl(port, ptr, rust_vec_len, data_len), +68 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), +69 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), +70 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), +71 => wire__crate__bridge__api__rust_api_prerender_media_impl(port, ptr, rust_vec_len, data_len), +72 => wire__crate__bridge__api__rust_api_purge_media_temp_folder_impl(port, ptr, rust_vec_len, data_len), +73 => wire__crate__bridge__api__rust_api_purge_widget_media_impl(port, ptr, rust_vec_len, data_len), +74 => wire__crate__bridge__api__rust_api_refresh_widget_manifest_impl(port, ptr, rust_vec_len, data_len), +75 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), +76 => wire__crate__bridge__api__rust_api_register_home_widget_impl(port, ptr, rust_vec_len, data_len), +77 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +78 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +79 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), +80 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), +81 => wire__crate__bridge__api__rust_api_remove_media_files_impl(port, ptr, rust_vec_len, data_len), +82 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), +83 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), +84 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), +85 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), +86 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), +87 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), +88 => wire__crate__bridge__api__rust_api_retry_pending_media_reuploads_impl(port, ptr, rust_vec_len, data_len), +89 => wire__crate__bridge__api__rust_api_reupload_pending_media_impl(port, ptr, rust_vec_len, data_len), +90 => wire__crate__bridge__api__rust_api_save_media_to_gallery_impl(port, ptr, rust_vec_len, data_len), +91 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), +92 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), +93 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), +94 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), +95 => wire__crate__bridge__api__rust_api_send_media_to_groups_impl(port, ptr, rust_vec_len, data_len), +96 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), +97 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), +98 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), +99 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), +100 => wire__crate__bridge__api__rust_api_set_home_widget_groups_impl(port, ptr, rust_vec_len, data_len), +101 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), +102 => wire__crate__bridge__api__rust_api_set_media_display_limit_impl(port, ptr, rust_vec_len, data_len), +103 => wire__crate__bridge__api__rust_api_set_media_requires_authentication_impl(port, ptr, rust_vec_len, data_len), +104 => wire__crate__bridge__api__rust_api_set_media_trim_impl(port, ptr, rust_vec_len, data_len), +105 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), +106 => wire__crate__bridge__api__rust_api_store_media_impl(port, ptr, rust_vec_len, data_len), +107 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), +108 => wire__crate__bridge__api__rust_api_sync_widget_permissions_impl(port, ptr, rust_vec_len, data_len), +109 => wire__crate__bridge__api__rust_api_toggle_media_remove_audio_impl(port, ptr, rust_vec_len, data_len), +110 => wire__crate__bridge__api__rust_api_try_request_contact_by_id_impl(port, ptr, rust_vec_len, data_len), +111 => wire__crate__bridge__api__rust_api_unregister_home_widget_impl(port, ptr, rust_vec_len, data_len), +112 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), +113 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), +114 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), +115 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), +116 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), +117 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), +118 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), +119 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), +120 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), +121 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), +122 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), +123 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), +124 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +125 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), +126 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +127 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), +128 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +129 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +130 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +131 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), +132 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), +133 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), +134 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), +135 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len), +136 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), +137 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), +138 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), +139 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), +140 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), +141 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), +142 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), +143 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), +144 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), +145 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +146 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +147 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), +148 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), +150 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), +151 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), +152 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), +153 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), +154 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -6767,12 +7043,12 @@ fn pde_ffi_dispatcher_sync_impl( 37 => { wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len) } - 142 => wire__crate__bridge__user_config__user_config_api_clone_impl( + 149 => wire__crate__bridge__user_config__user_config_api_clone_impl( ptr, rust_vec_len, data_len, ), - 148 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), + 155 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -6836,6 +7112,7 @@ impl flutter_rust_bridge::IntoDart for crate::bridge::api::ApiEventKind { Self::AppOutdated => 4.into_dart(), Self::NewDeviceRegistered => 5.into_dart(), Self::LoginTokenMigrated => 6.into_dart(), + Self::WidgetMediaReceived => 7.into_dart(), _ => unreachable!(), } } @@ -7792,6 +8069,7 @@ impl SseEncode for crate::bridge::api::ApiEventKind { crate::bridge::api::ApiEventKind::AppOutdated => 4, crate::bridge::api::ApiEventKind::NewDeviceRegistered => 5, crate::bridge::api::ApiEventKind::LoginTokenMigrated => 6, + crate::bridge::api::ApiEventKind::WidgetMediaReceived => 7, _ => { unimplemented!(""); } diff --git a/rust/src/native/notifications.rs b/rust/src/native/notifications.rs index 2c861b37..0800e946 100644 --- a/rust/src/native/notifications.rs +++ b/rust/src/native/notifications.rs @@ -20,6 +20,7 @@ static NOTIFICATION_WORKER: LazyLock> = LazyLock::new(|| Mutex::new(() #[derive(Serialize)] struct NativeNotificationResponse { ok: bool, + widget_refresh: bool, batch: Option, fallback: Option, error: Option, @@ -48,12 +49,22 @@ unsafe fn required_string(pointer: *const c_char, name: &str) -> Result Result { - tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build() - .map_err(|error| format!("could not create notification runtime: {error}")) +/// One runtime for the whole worker process. `twonly_notification_process` +/// hands its batch back before the background socket is done with it, so the +/// tasks it spawned have to outlive that call — a per-call runtime would drop +/// them on the way out and leave `twonly_notification_finalize` with a dead +/// connection. +static NOTIFICATION_RUNTIME: LazyLock> = + LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .map_err(|error| format!("could not create notification runtime: {error}")) + }); + +fn runtime() -> Result<&'static tokio::runtime::Runtime, String> { + NOTIFICATION_RUNTIME.as_ref().map_err(Clone::clone) } fn worker_lock() -> MutexGuard<'static, ()> { @@ -93,6 +104,7 @@ pub unsafe extern "C" fn twonly_notification_process( match result { Ok(Ok(batch)) => response_json(NativeNotificationResponse { ok: true, + widget_refresh: true, batch: Some(batch), fallback: Some(notifications::fallback_presentation( unsafe { required_string(locale, "locale") } @@ -103,6 +115,7 @@ pub unsafe extern "C" fn twonly_notification_process( }), Ok(Err(error)) => response_json(NativeNotificationResponse { ok: false, + widget_refresh: false, batch: None, fallback: Some(notifications::fallback_presentation( unsafe { required_string(locale, "locale") } @@ -113,6 +126,7 @@ pub unsafe extern "C" fn twonly_notification_process( }), Err(_) => response_json(NativeNotificationResponse { ok: false, + widget_refresh: false, batch: None, fallback: Some(notifications::fallback_presentation("en")), error: Some("notification worker panicked".into()), @@ -120,6 +134,33 @@ pub unsafe extern "C" fn twonly_notification_process( } } +/// Settles the deferred wake-up work once the caller has rendered the batch: +/// media downloads, widget upkeep, and closing the background socket. Safe to +/// skip — an OS that reclaims the worker first only defers this to the next +/// wake-up or app launch. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn twonly_notification_finalize(deadline_ms: u64) -> *mut c_char { + let result = catch_unwind(AssertUnwindSafe(|| { + let _worker = worker_lock(); + runtime()? + .block_on(notifications::finalize_wakeup(deadline_ms)) + .map_err(|error| error.to_string()) + })); + + let error = match result { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error), + Err(_) => Some("notification finalization panicked".into()), + }; + response_json(NativeNotificationResponse { + ok: error.is_none(), + widget_refresh: error.is_none(), + batch: None, + fallback: None, + error, + }) +} + /// Marks successfully scheduled native events as delivered. `event_ids_json` /// must be a JSON string array. #[unsafe(no_mangle)] @@ -142,18 +183,21 @@ pub unsafe extern "C" fn twonly_notification_acknowledge( match result { Ok(Ok(())) => response_json(NativeNotificationResponse { ok: true, + widget_refresh: false, batch: None, fallback: None, error: None, }), Ok(Err(error)) => response_json(NativeNotificationResponse { ok: false, + widget_refresh: false, batch: None, fallback: None, error: Some(error), }), Err(_) => response_json(NativeNotificationResponse { ok: false, + widget_refresh: false, batch: None, fallback: None, error: Some("notification acknowledgement panicked".into()), @@ -236,6 +280,26 @@ mod android_jni { ) } + #[unsafe(no_mangle)] + pub extern "system" fn Java_eu_twonly_notifications_NativeNotificationBridge_finalizeWakeup( + mut env: JNIEnv<'_>, + _class: JClass<'_>, + deadline_ms: jlong, + ) -> jstring { + let pointer = unsafe { twonly_notification_finalize(deadline_ms.max(0) as u64) }; + let json = if pointer.is_null() { + r#"{"ok":false,"error":"Rust notification finalization returned null"}"#.to_owned() + } else { + // SAFETY: The C ABI returns a valid owned CString. + let json = unsafe { CStr::from_ptr(pointer) } + .to_string_lossy() + .into_owned(); + unsafe { twonly_notification_string_free(pointer) }; + json + }; + return_string(&mut env, json) + } + #[unsafe(no_mangle)] pub extern "system" fn Java_eu_twonly_notifications_NativeNotificationBridge_acknowledge( mut env: JNIEnv<'_>, diff --git a/rust/src/services/direct_media_upload.rs b/rust/src/services/direct_media_upload.rs index 2a5ea22f..3b004cd2 100644 --- a/rust/src/services/direct_media_upload.rs +++ b/rust/src/services/direct_media_upload.rs @@ -22,7 +22,6 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; -const REFRESH_BEFORE_SECONDS: i64 = 24 * 60 * 60; /// When to top the slot cache up, until the server has said otherwise. /// /// A slot is the one part of preparing a send that needs the network, so the @@ -44,6 +43,16 @@ const WATCH_MAX_DELAY: Duration = Duration::from_secs(120); static WATCHING: AtomicBool = AtomicBool::new(false); static API_NAMESPACE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +/// One slot request at a time. +/// +/// The server reads the known-slot list of a request as the whole truth for the +/// account and abandons every reserved slot missing from it. Two requests in +/// flight at once therefore destroy each other's slots: the second was prepared +/// before the first's answer was stored, so it asks the server to give up +/// exactly the slots the first had just been handed. The client then holds a +/// cache of slots that are already dead, and every send that reserves one of +/// them fails. +static PRELOAD_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); /// Bumped every time something asks to be watched. A watcher already in flight /// reads this as "new work arrived" and drops back to the short poll interval. @@ -62,7 +71,6 @@ impl Drop for WatchingGuard { WATCHING.store(false, Ordering::SeqCst); } } - /// The OS finishes a transfer without telling this process, so a send would keep /// showing as "sending" until the app is restarted. While the app is alive, poll /// the server for the attachments it is still waiting on, backing off as the @@ -150,6 +158,7 @@ struct MessageRow { created_at: i64, quotes_message_id: Option, additional_message_data: Option>, + is_widget_media: i64, } #[derive(FromRow)] @@ -198,6 +207,17 @@ async fn reset_upload_namespace( if stored.as_deref() == Some(namespace) { return Ok((stored, 0)); } + if stored.is_none() { + sqlx::query( + r#"INSERT INTO app_metadata(key, value) VALUES(?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value"#, + ) + .bind(API_NAMESPACE_KEY) + .bind(namespace) + .execute(&database.pool) + .await?; + return Ok((None, 0)); + } let stale_files = sqlx::query_as::<_, (String, String, String, String)>( r#"SELECT attachment_id, multipart_path, manifest_path, complete_body_path @@ -325,14 +345,33 @@ impl DirectMediaUploadService { }); } - pub async fn preload_slots(&self) -> Result { - self.ensure_api_namespace().await?; + /// Every slot this client still holds: cached and in flight alike. + /// + /// The server abandons each reserved slot of this account that is missing + /// from the list, so an omission is not merely a lost cache entry - a slot + /// the OS is still uploading against dies with `not_in_client_cache`, and + /// the send has to start over. Reserved slots are exactly the ones a + /// refill can race, because a refill runs on every reconnect and after + /// every settled transfer, both of which happen while a send is in flight. + async fn locally_known_slot_ids(&self) -> Result> { let database = self.ctx.app_db.read().await.clone(); - let known = sqlx::query_scalar::<_, String>( - "SELECT attachment_id FROM direct_media_upload_slots WHERE state IN ('cached', 'reserved') AND expires_at > CAST(strftime('%s','now') AS INTEGER)", + Ok(sqlx::query_scalar::<_, String>( + r#"SELECT attachment_id FROM direct_media_upload_slots + WHERE state IN ('cached', 'reserved') + AND expires_at > CAST(strftime('%s','now') AS INTEGER)"#, ) .fetch_all(&database.pool) - .await?; + .await?) + } + + pub async fn preload_slots(&self) -> Result { + self.ensure_api_namespace().await?; + // Held across the whole exchange: the list below is only true while no + // other request can be answered, and stays true until the answer has + // been stored. + let _guard = PRELOAD_LOCK.lock().await; + let known = self.locally_known_slot_ids().await?; + let database = self.ctx.app_db.read().await.clone(); let request = RequestUploadSlots { locally_known_slot_ids: known, }; @@ -365,6 +404,13 @@ impl DirectMediaUploadService { .map_err(|error| TwonlyError::Generic(error.to_string()))?, )?; let count = slots.slots.len(); + tracing::info!( + known_sent = request.locally_known_slot_ids.len(), + slots_received = count, + max_active = slots.maximum_active_slots, + refill_threshold = slots.refill_threshold, + "direct-media upload slots response from server" + ); let mut transaction = database.pool.begin().await?; // The server decides how deep this cache may be. Remembering the number // keeps an offline launch from refilling against a stale guess. @@ -387,7 +433,13 @@ impl DirectMediaUploadService { (attachment_id, expires_at, maximum_object_bytes, upload_url, upload_fields_json, capability, state) VALUES (?, ?, ?, ?, ?, ?, 'cached') - ON CONFLICT(attachment_id) DO NOTHING"#, + ON CONFLICT(attachment_id) DO UPDATE SET + expires_at = excluded.expires_at, + maximum_object_bytes = excluded.maximum_object_bytes, + upload_url = excluded.upload_url, + upload_fields_json = excluded.upload_fields_json, + capability = excluded.capability, + state = 'cached'"#, ) .bind(slot.attachment_id) .bind(slot.expires_at_unix_seconds) @@ -406,9 +458,8 @@ impl DirectMediaUploadService { let database = self.ctx.app_db.read().await.clone(); Ok(sqlx::query_scalar( r#"SELECT COUNT(*) FROM direct_media_upload_slots - WHERE state = 'cached' AND expires_at > CAST(strftime('%s','now') AS INTEGER) + ?"#, + WHERE state = 'cached' AND expires_at > CAST(strftime('%s','now') AS INTEGER)"#, ) - .bind(REFRESH_BEFORE_SECONDS) .fetch_one(&database.pool) .await?) } @@ -434,13 +485,19 @@ impl DirectMediaUploadService { if usable > self.refill_threshold().await { return Ok(()); } - match self.preload_slots().await { - Ok(_) => Ok(()), - Err(error) if usable > 0 => { - tracing::warn!(%error, usable, "slot refill failed; using cached direct-media slot"); - Ok(()) + let preload_result = self.preload_slots().await; + let usable_after = self.usable_slot_count().await?; + if usable_after > 0 { + if let Err(error) = preload_result { + tracing::warn!(%error, usable_after, "slot refill failed; using cached direct-media slot"); } - Err(error) => Err(error), + Ok(()) + } else { + preload_result.and_then(|_| { + Err(TwonlyError::Generic( + "no direct-media upload slots available from server".into(), + )) + }) } } @@ -453,13 +510,12 @@ impl DirectMediaUploadService { WHERE attachment_id = ( SELECT attachment_id FROM direct_media_upload_slots WHERE state = 'cached' - AND expires_at > CAST(strftime('%s','now') AS INTEGER) + ? + AND expires_at > CAST(strftime('%s','now') AS INTEGER) ORDER BY expires_at ASC LIMIT 1 ) RETURNING attachment_id, expires_at, maximum_object_bytes, upload_url, upload_fields_json, capability"#, ) - .bind(REFRESH_BEFORE_SECONDS) .fetch_optional(&mut *transaction) .await? .ok_or_else(|| TwonlyError::Generic("no usable direct-media upload slot".into()))?; @@ -491,6 +547,16 @@ impl DirectMediaUploadService { pub async fn reconcile(&self) -> Result<()> { self.ensure_api_namespace().await?; let database = self.ctx.app_db.read().await.clone(); + + // Release any orphaned reserved slots (slots marked reserved with no associated job) + let _ = sqlx::query( + r#"UPDATE direct_media_upload_slots SET state = 'cached' + WHERE state = 'reserved' + AND attachment_id NOT IN (SELECT attachment_id FROM direct_media_upload_jobs)"#, + ) + .execute(&database.pool) + .await; + let jobs = sqlx::query_as::<_, PendingJob>( r#"SELECT j.attachment_id, j.media_id, j.multipart_path, j.manifest_path, j.complete_body_path, s.capability, j.expires_at @@ -504,10 +570,8 @@ impl DirectMediaUploadService { let now = chrono::Utc::now().timestamp(); for job in jobs { - let state = match self.fetch_status(&job).await { - Ok(status) => { - AttachmentState::try_from(status.state).unwrap_or(AttachmentState::Unknown) - } + let status = match self.fetch_status(&job).await { + Ok(status) => status, Err(error) => { tracing::warn!( attachment_id = job.attachment_id, @@ -517,10 +581,14 @@ impl DirectMediaUploadService { continue; } }; + let state = AttachmentState::try_from(status.state).unwrap_or(AttachmentState::Unknown); tracing::info!( attachment_id = job.attachment_id, media_id = job.media_id, ?state, + // The server's word on why a transfer was given up on, which is + // the only place a cause such as `not_in_client_cache` appears. + reason = status.rejection_reason, "read direct-media attachment status" ); match state { @@ -528,6 +596,9 @@ impl DirectMediaUploadService { AttachmentState::Rejected => self.settle(&job, Outcome::Rejected).await?, AttachmentState::Expired | AttachmentState::Abandoned => { self.settle(&job, Outcome::Retry).await?; + if status.rejection_reason == "not_in_client_cache" { + self.purge_cached_slots().await; + } } // Still reserved or uploading. Only give up once the slot's own // capability has expired, because the native task stops then too. @@ -538,6 +609,27 @@ impl DirectMediaUploadService { Ok(()) } + /// The server gave this account's reserved slots up while this client was + /// still holding them, so the cache it holds is a generation the server has + /// swept: the other slots in it are just as dead, and each would cost a + /// failed send to discover. Dropping them costs one refill instead. + async fn purge_cached_slots(&self) { + let database = self.ctx.app_db.read().await.clone(); + let purged = sqlx::query("DELETE FROM direct_media_upload_slots WHERE state = 'cached'") + .execute(&database.pool) + .await + .map(|result| result.rows_affected()) + .unwrap_or_default(); + drop(database); + if purged > 0 { + tracing::warn!( + purged, + "dropped cached direct-media slots the server had already abandoned" + ); + self.spawn_slot_refill(); + } + } + async fn fetch_status(&self, job: &PendingJob) -> Result { let response = reqwest::Client::new() .get(Self::api_url(&format!( @@ -596,14 +688,15 @@ impl DirectMediaUploadService { } else { "abandoned" }; - // The cache has just lost one; refill in the background so the next - // send does not have to be online to reserve one. - self.spawn_slot_refill(); sqlx::query("UPDATE direct_media_upload_slots SET state = ? WHERE attachment_id = ?") .bind(slot_state) .bind(&job.attachment_id) .execute(&database.pool) .await?; + // The cache has just lost one; refill in the background so the next + // send does not have to be online to reserve one. Only once this slot + // has left `reserved`, so the refill does not report it as still held. + self.spawn_slot_refill(); match outcome { Outcome::Uploaded => { @@ -654,6 +747,7 @@ impl DirectMediaUploadService { } pub async fn prepare_and_schedule(&self, media_id: &str) -> Result { + self.cleanup_stale_media_jobs(media_id).await; let slot = self.reserve_slot().await?; match self.prepare_reserved(media_id, &slot).await { Ok(()) => Ok(slot.attachment_id), @@ -664,6 +758,34 @@ impl DirectMediaUploadService { } } + async fn cleanup_stale_media_jobs(&self, media_id: &str) { + let database = self.ctx.app_db.read().await.clone(); + let jobs = sqlx::query_as::<_, (String, String, String, String)>( + r#"DELETE FROM direct_media_upload_jobs WHERE media_id = ? + RETURNING attachment_id, multipart_path, manifest_path, complete_body_path"#, + ) + .bind(media_id) + .fetch_all(&database.pool) + .await + .unwrap_or_default(); + + for (attachment_id, multipart, manifest, complete) in jobs { + for path in [&multipart, &manifest, &complete] { + let _ = std::fs::remove_file(path); + } + let job_dir = PathBuf::from(&self.ctx.config.data_dir) + .join("direct-media-upload") + .join(&attachment_id); + let _ = std::fs::remove_dir(job_dir); + let _ = sqlx::query( + "UPDATE direct_media_upload_slots SET state = 'cached' WHERE attachment_id = ?", + ) + .bind(&attachment_id) + .execute(&database.pool) + .await; + } + } + /// Preparation failed somewhere between writing the job row and handing it /// to the native uploader. Nothing was scheduled, so the half-built job and /// its files are removed and the slot goes back into the cache unused. @@ -833,7 +955,7 @@ impl DirectMediaUploadService { let messages = sqlx::query_as::<_, MessageRow>( r#"SELECT group_id, message_id, created_at, quotes_message_id, - additional_message_data + additional_message_data, is_widget_media FROM messages WHERE media_id = ?"#, ) .bind(media_id) @@ -880,6 +1002,7 @@ impl DirectMediaUploadService { encryption_mac: Some(tag.to_vec()), encryption_nonce: Some(nonce.to_vec()), additional_message_data: message.additional_message_data.clone(), + widget_only: Some(message.is_widget_media != 0), }), ..Default::default() }; @@ -1094,4 +1217,183 @@ mod tests { ); assert!(bytes.ends_with(b"\r\n--boundary--\r\n")); } + + #[tokio::test] + async fn usable_slot_count_and_reserve_accept_unexpired_slots() { + let directory = tempfile::tempdir().unwrap(); + let ctx = Context::init_for_testing( + directory.path().join("database"), + directory.path().join("data"), + ) + .await + .unwrap(); + let service = DirectMediaUploadService::new(&ctx); + + // Slot expiring in 1 hour (which is < 24 hours) + let soon = chrono::Utc::now().timestamp() + 3600; + let database = ctx.app_db.read().await.clone(); + sqlx::query( + r#"INSERT INTO direct_media_upload_slots + (attachment_id, expires_at, maximum_object_bytes, upload_url, + upload_fields_json, capability, state) + VALUES('slot-1', ?, 1000000, 'https://objects.example', '{}', x'01', 'cached')"#, + ) + .bind(soon) + .execute(&database.pool) + .await + .unwrap(); + + assert_eq!(service.usable_slot_count().await.unwrap(), 1); + + // Slot should be successfully reserved even though remaining TTL < 24 hours + let mut transaction = database.pool.begin().await.unwrap(); + let slot = sqlx::query_as::<_, CachedSlot>( + r#"UPDATE direct_media_upload_slots SET state = 'reserved' + WHERE attachment_id = ( + SELECT attachment_id FROM direct_media_upload_slots + WHERE state = 'cached' + AND expires_at > CAST(strftime('%s','now') AS INTEGER) + ORDER BY expires_at ASC LIMIT 1 + ) + RETURNING attachment_id, expires_at, maximum_object_bytes, upload_url, + upload_fields_json, capability"#, + ) + .fetch_optional(&mut *transaction) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + assert!(slot.is_some()); + assert_eq!(slot.unwrap().attachment_id, "slot-1"); + } + + #[tokio::test] + async fn reconcile_reclaims_orphaned_reserved_slots() { + let directory = tempfile::tempdir().unwrap(); + let ctx = Context::init_for_testing( + directory.path().join("database"), + directory.path().join("data"), + ) + .await + .unwrap(); + let service = DirectMediaUploadService::new(&ctx); + + let database = ctx.app_db.read().await.clone(); + sqlx::query( + r#"INSERT INTO direct_media_upload_slots + (attachment_id, expires_at, maximum_object_bytes, upload_url, + upload_fields_json, capability, state) + VALUES('orphaned-slot', 9999999999, 1000000, 'https://objects.example', '{}', x'01', 'reserved')"#, + ) + .execute(&database.pool) + .await + .unwrap(); + + assert_eq!(service.usable_slot_count().await.unwrap(), 0); + + service.reconcile().await.unwrap(); + + assert_eq!(service.usable_slot_count().await.unwrap(), 1); + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM direct_media_upload_slots WHERE attachment_id = 'orphaned-slot'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(state, "cached"); + } + + #[tokio::test] + async fn stale_media_jobs_are_cleaned_up_and_slots_cached() { + let directory = tempfile::tempdir().unwrap(); + let ctx = Context::init_for_testing( + directory.path().join("database"), + directory.path().join("data"), + ) + .await + .unwrap(); + let service = DirectMediaUploadService::new(&ctx); + + let job_dir = directory.path().join("data/direct-media-upload/slot-stale"); + std::fs::create_dir_all(&job_dir).unwrap(); + let multipart = job_dir.join("media.multipart"); + let manifest = job_dir.join("manifest.pb"); + let complete = job_dir.join("complete.pb"); + for p in [&multipart, &manifest, &complete] { + std::fs::write(p, b"data").unwrap(); + } + + let database = ctx.app_db.read().await.clone(); + sqlx::query( + r#"INSERT INTO direct_media_upload_slots + (attachment_id, expires_at, maximum_object_bytes, upload_url, + upload_fields_json, capability, state) + VALUES('slot-stale', 9999999999, 1000000, 'https://objects.example', '{}', x'01', 'reserved')"#, + ) + .execute(&database.pool) + .await + .unwrap(); + + sqlx::query( + r#"INSERT INTO direct_media_upload_jobs + (attachment_id, media_id, multipart_path, manifest_path, + complete_body_path, native_descriptor_json, state, expires_at) + VALUES('slot-stale', 'media-stale', ?, ?, ?, '{}', 'scheduled', 9999999999)"#, + ) + .bind(multipart.to_string_lossy().as_ref()) + .bind(manifest.to_string_lossy().as_ref()) + .bind(complete.to_string_lossy().as_ref()) + .execute(&database.pool) + .await + .unwrap(); + + assert_eq!(service.usable_slot_count().await.unwrap(), 0); + + service.cleanup_stale_media_jobs("media-stale").await; + + assert_eq!(service.usable_slot_count().await.unwrap(), 1); + assert!(!multipart.exists()); + assert!(!job_dir.exists()); + } + + #[tokio::test] + async fn known_slot_ids_include_in_flight_reservations() { + let directory = tempfile::tempdir().unwrap(); + let ctx = Context::init_for_testing( + directory.path().join("database"), + directory.path().join("data"), + ) + .await + .unwrap(); + let service = DirectMediaUploadService::new(&ctx); + + let database = ctx.app_db.read().await.clone(); + let expired = chrono::Utc::now().timestamp() - 60; + for (attachment_id, state, expires_at) in [ + ("slot-cached", "cached", 9999999999), + ("slot-reserved", "reserved", 9999999999), + ("slot-consumed", "consumed", 9999999999), + ("slot-expired", "cached", expired), + ] { + sqlx::query( + r#"INSERT INTO direct_media_upload_slots + (attachment_id, expires_at, maximum_object_bytes, upload_url, + upload_fields_json, capability, state) + VALUES(?, ?, 1000000, 'https://objects.example', '{}', x'01', ?)"#, + ) + .bind(attachment_id) + .bind(expires_at) + .bind(state) + .execute(&database.pool) + .await + .unwrap(); + } + + let mut known = service.locally_known_slot_ids().await.unwrap(); + known.sort(); + + // The reserved slot is the one an upload is running against: leaving it + // out makes the server abandon the transfer as `not_in_client_cache`. + assert_eq!(known, vec!["slot-cached", "slot-reserved"]); + } } diff --git a/rust/src/services/home_widget.rs b/rust/src/services/home_widget.rs new file mode 100644 index 00000000..9d55a4ba --- /dev/null +++ b/rust/src/services/home_widget.rs @@ -0,0 +1,429 @@ +/* + * Copyright (c) 2026, Tobias Müller git@tsmr.eu + */ + +//! Home-screen widget state and its native-process manifest. +//! +//! WidgetKit and AppWidget never open the encrypted application database. They +//! only read the JSON and PNG files produced here under the shared data root. + +use crate::api::messages::outgoing::send_c2c_message_to_contact; +use crate::api::proto::client::EncryptedContent; +use crate::context::Context; +use crate::error::Result; +use crate::services::mediafiles::MediaFileService; +use prost::Message as _; +use serde::Serialize; +use sqlx::FromRow; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +const WIDGET_LIFETIME_SECONDS: i64 = 24 * 60 * 60; + +/// Mirrors `staleWidgetSeconds` in the iOS widget: an entry that has not +/// refreshed within this belongs to a widget that is no longer placed, and its +/// contact groups must stop granting anyone permission to share. +const STALE_WIDGET_SECONDS: i64 = 48 * 60 * 60; + +/// WidgetKit gives an extension roughly 30 MB, and a decoded full-resolution +/// camera image alone exceeds that, which the system answers by killing the +/// extension mid-render: the home screen then shows an empty widget. The +/// largest supported family never needs more than this on either platform. +const WIDGET_IMAGE_MAX_EDGE: u32 = 1200; + +#[derive(Serialize)] +struct WidgetManifest { + version: u8, + updated_at: i64, + groups: Vec, + images: Vec, +} + +#[derive(Serialize, FromRow)] +struct ManifestGroup { + id: i64, + name: String, + emoji: Option, +} + +#[derive(Serialize)] +struct ManifestImage { + media_id: String, + path: String, + sender: String, + group_ids: Vec, + expires_at: i64, +} + +#[derive(FromRow)] +struct WidgetMediaRow { + media_id: String, + media_type: String, + sender_id: i64, + sender: String, + created_at: i64, +} + +#[derive(FromRow)] +struct ExpiredMediaRow { + media_id: String, + media_type: String, +} + +fn widget_root(ctx: &Context) -> PathBuf { + Path::new(&ctx.config.data_dir).join("widget") +} + +fn config_path(ctx: &Context) -> PathBuf { + widget_root(ctx).join("native-config.json") +} + +/// Contacts reachable through at least one contact group used by a placed +/// widget. Group-valued members are expanded through their ordinary members. +pub async fn allowed_contact_ids(ctx: &Arc) -> Result> { + let database = ctx.app_db.read().await.clone(); + let ids = sqlx::query_scalar::<_, i64>( + r#"SELECT DISTINCT contact_id FROM ( + SELECT cgm.user_id AS contact_id + FROM home_widget_groups hwg + JOIN contact_group_members cgm + ON cgm.contact_group_id = hwg.contact_group_id + WHERE cgm.user_id IS NOT NULL + UNION + SELECT gm.contact_id + FROM home_widget_groups hwg + JOIN contact_group_members cgm + ON cgm.contact_group_id = hwg.contact_group_id + JOIN group_members gm ON gm.group_id = cgm.group_id + WHERE cgm.group_id IS NOT NULL + AND (gm.member_state IS NULL OR gm.member_state != 'leftGroup') + ) ORDER BY contact_id"#, + ) + .fetch_all(&database.pool) + .await?; + Ok(ids) +} + +/// Reconciles the derived grant set and explicitly announces every change. +pub async fn sync_permissions(ctx: &Arc) -> Result<()> { + import_native_configuration(ctx).await?; + let allowed: BTreeSet = allowed_contact_ids(ctx).await?.into_iter().collect(); + let database = ctx.app_db.read().await.clone(); + let current = sqlx::query_as::<_, (i64, i64)>( + "SELECT user_id, widget_sharing_granted FROM contacts WHERE accepted = 1", + ) + .fetch_all(&database.pool) + .await?; + + let mut changed = Vec::new(); + let mut transaction = database.pool.begin().await?; + for (contact_id, granted) in current { + let next = allowed.contains(&contact_id); + if (granted != 0) == next { + continue; + } + sqlx::query("UPDATE contacts SET widget_sharing_granted = ? WHERE user_id = ?") + .bind(next) + .bind(contact_id) + .execute(&mut *transaction) + .await?; + changed.push((contact_id, next)); + } + transaction.commit().await?; + drop(database); + + for (contact_id, allowed) in changed { + send_c2c_message_to_contact() + .ctx(ctx) + .contact_id(contact_id) + .encrypted_content( + EncryptedContent { + widget_sharing_allowed: Some(allowed), + ..Default::default() + } + .encode_to_vec(), + ) + .call() + .await?; + } + refresh_manifest(ctx).await +} + +pub async fn register_widget(ctx: &Arc, widget_id: &str, platform: &str) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + sqlx::query("INSERT OR IGNORE INTO home_widgets(widget_id, platform) VALUES (?, ?)") + .bind(widget_id) + .bind(platform) + .execute(&database.pool) + .await?; + sync_permissions(ctx).await +} + +pub async fn unregister_widget(ctx: &Arc, widget_id: &str) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + sqlx::query("DELETE FROM home_widgets WHERE widget_id = ?") + .bind(widget_id) + .execute(&database.pool) + .await?; + sync_permissions(ctx).await +} + +pub async fn set_widget_groups( + ctx: &Arc, + widget_id: &str, + platform: &str, + contact_group_ids: &[i64], +) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + sqlx::query("INSERT OR IGNORE INTO home_widgets(widget_id, platform) VALUES (?, ?)") + .bind(widget_id) + .bind(platform) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM home_widget_groups WHERE widget_id = ?") + .bind(widget_id) + .execute(&mut *transaction) + .await?; + for group_id in contact_group_ids { + sqlx::query( + "INSERT OR IGNORE INTO home_widget_groups(widget_id, contact_group_id) VALUES (?, ?)", + ) + .bind(widget_id) + .bind(group_id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + sync_permissions(ctx).await +} + +/// Imports native placement/config changes. Both widget implementations write +/// `{ widgets: [{id, platform, group_ids}] }` atomically to this file. +async fn import_native_configuration(ctx: &Arc) -> Result<()> { + #[derive(serde::Deserialize)] + struct NativeConfig { + #[serde(default)] + widgets: Vec, + } + #[derive(serde::Deserialize)] + struct NativeWidget { + id: String, + platform: String, + #[serde(default)] + group_ids: Vec, + /// When this widget last rebuilt itself. Only iOS records it: nothing + /// tells a WidgetKit extension that its widget was removed, so an entry + /// that stops refreshing is how a removal becomes visible. Android + /// enumerates its widgets directly and writes no timestamp. + #[serde(default)] + last_seen: Option, + } + + let path = config_path(ctx); + let Ok(bytes) = std::fs::read(path) else { + return Ok(()); + }; + let config: NativeConfig = serde_json::from_slice(&bytes)?; + let database = ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + sqlx::query("DELETE FROM home_widgets WHERE 1") + .execute(&mut *transaction) + .await?; + let oldest_live = chrono::Utc::now().timestamp() - STALE_WIDGET_SECONDS; + for widget in config.widgets { + if widget.last_seen.is_some_and(|seen| seen < oldest_live) { + continue; + } + sqlx::query("INSERT INTO home_widgets(widget_id, platform) VALUES (?, ?)") + .bind(&widget.id) + .bind(&widget.platform) + .execute(&mut *transaction) + .await?; + for group_id in widget.group_ids { + sqlx::query( + "INSERT OR IGNORE INTO home_widget_groups(widget_id, contact_group_id) VALUES (?, ?)", + ) + .bind(&widget.id) + .bind(group_id) + .execute(&mut *transaction) + .await?; + } + } + transaction.commit().await?; + Ok(()) +} + +async fn contact_group_ids_for_sender(ctx: &Arc, sender_id: i64) -> Result> { + let database = ctx.app_db.read().await.clone(); + Ok(sqlx::query_scalar::<_, i64>( + r#"SELECT DISTINCT contact_group_id FROM ( + SELECT contact_group_id FROM contact_group_members WHERE user_id = ? + UNION + SELECT cgm.contact_group_id + FROM contact_group_members cgm + JOIN group_members gm ON gm.group_id = cgm.group_id + WHERE gm.contact_id = ? + ) ORDER BY contact_group_id"#, + ) + .bind(sender_id) + .bind(sender_id) + .fetch_all(&database.pool) + .await?) +} + +pub async fn refresh_manifest(ctx: &Arc) -> Result<()> { + let root = widget_root(ctx); + let images_dir = root.join("images"); + std::fs::create_dir_all(&images_dir)?; + + let database = ctx.app_db.read().await.clone(); + let groups = sqlx::query_as::<_, ManifestGroup>( + "SELECT id, name, emoji FROM contact_groups ORDER BY name COLLATE NOCASE, id", + ) + .fetch_all(&database.pool) + .await?; + let rows = sqlx::query_as::<_, WidgetMediaRow>( + r#"SELECT f.media_id, f.type AS media_type, m.sender_id, + COALESCE(c.display_name, c.username) AS sender, m.created_at + FROM messages m + JOIN media_files f ON f.media_id = m.media_id + JOIN contacts c ON c.user_id = m.sender_id + WHERE m.is_widget_media = 1 AND f.is_widget_media = 1 + AND f.download_state = 'ready' + AND m.created_at > CAST(strftime('%s','now') AS INTEGER) - 86400 + ORDER BY m.created_at DESC"#, + ) + .fetch_all(&database.pool) + .await?; + drop(database); + + let files = MediaFileService::new(ctx); + let mut images = Vec::with_capacity(rows.len()); + for row in rows { + let source = files.temp_path(&row.media_id, &row.media_type); + if !source.exists() { + continue; + } + let destination = images_dir.join(format!("{}.png", row.media_id)); + // One unreadable file must not abort the whole manifest: bailing out + // here would freeze every other widget image at its last version. + let decoded = match image::open(&source) { + Ok(decoded) => decoded, + Err(error) => { + tracing::warn!(media_id = %row.media_id, %error, "widget image decode failed"); + continue; + } + }; + let scaled = if decoded.width() > WIDGET_IMAGE_MAX_EDGE + || decoded.height() > WIDGET_IMAGE_MAX_EDGE + { + decoded.resize( + WIDGET_IMAGE_MAX_EDGE, + WIDGET_IMAGE_MAX_EDGE, + image::imageops::FilterType::Triangle, + ) + } else { + decoded + }; + if let Err(error) = scaled.save_with_format(&destination, image::ImageFormat::Png) { + tracing::warn!(media_id = %row.media_id, %error, "widget image encode failed"); + continue; + } + images.push(ManifestImage { + media_id: row.media_id, + path: destination.display().to_string(), + sender: row.sender, + group_ids: contact_group_ids_for_sender(ctx, row.sender_id).await?, + expires_at: row.created_at + WIDGET_LIFETIME_SECONDS, + }); + } + + let manifest = WidgetManifest { + version: 1, + updated_at: chrono::Utc::now().timestamp(), + groups, + images, + }; + let temporary = root.join("manifest.json.tmp"); + let destination = root.join("manifest.json"); + std::fs::write(&temporary, serde_json::to_vec(&manifest)?)?; + std::fs::rename(temporary, destination)?; + Ok(()) +} + +/// Removes one image the user dismissed from a widget, everywhere it lives. +/// +/// Deliberately not a "hide": the sender shared it into a widget on the home +/// screen, so taking it back has to remove the bytes rather than filter them +/// out of one view. +pub async fn delete_media(ctx: &Arc, media_id: &str) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + let media_type = sqlx::query_scalar::<_, String>( + "SELECT type FROM media_files WHERE media_id = ? AND is_widget_media = 1", + ) + .bind(media_id) + .fetch_optional(&database.pool) + .await?; + let Some(media_type) = media_type else { + // Already gone: the expiry sweep and the user can race each other. + return Ok(()); + }; + + let mut transaction = database.pool.begin().await?; + sqlx::query("DELETE FROM messages WHERE media_id = ?") + .bind(media_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM media_files WHERE media_id = ?") + .bind(media_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + drop(database); + + MediaFileService::new(ctx).remove_files(media_id, &media_type)?; + let _ = std::fs::remove_file( + widget_root(ctx) + .join("images") + .join(format!("{media_id}.png")), + ); + refresh_manifest(ctx).await +} + +pub async fn purge_widget_media(ctx: &Arc) -> Result<()> { + let database = ctx.app_db.read().await.clone(); + let expired = sqlx::query_as::<_, ExpiredMediaRow>( + r#"SELECT DISTINCT f.media_id, f.type AS media_type + FROM media_files f JOIN messages m ON m.media_id = f.media_id + WHERE f.is_widget_media = 1 AND m.created_at <= ?"#, + ) + .bind(chrono::Utc::now().timestamp() - WIDGET_LIFETIME_SECONDS) + .fetch_all(&database.pool) + .await?; + let mut transaction = database.pool.begin().await?; + for media in &expired { + sqlx::query("DELETE FROM messages WHERE media_id = ?") + .bind(&media.media_id) + .execute(&mut *transaction) + .await?; + sqlx::query("DELETE FROM media_files WHERE media_id = ?") + .bind(&media.media_id) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + drop(database); + + let files = MediaFileService::new(ctx); + for media in expired { + files.remove_files(&media.media_id, &media.media_type)?; + let _ = std::fs::remove_file( + widget_root(ctx) + .join("images") + .join(format!("{}.png", media.media_id)), + ); + } + refresh_manifest(ctx).await +} diff --git a/rust/src/services/media_upload.rs b/rust/src/services/media_upload.rs index bc3de5e8..ba16ff34 100644 --- a/rust/src/services/media_upload.rs +++ b/rust/src/services/media_upload.rs @@ -64,6 +64,7 @@ struct MediaRow { upload_state: Option, requires_authentication: i64, is_draft_media: i64, + is_widget_media: i64, display_limit_in_milliseconds: Option, reupload_requested_by: Option, remove_audio: Option, @@ -151,11 +152,30 @@ impl MediaUploadService { media_id: String, group_ids: Vec, additional_message_data: Option>, + widget_only: bool, ) -> Result<()> { let database = self.ctx.app_db.read().await.clone(); let now = chrono::Utc::now().timestamp(); let mut transaction = database.pool.begin().await?; + if widget_only { + let media = sqlx::query_as::<_, (String, Option)>( + "SELECT type, display_limit_in_milliseconds FROM media_files WHERE media_id = ?", + ) + .bind(&media_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| TwonlyError::Generic(format!("media {media_id} does not exist")))?; + if media.0 != "image" || media.1.is_some() { + return Err(TwonlyError::Generic( + "widget sends require an unlimited image".into(), + )); + } + sqlx::query("UPDATE media_files SET is_widget_media = 1 WHERE media_id = ?") + .bind(&media_id) + .execute(&mut *transaction) + .await?; + } sqlx::query("UPDATE media_files SET is_draft_media = 0 WHERE is_draft_media = 1") .execute(&mut *transaction) .await?; @@ -169,6 +189,21 @@ impl MediaUploadService { .bind(group_id) .fetch_all(&mut *transaction) .await?; + if widget_only + && (members.len() != 1 + || sqlx::query_scalar::<_, i64>( + "SELECT widget_sharing_allowed FROM contacts WHERE user_id = ?", + ) + .bind(members.first().map(|member| member.0).unwrap_or_default()) + .fetch_optional(&mut *transaction) + .await? + .unwrap_or(0) + == 0) + { + return Err(TwonlyError::Generic( + "recipient has not granted widget sharing".into(), + )); + } // A direct chat whose only peer deleted their account has nobody // left to receive the media. if members.len() == 1 && members[0].1 != 0 { @@ -179,26 +214,30 @@ impl MediaUploadService { let message_id = new_uuid_v7(); sqlx::query( r#"INSERT INTO messages - (group_id, message_id, type, media_id, additional_message_data, created_at) - VALUES (?, ?, 'media', ?, ?, ?)"#, + (group_id, message_id, type, media_id, additional_message_data, + is_widget_media, created_at) + VALUES (?, ?, 'media', ?, ?, ?, ?)"#, ) .bind(group_id) .bind(&message_id) .bind(&media_id) .bind(additional_message_data.as_deref()) + .bind(widget_only) .bind(now) .execute(&mut *transaction) .await?; - sqlx::query( - r#"UPDATE groups SET archived = 0, deleted_content = 0, - last_message_exchange = MAX(last_message_exchange, ?) - WHERE group_id = ?"#, - ) - .bind(now) - .bind(group_id) - .execute(&mut *transaction) - .await?; - Group::record_media_exchange(&mut transaction, group_id, false, now).await?; + if !widget_only { + sqlx::query( + r#"UPDATE groups SET archived = 0, deleted_content = 0, + last_message_exchange = MAX(last_message_exchange, ?) + WHERE group_id = ?"#, + ) + .bind(now) + .bind(group_id) + .execute(&mut *transaction) + .await?; + Group::record_media_exchange(&mut transaction, group_id, false, now).await?; + } } transaction.commit().await?; drop(database); @@ -408,7 +447,7 @@ impl MediaUploadService { let database = self.ctx.app_db.read().await.clone(); let pending = sqlx::query_as::<_, MediaRow>( r#"SELECT media_id, type AS media_type, upload_state, requires_authentication, - is_draft_media, display_limit_in_milliseconds, reupload_requested_by, + is_draft_media, is_widget_media, display_limit_in_milliseconds, reupload_requested_by, remove_audio, trim_start_ms, trim_end_ms, created_at FROM media_files WHERE upload_state IN @@ -860,7 +899,7 @@ impl MediaUploadService { // Nothing in the database refers to these bytes any more. return Ok(true); }; - if media.is_draft_media != 0 { + if media.is_draft_media != 0 || media.is_widget_media != 0 { return Ok(false); } // The plaintext is the upload's input, so it must outlive the transfer. @@ -1073,7 +1112,7 @@ impl MediaUploadService { let database = self.ctx.app_db.read().await.clone(); Ok(sqlx::query_as::<_, MediaRow>( r#"SELECT media_id, type AS media_type, upload_state, requires_authentication, - is_draft_media, display_limit_in_milliseconds, reupload_requested_by, + is_draft_media, is_widget_media, display_limit_in_milliseconds, reupload_requested_by, remove_audio, trim_start_ms, trim_end_ms, created_at FROM media_files WHERE media_id = ?"#, ) diff --git a/rust/src/services/mediafiles.rs b/rust/src/services/mediafiles.rs index b5bb634a..e0a0fdc2 100644 --- a/rust/src/services/mediafiles.rs +++ b/rust/src/services/mediafiles.rs @@ -5,7 +5,8 @@ use crate::api::messages::outgoing::send_c2c_message_to_contact; use crate::api::proto::client::{encrypted_content, EncryptedContent}; -use crate::bridge::api::RustApi; +use crate::api::runtime::API_EVENTS; +use crate::bridge::api::{ApiEvent, ApiEventKind, RustApi}; use crate::context::Context; use crate::error::{Result, TwonlyError}; use chacha20poly1305::aead::{AeadInPlace, KeyInit}; @@ -13,9 +14,24 @@ use chacha20poly1305::{ChaCha20Poly1305, Nonce, Tag}; use prost::Message as _; use sha2::{Digest, Sha256}; use sqlx::{Sqlite, Transaction}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::Duration; +use tokio::sync::Mutex; + +static DOWNLOAD_LOCKS: LazyLock>>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); + +/// One media file is only ever downloaded by one task. Two of them share the +/// ciphertext and plaintext paths, so a second concurrent run reads what the +/// first is still writing and fails the authentication tag on whole bytes. +fn download_lock(media_id: &str) -> Arc> { + let mut locks = DOWNLOAD_LOCKS + .lock() + .unwrap_or_else(|error| error.into_inner()); + locks.entry(media_id.to_owned()).or_default().clone() +} struct DownloadMedia { media_id: String, @@ -42,16 +58,21 @@ impl MediaFileService { Self { ctx: ctx.clone() } } + /// A terminated process leaves its transfer claimed, so `downloading` is + /// picked up here as well. The reset happens per media file under its + /// download lock rather than as one table-wide sweep: a sweep also + /// un-claims the transfers that are still running, and the second + /// downloader it lets in then races the first over the same files. pub async fn download_pending(&self) -> Result<()> { let database = self.ctx.app_db.read().await.clone(); - // A terminated process can leave a download in this intermediate state. - sqlx::query!( - "UPDATE media_files SET download_state = 'pending' WHERE download_state = 'downloading'" - ) - .execute(&database.pool) - .await?; + // The background wake-up runs this on a budget of a few seconds, so the + // order decides what actually gets through: widget media is the whole + // reason that wake-up happened, and newer media is what the user is + // about to look at. let media_ids = sqlx::query_scalar!( - "SELECT media_id FROM media_files WHERE download_state = 'pending'" + r#"SELECT media_id FROM media_files + WHERE download_state IN ('pending', 'downloading') + ORDER BY is_widget_media DESC, created_at DESC"# ) .fetch_all(&database.pool) .await?; @@ -66,7 +87,19 @@ impl MediaFileService { } pub async fn download(&self, media_id: &str) -> Result<()> { + let lock = download_lock(media_id); + let _guard = lock.lock().await; + let database = self.ctx.app_db.read().await.clone(); + // Nothing in this process holds the transfer any more, so a claim left + // over from a process the OS reclaimed mid-download is recoverable. + sqlx::query!( + r#"UPDATE media_files SET download_state = 'pending' + WHERE media_id = ? AND download_state = 'downloading'"#, + media_id, + ) + .execute(&database.pool) + .await?; let claimed = sqlx::query!( r#"UPDATE media_files SET download_state = 'downloading' WHERE media_id = ? AND download_state = 'pending'"#, @@ -184,48 +217,96 @@ impl MediaFileService { .ok_or_else(|| TwonlyError::Generic(format!("media {media_id} not found")))?; let encrypted_path = self.encrypted_path(&media.media_id, &media.media_type); - if !encrypted_path.exists() { - let token = media.download_token.as_deref().ok_or_else(|| { - TwonlyError::Generic(format!("media {media_id} has no download token")) - })?; - let url = format!( - "{}download/{}", - RustApi::api_base_url("https".into()), - hex::encode(token) - ); - let response = reqwest::Client::new() - .get(url) - .timeout(Duration::from_secs(30)) - .send() - .await - .map_err(|error| TwonlyError::Generic(error.to_string()))?; - let status = response.status(); - if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::FORBIDDEN - { - self.request_reupload(media_id).await?; - return Ok(()); - } - if !status.is_success() { - return Err(TwonlyError::Generic(format!( - "media download returned HTTP {status}" - ))); - } - let bytes = response - .bytes() - .await - .map_err(|error| TwonlyError::Generic(error.to_string()))?; - Self::ensure_parent(&encrypted_path)?; - std::fs::write(&encrypted_path, bytes)?; + if !encrypted_path.exists() && !self.fetch_encrypted(&media, &encrypted_path).await? { + return Ok(()); } if let Err(error) = self.decrypt(&media, &encrypted_path).await { - tracing::warn!(media_id, %error, "media decryption failed; requesting reupload"); - self.request_reupload(media_id).await?; - return Ok(()); + // The ciphertext on disk cannot be authenticated. Dropping it and + // fetching it again is what separates a transfer that was cut short + // from a key that genuinely does not match: without this, one + // truncated download poisons every later attempt at the same file. + tracing::warn!(media_id, %error, "media decryption failed; downloading it again"); + let _ = std::fs::remove_file(&encrypted_path); + if !self.fetch_encrypted(&media, &encrypted_path).await? { + return Ok(()); + } + if let Err(error) = self.decrypt(&media, &encrypted_path).await { + tracing::warn!(media_id, %error, "media decryption failed; requesting reupload"); + let _ = std::fs::remove_file(&encrypted_path); + self.request_reupload(media_id).await?; + return Ok(()); + } + } + let is_widget = sqlx::query_scalar::<_, i64>( + "SELECT is_widget_media FROM media_files WHERE media_id = ?", + ) + .bind(media_id) + .fetch_optional(&database.pool) + .await? + .unwrap_or(0) + != 0; + if is_widget { + crate::services::home_widget::refresh_manifest(&self.ctx).await?; + // Rewriting the manifest changes nothing on the home screen by + // itself: both platforms keep drawing what they built earlier. The + // widgets are native, so only Flutter can ask them to redraw, and + // the notification worker refreshes them on its own path. + let _ = API_EVENTS.send(ApiEvent { + kind: ApiEventKind::WidgetMediaReceived, + state: None, + message: None, + }); } Ok(()) } + /// Fetches the ciphertext and puts it in place as one step. + /// + /// The bytes land under a partial name and are renamed once the whole body + /// is on disk, because the presence of `encrypted_path` is what every later + /// attempt reads as "already downloaded". A process the OS reclaims + /// mid-transfer must therefore leave nothing behind under that name. + /// + /// Returns `false` when the object is gone from the server and a reupload + /// has been requested instead. + async fn fetch_encrypted(&self, media: &DownloadMedia, encrypted_path: &Path) -> Result { + let media_id = &media.media_id; + let token = media.download_token.as_deref().ok_or_else(|| { + TwonlyError::Generic(format!("media {media_id} has no download token")) + })?; + let url = format!( + "{}download/{}", + RustApi::api_base_url("https".into()), + hex::encode(token) + ); + let response = reqwest::Client::new() + .get(url) + .timeout(Duration::from_secs(30)) + .send() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::FORBIDDEN { + self.request_reupload(media_id).await?; + return Ok(false); + } + if !status.is_success() { + return Err(TwonlyError::Generic(format!( + "media download returned HTTP {status}" + ))); + } + let bytes = response + .bytes() + .await + .map_err(|error| TwonlyError::Generic(error.to_string()))?; + let partial_path = self.partial_download_path(media_id, &media.media_type); + Self::ensure_parent(&partial_path)?; + std::fs::write(&partial_path, bytes)?; + std::fs::rename(&partial_path, encrypted_path)?; + Ok(true) + } + async fn decrypt(&self, media: &DownloadMedia, encrypted_path: &Path) -> Result<()> { let key = media.encryption_key.as_deref().ok_or_else(|| { TwonlyError::Generic(format!("media {} has no encryption key", media.media_id)) @@ -281,22 +362,30 @@ impl MediaFileService { pub async fn request_reupload(&self, media_id: &str) -> Result<()> { let database = self.ctx.app_db.read().await.clone(); - sqlx::query!( - "UPDATE media_files SET download_state = 'reuploadRequested' WHERE media_id = ?", - media_id, - ) - .execute(&database.pool) - .await?; - + // Widget media is marked opened the moment it arrives, since nobody + // ever opens it in a chat. Asking only for what has not been opened + // would therefore never ask for it at all, and the state set below + // hides the message for good. let targets = sqlx::query_as!( ReuploadTarget, r#"SELECT message_id, sender_id AS "sender_id!: i64" FROM messages - WHERE media_id = ? AND opened_at IS NULL AND sender_id IS NOT NULL"#, + WHERE media_id = ? AND sender_id IS NOT NULL + AND (opened_at IS NULL OR is_widget_media = 1)"#, media_id, ) .fetch_all(&database.pool) .await?; + // A concurrent attempt that already produced the plaintext outranks + // this one; overwriting `ready` would hide a message that is fine. + sqlx::query!( + r#"UPDATE media_files SET download_state = 'reuploadRequested' + WHERE media_id = ? AND download_state IS NOT 'ready'"#, + media_id, + ) + .execute(&database.pool) + .await?; + drop(database); for target in targets { @@ -371,6 +460,8 @@ impl MediaFileService { base.join("tmp").join(format!("{media_id}.{extension}")), base.join("tmp") .join(format!("{media_id}.encrypted.{extension}")), + base.join("tmp") + .join(format!("{media_id}.download.{extension}")), base.join("tmp") .join(format!("{media_id}.original.{extension}")), base.join("tmp") @@ -474,6 +565,17 @@ impl MediaFileService { )) } + /// Where an in-flight download accumulates before it is renamed onto + /// [`Self::encrypted_path`]. + fn partial_download_path(&self, media_id: &str, media_type: &str) -> PathBuf { + PathBuf::from(&self.ctx.config.data_dir) + .join("mediafiles/tmp") + .join(format!( + "{media_id}.download.{}", + Self::extension(media_type) + )) + } + pub(crate) fn extension(media_type: &str) -> &'static str { match media_type { "video" => "mp4", diff --git a/rust/src/services/mod.rs b/rust/src/services/mod.rs index 01670689..45c8e323 100644 --- a/rust/src/services/mod.rs +++ b/rust/src/services/mod.rs @@ -8,6 +8,7 @@ pub mod background; pub mod contacts; pub mod direct_media_upload; pub mod groups; +pub mod home_widget; pub mod media_codec; pub mod media_exif; pub mod media_upload; diff --git a/rust/src/services/notifications.rs b/rust/src/services/notifications.rs index 2c2384b0..cc3c0918 100644 --- a/rust/src/services/notifications.rs +++ b/rust/src/services/notifications.rs @@ -144,6 +144,13 @@ pub(crate) async fn record_incoming_event( receipt_id: &str, content: &proto::EncryptedContent, ) -> Result<()> { + if content + .media + .as_ref() + .is_some_and(|media| media.widget_only == Some(true)) + { + return Ok(()); + } let blocked = sqlx::query_scalar!( "SELECT blocked FROM contacts WHERE user_id = ?", from_user_id @@ -393,10 +400,12 @@ pub async fn pending_batch(ctx: &Arc, locale: &str) -> Result Result<()> { + let ctx = Context::get_static()?.clone(); + let owns_connection = ctx.is_notification_runtime(); + let deadline = std::time::Duration::from_millis(deadline_ms.min(28_000)); + let started = std::time::Instant::now(); + let remaining = || deadline.saturating_sub(started.elapsed()); + + // In a killed-app notification process the spawned receive task only lives + // as long as the shared notification runtime, so this is the last chance to + // settle widget downloads before the OS reclaims the worker. They get most + // of the budget but not all of it: a download nothing publishes to the + // manifest below was not worth making. + if owns_connection { + let media_files = crate::services::mediafiles::MediaFileService::new(&ctx); + match tokio::time::timeout(deadline.mul_f32(0.75), media_files.download_pending()).await { + Ok(Err(error)) => tracing::warn!(%error, "background media download failed"), + Err(error) => tracing::info!(%error, "background media download window elapsed"), + Ok(Ok(())) => {} + } + } + + match tokio::time::timeout( + remaining(), + crate::services::home_widget::purge_widget_media(&ctx), + ) + .await + { + Ok(Err(error)) => tracing::warn!(%error, "widget-media maintenance failed during wake-up"), + Err(error) => tracing::info!(%error, "widget-media maintenance window elapsed"), + Ok(Ok(())) => {} + } if owns_connection && ctx.is_notification_runtime() { - ApiRuntime::close(&ctx).await?; + // Bounded on its own rather than out of what is left: closing costs a + // round trip at most, and a server that never answers the handshake + // must not hold the worker open. The socket dies with the process + // either way. + if tokio::time::timeout(std::time::Duration::from_secs(1), ApiRuntime::close(&ctx)) + .await + .is_err() + { + tracing::info!("background socket did not close gracefully in time"); + } } - Ok(batch) + Ok(()) } /// Persists a token refresh from the native Android FCM callback. The normal diff --git a/rust/tests/api/media.rs b/rust/tests/api/media.rs index b2e6f54f..36a2029d 100644 --- a/rust/tests/api/media.rs +++ b/rust/tests/api/media.rs @@ -93,6 +93,7 @@ async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> { requires_authentication: false, display_limit_in_milliseconds: Some(0), additional_message_data: None, + widget_only: None, quote_message_id: None, }), ..Default::default() @@ -204,6 +205,7 @@ async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> { requires_authentication: false, display_limit_in_milliseconds: Some(0), additional_message_data: None, + widget_only: None, quote_message_id: None, }), ..Default::default() diff --git a/rust_builder/ios/Classes/TwonlyNotifications.h b/rust_builder/ios/Classes/TwonlyNotifications.h index fc84b33e..b030cdd8 100644 --- a/rust_builder/ios/Classes/TwonlyNotifications.h +++ b/rust_builder/ios/Classes/TwonlyNotifications.h @@ -16,6 +16,11 @@ char *twonly_notification_process( char *twonly_notification_acknowledge(const char *event_ids_json); +/* Settles the work deferred behind the alert — media downloads, widget upkeep, + and closing the background socket. Call it only after the batch from + `twonly_notification_process` has been rendered; skipping it is safe. */ +char *twonly_notification_finalize(uint64_t deadline_ms); + void twonly_notification_string_free(char *pointer); /* Runs one maintenance job: preparing a single media file when `media_id` is diff --git a/test/visual/chat_contacts_entry_test.dart b/test/visual/chat_contacts_entry_test.dart index 9c90f12f..53fa9df4 100644 --- a/test/visual/chat_contacts_entry_test.dart +++ b/test/visual/chat_contacts_entry_test.dart @@ -25,6 +25,7 @@ Message buildMessage(List? payload) => Message( mediaStored: false, mediaReopened: false, isDeletedFromSender: false, + isWidgetMedia: false, createdAt: DateTime.now(), );