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

View file

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

View file

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

View file

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

View file

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

View file

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

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/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<AdditionalAccount> {
@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!,
),
);
}
}
}
},
),
],
},
),
],
),
),
),
);

View file

@ -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<SubscriptionView> {
bool loaded = false;
bool testerRequested = true;
FrbPlanBalance? ballance;
Contact? additionalOwner;
String? additionalOwnerName;
@override
@ -45,11 +50,10 @@ class _SubscriptionViewState extends State<SubscriptionView> {
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<SubscriptionView> {
),
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<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 {
const PlanCard({
required this.plan,
@ -278,44 +388,72 @@ class _PlanCardState extends State<PlanCard> {
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 {

View file

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

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

View file

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

View file

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

View file

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

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
// 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()

View file

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

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

View file

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

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 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<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>,
#[builder(with = |value: bool| value as i64)]
blocked: Option<i64>,
#[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?;

View file

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

View file

@ -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<Tester> {
@ -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(())
}