fix issue with the plus plan and notifications

This commit is contained in:
otsmr 2026-09-01 17:28:06 +02:00
parent ae7d3c750e
commit 6622040a26
25 changed files with 651 additions and 191 deletions

View file

@ -1,13 +1,31 @@
package eu.twonly.notifications package eu.twonly.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
internal fun nativeNotificationId(value: String): Int = value.hashCode() and Int.MAX_VALUE 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. * Forwards taps on natively rendered notifications into Flutter.
* *

View file

@ -1,6 +1,10 @@
package eu.twonly.notifications package eu.twonly.notifications
import android.app.PendingIntent
import android.content.Intent
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
@ -8,9 +12,20 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager import androidx.work.WorkManager
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import eu.twonly.MainActivity
import eu.twonly.R
class TwonlyFirebaseMessagingService : FirebaseMessagingService() { class TwonlyFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) { 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") { if (message.data["kind"] != "message_wakeup" || message.data["version"] != "1") {
Log.w(TAG, "Ignoring unsupported opaque FCM payload") Log.w(TAG, "Ignoring unsupported opaque FCM payload")
return 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 { private companion object {
const val TAG = "TwonlyFCM" const val TAG = "TwonlyFCM"
const val UNIQUE_WORK = "twonly-native-notification-drain" const val UNIQUE_WORK = "twonly-native-notification-drain"
const val INPUT_MESSAGE_ID = "fcm_message_id" const val INPUT_MESSAGE_ID = "fcm_message_id"
const val SERVER_ALERT_ID = 0x74776F01
} }
} }

View file

@ -1,12 +1,9 @@
package eu.twonly.notifications package eu.twonly.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -32,7 +29,7 @@ class TwonlyNotificationWorker(
RUST_DEADLINE_MS, RUST_DEADLINE_MS,
), ),
) )
ensureChannel() ensureNotificationChannel(applicationContext)
val batch = response.batch val batch = response.batch
if (!response.ok || batch == null) { if (!response.ok || batch == null) {
// Rust could not reach the mailbox. Show the generic alert so a // Rust could not reach the mailbox. Show the generic alert so a
@ -86,7 +83,7 @@ class TwonlyNotificationWorker(
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 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) .setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(addition.title) .setContentTitle(addition.title)
.setContentText(addition.body) .setContentText(addition.body)
@ -107,7 +104,7 @@ class TwonlyNotificationWorker(
} }
private fun showFallback(presentation: NativeNotificationPresentation) { 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) .setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(presentation.title) .setContentTitle(presentation.title)
.setContentText(presentation.body) .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 { private companion object {
const val TAG = "TwonlyNotification" const val TAG = "TwonlyNotification"
const val CHANNEL_ID = "twonly_messages_v2"
const val FALLBACK_ID = 0x74776F const val FALLBACK_ID = 0x74776F
const val MAX_RETRIES = 2 const val MAX_RETRIES = 2
const val RUST_DEADLINE_MS = 25_000L const val RUST_DEADLINE_MS = 25_000L

View file

@ -1196,11 +1196,17 @@ abstract class AppLocalizations {
/// **'Upgrade to {planId}{sufix}'** /// **'Upgrade to {planId}{sufix}'**
String upgradeToPaidPlanButton(Object planId, Object sufix); String upgradeToPaidPlanButton(Object planId, Object sufix);
/// No description provided for @partOfPaidPlanOf. /// No description provided for @partOfPaidPlanFrom.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'You are part of the paid plan of {username}!'** /// **'Part of the paid plan of'**
String partOfPaidPlanOf(Object username); String get partOfPaidPlanFrom;
/// No description provided for @subscriptionCurrentPlanBadge.
///
/// In en, this message translates to:
/// **'Current'**
String get subscriptionCurrentPlanBadge;
/// No description provided for @year. /// No description provided for @year.
/// ///
@ -1229,67 +1235,67 @@ abstract class AppLocalizations {
/// No description provided for @proFeature1. /// No description provided for @proFeature1.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Unlimited media file uploads'** /// **'Unlimited media file uploads'**
String get proFeature1; String get proFeature1;
/// No description provided for @proFeature2. /// No description provided for @proFeature2.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'1 additional Plus user'** /// **'1 additional Plus user'**
String get proFeature2; String get proFeature2;
/// No description provided for @proFeature3. /// No description provided for @proFeature3.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'25GB Memories storage'** /// **'25GB Memories storage'**
String get proFeature3; String get proFeature3;
/// No description provided for @proFeature4. /// No description provided for @proFeature4.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Restore flames'** /// **'Restore flames'**
String get proFeature4; String get proFeature4;
/// No description provided for @familyFeature1. /// No description provided for @familyFeature1.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Unlimited media file uploads'** /// **'Unlimited media file uploads'**
String get familyFeature1; String get familyFeature1;
/// No description provided for @familyFeature2. /// No description provided for @familyFeature2.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'4 additional Plus user'** /// **'4 additional Plus user'**
String get familyFeature2; String get familyFeature2;
/// No description provided for @familyFeature3. /// No description provided for @familyFeature3.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'50GB Memories storage'** /// **'50GB Memories storage'**
String get familyFeature3; String get familyFeature3;
/// No description provided for @familyFeature4. /// No description provided for @familyFeature4.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'✓ Support twonly'** /// **'Restore flames'**
String get familyFeature4; String get familyFeature4;
/// No description provided for @freeFeature1. /// No description provided for @freeFeature1.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'10 Media file uploads per day'** /// **'10 Media file uploads per day'**
String get freeFeature1; String get freeFeature1;
/// No description provided for @plusFeature1. /// No description provided for @plusFeature1.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Unlimited media file uploads'** /// **'Unlimited media file uploads'**
String get plusFeature1; String get plusFeature1;
/// No description provided for @plusFeature2. /// No description provided for @plusFeature2.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Additional features (coming-soon)'** /// **'Additional features (coming-soon)'**
String get plusFeature2; String get plusFeature2;
/// No description provided for @manageAdditionalUsers. /// No description provided for @manageAdditionalUsers.

View file

@ -614,9 +614,10 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String partOfPaidPlanOf(Object username) { String get partOfPaidPlanFrom => 'Teil des bezahlten Plans von';
return 'Du bist Teil des bezahlten Plans von $username!';
} @override
String get subscriptionCurrentPlanBadge => 'Aktuell';
@override @override
String get year => 'Jahr'; String get year => 'Jahr';
@ -631,37 +632,37 @@ class AppLocalizationsDe extends AppLocalizations {
String get monthly => 'Monatlich'; String get monthly => 'Monatlich';
@override @override
String get proFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; String get proFeature1 => 'Unbegrenzte Medien-Datei-Uploads';
@override @override
String get proFeature2 => '1 zusätzlicher Plus Benutzer'; String get proFeature2 => '1 zusätzlicher Plus Benutzer';
@override @override
String get proFeature3 => '25GB Memories Speicher'; String get proFeature3 => '25GB Memories Speicher';
@override @override
String get proFeature4 => 'Flammen wiederherstellen'; String get proFeature4 => 'Flammen wiederherstellen';
@override @override
String get familyFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; String get familyFeature1 => 'Unbegrenzte Medien-Datei-Uploads';
@override @override
String get familyFeature2 => '4 zusätzliche Plus Benutzer'; String get familyFeature2 => '4 zusätzliche Plus Benutzer';
@override @override
String get familyFeature3 => '50GB Memories Speicher'; String get familyFeature3 => '50GB Memories Speicher';
@override @override
String get familyFeature4 => 'Flammen wiederherstellen'; String get familyFeature4 => 'Flammen wiederherstellen';
@override @override
String get freeFeature1 => '10 Medien-Datei-Uploads pro Tag'; String get freeFeature1 => '10 Medien-Datei-Uploads pro Tag';
@override @override
String get plusFeature1 => 'Unbegrenzte Medien-Datei-Uploads'; String get plusFeature1 => 'Unbegrenzte Medien-Datei-Uploads';
@override @override
String get plusFeature2 => 'Zusatzfunktionen (coming-soon)'; String get plusFeature2 => 'Zusatzfunktionen (coming-soon)';
@override @override
String get manageAdditionalUsers => 'Zusätzliche Benutzer verwalten'; String get manageAdditionalUsers => 'Zusätzliche Benutzer verwalten';

View file

@ -610,9 +610,10 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String partOfPaidPlanOf(Object username) { String get partOfPaidPlanFrom => 'Part of the paid plan of';
return 'You are part of the paid plan of $username!';
} @override
String get subscriptionCurrentPlanBadge => 'Current';
@override @override
String get year => 'year'; String get year => 'year';
@ -627,37 +628,37 @@ class AppLocalizationsEn extends AppLocalizations {
String get monthly => 'Monthly'; String get monthly => 'Monthly';
@override @override
String get proFeature1 => 'Unlimited media file uploads'; String get proFeature1 => 'Unlimited media file uploads';
@override @override
String get proFeature2 => '1 additional Plus user'; String get proFeature2 => '1 additional Plus user';
@override @override
String get proFeature3 => '25GB Memories storage'; String get proFeature3 => '25GB Memories storage';
@override @override
String get proFeature4 => 'Restore flames'; String get proFeature4 => 'Restore flames';
@override @override
String get familyFeature1 => 'Unlimited media file uploads'; String get familyFeature1 => 'Unlimited media file uploads';
@override @override
String get familyFeature2 => '4 additional Plus user'; String get familyFeature2 => '4 additional Plus user';
@override @override
String get familyFeature3 => '50GB Memories storage'; String get familyFeature3 => '50GB Memories storage';
@override @override
String get familyFeature4 => '✓ Support twonly'; String get familyFeature4 => 'Restore flames';
@override @override
String get freeFeature1 => '10 Media file uploads per day'; String get freeFeature1 => '10 Media file uploads per day';
@override @override
String get plusFeature1 => 'Unlimited media file uploads'; String get plusFeature1 => 'Unlimited media file uploads';
@override @override
String get plusFeature2 => 'Additional features (coming-soon)'; String get plusFeature2 => 'Additional features (coming-soon)';
@override @override
String get manageAdditionalUsers => 'Manage additional users'; String get manageAdditionalUsers => 'Manage additional users';

View file

@ -9,6 +9,7 @@ import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.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/components/snackbar.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/subscription/select_additional_users.view.dart'; import 'package:twonly/src/visual/views/settings/subscription/select_additional_users.view.dart';
@ -189,61 +190,77 @@ class _AdditionalAccountState extends State<AdditionalAccount> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Card( return Padding(
elevation: 4, padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Card(
child: Padding( elevation: 0,
padding: const EdgeInsets.all(16), color: context.color.surfaceContainer,
child: Row( shape: RoundedRectangleBorder(
mainAxisAlignment: MainAxisAlignment.spaceBetween, borderRadius: BorderRadius.circular(16),
children: [ ),
Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(16),
children: [ child: Row(
Text( children: [
username, AvatarIcon(contactId: widget.account.userId, fontSize: 24),
style: const TextStyle( const SizedBox(width: 16),
fontSize: 20, Expanded(
fontWeight: FontWeight.bold, child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
const SizedBox(height: 4), Text(
Text( username,
widget.account.planId, maxLines: 1,
style: const TextStyle(fontSize: 16, color: Colors.grey), overflow: TextOverflow.ellipsis,
), style: Theme.of(context).textTheme.titleMedium?.copyWith(
], fontWeight: FontWeight.bold,
), ),
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,
), ),
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 (remove) {
if (res.isSuccess) { final res = await rustApiResult(
widget.refresh(); RustApi.removeAdditionalUser(
} else { userId: widget.account.userId,
showSnackbar(
context,
errorCodeToText(
context,
res.error!,
), ),
); );
if (!context.mounted) return;
if (res.isSuccess) {
widget.refresh();
} else {
showSnackbar(
context,
errorCodeToText(
context,
res.error!,
),
);
}
} }
} },
}, ),
), ],
], ),
), ),
), ),
); );

View file

@ -3,13 +3,17 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:twonly/locator.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/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/model/purchasable_product.model.dart';
import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.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/better_list_title.element.dart';
import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/elements/my_button.element.dart';
import 'package:twonly/src/visual/views/settings/subscription/additional_users.view.dart'; import 'package:twonly/src/visual/views/settings/subscription/additional_users.view.dart';
@ -26,6 +30,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
bool loaded = false; bool loaded = false;
bool testerRequested = true; bool testerRequested = true;
FrbPlanBalance? ballance; FrbPlanBalance? ballance;
Contact? additionalOwner;
String? additionalOwnerName; String? additionalOwnerName;
@override @override
@ -45,11 +50,10 @@ class _SubscriptionViewState extends State<SubscriptionView> {
final contact = await twonlyDB.contactsDao final contact = await twonlyDB.contactsDao
.getContactByUserId(ownerId) .getContactByUserId(ownerId)
.getSingleOrNull(); .getSingleOrNull();
if (contact != null) { additionalOwner = contact;
additionalOwnerName = getContactDisplayName(contact); additionalOwnerName = contact == null
} else { ? ownerId.toString()
additionalOwnerName = ownerId.toString(); : getContactDisplayName(contact);
}
} }
if (!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
@ -123,12 +127,9 @@ class _SubscriptionViewState extends State<SubscriptionView> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (additionalOwnerName != null) if (additionalOwnerName != null)
Center( PlanOwnerCard(
child: Text( owner: additionalOwner,
context.lang.partOfPaidPlanOf(additionalOwnerName!), ownerName: additionalOwnerName!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.orange),
),
), ),
if (isPayingUser(currentPlan)) if (isPayingUser(currentPlan))
PlanCard( PlanCard(
@ -200,6 +201,115 @@ class _SubscriptionViewState extends State<SubscriptionView> {
} }
} }
/// 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 { class PlanCard extends StatefulWidget {
const PlanCard({ const PlanCard({
required this.plan, required this.plan,
@ -278,44 +388,72 @@ class _PlanCardState extends State<PlanCard> {
default: default:
} }
final isCurrent = currentPlan == widget.plan;
return Padding( return Padding(
padding: const EdgeInsets.only(left: 16, right: 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Card( child: Card(
elevation: 4, elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
color: context.color.surfaceContainer, color: context.color.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ children: [
Text( Container(
widget.plan.name, padding: const EdgeInsets.all(12),
textAlign: TextAlign.center, decoration: BoxDecoration(
style: const TextStyle( color: context.color.primary.withValues(alpha: 0.1),
fontSize: 24, shape: BoxShape.circle,
fontWeight: FontWeight.bold, ),
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( ...features.map(
(feature) => Padding( (feature) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 3),
child: Text( child: Row(
feature, crossAxisAlignment: CrossAxisAlignment.start,
textAlign: TextAlign.center, 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), const SizedBox(height: 16),
if (currentPlan == widget.plan && if (isCurrent && widget.plan != SubscriptionPlan.Tester)
widget.plan != SubscriptionPlan.Tester)
MyButton( MyButton(
variant: MyButtonVariant.primaryMiddle, variant: MyButtonVariant.primaryMiddle,
onPressed: () async { onPressed: () async {

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -8,5 +8,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "e89767b813c0619775a9224a1767cfa1dcd44a0bec75f80e4875a097ab5b4491" "hash": "364c425bacfd4c2db6844cca30b58b30de02b79c6461732fd3d3c5f48246edd1"
} }

View file

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

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -8,5 +8,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "be0c18b2b1c60c720364458799336262f49d3cf2473f7d7305c81047d0cc7aa5" "hash": "90b3bd6a65232aaf0f49ad4277e5a79948b901302a3eec44b49c74e4f0185991"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -8,5 +8,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "53e9edf1571d1f0fe88384aa3423846fd4a4d9a202c5d7e706c54c7a91df215d" "hash": "9de9386526cbd0e01b7198850b5327895a4a58c040b40f80858a9bc1eca96e4e"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@ -8,5 +8,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "96dfd5c9fea55bbc19f096c6d3d2d15cbf4bd389ccca0ea4831ddbd956b711c5" "hash": "b81ab59ec11800f5cbd9b73c6367f80251c00070e916cf5a16d6c44145476c66"
} }

View file

@ -288,6 +288,17 @@
"name": "media_received_counter" "name": "media_received_counter"
} }
} }
},
{
"name": "requested_by_user",
"ordinal": 26,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "requested_by_user"
}
}
} }
], ],
"parameters": { "parameters": {
@ -319,6 +330,7 @@
true, true,
true, true,
false, false,
false,
false false
] ]
}, },

View file

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

View file

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

View file

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

View file

@ -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 // 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. // offline for example): In this case the contact can also be accepted blindly.
let auto_accept = contact.as_ref().is_some_and(|contact| { 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 { if auto_accept {
UpdateContact::builder() UpdateContact::builder()
.user_id(from_user_id) .user_id(from_user_id)
.requested(false) .requested(false)
.requested_by_user(false)
.accepted(true) .accepted(true)
.deleted_by_user(false) .deleted_by_user(false)
.build() .build()
@ -152,16 +153,20 @@ pub(crate) async fn handle_contact_request(
return Ok(()); 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(()); return Ok(());
} }
UpdateContact::builder() UpdateContact::builder()
.user_id(from_user_id) .user_id(from_user_id)
.requested(false) .requested(false)
.requested_by_user(false)
.accepted(true) .accepted(true)
.deleted_by_user(false) .deleted_by_user(false)
.only_if_not_requested(true)
.build() .build()
.update(tr) .update(tr)
.await?; .await?;
@ -172,6 +177,7 @@ pub(crate) async fn handle_contact_request(
UpdateContact::builder() UpdateContact::builder()
.user_id(from_user_id) .user_id(from_user_id)
.requested(false) .requested(false)
.requested_by_user(false)
.accepted(false) .accepted(false)
.deleted_by_user(true) .deleted_by_user(true)
.build() .build()

View file

@ -36,10 +36,14 @@ pub(crate) async fn handle_error_message(
.execute(&mut **t) .execute(&mut **t)
.await?; .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() UpdateContact::builder()
.user_id(from_user_id) .user_id(from_user_id)
.accepted(false) .accepted(false)
.requested(true) .deleted_by_user(false)
.build() .build()
.update(t) .update(t)
.await?; .await?;

View file

@ -189,10 +189,14 @@ pub(crate) async fn ensure_contact_exists(ctx: &Arc<Context>, from_user_id: i64)
tracing::info!("Loaded username: {username}"); 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!( sqlx::query!(
r#" r#"
INSERT OR IGNORE INTO contacts(user_id, username, signal_version, accepted, requested) INSERT OR IGNORE INTO contacts(user_id, username, signal_version, accepted, requested, deleted_by_user)
VALUES (?, ?, ?, 0, 1) VALUES (?, ?, ?, 0, 0, 1)
"#, "#,
from_user_id, from_user_id,
username, username,

View file

@ -103,6 +103,7 @@ impl ApiAuthHandshaker {
}; };
if let server_to_client::response::ok::Ok::Authenticated(authenticated) = &ok { if let server_to_client::response::ok::Ok::Authenticated(authenticated) = &ok {
persist_plan(&self.context, &authenticated.plan).await;
let _ = self.events.send(ApiEvent { let _ = self.events.send(ApiEvent {
kind: ApiEventKind::PlanUpdated, kind: ApiEventKind::PlanUpdated,
state: None, 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 { impl ApiClient {
// publish_plan method logic moved inside Handshaker temporarily, but ApiClient should still have it if needed. // 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<()> { 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 { let _ = self.events.send(ApiEvent {
kind: ApiEventKind::PlanUpdated, kind: ApiEventKind::PlanUpdated,
state: None, state: None,

View file

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

View file

@ -21,6 +21,7 @@ pub struct Contact {
pub accepted: i64, pub accepted: i64,
pub deleted_by_user: i64, pub deleted_by_user: i64,
pub requested: i64, pub requested: i64,
pub requested_by_user: i64,
pub blocked: i64, pub blocked: i64,
pub verified: i64, pub verified: i64,
pub account_deleted: i64, pub account_deleted: i64,
@ -53,11 +54,11 @@ pub struct UpdateContact {
#[builder(with = |value: bool| value as i64)] #[builder(with = |value: bool| value as i64)]
requested: Option<i64>, requested: Option<i64>,
#[builder(with = |value: bool| value as i64)] #[builder(with = |value: bool| value as i64)]
requested_by_user: Option<i64>,
#[builder(with = |value: bool| value as i64)]
deleted_by_user: Option<i64>, deleted_by_user: Option<i64>,
#[builder(with = |value: bool| value as i64)] #[builder(with = |value: bool| value as i64)]
blocked: Option<i64>, blocked: Option<i64>,
#[builder(default)]
only_if_not_requested: bool,
} }
impl UpdateContact { impl UpdateContact {
@ -92,9 +93,10 @@ impl Contact {
signal_version = COALESCE(?, signal_version), signal_version = COALESCE(?, signal_version),
accepted = COALESCE(?, accepted), accepted = COALESCE(?, accepted),
requested = COALESCE(?, requested), requested = COALESCE(?, requested),
requested_by_user = COALESCE(?, requested_by_user),
deleted_by_user = COALESCE(?, deleted_by_user), deleted_by_user = COALESCE(?, deleted_by_user),
blocked = COALESCE(?, blocked) blocked = COALESCE(?, blocked)
WHERE user_id = ? AND (? = 0 OR requested = 0) WHERE user_id = ?
"#, "#,
contact.username, contact.username,
update_display_name, update_display_name,
@ -105,10 +107,10 @@ impl Contact {
contact.signal_version, contact.signal_version,
contact.accepted, contact.accepted,
contact.requested, contact.requested,
contact.requested_by_user,
contact.deleted_by_user, contact.deleted_by_user,
contact.blocked, contact.blocked,
contact.user_id, contact.user_id,
contact.only_if_not_requested,
) )
.execute(&mut **t) .execute(&mut **t)
.await?; .await?;
@ -122,31 +124,32 @@ impl Contact {
) -> Result<()> { ) -> Result<()> {
sqlx::query!( sqlx::query!(
r#" r#"
INSERT INTO contacts(user_id, username, signal_version, accepted, requested, deleted_by_user, blocked) 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)) VALUES (?, COALESCE(?, '[Unknown]'), COALESCE(?, 'v2'), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0), COALESCE(?, 0))
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
username = COALESCE(?, contacts.username), username = COALESCE(?, contacts.username),
signal_version = COALESCE(?, contacts.signal_version), signal_version = COALESCE(?, contacts.signal_version),
accepted = COALESCE(?, contacts.accepted), accepted = COALESCE(?, contacts.accepted),
requested = COALESCE(?, contacts.requested), requested = COALESCE(?, contacts.requested),
requested_by_user = COALESCE(?, contacts.requested_by_user),
deleted_by_user = COALESCE(?, contacts.deleted_by_user), deleted_by_user = COALESCE(?, contacts.deleted_by_user),
blocked = COALESCE(?, contacts.blocked) blocked = COALESCE(?, contacts.blocked)
WHERE ? = 0 OR contacts.requested = 0
"#, "#,
contact.user_id, contact.user_id,
contact.username, contact.username,
contact.signal_version, contact.signal_version,
contact.accepted, contact.accepted,
contact.requested, contact.requested,
contact.requested_by_user,
contact.deleted_by_user, contact.deleted_by_user,
contact.blocked, contact.blocked,
contact.username, contact.username,
contact.signal_version, contact.signal_version,
contact.accepted, contact.accepted,
contact.requested, contact.requested,
contact.requested_by_user,
contact.deleted_by_user, contact.deleted_by_user,
contact.blocked, contact.blocked,
contact.only_if_not_requested,
) )
.execute(&mut **t) .execute(&mut **t)
.await?; .await?;

View file

@ -213,6 +213,7 @@ impl ContactService {
UpdateContact::builder() UpdateContact::builder()
.user_id(contact_id) .user_id(contact_id)
.requested(false) .requested(false)
.requested_by_user(false)
.accepted(true) .accepted(true)
.deleted_by_user(false) .deleted_by_user(false)
.build() .build()
@ -248,6 +249,7 @@ impl ContactService {
UpdateContact::builder() UpdateContact::builder()
.user_id(contact_id) .user_id(contact_id)
.requested(false) .requested(false)
.requested_by_user(false)
.accepted(false) .accepted(false)
.deleted_by_user(true) .deleted_by_user(true)
.build() .build()
@ -305,6 +307,21 @@ impl ContactService {
request_type: encrypted_content::contact_request::Type, request_type: encrypted_content::contact_request::Type,
blocking: bool, blocking: bool,
) -> Result<()> { ) -> 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() send_c2c_message_to_contact()
.ctx(&self.ctx) .ctx(&self.ctx)
.contact_id(contact_id) .contact_id(contact_id)

View file

@ -1,8 +1,12 @@
use super::{init_tracing, Tester}; 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::api::Server;
use rust_lib_twonly::bridge::api::ApiConnectionState; use rust_lib_twonly::bridge::api::ApiConnectionState;
use rust_lib_twonly::database::app::tables::Group; use rust_lib_twonly::database::app::tables::Group;
use rust_lib_twonly::services::contacts::ContactService; use rust_lib_twonly::services::contacts::ContactService;
use rust_lib_twonly::services::groups::GroupService;
use rust_lib_twonly::services::messages::MessageService; use rust_lib_twonly::services::messages::MessageService;
async fn create_authenticated_tester() -> anyhow::Result<Tester> { async fn create_authenticated_tester() -> anyhow::Result<Tester> {
@ -112,3 +116,154 @@ async fn test_check_for_deleted_usernames() -> anyhow::Result<()> {
Ok(()) 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(())
}