mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-02 01:24:07 +00:00
fix issue with the plus plan and notifications
This commit is contained in:
parent
ae7d3c750e
commit
6622040a26
25 changed files with 651 additions and 191 deletions
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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?;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue