diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt b/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt index 4d99ad1f..391330ff 100644 --- a/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt +++ b/android/app/src/main/kotlin/eu/twonly/notifications/NotificationTapChannel.kt @@ -1,13 +1,31 @@ package eu.twonly.notifications +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context import android.content.Intent +import android.os.Build import androidx.core.app.NotificationManagerCompat import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel internal fun nativeNotificationId(value: String): Int = value.hashCode() and Int.MAX_VALUE +/** The single channel every twonly notification is posted on. */ +internal const val NOTIFICATION_CHANNEL_ID = "twonly_messages_v2" + +internal fun ensureNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val label = context.applicationInfo.loadLabel(context.packageManager).toString() + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + label, + NotificationManager.IMPORTANCE_HIGH, + ) + context.getSystemService(NotificationManager::class.java) + .createNotificationChannel(channel) +} + /** * Forwards taps on natively rendered notifications into Flutter. * diff --git a/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyFirebaseMessagingService.kt b/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyFirebaseMessagingService.kt index c63e6bbd..1d06433a 100644 --- a/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyFirebaseMessagingService.kt +++ b/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyFirebaseMessagingService.kt @@ -1,6 +1,10 @@ package eu.twonly.notifications +import android.app.PendingIntent +import android.content.Intent import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder @@ -8,9 +12,20 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import eu.twonly.MainActivity +import eu.twonly.R class TwonlyFirebaseMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { + // Server side alerts (plan changes, passwordless recovery) carry a ready + // made notification instead of the opaque wake-up envelope. Android only + // presents those by itself while twonly is backgrounded, so dropping them + // here loses every alert that arrives with the app open. + message.notification?.let { notification -> + showServerNotification(notification.title, notification.body) + return + } + if (message.data["kind"] != "message_wakeup" || message.data["version"] != "1") { Log.w(TAG, "Ignoring unsupported opaque FCM payload") return @@ -36,9 +51,39 @@ class TwonlyFirebaseMessagingService : FirebaseMessagingService() { } } + private fun showServerNotification(title: String?, body: String?) { + if (title.isNullOrEmpty() && body.isNullOrEmpty()) return + ensureNotificationChannel(applicationContext) + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = PendingIntent.getActivity( + applicationContext, + SERVER_ALERT_ID, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + try { + NotificationManagerCompat.from(applicationContext) + .notify(SERVER_ALERT_ID, notification) + } catch (error: SecurityException) { + Log.w(TAG, "Notification permission is unavailable", error) + } + } + private companion object { const val TAG = "TwonlyFCM" const val UNIQUE_WORK = "twonly-native-notification-drain" const val INPUT_MESSAGE_ID = "fcm_message_id" + const val SERVER_ALERT_ID = 0x74776F01 } } 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 0ee61a12..c1eb00c6 100644 --- a/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt +++ b/android/app/src/main/kotlin/eu/twonly/notifications/TwonlyNotificationWorker.kt @@ -1,12 +1,9 @@ package eu.twonly.notifications -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.BitmapFactory -import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -32,7 +29,7 @@ class TwonlyNotificationWorker( RUST_DEADLINE_MS, ), ) - ensureChannel() + ensureNotificationChannel(applicationContext) val batch = response.batch if (!response.ok || batch == null) { // Rust could not reach the mailbox. Show the generic alert so a @@ -86,7 +83,7 @@ class TwonlyNotificationWorker( intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) - val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(addition.title) .setContentText(addition.body) @@ -107,7 +104,7 @@ class TwonlyNotificationWorker( } private fun showFallback(presentation: NativeNotificationPresentation) { - val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(presentation.title) .setContentText(presentation.body) @@ -121,23 +118,8 @@ class TwonlyNotificationWorker( } } - private fun ensureChannel() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val channel = NotificationChannel( - CHANNEL_ID, - applicationLabel(), - NotificationManager.IMPORTANCE_HIGH, - ) - applicationContext.getSystemService(NotificationManager::class.java) - .createNotificationChannel(channel) - } - - private fun applicationLabel(): String = - applicationContext.applicationInfo.loadLabel(applicationContext.packageManager).toString() - private companion object { const val TAG = "TwonlyNotification" - const val CHANNEL_ID = "twonly_messages_v2" const val FALLBACK_ID = 0x74776F const val MAX_RETRIES = 2 const val RUST_DEADLINE_MS = 25_000L diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 9c231205..15d245b7 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1196,11 +1196,17 @@ abstract class AppLocalizations { /// **'Upgrade to {planId}{sufix}'** String upgradeToPaidPlanButton(Object planId, Object sufix); - /// No description provided for @partOfPaidPlanOf. + /// No description provided for @partOfPaidPlanFrom. /// /// In en, this message translates to: - /// **'You are part of the paid plan of {username}!'** - String partOfPaidPlanOf(Object username); + /// **'Part of the paid plan of'** + String get partOfPaidPlanFrom; + + /// No description provided for @subscriptionCurrentPlanBadge. + /// + /// In en, this message translates to: + /// **'Current'** + String get subscriptionCurrentPlanBadge; /// No description provided for @year. /// @@ -1229,67 +1235,67 @@ abstract class AppLocalizations { /// No description provided for @proFeature1. /// /// In en, this message translates to: - /// **'✓ Unlimited media file uploads'** + /// **'Unlimited media file uploads'** String get proFeature1; /// No description provided for @proFeature2. /// /// In en, this message translates to: - /// **'✓ 1 additional Plus user'** + /// **'1 additional Plus user'** String get proFeature2; /// No description provided for @proFeature3. /// /// In en, this message translates to: - /// **'✓ 25GB Memories storage'** + /// **'25GB Memories storage'** String get proFeature3; /// No description provided for @proFeature4. /// /// In en, this message translates to: - /// **'✓ Restore flames'** + /// **'Restore flames'** String get proFeature4; /// No description provided for @familyFeature1. /// /// In en, this message translates to: - /// **'✓ Unlimited media file uploads'** + /// **'Unlimited media file uploads'** String get familyFeature1; /// No description provided for @familyFeature2. /// /// In en, this message translates to: - /// **'✓ 4 additional Plus user'** + /// **'4 additional Plus user'** String get familyFeature2; /// No description provided for @familyFeature3. /// /// In en, this message translates to: - /// **'✓ 50GB Memories storage'** + /// **'50GB Memories storage'** String get familyFeature3; /// No description provided for @familyFeature4. /// /// In en, this message translates to: - /// **'✓ Support twonly'** + /// **'Restore flames'** String get familyFeature4; /// No description provided for @freeFeature1. /// /// In en, this message translates to: - /// **'✓ 10 Media file uploads per day'** + /// **'10 Media file uploads per day'** String get freeFeature1; /// No description provided for @plusFeature1. /// /// In en, this message translates to: - /// **'✓ Unlimited media file uploads'** + /// **'Unlimited media file uploads'** String get plusFeature1; /// No description provided for @plusFeature2. /// /// In en, this message translates to: - /// **'✓ Additional features (coming-soon)'** + /// **'Additional features (coming-soon)'** String get plusFeature2; /// No description provided for @manageAdditionalUsers. diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 8ff73f8f..0495b731 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -614,9 +614,10 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String partOfPaidPlanOf(Object username) { - return 'Du bist Teil des bezahlten Plans von $username!'; - } + String get partOfPaidPlanFrom => 'Teil des bezahlten Plans von'; + + @override + String get subscriptionCurrentPlanBadge => 'Aktuell'; @override String get year => 'Jahr'; @@ -631,37 +632,37 @@ class AppLocalizationsDe extends AppLocalizations { String get monthly => 'Monatlich'; @override - String get proFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; + String get proFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; @override - String get proFeature2 => '✓ 1 zusätzlicher Plus Benutzer'; + String get proFeature2 => '1 zusätzlicher Plus Benutzer'; @override - String get proFeature3 => '✓ 25GB Memories Speicher'; + String get proFeature3 => '25GB Memories Speicher'; @override - String get proFeature4 => '✓ Flammen wiederherstellen'; + String get proFeature4 => 'Flammen wiederherstellen'; @override - String get familyFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; + String get familyFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; @override - String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer'; + String get familyFeature2 => '4 zusätzliche Plus Benutzer'; @override - String get familyFeature3 => '✓ 50GB Memories Speicher'; + String get familyFeature3 => '50GB Memories Speicher'; @override - String get familyFeature4 => '✓ Flammen wiederherstellen'; + String get familyFeature4 => 'Flammen wiederherstellen'; @override - String get freeFeature1 => '✓ 10 Medien-Datei-Uploads pro Tag'; + String get freeFeature1 => '10 Medien-Datei-Uploads pro Tag'; @override - String get plusFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads'; + String get plusFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; @override - String get plusFeature2 => '✓ Zusatzfunktionen (coming-soon)'; + String get plusFeature2 => 'Zusatzfunktionen (coming-soon)'; @override String get manageAdditionalUsers => 'Zusätzliche Benutzer verwalten'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 5da5fb6d..cd45d0f2 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -610,9 +610,10 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String partOfPaidPlanOf(Object username) { - return 'You are part of the paid plan of $username!'; - } + String get partOfPaidPlanFrom => 'Part of the paid plan of'; + + @override + String get subscriptionCurrentPlanBadge => 'Current'; @override String get year => 'year'; @@ -627,37 +628,37 @@ class AppLocalizationsEn extends AppLocalizations { String get monthly => 'Monthly'; @override - String get proFeature1 => '✓ Unlimited media file uploads'; + String get proFeature1 => 'Unlimited media file uploads'; @override - String get proFeature2 => '✓ 1 additional Plus user'; + String get proFeature2 => '1 additional Plus user'; @override - String get proFeature3 => '✓ 25GB Memories storage'; + String get proFeature3 => '25GB Memories storage'; @override - String get proFeature4 => '✓ Restore flames'; + String get proFeature4 => 'Restore flames'; @override - String get familyFeature1 => '✓ Unlimited media file uploads'; + String get familyFeature1 => 'Unlimited media file uploads'; @override - String get familyFeature2 => '✓ 4 additional Plus user'; + String get familyFeature2 => '4 additional Plus user'; @override - String get familyFeature3 => '✓ 50GB Memories storage'; + String get familyFeature3 => '50GB Memories storage'; @override - String get familyFeature4 => '✓ Support twonly'; + String get familyFeature4 => 'Restore flames'; @override - String get freeFeature1 => '✓ 10 Media file uploads per day'; + String get freeFeature1 => '10 Media file uploads per day'; @override - String get plusFeature1 => '✓ Unlimited media file uploads'; + String get plusFeature1 => 'Unlimited media file uploads'; @override - String get plusFeature2 => '✓ Additional features (coming-soon)'; + String get plusFeature2 => 'Additional features (coming-soon)'; @override String get manageAdditionalUsers => 'Manage additional users'; diff --git a/lib/src/visual/views/settings/subscription/additional_users.view.dart b/lib/src/visual/views/settings/subscription/additional_users.view.dart index a5756f91..cadd4a73 100644 --- a/lib/src/visual/views/settings/subscription/additional_users.view.dart +++ b/lib/src/visual/views/settings/subscription/additional_users.view.dart @@ -9,6 +9,7 @@ import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/snackbar.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/settings/subscription/select_additional_users.view.dart'; @@ -189,61 +190,77 @@ class _AdditionalAccountState extends State { @override Widget build(BuildContext context) { - return Card( - elevation: 4, - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - username, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - widget.account.planId, - style: const TextStyle(fontSize: 16, color: Colors.grey), - ), - ], - ), - IconButton( - icon: const FaIcon(FontAwesomeIcons.userXmark, size: 16), - onPressed: () async { - final remove = await showAlertDialog( - context, - context.lang.additionalUserRemoveTitle, - context.lang.additionalUserRemoveDesc, - ); - if (remove) { - final res = await rustApiResult( - RustApi.removeAdditionalUser( - userId: widget.account.userId, + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Card( + elevation: 0, + color: context.color.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + AvatarIcon(contactId: widget.account.userId, fontSize: 24), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), ), + const SizedBox(height: 4), + Text( + widget.account.planId, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: FaIcon( + FontAwesomeIcons.userXmark, + size: 16, + color: context.color.onSurfaceVariant, + ), + onPressed: () async { + final remove = await showAlertDialog( + context, + context.lang.additionalUserRemoveTitle, + context.lang.additionalUserRemoveDesc, ); - if (!context.mounted) return; - if (res.isSuccess) { - widget.refresh(); - } else { - showSnackbar( - context, - errorCodeToText( - context, - res.error!, + if (remove) { + final res = await rustApiResult( + RustApi.removeAdditionalUser( + userId: widget.account.userId, ), ); + if (!context.mounted) return; + if (res.isSuccess) { + widget.refresh(); + } else { + showSnackbar( + context, + errorCodeToText( + context, + res.error!, + ), + ); + } } - } - }, - ), - ], + }, + ), + ], + ), ), ), ); diff --git a/lib/src/visual/views/settings/subscription/subscription.view.dart b/lib/src/visual/views/settings/subscription/subscription.view.dart index d98f4660..96337785 100644 --- a/lib/src/visual/views/settings/subscription/subscription.view.dart +++ b/lib/src/visual/views/settings/subscription/subscription.view.dart @@ -3,13 +3,17 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/purchasable_product.model.dart'; import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/elements/better_list_title.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/settings/subscription/additional_users.view.dart'; @@ -26,6 +30,7 @@ class _SubscriptionViewState extends State { bool loaded = false; bool testerRequested = true; FrbPlanBalance? ballance; + Contact? additionalOwner; String? additionalOwnerName; @override @@ -45,11 +50,10 @@ class _SubscriptionViewState extends State { final contact = await twonlyDB.contactsDao .getContactByUserId(ownerId) .getSingleOrNull(); - if (contact != null) { - additionalOwnerName = getContactDisplayName(contact); - } else { - additionalOwnerName = ownerId.toString(); - } + additionalOwner = contact; + additionalOwnerName = contact == null + ? ownerId.toString() + : getContactDisplayName(contact); } if (!mounted) return; setState(() {}); @@ -123,12 +127,9 @@ class _SubscriptionViewState extends State { ), const SizedBox(height: 16), if (additionalOwnerName != null) - Center( - child: Text( - context.lang.partOfPaidPlanOf(additionalOwnerName!), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.orange), - ), + PlanOwnerCard( + owner: additionalOwner, + ownerName: additionalOwnerName!, ), if (isPayingUser(currentPlan)) PlanCard( @@ -200,6 +201,115 @@ class _SubscriptionViewState extends State { } } +/// The owner whose paid plan is covering this account. Tapping it opens their +/// profile, so the plan can be traced back to a person instead of a name in a +/// sentence. An owner who is not (or no longer) a contact still gets the card, +/// just without somewhere to navigate to. +class PlanOwnerCard extends StatelessWidget { + const PlanOwnerCard({ + required this.owner, + required this.ownerName, + super.key, + }); + + final Contact? owner; + final String ownerName; + + @override + Widget build(BuildContext context) { + final owner = this.owner; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Card( + elevation: 0, + color: context.color.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: owner == null + ? null + : () => context.push(Routes.profileContact(owner.userId)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + AvatarIcon( + contactId: owner?.userId, + fontSize: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.lang.partOfPaidPlanFrom, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.color.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + ownerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + ), + if (owner != null) ...[ + const SizedBox(width: 3), + Icon( + Icons.chevron_right_rounded, + color: context.color.onSurfaceVariant, + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +/// Marks the plan the account is actually on, so the card that matters is +/// recognisable without reading any of the copy. +class CurrentPlanBadge extends StatelessWidget { + const CurrentPlanBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: context.color.primary, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + context.lang.subscriptionCurrentPlanBadge, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: isDarkMode(context) ? Colors.black : Colors.white, + ), + ), + ); + } +} + +FaIconData planIcon(SubscriptionPlan plan) => switch (plan) { + SubscriptionPlan.Free => FontAwesomeIcons.circleUser, + SubscriptionPlan.Plus => FontAwesomeIcons.star, + SubscriptionPlan.Pro => FontAwesomeIcons.bolt, + SubscriptionPlan.Family => FontAwesomeIcons.peopleRoof, + SubscriptionPlan.Tester => FontAwesomeIcons.flask, +}; + class PlanCard extends StatefulWidget { const PlanCard({ required this.plan, @@ -278,44 +388,72 @@ class _PlanCardState extends State { default: } + final isCurrent = currentPlan == widget.plan; return Padding( - padding: const EdgeInsets.only(left: 16, right: 16), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Card( - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), + elevation: 0, color: context.color.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + padding: const EdgeInsets.all(16), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - Text( - widget.plan.name, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.color.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: FaIcon( + planIcon(widget.plan), + color: context.color.primary, + size: 24, ), ), + const SizedBox(width: 16), + Expanded( + child: Text( + widget.plan.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (isCurrent) const CurrentPlanBadge(), ], ), - const SizedBox(height: 10), + const SizedBox(height: 16), ...features.map( (feature) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text( - feature, - textAlign: TextAlign.center, + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.check_rounded, + size: 18, + color: context.color.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + feature, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: context.color.onSurfaceVariant), + ), + ), + ], ), ), ), - const SizedBox(height: 10), - if (currentPlan == widget.plan && - widget.plan != SubscriptionPlan.Tester) + const SizedBox(height: 16), + if (isCurrent && widget.plan != SubscriptionPlan.Tester) MyButton( variant: MyButtonVariant.primaryMiddle, onPressed: () async { diff --git a/rust/.sqlx/query-e89767b813c0619775a9224a1767cfa1dcd44a0bec75f80e4875a097ab5b4491.json b/rust/.sqlx/query-364c425bacfd4c2db6844cca30b58b30de02b79c6461732fd3d3c5f48246edd1.json similarity index 63% rename from rust/.sqlx/query-e89767b813c0619775a9224a1767cfa1dcd44a0bec75f80e4875a097ab5b4491.json rename to rust/.sqlx/query-364c425bacfd4c2db6844cca30b58b30de02b79c6461732fd3d3c5f48246edd1.json index afb19bfc..ad3ac485 100644 --- a/rust/.sqlx/query-e89767b813c0619775a9224a1767cfa1dcd44a0bec75f80e4875a097ab5b4491.json +++ b/rust/.sqlx/query-364c425bacfd4c2db6844cca30b58b30de02b79c6461732fd3d3c5f48246edd1.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE messages SET ack_by_server = ? WHERE media_id = ?", + "query": "UPDATE messages SET ack_by_server = ? WHERE media_id = ? AND ack_by_server IS NULL", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "e89767b813c0619775a9224a1767cfa1dcd44a0bec75f80e4875a097ab5b4491" + "hash": "364c425bacfd4c2db6844cca30b58b30de02b79c6461732fd3d3c5f48246edd1" } diff --git a/rust/.sqlx/query-47802f3954f7e8a17c7cb78cbaf72e68a2191c2a35eb3bb8f045765689d6520e.json b/rust/.sqlx/query-47802f3954f7e8a17c7cb78cbaf72e68a2191c2a35eb3bb8f045765689d6520e.json deleted file mode 100644 index 0a769c10..00000000 --- a/rust/.sqlx/query-47802f3954f7e8a17c7cb78cbaf72e68a2191c2a35eb3bb8f045765689d6520e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO contacts(user_id, username, signal_version, accepted, requested, deleted_by_user, blocked)\n VALUES (?, COALESCE(?, '[Unknown]'), COALESCE(?, 'v2'), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0))\n ON CONFLICT(user_id) DO UPDATE SET\n username = COALESCE(?, contacts.username),\n signal_version = COALESCE(?, contacts.signal_version),\n accepted = COALESCE(?, contacts.accepted),\n requested = COALESCE(?, contacts.requested),\n deleted_by_user = COALESCE(?, contacts.deleted_by_user),\n blocked = COALESCE(?, contacts.blocked)\n WHERE ? = 0 OR contacts.requested = 0\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "47802f3954f7e8a17c7cb78cbaf72e68a2191c2a35eb3bb8f045765689d6520e" -} diff --git a/rust/.sqlx/query-be0c18b2b1c60c720364458799336262f49d3cf2473f7d7305c81047d0cc7aa5.json b/rust/.sqlx/query-90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991.json similarity index 67% rename from rust/.sqlx/query-be0c18b2b1c60c720364458799336262f49d3cf2473f7d7305c81047d0cc7aa5.json rename to rust/.sqlx/query-90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991.json index 07f1a1bb..05e97e3f 100644 --- a/rust/.sqlx/query-be0c18b2b1c60c720364458799336262f49d3cf2473f7d7305c81047d0cc7aa5.json +++ b/rust/.sqlx/query-90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991.json @@ -1,6 +1,6 @@ { "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 deleted_by_user = COALESCE(?, deleted_by_user),\n blocked = COALESCE(?, blocked)\n WHERE user_id = ? AND (? = 0 OR requested = 0)\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 WHERE user_id = ?\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "be0c18b2b1c60c720364458799336262f49d3cf2473f7d7305c81047d0cc7aa5" + "hash": "90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991" } diff --git a/rust/.sqlx/query-53e9edf1571d1f0fe88384aa3423846fd4a4d9a202c5d7e706c54c7a91df215d.json b/rust/.sqlx/query-9de9386526cbd0e01b7198850b5327895a4a58c040b40f80858a9bc1eca96e4e.json similarity index 58% rename from rust/.sqlx/query-53e9edf1571d1f0fe88384aa3423846fd4a4d9a202c5d7e706c54c7a91df215d.json rename to rust/.sqlx/query-9de9386526cbd0e01b7198850b5327895a4a58c040b40f80858a9bc1eca96e4e.json index 1c06c82b..ff97f52d 100644 --- a/rust/.sqlx/query-53e9edf1571d1f0fe88384aa3423846fd4a4d9a202c5d7e706c54c7a91df215d.json +++ b/rust/.sqlx/query-9de9386526cbd0e01b7198850b5327895a4a58c040b40f80858a9bc1eca96e4e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n INSERT INTO message_actions(message_id, contact_id, type, action_at)\n SELECT messages.message_id, group_members.contact_id, 'ackByServerAt', ?\n FROM messages\n JOIN group_members ON group_members.group_id = messages.group_id\n WHERE messages.media_id = ?\n AND (group_members.member_state IS NULL OR group_members.member_state != 'leftGroup')\n ON CONFLICT(message_id, contact_id, type)\n DO UPDATE SET action_at = excluded.action_at\n ", + "query": "\n INSERT INTO message_actions(message_id, contact_id, type, action_at)\n SELECT messages.message_id, group_members.contact_id, 'ackByServerAt', ?\n FROM messages\n JOIN group_members ON group_members.group_id = messages.group_id\n WHERE messages.media_id = ?\n AND (group_members.member_state IS NULL OR group_members.member_state != 'leftGroup')\n -- The first acknowledgement is the true one; a later settle of the\n -- same media must not move a timestamp that already stands.\n ON CONFLICT(message_id, contact_id, type)\n DO NOTHING\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "53e9edf1571d1f0fe88384aa3423846fd4a4d9a202c5d7e706c54c7a91df215d" + "hash": "9de9386526cbd0e01b7198850b5327895a4a58c040b40f80858a9bc1eca96e4e" } diff --git a/rust/.sqlx/query-96dfd5c9fea55bbc19f096c6d3d2d15cbf4bd389ccca0ea4831ddbd956b711c5.json b/rust/.sqlx/query-b81ab59ec11800f5cbd9b73c6367f80251c00070e916cf5a16d6c44145476c66.json similarity index 51% rename from rust/.sqlx/query-96dfd5c9fea55bbc19f096c6d3d2d15cbf4bd389ccca0ea4831ddbd956b711c5.json rename to rust/.sqlx/query-b81ab59ec11800f5cbd9b73c6367f80251c00070e916cf5a16d6c44145476c66.json index 2a637e29..22a1fbd9 100644 --- a/rust/.sqlx/query-96dfd5c9fea55bbc19f096c6d3d2d15cbf4bd389ccca0ea4831ddbd956b711c5.json +++ b/rust/.sqlx/query-b81ab59ec11800f5cbd9b73c6367f80251c00070e916cf5a16d6c44145476c66.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n INSERT OR IGNORE INTO contacts(user_id, username, signal_version, accepted, requested)\n VALUES (?, ?, ?, 0, 1)\n ", + "query": "\n INSERT OR IGNORE INTO contacts(user_id, username, signal_version, accepted, requested, deleted_by_user)\n VALUES (?, ?, ?, 0, 0, 1)\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "96dfd5c9fea55bbc19f096c6d3d2d15cbf4bd389ccca0ea4831ddbd956b711c5" + "hash": "b81ab59ec11800f5cbd9b73c6367f80251c00070e916cf5a16d6c44145476c66" } diff --git a/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json b/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json index 4f2971be..7a8784e3 100644 --- a/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json +++ b/rust/.sqlx/query-c618a305e1355fcefa5c8420544779b5fe4f003a16ba01ee2834f028fabf213f.json @@ -288,6 +288,17 @@ "name": "media_received_counter" } } + }, + { + "name": "requested_by_user", + "ordinal": 26, + "type_info": "Integer", + "origin": { + "Table": { + "table": "contacts", + "name": "requested_by_user" + } + } } ], "parameters": { @@ -319,6 +330,7 @@ true, true, false, + false, false ] }, diff --git a/rust/.sqlx/query-e289178012389d57c467b0bc60f42c5173c81e3afaa26d2f78bc52463963da78.json b/rust/.sqlx/query-e289178012389d57c467b0bc60f42c5173c81e3afaa26d2f78bc52463963da78.json deleted file mode 100644 index 73ad047c..00000000 --- a/rust/.sqlx/query-e289178012389d57c467b0bc60f42c5173c81e3afaa26d2f78bc52463963da78.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n UPDATE receipts\n SET ack_by_server_at = ?, retry_count = 1, last_retry = ?, mark_for_retry = NULL\n WHERE EXISTS(\n SELECT 1\n FROM messages\n JOIN group_members ON group_members.group_id = messages.group_id\n WHERE messages.message_id = receipts.message_id\n AND messages.media_id = ?\n AND group_members.contact_id = receipts.contact_id\n AND (group_members.member_state IS NULL OR group_members.member_state != 'leftGroup')\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "e289178012389d57c467b0bc60f42c5173c81e3afaa26d2f78bc52463963da78" -} diff --git a/rust/.sqlx/query-f3fc40b8fcf39e6ce12610a309c9438417412c1023bfbfae3c1295186ff5993e.json b/rust/.sqlx/query-f3fc40b8fcf39e6ce12610a309c9438417412c1023bfbfae3c1295186ff5993e.json new file mode 100644 index 00000000..ef270fff --- /dev/null +++ b/rust/.sqlx/query-f3fc40b8fcf39e6ce12610a309c9438417412c1023bfbfae3c1295186ff5993e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE receipts\n SET ack_by_server_at = ?, retry_count = 1, last_retry = ?, mark_for_retry = NULL\n WHERE ack_by_server_at IS NULL\n AND EXISTS(\n SELECT 1\n FROM messages\n JOIN group_members ON group_members.group_id = messages.group_id\n WHERE messages.message_id = receipts.message_id\n AND messages.media_id = ?\n AND group_members.contact_id = receipts.contact_id\n AND (group_members.member_state IS NULL OR group_members.member_state != 'leftGroup')\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "f3fc40b8fcf39e6ce12610a309c9438417412c1023bfbfae3c1295186ff5993e" +} diff --git a/rust/.sqlx/query-f9781437f2e81f692b2eb6e3760cd031856ef912a498275a4026754c84fc5839.json b/rust/.sqlx/query-f9781437f2e81f692b2eb6e3760cd031856ef912a498275a4026754c84fc5839.json new file mode 100644 index 00000000..68414517 --- /dev/null +++ b/rust/.sqlx/query-f9781437f2e81f692b2eb6e3760cd031856ef912a498275a4026754c84fc5839.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO contacts(user_id, username, signal_version, accepted, requested, requested_by_user, deleted_by_user, blocked)\n VALUES (?, COALESCE(?, '[Unknown]'), COALESCE(?, 'v2'), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0))\n ON CONFLICT(user_id) DO UPDATE SET\n username = COALESCE(?, contacts.username),\n signal_version = COALESCE(?, contacts.signal_version),\n accepted = COALESCE(?, contacts.accepted),\n requested = COALESCE(?, contacts.requested),\n requested_by_user = COALESCE(?, contacts.requested_by_user),\n deleted_by_user = COALESCE(?, contacts.deleted_by_user),\n blocked = COALESCE(?, contacts.blocked)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 15 + }, + "nullable": [] + }, + "hash": "f9781437f2e81f692b2eb6e3760cd031856ef912a498275a4026754c84fc5839" +} diff --git a/rust/src/api/messages/incoming/contact.rs b/rust/src/api/messages/incoming/contact.rs index b6093798..be1d378f 100644 --- a/rust/src/api/messages/incoming/contact.rs +++ b/rust/src/api/messages/incoming/contact.rs @@ -93,13 +93,14 @@ pub(crate) async fn handle_contact_request( // Or the user has also requested fromUserId. This means that both user have requested each other (while been // offline for example): In this case the contact can also be accepted blindly. let auto_accept = contact.as_ref().is_some_and(|contact| { - contact.accepted != 0 || (contact.requested == 0 && contact.deleted_by_user == 0) + contact.blocked == 0 && (contact.accepted != 0 || contact.requested_by_user != 0) }); if auto_accept { UpdateContact::builder() .user_id(from_user_id) .requested(false) + .requested_by_user(false) .accepted(true) .deleted_by_user(false) .build() @@ -152,16 +153,20 @@ pub(crate) async fn handle_contact_request( return Ok(()); }; - if contact.requested != 0 || contact.deleted_by_user != 0 { + // An accept is only ours to honour if we actually asked. Keying + // that off `requested`/`deleted_by_user` instead -- both routine + // for someone met in a shared group -- dropped real accepts and + // left the two sides permanently out of step. + if contact.blocked != 0 || (contact.requested_by_user == 0 && contact.accepted == 0) { return Ok(()); } UpdateContact::builder() .user_id(from_user_id) .requested(false) + .requested_by_user(false) .accepted(true) .deleted_by_user(false) - .only_if_not_requested(true) .build() .update(tr) .await?; @@ -172,6 +177,7 @@ pub(crate) async fn handle_contact_request( UpdateContact::builder() .user_id(from_user_id) .requested(false) + .requested_by_user(false) .accepted(false) .deleted_by_user(true) .build() diff --git a/rust/src/api/messages/incoming/errors.rs b/rust/src/api/messages/incoming/errors.rs index 12dd14b8..a51dc8a5 100644 --- a/rust/src/api/messages/incoming/errors.rs +++ b/rust/src/api/messages/incoming/errors.rs @@ -36,10 +36,14 @@ pub(crate) async fn handle_error_message( .execute(&mut **t) .await?; + // Clearing `accepted` is what parks the receipt above until + // their accept arrives. `requested` stays untouched: claiming + // *they* requested *us* showed a phantom request whose accept + // released the parked receipt and produced this error again. UpdateContact::builder() .user_id(from_user_id) .accepted(false) - .requested(true) + .deleted_by_user(false) .build() .update(t) .await?; diff --git a/rust/src/api/messages/incoming/messages.rs b/rust/src/api/messages/incoming/messages.rs index 04dc93dd..e466399b 100644 --- a/rust/src/api/messages/incoming/messages.rs +++ b/rust/src/api/messages/incoming/messages.rs @@ -189,10 +189,14 @@ pub(crate) async fn ensure_contact_exists(ctx: &Arc, from_user_id: i64) tracing::info!("Loaded username: {username}"); + // Hidden, not requested: a first message from a stranger is not itself a + // contact request, and `requested = 1` here turned "added me to a group" + // into a phantom one. The direct-chat branch in `incoming.rs` and + // `handle_contact_request` set `requested` when it is really meant. sqlx::query!( r#" - INSERT OR IGNORE INTO contacts(user_id, username, signal_version, accepted, requested) - VALUES (?, ?, ?, 0, 1) + INSERT OR IGNORE INTO contacts(user_id, username, signal_version, accepted, requested, deleted_by_user) + VALUES (?, ?, ?, 0, 0, 1) "#, from_user_id, username, diff --git a/rust/src/api/runtime/auth.rs b/rust/src/api/runtime/auth.rs index 975c4b54..c0b5a9e8 100644 --- a/rust/src/api/runtime/auth.rs +++ b/rust/src/api/runtime/auth.rs @@ -103,6 +103,7 @@ impl ApiAuthHandshaker { }; if let server_to_client::response::ok::Ok::Authenticated(authenticated) = &ok { + persist_plan(&self.context, &authenticated.plan).await; let _ = self.events.send(ApiEvent { kind: ApiEventKind::PlanUpdated, state: None, @@ -429,9 +430,42 @@ impl Handshaker for ApiAuthHandshaker { } } +/// The server owns the subscription plan, so what it reports has to be written +/// to `user.json` and not only broadcast as a [`ApiEventKind::PlanUpdated`] +/// event. Everything that reads the plan without listening for that event reads +/// the stored value: the premium feature gates, the recording budget, and +/// `PurchasesProvider`, which re-reads it on every connection state change and +/// would otherwise overwrite the plan it was just told about with the `Free` +/// default. +async fn persist_plan(context: &Context, plan: &str) { + // A reconnect reports the same plan almost every time, and every write + // pushes a config update into Flutter, so only changes are worth storing. + if matches!( + UserConfig::load_from(context), + Ok(Some(config)) if config.subscription_plan == plan + ) { + return; + } + let updated = match UserConfig::update(context, |config| { + config.subscription_plan = plan.to_owned(); + }) { + Ok(config) => config, + Err(error) => { + tracing::warn!("could not persist the subscription plan: {error}"); + return; + } + }; + if let Ok(callbacks) = crate::bridge::callbacks::get_callbacks() { + (callbacks.api.user_config_changed)(updated).await; + } +} + impl ApiClient { // publish_plan method logic moved inside Handshaker temporarily, but ApiClient should still have it if needed. pub(crate) async fn publish_plan(&self, plan: String) -> Result<()> { + if let Some(context) = self.context.upgrade() { + persist_plan(&context, &plan).await; + } let _ = self.events.send(ApiEvent { kind: ApiEventKind::PlanUpdated, state: None, diff --git a/rust/src/database/app/migrations/0011_outgoing_contact_request.sql b/rust/src/database/app/migrations/0011_outgoing_contact_request.sql new file mode 100644 index 00000000..6769f65d --- /dev/null +++ b/rust/src/database/app/migrations/0011_outgoing_contact_request.sql @@ -0,0 +1,17 @@ +-- Whether *we* sent this contact a request that is still outstanding. +-- +-- Until now that was only implied, by a row sitting at +-- `accepted = requested = deleted_by_user = blocked = 0`. Every other way a +-- row reaches that shape -- a peer met in a shared group, a backup restore, +-- an avatar lookup -- was therefore indistinguishable from "we asked them", +-- so an unsolicited `Accept` could make its sender an accepted contact and an +-- unsolicited `Request` was auto-accepted. Recording the send makes both +-- checks answer the question they actually mean to ask. +ALTER TABLE contacts ADD COLUMN requested_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (requested_by_user IN (0, 1)); + +-- Carry in-flight handshakes over the upgrade. This is exactly the shape the +-- old accept path honoured, so it grants nothing that was not already +-- reachable before. +UPDATE contacts SET requested_by_user = 1 +WHERE accepted = 0 AND requested = 0 AND deleted_by_user = 0 AND blocked = 0; diff --git a/rust/src/database/app/tables/contact.rs b/rust/src/database/app/tables/contact.rs index 527dca74..e5849b92 100644 --- a/rust/src/database/app/tables/contact.rs +++ b/rust/src/database/app/tables/contact.rs @@ -21,6 +21,7 @@ pub struct Contact { pub accepted: i64, pub deleted_by_user: i64, pub requested: i64, + pub requested_by_user: i64, pub blocked: i64, pub verified: i64, pub account_deleted: i64, @@ -53,11 +54,11 @@ pub struct UpdateContact { #[builder(with = |value: bool| value as i64)] requested: Option, #[builder(with = |value: bool| value as i64)] + requested_by_user: Option, + #[builder(with = |value: bool| value as i64)] deleted_by_user: Option, #[builder(with = |value: bool| value as i64)] blocked: Option, - #[builder(default)] - only_if_not_requested: bool, } impl UpdateContact { @@ -92,9 +93,10 @@ impl Contact { signal_version = COALESCE(?, signal_version), accepted = COALESCE(?, accepted), requested = COALESCE(?, requested), + requested_by_user = COALESCE(?, requested_by_user), deleted_by_user = COALESCE(?, deleted_by_user), blocked = COALESCE(?, blocked) - WHERE user_id = ? AND (? = 0 OR requested = 0) + WHERE user_id = ? "#, contact.username, update_display_name, @@ -105,10 +107,10 @@ impl Contact { contact.signal_version, contact.accepted, contact.requested, + contact.requested_by_user, contact.deleted_by_user, contact.blocked, contact.user_id, - contact.only_if_not_requested, ) .execute(&mut **t) .await?; @@ -122,31 +124,32 @@ impl Contact { ) -> Result<()> { sqlx::query!( r#" - INSERT INTO contacts(user_id, username, signal_version, accepted, requested, deleted_by_user, blocked) - VALUES (?, COALESCE(?, '[Unknown]'), COALESCE(?, 'v2'), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0)) + INSERT INTO contacts(user_id, username, signal_version, accepted, requested, requested_by_user, deleted_by_user, blocked) + VALUES (?, COALESCE(?, '[Unknown]'), COALESCE(?, 'v2'), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0)) ON CONFLICT(user_id) DO UPDATE SET username = COALESCE(?, contacts.username), signal_version = COALESCE(?, contacts.signal_version), accepted = COALESCE(?, contacts.accepted), requested = COALESCE(?, contacts.requested), + requested_by_user = COALESCE(?, contacts.requested_by_user), deleted_by_user = COALESCE(?, contacts.deleted_by_user), blocked = COALESCE(?, contacts.blocked) - WHERE ? = 0 OR contacts.requested = 0 "#, contact.user_id, contact.username, contact.signal_version, contact.accepted, contact.requested, + contact.requested_by_user, contact.deleted_by_user, contact.blocked, contact.username, contact.signal_version, contact.accepted, contact.requested, + contact.requested_by_user, contact.deleted_by_user, contact.blocked, - contact.only_if_not_requested, ) .execute(&mut **t) .await?; diff --git a/rust/src/services/contacts.rs b/rust/src/services/contacts.rs index 6f64f122..a2ee1946 100644 --- a/rust/src/services/contacts.rs +++ b/rust/src/services/contacts.rs @@ -213,6 +213,7 @@ impl ContactService { UpdateContact::builder() .user_id(contact_id) .requested(false) + .requested_by_user(false) .accepted(true) .deleted_by_user(false) .build() @@ -248,6 +249,7 @@ impl ContactService { UpdateContact::builder() .user_id(contact_id) .requested(false) + .requested_by_user(false) .accepted(false) .deleted_by_user(true) .build() @@ -305,6 +307,21 @@ impl ContactService { request_type: encrypted_content::contact_request::Type, blocking: bool, ) -> Result<()> { + // Recorded here rather than at the call sites so no path can send a + // request without it: it is what later tells an incoming accept apart + // from an unsolicited one. + if request_type == encrypted_content::contact_request::Type::Request { + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + UpdateContact::builder() + .user_id(contact_id) + .requested_by_user(true) + .build() + .update(&mut transaction) + .await?; + transaction.commit().await?; + } + send_c2c_message_to_contact() .ctx(&self.ctx) .contact_id(contact_id) diff --git a/rust/tests/api/contacts.rs b/rust/tests/api/contacts.rs index 6a3ea081..fbab4a3b 100644 --- a/rust/tests/api/contacts.rs +++ b/rust/tests/api/contacts.rs @@ -1,8 +1,12 @@ use super::{init_tracing, Tester}; +use prost::Message as _; +use rust_lib_twonly::api::messages::outgoing::send_c2c_message_to_contact; +use rust_lib_twonly::api::proto::client::{self as proto, encrypted_content}; use rust_lib_twonly::api::Server; use rust_lib_twonly::bridge::api::ApiConnectionState; use rust_lib_twonly::database::app::tables::Group; use rust_lib_twonly::services::contacts::ContactService; +use rust_lib_twonly::services::groups::GroupService; use rust_lib_twonly::services::messages::MessageService; async fn create_authenticated_tester() -> anyhow::Result { @@ -112,3 +116,154 @@ async fn test_check_for_deleted_usernames() -> anyhow::Result<()> { Ok(()) } + +/// Two users who met in a shared group must converge on a single accepted +/// contact when the requested side accepts. The group leaves both sides with a +/// hidden row for each other, and an unaccepted inbound message flips a row to +/// `requested`; keying the accept off either of those states dropped real +/// accepts, built only one half of the direct chat, and made the next message +/// across it bounce back as a fresh request. +#[tokio::test] +async fn test_accept_lands_for_contacts_met_in_a_group() -> anyhow::Result<()> { + init_tracing(); + let tester_c = create_authenticated_tester().await?; + let tester_a = create_authenticated_tester().await?; + let tester_b = create_authenticated_tester().await?; + + // C befriends A and B so it can put both of them in one group. + for tester in [&tester_a, &tester_b] { + ContactService::new(&tester_c.context) + .request_by_username(tester.username.clone(), true) + .await?; + tester + .wait_for_contact_state(tester_c.user_id, false, true) + .await?; + ContactService::new(&tester.context) + .accept_request(tester_c.user_id, true) + .await?; + tester_c + .wait_for_contact_state(tester.user_id, true, false) + .await?; + } + + let group_name = "Met In A Group"; + GroupService::new(&tester_c.context) + .create_group(group_name.into(), vec![tester_a.user_id, tester_b.user_id]) + .await?; + + let group_id = { + let db_c = tester_c.context.app_db.read().await.clone(); + sqlx::query_scalar!( + "SELECT group_id FROM groups WHERE is_direct_chat = 0 ORDER BY rowid DESC LIMIT 1" + ) + .fetch_one(&db_c.pool) + .await? + }; + tester_a + .wait_for_group_exists(&group_id, group_name) + .await?; + tester_b + .wait_for_group_exists(&group_id, group_name) + .await?; + + // Being put in a group with somebody is not a contact request from them. + tester_a + .wait_for_contact_state(tester_b.user_id, false, false) + .await?; + tester_b + .wait_for_contact_state(tester_a.user_id, false, false) + .await?; + + // A really does request B, then lands back in the state the group and an + // unaccepted inbound message leave behind while the accept is in flight. + ContactService::new(&tester_a.context) + .request_by_username(tester_b.username.clone(), true) + .await?; + tester_b + .wait_for_contact_state(tester_a.user_id, false, true) + .await?; + { + let db_a = tester_a.context.app_db.read().await.clone(); + sqlx::query!( + "UPDATE contacts SET requested = 1, deleted_by_user = 1 WHERE user_id = ?", + tester_b.user_id, + ) + .execute(&db_a.pool) + .await?; + } + + ContactService::new(&tester_b.context) + .accept_request(tester_a.user_id, true) + .await?; + + tester_a + .wait_for_contact_state(tester_b.user_id, true, false) + .await?; + + // Both halves of the direct chat exist now, so a message crosses it + // instead of bouncing back as a fresh contact request. + let direct_chat_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id); + let msg_id = MessageService::new(&tester_b.context) + .insert_and_send_text(direct_chat_id, "Hello from the group".into(), None) + .await?; + tester_a + .wait_for_text_message(&msg_id, tester_b.user_id, "Hello from the group") + .await?; + tester_b + .wait_for_contact_state(tester_a.user_id, true, false) + .await?; + + Ok(()) +} + +/// An accept nobody asked for must not make its sender a contact. +#[tokio::test] +async fn test_unsolicited_accept_does_not_add_a_contact() -> anyhow::Result<()> { + init_tracing(); + let tester_a = create_authenticated_tester().await?; + let tester_b = create_authenticated_tester().await?; + + // B learns A's keys and claims A accepted a request A never sent. + ContactService::new(&tester_b.context) + .request_by_username(tester_a.username.clone(), true) + .await?; + tester_a + .wait_for_contact_state(tester_b.user_id, false, true) + .await?; + let accept = proto::EncryptedContent { + contact_request: Some(encrypted_content::ContactRequest { + r#type: encrypted_content::contact_request::Type::Accept as i32, + }), + ..Default::default() + }; + send_c2c_message_to_contact() + .ctx(&tester_b.context) + .contact_id(tester_a.user_id) + .encrypted_content(accept.encode_to_vec()) + .blocking(true) + .call() + .await?; + + // The notification is recorded after the handler, in the same inbound + // transaction, so its arrival means the accept has been dealt with. + tester_a + .wait_for_notification("accept_request", tester_b.user_id) + .await?; + + // A keeps the pending request and gains neither an accepted contact nor a + // direct chat off the back of it. + tester_a + .wait_for_contact_state(tester_b.user_id, false, true) + .await?; + let direct_chat_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id); + let db_a = tester_a.context.app_db.read().await.clone(); + let direct_chat_exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM groups WHERE group_id = ?)", + direct_chat_id, + ) + .fetch_one(&db_a.pool) + .await?; + assert_eq!(direct_chat_exists, 0, "unsolicited accept created a chat"); + + Ok(()) +}