working native push notification

This commit is contained in:
otsmr 2026-08-29 22:26:01 +02:00
parent 6a711431aa
commit f534089dcf
329 changed files with 8615 additions and 2216 deletions

View file

@ -73,6 +73,9 @@ flutter {
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation platform('com.google.firebase:firebase-bom:34.9.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'androidx.work:work-runtime:2.10.2'
implementation 'com.otaliastudios:transcoder:0.11.0'
implementation 'androidx.core:core-splashscreen:1.0.1'
}

View file

@ -71,6 +71,25 @@
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
tools:node="remove">
</service>
<service
android:name=".notifications.TwonlyFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Twonly owns background FCM delivery natively. Keep the Flutter
plugin for foreground permission/token APIs, but never allow it
to launch a Dart background isolate. -->
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingBackgroundService"
tools:node="remove" />
<receiver
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingReceiver"
tools:node="remove" />
<meta-data
android:name="eu.twonly.service.TWONLY_LOGO"
android:resource="@drawable/ic_launcher_foreground" />

View file

@ -7,6 +7,7 @@ import android.view.KeyEvent.KEYCODE_VOLUME_DOWN
import android.view.KeyEvent.KEYCODE_VOLUME_UP
import io.flutter.embedding.engine.FlutterEngine
import android.content.Context
import android.content.Intent
import io.crates.keyring.Keyring
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import android.os.Bundle
@ -16,6 +17,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import io.flutter.plugin.common.MethodChannel
import eu.twonly.notifications.NotificationTapChannel
class MainActivity : FlutterFragmentActivity() {
private val CHANNEL = "eu.twonly/photo_picker"
@ -26,6 +28,10 @@ class MainActivity : FlutterFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
// Buffer a notification tap before the Flutter engine exists so the
// cold-start route is not lost.
NotificationTapChannel.handleIntent(intent)
pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uris ->
if (uris.isNotEmpty()) {
val uriStrings = uris.map { it.toString() }
@ -39,6 +45,12 @@ class MainActivity : FlutterFragmentActivity() {
super.onCreate(savedInstanceState)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
NotificationTapChannel.handleIntent(intent)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KEYCODE_VOLUME_DOWN && eventSink != null) {
eventSink!!.success(true)
@ -58,6 +70,8 @@ class MainActivity : FlutterFragmentActivity() {
VideoCompressionChannel.configure(flutterEngine, applicationContext)
NotificationTapChannel.configure(flutterEngine, applicationContext)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"pickImages" -> {
@ -88,4 +102,9 @@ class MainActivity : FlutterFragmentActivity() {
}
}
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
NotificationTapChannel.detach()
super.cleanUpFlutterEngine(flutterEngine)
}
}

View file

@ -0,0 +1,25 @@
package eu.twonly.notifications
internal object NativeNotificationBridge {
init {
System.loadLibrary("rust_lib_twonly")
}
@JvmStatic
external fun process(
databaseDirectory: String,
dataDirectory: String,
locale: String,
deadlineMs: Long,
): String
@JvmStatic
external fun acknowledge(eventIdsJson: String): String
@JvmStatic
external fun storeFcmToken(
databaseDirectory: String,
dataDirectory: String,
token: String,
): String
}

View file

@ -0,0 +1,77 @@
package eu.twonly.notifications
import org.json.JSONObject
internal data class NativeNotificationPresentation(
val title: String,
val body: String,
)
internal data class NativeNotificationAddition(
val eventId: String,
val notificationId: String,
val conversationId: String?,
val senderId: Long,
val senderName: String,
val title: String,
val body: String,
val createdAt: Long,
val avatarPath: String?,
)
internal data class NativeNotificationBatch(
val additions: List<NativeNotificationAddition>,
val removals: List<String>,
val badgeCount: Long,
val completed: Boolean,
)
internal data class NativeNotificationResponse(
val ok: Boolean,
val batch: NativeNotificationBatch?,
val fallback: NativeNotificationPresentation?,
) {
companion object {
fun parse(json: String): NativeNotificationResponse {
val root = JSONObject(json)
val batchJson = root.optJSONObject("batch")
val additions = batchJson?.optJSONArray("additions")?.let { array ->
List(array.length()) { index ->
val item = array.getJSONObject(index)
NativeNotificationAddition(
eventId = item.getString("event_id"),
notificationId = item.getString("notification_id"),
conversationId = item.nullableString("conversation_id"),
senderId = item.getLong("sender_id"),
senderName = item.getString("sender_name"),
title = item.getString("title"),
body = item.getString("body"),
createdAt = item.getLong("created_at"),
avatarPath = item.nullableString("avatar_path"),
)
}
}.orEmpty()
val removals = batchJson?.optJSONArray("removals")?.let { array ->
List(array.length()) { index -> array.getString(index) }
}.orEmpty()
val batch = batchJson?.let {
NativeNotificationBatch(
additions = additions,
removals = removals,
badgeCount = it.optLong("badge_count"),
completed = it.optBoolean("completed"),
)
}
val fallback = root.optJSONObject("fallback")?.let {
NativeNotificationPresentation(
title = it.getString("title"),
body = it.getString("body"),
)
}
return NativeNotificationResponse(root.optBoolean("ok"), batch, fallback)
}
}
}
private fun JSONObject.nullableString(key: String): String? =
if (isNull(key)) null else optString(key).takeIf(String::isNotEmpty)

View file

@ -0,0 +1,83 @@
package eu.twonly.notifications
import android.content.Context
import android.content.Intent
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
/**
* Forwards taps on natively rendered notifications into Flutter.
*
* Only the opaque conversation identifier travels across this channel; the
* route itself is built in Dart so Kotlin never duplicates Flutter routing.
*/
object NotificationTapChannel {
private const val CHANNEL = "eu.twonly/notificationTap"
const val EXTRA_CONVERSATION_ID = "conversation_id"
private var channel: MethodChannel? = null
private var pendingConversationId: String? = null
private var pendingLaunch = false
fun configure(flutterEngine: FlutterEngine, context: Context) {
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel.setMethodCallHandler { call, result ->
when (call.method) {
"consumeInitialNotification" -> {
val launched = pendingLaunch
val conversationId = pendingConversationId
pendingLaunch = false
pendingConversationId = null
result.success(
if (launched) {
mapOf(EXTRA_CONVERSATION_ID to conversationId)
} else {
null
},
)
}
"cancelNotifications" -> {
val notificationIds = call.argument<List<String>>("notification_ids").orEmpty()
val manager = NotificationManagerCompat.from(context.applicationContext)
notificationIds.forEach { manager.cancel(nativeNotificationId(it)) }
result.success(null)
}
else -> result.notImplemented()
}
}
this.channel = channel
}
fun detach() {
channel?.setMethodCallHandler(null)
channel = null
}
/**
* Records the launch intent before Flutter attaches. Called for both the
* cold-start intent and every `onNewIntent`; a live channel is notified
* immediately, otherwise the tap is buffered for `consumeInitialNotification`.
*/
fun handleIntent(intent: Intent?) {
if (intent?.hasExtra(EXTRA_CONVERSATION_ID) != true) return
val conversationId =
intent.getStringExtra(EXTRA_CONVERSATION_ID)?.takeIf(String::isNotEmpty)
// A tap must only route once, even if the activity is recreated with
// the same intent after a configuration change.
intent.removeExtra(EXTRA_CONVERSATION_ID)
val channel = this.channel
if (channel == null) {
pendingLaunch = true
pendingConversationId = conversationId
return
}
channel.invokeMethod(
"onNotificationTapped",
mapOf(EXTRA_CONVERSATION_ID to conversationId),
)
}
}

View file

@ -0,0 +1,44 @@
package eu.twonly.notifications
import android.util.Log
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
class TwonlyFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
if (message.data["kind"] != "message_wakeup" || message.data["version"] != "1") {
Log.w(TAG, "Ignoring unsupported opaque FCM payload")
return
}
val request = OneTimeWorkRequestBuilder<TwonlyNotificationWorker>()
.setInputData(Data.Builder().putString(INPUT_MESSAGE_ID, message.messageId).build())
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(applicationContext).enqueueUniqueWork(
UNIQUE_WORK,
ExistingWorkPolicy.APPEND_OR_REPLACE,
request,
)
}
override fun onNewToken(token: String) {
try {
val directory = applicationContext.filesDir.absolutePath
NativeNotificationBridge.storeFcmToken(directory, directory, token)
} catch (error: Throwable) {
Log.e(TAG, "Could not persist refreshed FCM token in Rust", error)
}
}
private companion object {
const val TAG = "TwonlyFCM"
const val UNIQUE_WORK = "twonly-native-notification-drain"
const val INPUT_MESSAGE_ID = "fcm_message_id"
}
}

View file

@ -0,0 +1,156 @@
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
import androidx.core.app.Person
import androidx.core.graphics.drawable.IconCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import eu.twonly.MainActivity
import eu.twonly.R
import java.util.Locale
import org.json.JSONArray
class TwonlyNotificationWorker(
appContext: Context,
params: WorkerParameters,
) : Worker(appContext, params) {
override fun doWork(): Result {
return try {
val directory = applicationContext.filesDir.absolutePath
val response = NativeNotificationResponse.parse(
NativeNotificationBridge.process(
directory,
directory,
Locale.getDefault().toLanguageTag(),
RUST_DEADLINE_MS,
),
)
ensureChannel()
val batch = response.batch
if (!response.ok || batch == null) {
// Rust could not reach the mailbox. Show the generic alert so a
// high-priority wake-up still produces a notification, and retry.
response.fallback?.let(::showFallback)
return if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.success()
}
val manager = NotificationManagerCompat.from(applicationContext)
batch.removals.forEach { manager.cancel(nativeNotificationId(it)) }
val delivered = batch.additions.filter { addition ->
showAddition(manager, addition)
}.map(NativeNotificationAddition::eventId)
if (delivered.isNotEmpty()) {
NativeNotificationBridge.acknowledge(JSONArray(delivered).toString())
// Real notifications supersede a placeholder from an earlier attempt.
manager.cancel(FALLBACK_ID)
}
// An empty batch is the normal outcome of a duplicate wake-up or of
// traffic that is not user visible, so it must not retry. Only an
// undrained mailbox is worth another attempt.
if (!batch.completed && runAttemptCount < MAX_RETRIES) {
Result.retry()
} else {
Result.success()
}
} catch (error: Throwable) {
Log.e(TAG, "Native notification processing failed", error)
if (runAttemptCount < MAX_RETRIES) Result.retry() else Result.failure()
}
}
private fun showAddition(
manager: NotificationManagerCompat,
addition: NativeNotificationAddition,
): Boolean {
val avatar = addition.avatarPath
?.let(BitmapFactory::decodeFile)
?.let(IconCompat::createWithBitmap)
val sender = Person.Builder()
.setName(addition.senderName)
.setKey(addition.senderId.toString())
.setIcon(avatar)
.build()
val user = Person.Builder().setName(applicationLabel()).setKey("twonly-user").build()
val style = NotificationCompat.MessagingStyle(user)
.addMessage(addition.body, addition.createdAt * 1_000, sender)
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
// Only opaque identifiers cross into the activity; Dart owns routing.
putExtra(
NotificationTapChannel.EXTRA_CONVERSATION_ID,
addition.conversationId.orEmpty(),
)
}
val pendingIntent = PendingIntent.getActivity(
applicationContext,
nativeNotificationId(addition.notificationId),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(addition.title)
.setContentText(addition.body)
.setStyle(style)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(addition.conversationId ?: addition.senderId.toString())
.build()
return try {
manager.notify(nativeNotificationId(addition.notificationId), notification)
true
} catch (error: SecurityException) {
Log.w(TAG, "Notification permission is unavailable", error)
false
}
}
private fun showFallback(presentation: NativeNotificationPresentation) {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(presentation.title)
.setContentText(presentation.body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
try {
NotificationManagerCompat.from(applicationContext).notify(FALLBACK_ID, notification)
} catch (error: SecurityException) {
Log.w(TAG, "Notification permission is unavailable", error)
}
}
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

@ -2,6 +2,14 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.developer.usernotifications.filtering</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.twonly.runtime</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)eu.twonly.shared</string>

View file

@ -1,326 +1,273 @@
import CryptoKit
import Foundation
import Security
import Intents
import UserNotifications
import rust_lib_twonly
class NotificationService: UNNotificationServiceExtension {
private let runtimeAppGroup = "group.eu.twonly.runtime"
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
private struct NativeNotificationResponse: Decodable {
let ok: Bool
let batch: NativeNotificationBatch?
let error: String?
}
private struct NativeNotificationBatch: Decodable {
let additions: [NativeNotificationAddition]
let removals: [String]
let badgeCount: Int64
let completed: Bool
enum CodingKeys: String, CodingKey {
case additions, removals, completed
case badgeCount = "badge_count"
}
}
private struct NativeNotificationAddition: Decodable {
let eventId: String
let notificationId: String
let conversationId: String?
let senderId: Int64
let senderName: String
let title: String
let body: String
let conversationName: String?
let isGroup: Bool
let messageId: String?
let kind: String
let content: String?
let createdAt: Int64
let avatarPath: String?
enum CodingKeys: String, CodingKey {
case kind, content, title, body
case eventId = "event_id"
case notificationId = "notification_id"
case conversationId = "conversation_id"
case senderId = "sender_id"
case senderName = "sender_name"
case conversationName = "conversation_name"
case isGroup = "is_group"
case messageId = "message_id"
case createdAt = "created_at"
case avatarPath = "avatar_path"
}
}
final class NotificationService: UNNotificationServiceExtension {
private let finishLock = NSLock()
private var hasFinished = false
private var contentHandler: ((UNNotificationContent) -> Void)?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
// Store the current timestamp in Keychain for iOS FCM messaging tracking
let nowMs = String(format: "%.0f", Date().timeIntervalSince1970 * 1000)
writeToKeychain(key: "last_fcm_message_timestamp", value: nowMs)
NSLog("Received APNs push notification, updated last_fcm_message_timestamp to \(nowMs)")
guard let runtimeDirectory = Self.runtimeDirectory() else {
suppress(reason: "shared runtime directory is unavailable")
return
}
if let bestAttemptContent = bestAttemptContent {
guard bestAttemptContent.userInfo as? [String: Any] != nil,
let push_data = bestAttemptContent.userInfo["push_data"] as? String
DispatchQueue.global(qos: .userInitiated).async {
guard let response = Self.processWakeup(runtimeDirectory: runtimeDirectory) else {
self.suppress(reason: "notification worker returned no response")
return
}
guard response.ok,
let batch = response.batch,
!batch.additions.isEmpty
else {
return contentHandler(bestAttemptContent)
self.suppress(reason: response.error ?? "notification worker returned no messages")
return
}
let data = getPushNotificationData(pushData: push_data)
if data != nil {
if data!.title == "blocked" {
NSLog("Block message because user is blocked!")
// https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.usernotifications.filtering
return contentHandler(UNNotificationContent())
}
bestAttemptContent.title = data!.title
bestAttemptContent.body = data!.body
bestAttemptContent.threadIdentifier = String(format: "%d", data!.notificationId)
} else {
NSLog("Could not decrypt message. Show default.")
bestAttemptContent.title = "\(bestAttemptContent.title)"
}
contentHandler(bestAttemptContent)
self.render(batch: batch, original: request.content)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension will be terminated by the system.
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
contentHandler(bestAttemptContent)
}
suppress(reason: "notification service extension timed out")
}
}
private func render(batch: NativeNotificationBatch, original: UNNotificationContent) {
let center = UNUserNotificationCenter.current()
let group = DispatchGroup()
let stateLock = NSLock()
var deliveredEventIds: [String] = []
var finalContent: UNNotificationContent = original
func getPushNotificationData(pushData: String) -> (
title: String, body: String, notificationId: Int64
)? {
guard let data = Data(base64Encoded: pushData) else {
NSLog("Failed to decode base64 string")
return nil
}
do {
let pushData = try EncryptedPushNotification(serializedBytes: data)
var pushNotification: PushNotification?
var pushUser: PushUser?
// Check the keyId
if pushData.keyID == 0 {
let key = "InsecureOnlyUsedForAddingContact".data(using: .utf8)!
pushNotification = tryDecryptMessage(key: key, pushData: pushData)
} else {
let pushUsers = getPushUsers()
if pushUsers != nil {
for tryPushUser in pushUsers! {
for pushKey in tryPushUser.pushKeys {
if pushKey.id == pushData.keyID {
pushNotification = tryDecryptMessage(
key: pushKey.key, pushData: pushData)
if pushNotification != nil {
pushUser = tryPushUser
if isUUIDNewer(pushUser!.lastMessageID, pushNotification!.messageID)
{
//return ("blocked", "blocked", 0)
}
break
}
}
}
if pushUser != nil { break }
}
} else {
NSLog("pushKeys are empty")
}
}
if pushUser?.blocked == true {
return ("blocked", "blocked", 0)
}
// Handle the push notification based on the pushKind
if let pushNotification = pushNotification {
if pushNotification.kind == .testNotification {
return ("Test Notification", "This is a test notification.", 0)
} else if pushUser != nil {
return (
pushUser!.displayName,
getPushNotificationText(pushNotification: pushNotification, userKnown: true).0, pushUser!.userID
)
} else {
let content = getPushNotificationText(pushNotification: pushNotification, userKnown: false)
return (
content.1, content.0, 1
)
}
} else {
NSLog("Failed to decrypt message or pushKind is nil")
}
return nil
} catch {
NSLog("Error decoding JSON: \(error)")
return nil
}
}
func isUUIDNewer(_ uuid1: String, _ uuid2: String) -> Bool {
guard uuid1.count >= 8, uuid2.count >= 8 else { return true }
let hex1 = String(uuid1.prefix(8))
let hex2 = String(uuid2.prefix(8))
guard let timestamp1 = UInt32(hex1, radix: 16),
let timestamp2 = UInt32(hex2, radix: 16)
else { return true }
return timestamp1 > timestamp2
}
func tryDecryptMessage(key: Data, pushData: EncryptedPushNotification) -> PushNotification? {
do {
// Create a nonce for ChaChaPoly
let nonce = try ChaChaPoly.Nonce(data: pushData.nonce)
// Create a sealed box for ChaChaPoly
let sealedBox = try ChaChaPoly.SealedBox(
nonce: nonce,
ciphertext: pushData.ciphertext,
tag: pushData.mac
)
// Decrypt the data using the key
let decryptedData = try ChaChaPoly.open(sealedBox, using: SymmetricKey(data: key))
// Here you can determine the PushKind based on the decrypted message
return try PushNotification(serializedBytes: decryptedData)
} catch {
NSLog("Decryption failed: \(error)")
}
return nil
}
func getPushUsers() -> [PushUser]? {
// Retrieve the data from secure storage (Keychain)
guard let pushUsersB64 = readFromKeychain(key: "push_keys_receiving") else {
NSLog("No data found for key: push_keys_receiving")
return nil
}
guard let pushUsersBytes = Data(base64Encoded: pushUsersB64) else {
NSLog("Failed to decode base64 push users")
return nil
}
do {
let pushUsers = try PushUsers(serializedBytes: pushUsersBytes)
return pushUsers.users
} catch {
NSLog("Error decoding JSON: \(error)")
return nil
}
}
// Helper function to read from Keychain
func readFromKeychain(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: "flutter_secure_storage_service",
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared", // Use your access group
]
var dataTypeRef: AnyObject? = nil
let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
if status == errSecSuccess {
if let data = dataTypeRef as? Data {
return String(data: data, encoding: .utf8)
}
}
return nil
}
// Helper function to write to Keychain
func writeToKeychain(key: String, value: String) {
guard let data = value.data(using: .utf8) else {
NSLog("Failed to convert value to data for keychain key: \(key)")
for (index, addition) in batch.additions.enumerated() {
group.enter()
communicationContent(for: addition, badgeCount: batch.badgeCount, original: original) {
content in
let isFinal = index == batch.additions.count - 1
if isFinal {
stateLock.lock()
finalContent = content
deliveredEventIds.append(addition.eventId)
stateLock.unlock()
group.leave()
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: "flutter_secure_storage_service",
kSecAttrAccessGroup as String: "CN332ZUGRP.eu.twonly.shared"
]
// Delete existing item first to ensure a clean overwrite
SecItemDelete(query as CFDictionary)
// Add the new item with background-compatible accessibility
var addQuery = query
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
NSLog("Failed to write keychain item for key \(key): \(status)")
let request = UNNotificationRequest(
identifier: addition.notificationId,
content: content,
trigger: nil
)
center.add(request) { error in
if let error {
NSLog("Could not schedule Twonly notification: \(error)")
} else {
NSLog("Successfully wrote keychain item for key: \(key)")
stateLock.lock()
deliveredEventIds.append(addition.eventId)
stateLock.unlock()
}
}
func getPushNotificationText(pushNotification: PushNotification, userKnown: Bool) -> (String, String) {
let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language
var pushNotificationText: [PushKind: String] = [:]
var title = "You"
var noTranslationFoundTitle = "You have a new message."
var noTranslationFoundBody = "Open twonly to learn more."
// Define the messages based on the system language
if systemLanguage.contains("de") { // German
title = "Du"
noTranslationFoundTitle = "Du hast eine neue Nachricht."
noTranslationFoundBody = "Öffne twonly um mehr zu erfahren."
if (userKnown) {
pushNotificationText = [
.text: "hat eine Nachricht{inGroup} gesendet.",
.twonly: "hat ein twonly{inGroup} gesendet.",
.video: "hat ein Video{inGroup} gesendet.",
.image: "hat ein Bild{inGroup} gesendet.",
.audio: "hat eine Sprachnachricht{inGroup} gesendet.",
.contactRequest: "möchte sich mit dir vernetzen.",
.acceptRequest: "ist jetzt mit dir vernetzt.",
.storedMediaFile: "hat dein Bild gespeichert.",
.reaction: "hat auf dein Bild reagiert.",
.testNotification: "Das ist eine Testbenachrichtigung.",
.reopenedMedia: "hat dein Bild erneut geöffnet.",
.reactionToVideo: "hat mit {{content}} auf dein Video reagiert.",
.reactionToText: "hat mit {{content}} auf deinen Text reagiert.",
.reactionToImage: "hat mit {{content}} auf dein Bild reagiert.",
.reactionToAudio: "hat mit {{content}} auf deine Sprachnachricht reagiert.",
.response: "hat dir{inGroup} geantwortet.",
.addedToGroup: "hat dich zu \"{{content}}\" hinzugefügt.",
]
} else {
pushNotificationText = [
.contactRequest: "hast eine neue Kontaktanfrage erhalten.",
]
}
} else {
if (userKnown) {
pushNotificationText = [
.text: "sent a message{inGroup}.",
.twonly: "sent a twonly{inGroup}.",
.video: "sent a video{inGroup}.",
.image: "sent an image{inGroup}.",
.audio: "sent a voice message{inGroup}.",
.contactRequest: "wants to connect with you.",
.acceptRequest: "is now connected with you.",
.storedMediaFile: "has stored your image.",
.reaction: "has reacted to your image.",
.testNotification: "This is a test notification.",
.reopenedMedia: "has reopened your image.",
.reactionToVideo: "has reacted with {{content}} to your video.",
.reactionToText: "has reacted with {{content}} to your text.",
.reactionToImage: "has reacted with {{content}} to your image.",
.reactionToAudio: "has reacted with {{content}} to your voice message.",
.response: "has responded{inGroup}.",
.addedToGroup: "has added you to \"{{content}}\"",
]
} else {
pushNotificationText = [
.contactRequest: "have received a new contact request.",
]
}
}
var content = pushNotificationText[pushNotification.kind] ?? ""
if (content == "") {
title = noTranslationFoundTitle
content = noTranslationFoundBody
}
if pushNotification.hasAdditionalContent {
content.replace("{{content}}", with: pushNotification.additionalContent)
content.replace("{inGroup}", with: " in {inGroup}")
content.replace("{inGroup}", with: pushNotification.additionalContent)
} else {
content.replace("{inGroup}", with: "")
}
// Return the corresponding message or an empty string if not found
return (content, title)
group.leave()
}
}
}
group.notify(queue: .global(qos: .userInitiated)) { [weak self] in
stateLock.lock()
let eventIds = deliveredEventIds
let content = finalContent
stateLock.unlock()
Self.acknowledge(eventIds: eventIds)
self?.finish(with: content)
}
}
private func communicationContent(
for addition: NativeNotificationAddition,
badgeCount: Int64,
original: UNNotificationContent,
completion: @escaping (UNNotificationContent) -> Void
) {
let mutable = (original.mutableCopy() as? UNMutableNotificationContent)
?? UNMutableNotificationContent()
mutable.title = addition.title
mutable.body = addition.body
mutable.threadIdentifier = addition.conversationId ?? String(addition.senderId)
mutable.badge = NSNumber(value: badgeCount)
mutable.sound = .default
var userInfo = mutable.userInfo
if let conversationId = addition.conversationId {
userInfo["conversation_id"] = conversationId
}
userInfo["notification_id"] = addition.notificationId
mutable.userInfo = userInfo
let avatar = addition.avatarPath
.flatMap { try? Data(contentsOf: URL(fileURLWithPath: $0)) }
.map(INImage.init(imageData:))
let sender = INPerson(
personHandle: INPersonHandle(value: String(addition.senderId), type: .unknown),
nameComponents: nil,
displayName: addition.senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: String(addition.senderId)
)
let groupName = addition.isGroup
? addition.conversationName.map(INSpeakableString.init(spokenPhrase:))
: nil
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: addition.body,
speakableGroupName: groupName,
conversationIdentifier: addition.conversationId ?? String(addition.senderId),
serviceName: "Twonly",
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate { error in
if let error {
NSLog("Could not donate Twonly communication intent: \(error)")
completion(mutable)
return
}
do {
completion(try mutable.updating(from: intent))
} catch {
NSLog("Could not create Twonly communication notification: \(error)")
completion(mutable)
}
}
}
private func finish(with content: UNNotificationContent) {
finishLock.lock()
guard !hasFinished else {
finishLock.unlock()
return
}
hasFinished = true
let handler = contentHandler
contentHandler = nil
finishLock.unlock()
handler?(content)
}
private func suppress(reason: String) {
NSLog("Suppressing Twonly wake-up notification: \(reason)")
finish(with: UNNotificationContent())
}
private static func runtimeDirectory() -> String? {
guard
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: runtimeAppGroup
)
else { return nil }
let directory = container.appendingPathComponent("runtime", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try (directory as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
return directory.path
} catch {
NSLog("Could not open Twonly runtime directory: \(error)")
return nil
}
}
private static func processWakeup(runtimeDirectory: String) -> NativeNotificationResponse? {
let locale = Locale.current.identifier
let pointer = runtimeDirectory.withCString { databaseDirectory in
runtimeDirectory.withCString { dataDirectory in
locale.withCString { locale in
twonly_notification_process(databaseDirectory, dataDirectory, locale, 24_000)
}
}
}
guard let pointer else { return nil }
defer { twonly_notification_string_free(pointer) }
let json = String(cString: pointer)
do {
return try JSONDecoder().decode(
NativeNotificationResponse.self,
from: Data(json.utf8)
)
} catch {
NSLog("Could not decode Twonly notification worker response: \(error)")
return nil
}
}
private static func acknowledge(eventIds: [String]) {
guard !eventIds.isEmpty, let data = try? JSONEncoder().encode(eventIds),
let json = String(data: data, encoding: .utf8)
else { return }
let pointer = json.withCString { twonly_notification_acknowledge($0) }
guard let pointer else { return }
twonly_notification_string_free(pointer)
}
}

View file

@ -1,447 +0,0 @@
// DO NOT EDIT.
// swift-format-ignore-file
// swiftlint:disable all
//
// Generated by the Swift generator plugin for the protocol buffer compiler.
// Source: push_notification.proto
//
// For information on using the generated types, please see the documentation:
// https://github.com/apple/swift-protobuf/
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
import SwiftProtobuf
// If the compiler emits an error on this type, it is because this file
// was generated by a version of the `protoc` Swift plug-in that is
// incompatible with the version of SwiftProtobuf to which you are linking.
// Please ensure that you are building against the same version of the API
// that was used to generate this file.
fileprivate nonisolated struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
typealias Version = _2
}
nonisolated enum PushKind: SwiftProtobuf.Enum, Swift.CaseIterable {
typealias RawValue = Int
case reaction // = 0
case response // = 1
case text // = 2
case video // = 3
case twonly // = 4
case image // = 5
case contactRequest // = 6
case acceptRequest // = 7
case storedMediaFile // = 8
case testNotification // = 9
case reopenedMedia // = 10
case reactionToVideo // = 11
case reactionToText // = 12
case reactionToImage // = 13
case reactionToAudio // = 14
case addedToGroup // = 15
case audio // = 16
case UNRECOGNIZED(Int)
init() {
self = .reaction
}
init?(rawValue: Int) {
switch rawValue {
case 0: self = .reaction
case 1: self = .response
case 2: self = .text
case 3: self = .video
case 4: self = .twonly
case 5: self = .image
case 6: self = .contactRequest
case 7: self = .acceptRequest
case 8: self = .storedMediaFile
case 9: self = .testNotification
case 10: self = .reopenedMedia
case 11: self = .reactionToVideo
case 12: self = .reactionToText
case 13: self = .reactionToImage
case 14: self = .reactionToAudio
case 15: self = .addedToGroup
case 16: self = .audio
default: self = .UNRECOGNIZED(rawValue)
}
}
var rawValue: Int {
switch self {
case .reaction: return 0
case .response: return 1
case .text: return 2
case .video: return 3
case .twonly: return 4
case .image: return 5
case .contactRequest: return 6
case .acceptRequest: return 7
case .storedMediaFile: return 8
case .testNotification: return 9
case .reopenedMedia: return 10
case .reactionToVideo: return 11
case .reactionToText: return 12
case .reactionToImage: return 13
case .reactionToAudio: return 14
case .addedToGroup: return 15
case .audio: return 16
case .UNRECOGNIZED(let i): return i
}
}
// The compiler won't synthesize support with the UNRECOGNIZED case.
static let allCases: [PushKind] = [
.reaction,
.response,
.text,
.video,
.twonly,
.image,
.contactRequest,
.acceptRequest,
.storedMediaFile,
.testNotification,
.reopenedMedia,
.reactionToVideo,
.reactionToText,
.reactionToImage,
.reactionToAudio,
.addedToGroup,
.audio,
]
}
nonisolated struct EncryptedPushNotification: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var keyID: Int64 = 0
var nonce: Data = Data()
var ciphertext: Data = Data()
var mac: Data = Data()
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
}
nonisolated struct PushNotification: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var kind: PushKind = .reaction
var messageID: String {
get {_messageID ?? String()}
set {_messageID = newValue}
}
/// Returns true if `messageID` has been explicitly set.
var hasMessageID: Bool {self._messageID != nil}
/// Clears the value of `messageID`. Subsequent reads from it will return its default value.
mutating func clearMessageID() {self._messageID = nil}
var additionalContent: String {
get {_additionalContent ?? String()}
set {_additionalContent = newValue}
}
/// Returns true if `additionalContent` has been explicitly set.
var hasAdditionalContent: Bool {self._additionalContent != nil}
/// Clears the value of `additionalContent`. Subsequent reads from it will return its default value.
mutating func clearAdditionalContent() {self._additionalContent = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
fileprivate var _messageID: String? = nil
fileprivate var _additionalContent: String? = nil
}
nonisolated struct PushUsers: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var users: [PushUser] = []
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
}
nonisolated struct PushUser: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var userID: Int64 = 0
var displayName: String = String()
var blocked: Bool = false
var lastMessageID: String {
get {_lastMessageID ?? String()}
set {_lastMessageID = newValue}
}
/// Returns true if `lastMessageID` has been explicitly set.
var hasLastMessageID: Bool {self._lastMessageID != nil}
/// Clears the value of `lastMessageID`. Subsequent reads from it will return its default value.
mutating func clearLastMessageID() {self._lastMessageID = nil}
var pushKeys: [PushKey] = []
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
fileprivate var _lastMessageID: String? = nil
}
nonisolated struct PushKey: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var id: Int64 = 0
var key: Data = Data()
var createdAtUnixTimestamp: Int64 = 0
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
}
// MARK: - Code below here is support for the SwiftProtobuf runtime.
nonisolated extension PushKind: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0REACTION\0\u{1}RESPONSE\0\u{1}TEXT\0\u{1}VIDEO\0\u{1}TWONLY\0\u{1}IMAGE\0\u{1}CONTACT_REQUEST\0\u{1}ACCEPT_REQUEST\0\u{1}STORED_MEDIA_FILE\0\u{1}TEST_NOTIFICATION\0\u{1}REOPENED_MEDIA\0\u{1}REACTION_TO_VIDEO\0\u{1}REACTION_TO_TEXT\0\u{1}REACTION_TO_IMAGE\0\u{1}REACTION_TO_AUDIO\0\u{1}ADDED_TO_GROUP\0\u{1}AUDIO\0")
}
nonisolated extension EncryptedPushNotification: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "EncryptedPushNotification"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}key_id\0\u{1}nonce\0\u{1}ciphertext\0\u{1}mac\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt64Field(value: &self.keyID) }()
case 2: try { try decoder.decodeSingularBytesField(value: &self.nonce) }()
case 3: try { try decoder.decodeSingularBytesField(value: &self.ciphertext) }()
case 4: try { try decoder.decodeSingularBytesField(value: &self.mac) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if self.keyID != 0 {
try visitor.visitSingularInt64Field(value: self.keyID, fieldNumber: 1)
}
if !self.nonce.isEmpty {
try visitor.visitSingularBytesField(value: self.nonce, fieldNumber: 2)
}
if !self.ciphertext.isEmpty {
try visitor.visitSingularBytesField(value: self.ciphertext, fieldNumber: 3)
}
if !self.mac.isEmpty {
try visitor.visitSingularBytesField(value: self.mac, fieldNumber: 4)
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: EncryptedPushNotification, rhs: EncryptedPushNotification) -> Bool {
if lhs.keyID != rhs.keyID {return false}
if lhs.nonce != rhs.nonce {return false}
if lhs.ciphertext != rhs.ciphertext {return false}
if lhs.mac != rhs.mac {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
nonisolated extension PushNotification: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "PushNotification"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}kind\0\u{3}message_id\0\u{3}additional_content\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularEnumField(value: &self.kind) }()
case 2: try { try decoder.decodeSingularStringField(value: &self._messageID) }()
case 3: try { try decoder.decodeSingularStringField(value: &self._additionalContent) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
if self.kind != .reaction {
try visitor.visitSingularEnumField(value: self.kind, fieldNumber: 1)
}
try { if let v = self._messageID {
try visitor.visitSingularStringField(value: v, fieldNumber: 2)
} }()
try { if let v = self._additionalContent {
try visitor.visitSingularStringField(value: v, fieldNumber: 3)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: PushNotification, rhs: PushNotification) -> Bool {
if lhs.kind != rhs.kind {return false}
if lhs._messageID != rhs._messageID {return false}
if lhs._additionalContent != rhs._additionalContent {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
nonisolated extension PushUsers: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "PushUsers"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}users\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeRepeatedMessageField(value: &self.users) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if !self.users.isEmpty {
try visitor.visitRepeatedMessageField(value: self.users, fieldNumber: 1)
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: PushUsers, rhs: PushUsers) -> Bool {
if lhs.users != rhs.users {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
nonisolated extension PushUser: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "PushUser"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}user_id\0\u{3}display_name\0\u{1}blocked\0\u{3}last_message_id\0\u{3}push_keys\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt64Field(value: &self.userID) }()
case 2: try { try decoder.decodeSingularStringField(value: &self.displayName) }()
case 3: try { try decoder.decodeSingularBoolField(value: &self.blocked) }()
case 4: try { try decoder.decodeSingularStringField(value: &self._lastMessageID) }()
case 5: try { try decoder.decodeRepeatedMessageField(value: &self.pushKeys) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
if self.userID != 0 {
try visitor.visitSingularInt64Field(value: self.userID, fieldNumber: 1)
}
if !self.displayName.isEmpty {
try visitor.visitSingularStringField(value: self.displayName, fieldNumber: 2)
}
if self.blocked != false {
try visitor.visitSingularBoolField(value: self.blocked, fieldNumber: 3)
}
try { if let v = self._lastMessageID {
try visitor.visitSingularStringField(value: v, fieldNumber: 4)
} }()
if !self.pushKeys.isEmpty {
try visitor.visitRepeatedMessageField(value: self.pushKeys, fieldNumber: 5)
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: PushUser, rhs: PushUser) -> Bool {
if lhs.userID != rhs.userID {return false}
if lhs.displayName != rhs.displayName {return false}
if lhs.blocked != rhs.blocked {return false}
if lhs._lastMessageID != rhs._lastMessageID {return false}
if lhs.pushKeys != rhs.pushKeys {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
nonisolated extension PushKey: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = "PushKey"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}key\0\u{3}created_at_unix_timestamp\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt64Field(value: &self.id) }()
case 2: try { try decoder.decodeSingularBytesField(value: &self.key) }()
case 3: try { try decoder.decodeSingularInt64Field(value: &self.createdAtUnixTimestamp) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
if self.id != 0 {
try visitor.visitSingularInt64Field(value: self.id, fieldNumber: 1)
}
if !self.key.isEmpty {
try visitor.visitSingularBytesField(value: self.key, fieldNumber: 2)
}
if self.createdAtUnixTimestamp != 0 {
try visitor.visitSingularInt64Field(value: self.createdAtUnixTimestamp, fieldNumber: 3)
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: PushKey, rhs: PushKey) -> Bool {
if lhs.id != rhs.id {return false}
if lhs.key != rhs.key {return false}
if lhs.createdAtUnixTimestamp != rhs.createdAtUnixTimestamp {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}

View file

@ -97,6 +97,5 @@ post_install do |installer|
end
target 'NotificationService' do
pod 'SwiftProtobuf'
# pod 'Firebase/Messaging'
pod 'rust_lib_twonly', :path => '.symlinks/plugins/rust_lib_twonly/ios'
end

View file

@ -93,8 +93,7 @@ PODS:
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
- rust_lib_twonly (0.0.1):
- Flutter
- rust_lib_twonly (0.0.1)
- screen_protector (1.5.1):
- Flutter
- ScreenProtectorKit (= 1.5.1)
@ -199,7 +198,7 @@ SPEC CHECKSUMS:
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
rust_lib_twonly: 73165b05d0cda50db45852db63f49caa7f319520
rust_lib_twonly: 6586fdf02e31cd8a3ad9f1ee84796da6bbf289bb
screen_protector: 18c6aca2dc5d2a832f6787a5318f97f03e9d3150
ScreenProtectorKit: 6ceb3e0808341a9bc15d175bff40dfdd4b32da71
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
@ -207,6 +206,6 @@ SPEC CHECKSUMS:
SwiftProtobuf: d724b5145bfc609d9a49c1e3e3a3dabb07273ffb
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: 245e6d5f26c858edb6b99a7d972cc93ead4d55cf
PODFILE CHECKSUM: f83bbaaed0b8c29b006472e50864d40e54617e24
COCOAPODS: 1.17.0

View file

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@ -14,6 +14,7 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -23,7 +24,6 @@
D25D4D7A2EFF41DB0029F805 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D25D4D702EFF41DB0029F805 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */; };
F3C66D726A2EB28484DF0B10 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 16FBC6F5B58E1C6646F5D447 /* GoogleService-Info.plist */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -91,6 +91,7 @@
70E8A5E1DA4031C0E3F86C77 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7FC59147CD9A45BFAC98EA05 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@ -115,7 +116,6 @@
E96A5ACA32A7118204F050A5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
EE2CCFEE4ABECF33852F7735 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F02F7A1D63544AA9F23A1085 /* Pods-NotificationService.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NotificationService.profile.xcconfig"; path = "Target Support Files/Pods-NotificationService/Pods-NotificationService.profile.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -311,9 +311,6 @@
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
@ -335,6 +332,9 @@
D25D4D792EFF41DB0029F805 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@ -385,9 +385,6 @@
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@ -419,6 +416,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@ -1319,12 +1319,14 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;

View file

@ -14,8 +14,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/app-check.git",
"state" : {
"revision" : "61b85103a1aeed8218f17c794687781505fbbef5",
"version" : "11.2.0"
"revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902",
"version" : "11.3.1"
}
},
{
@ -23,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382",
"version" : "12.14.0"
"revision" : "346daa9f46316aa372b35b317e18224acc2e9063",
"version" : "12.18.0"
}
},
{
@ -41,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
"state" : {
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
"version" : "3.6.0"
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
"version" : "3.6.1"
}
},
{
@ -50,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "219e564a8510e983e675c94f77f7f7c50049f22d",
"version" : "12.14.0"
"revision" : "f04760d460296cc0fa430935a7be212e5bd67fc5",
"version" : "12.18.0"
}
},
{
@ -59,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "617af071af9aa1d6a091d59a202910ac482128f9",
"version" : "10.1.0"
"revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974",
"version" : "10.1.1"
}
},
{
@ -68,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
"version" : "8.1.0"
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
"version" : "8.1.3"
}
},
{
@ -86,8 +86,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a",
"version" : "5.3.0"
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
"version" : "5.3.1"
}
},
{
@ -113,8 +113,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
"version" : "2.30910.0"
"revision" : "3851d94a41890dea16dc3db34caf60e585cb4163",
"version" : "2.30910.1"
}
},
{
@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
"version" : "2.4.0"
"revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837",
"version" : "2.4.1"
}
},
{

View file

@ -22,6 +22,9 @@ import workmanager_apple
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
// Background tasks call AppEnvironment.init() too, so this engine needs
// the runtime storage channel just as much as the implicit one.
RuntimeStorageChannel.register(with: registry)
}
WorkmanagerPlugin.registerPeriodicTask(
@ -51,6 +54,8 @@ import workmanager_apple
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
RuntimeStorageChannel.register(with: engineBridge.pluginRegistry)
NativeNotificationChannel.register(with: engineBridge.pluginRegistry)
}
override func userNotificationCenter(
@ -70,23 +75,139 @@ import workmanager_apple
) {
NSLog("userNotificationCenter:willPresent")
/*
debugging NotificationService
let pushKeys = getPushKey();
print(pushKeys)
let bestAttemptContent = notification.request.content
guard let _userInfo = bestAttemptContent.userInfo as? [String: Any],
let push_data = bestAttemptContent.userInfo["push_data"] as? String else {
return completionHandler([.alert, .sound])
}
let data = getPushNotificationData(pushDataJson: push_data)
print(data)
*/
completionHandler([.alert, .sound])
}
}
/// Withdraws only the native notifications whose message IDs Dart reports as
/// opened. The notification service extension's final alert keeps APNs' request
/// identifier, so both the identifier and our `notification_id` user-info field
/// have to be considered.
class NativeNotificationChannel {
private static let channelName = "eu.twonly/notificationTap"
private static let notificationIdsKey = "notification_ids"
static func register(with registry: FlutterPluginRegistry) {
guard let registrar = registry.registrar(forPlugin: "TwonlyNativeNotifications") else {
return
}
let channel = FlutterMethodChannel(
name: channelName,
binaryMessenger: registrar.messenger()
)
channel.setMethodCallHandler { call, result in
guard call.method == "cancelNotifications" else {
result(FlutterMethodNotImplemented)
return
}
guard
let arguments = call.arguments as? [String: Any],
let values = arguments[notificationIdsKey] as? [String]
else {
result(
FlutterError(
code: "invalid_notification_ids",
message: "notification_ids must be a list of strings",
details: nil
))
return
}
removeNotifications(Set(values), completion: result)
}
}
private static func removeNotifications(
_ notificationIds: Set<String>,
completion: @escaping FlutterResult
) {
guard !notificationIds.isEmpty else {
completion(nil)
return
}
let center = UNUserNotificationCenter.current()
center.getDeliveredNotifications { delivered in
let requestIds = delivered.compactMap { notification -> String? in
let request = notification.request
let notificationId = request.content.userInfo["notification_id"] as? String
return notificationIds.contains(request.identifier)
|| notificationId.map(notificationIds.contains) == true
? request.identifier
: nil
}
center.removeDeliveredNotifications(withIdentifiers: requestIds)
center.getPendingNotificationRequests { pending in
let pendingIds = pending.compactMap { request -> String? in
let notificationId = request.content.userInfo["notification_id"] as? String
return notificationIds.contains(request.identifier)
|| notificationId.map(notificationIds.contains) == true
? request.identifier
: nil
}
center.removePendingNotificationRequests(withIdentifiers: pendingIds)
DispatchQueue.main.async {
completion(nil)
}
}
}
}
}
/// Hands Dart the App Group container shared between the app and its
/// extensions. Must be registered on every Flutter engine that runs
/// `AppEnvironment.init()`, not just the implicit one.
class RuntimeStorageChannel {
private static let appGroupIdentifier = "group.eu.twonly.runtime"
static func register(with registry: FlutterPluginRegistry) {
guard let registrar = registry.registrar(forPlugin: "TwonlyRuntimeStorage") else {
return
}
register(with: registrar.messenger())
}
static func register(with messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(
name: "eu.twonly/runtime_storage",
binaryMessenger: messenger
)
channel.setMethodCallHandler { call, result in
guard call.method == "runtimeSupportDirectory" else {
result(FlutterMethodNotImplemented)
return
}
guard
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier
)
else {
result(
FlutterError(
code: "runtime_app_group_unavailable",
message: "Could not open \(appGroupIdentifier)",
details: nil
))
return
}
let runtimeDirectory = container.appendingPathComponent("runtime", isDirectory: true)
do {
try FileManager.default.createDirectory(
at: runtimeDirectory,
withIntermediateDirectories: true
)
try (runtimeDirectory as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
result(runtimeDirectory.path)
} catch {
result(
FlutterError(
code: "runtime_directory_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}

View file

@ -47,6 +47,10 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Use your camera to make photos or videos and share them encrypted with your friends.</string>
<key>NSFaceIDUsageDescription</key>

View file

@ -8,9 +8,12 @@
<array>
<string>applinks:me.twonly.eu</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.twonly.shareIntent</string>
<string>group.eu.twonly.runtime</string>
</array>
<key>keychain-access-groups</key>
<array>

View file

@ -8,9 +8,12 @@
<array>
<string>applinks:me.twonly.eu</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.twonly.shareIntent</string>
<string>group.eu.twonly.runtime</string>
</array>
<key>keychain-access-groups</key>
<array>

View file

@ -8,9 +8,12 @@
<array>
<string>applinks:me.twonly.eu</string>
</array>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.twonly.shareIntent</string>
<string>group.eu.twonly.runtime</string>
</array>
<key>keychain-access-groups</key>
<array>

View file

@ -8,6 +8,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `get_twonly_flutter`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `AnnouncedUser`, `OtherPromotion`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `initialize_twonly_notification`
Future<void> initializeTwonlyFlutter({required InitConfig config}) =>
RustLib.instance.api.crateBridgeInitializeTwonlyFlutter(config: config);

View file

@ -52,6 +52,19 @@ class LegacyTableMigrationCount {
class RustAppDatabase {
const RustAppDatabase();
/// Streams the tables Rust has committed to.
///
/// Rust owns the connection, so writes it makes on its own never pass
/// through the Drift compatibility executor and cannot invalidate Drift's
/// query streams. Dart forwards each batch into `notifyUpdates` so
/// `watch()` keeps reflecting Rust-side writes.
///
/// An empty list means "assume every table changed". It is sent right
/// after (re)subscribing, and whenever the broadcast channel drops
/// notifications, so Dart never silently keeps stale rows on screen.
static Stream<List<String>> changes() => RustLib.instance.api
.crateBridgeWrapperAppDatabaseRustAppDatabaseChanges();
static Future<SqlExecutionResult> execute({
required String statement,
required List<SqlValue> arguments,

View file

@ -84,7 +84,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -1286774374;
int get rustContentHash => -1780439173;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@ -409,6 +409,8 @@ abstract class RustLibApi extends BaseApi {
required List<PqcPreKeyInput> prekeys,
});
Stream<List<String>> crateBridgeWrapperAppDatabaseRustAppDatabaseChanges();
Future<SqlExecutionResult>
crateBridgeWrapperAppDatabaseRustAppDatabaseExecute({
required String statement,
@ -3283,6 +3285,43 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
],
);
@override
Stream<List<String>> crateBridgeWrapperAppDatabaseRustAppDatabaseChanges() {
final sink = RustStreamSink<List<String>>();
unawaited(
handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_StreamSink_list_String_Sse(sink, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 77,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta:
kCrateBridgeWrapperAppDatabaseRustAppDatabaseChangesConstMeta,
argValues: [sink],
apiImpl: this,
),
),
);
return sink.stream;
}
TaskConstMeta
get kCrateBridgeWrapperAppDatabaseRustAppDatabaseChangesConstMeta =>
const TaskConstMeta(
debugName: "rust_app_database_changes",
argNames: ["sink"],
);
@override
Future<SqlExecutionResult>
crateBridgeWrapperAppDatabaseRustAppDatabaseExecute({
@ -3298,7 +3337,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 77,
funcId: 78,
port: port_,
);
},
@ -3331,7 +3370,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 78,
funcId: 79,
port: port_,
);
},
@ -3364,7 +3403,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 79,
funcId: 80,
port: port_,
);
},
@ -3401,7 +3440,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 80,
funcId: 81,
port: port_,
);
},
@ -3433,7 +3472,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 81,
funcId: 82,
port: port_,
);
},
@ -3466,7 +3505,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 82,
funcId: 83,
port: port_,
);
},
@ -3501,7 +3540,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 83,
funcId: 84,
port: port_,
);
},
@ -3533,7 +3572,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 84,
funcId: 85,
port: port_,
);
},
@ -3571,7 +3610,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 85,
funcId: 86,
port: port_,
);
},
@ -3604,7 +3643,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 86,
funcId: 87,
port: port_,
);
},
@ -3642,7 +3681,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 87,
funcId: 88,
port: port_,
);
},
@ -3679,7 +3718,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 88,
funcId: 89,
port: port_,
);
},
@ -3716,7 +3755,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 89,
funcId: 90,
port: port_,
);
},
@ -3754,7 +3793,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 90,
funcId: 91,
port: port_,
);
},
@ -3792,7 +3831,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 91,
funcId: 92,
port: port_,
);
},
@ -3824,7 +3863,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 92,
funcId: 93,
port: port_,
);
},
@ -3857,7 +3896,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 93,
funcId: 94,
port: port_,
);
},
@ -3889,7 +3928,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 94,
funcId: 95,
port: port_,
);
},
@ -3924,7 +3963,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 95,
funcId: 96,
port: port_,
);
},
@ -3961,7 +4000,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 96,
funcId: 97,
port: port_,
);
},
@ -3993,7 +4032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 97,
funcId: 98,
port: port_,
);
},
@ -4025,7 +4064,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 98,
funcId: 99,
port: port_,
);
},
@ -4060,7 +4099,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 99,
funcId: 100,
port: port_,
);
},
@ -4099,7 +4138,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 100,
funcId: 101,
port: port_,
);
},
@ -4136,7 +4175,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 101,
funcId: 102,
port: port_,
);
},
@ -4166,7 +4205,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 102,
funcId: 103,
port: port_,
);
},
@ -4198,7 +4237,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 103,
funcId: 104,
port: port_,
);
},
@ -4233,7 +4272,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 104,
funcId: 105,
port: port_,
);
},
@ -4265,7 +4304,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 105,
funcId: 106,
port: port_,
);
},
@ -4303,7 +4342,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 106,
funcId: 107,
port: port_,
);
},
@ -4342,7 +4381,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 107,
funcId: 108,
port: port_,
);
},
@ -4377,7 +4416,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 108,
funcId: 109,
port: port_,
);
},
@ -4412,7 +4451,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 109,
funcId: 110,
port: port_,
);
},
@ -4447,7 +4486,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 110,
funcId: 111,
port: port_,
);
},
@ -4480,7 +4519,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 111,
funcId: 112,
)!;
},
codec: SseCodec(
@ -4520,7 +4559,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 112,
funcId: 113,
port: port_,
);
},
@ -4565,7 +4604,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 113,
funcId: 114,
port: port_,
);
},
@ -4595,7 +4634,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 114,
funcId: 115,
port: port_,
);
},
@ -4628,7 +4667,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 115,
funcId: 116,
port: port_,
);
},
@ -4663,7 +4702,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 116,
funcId: 117,
port: port_,
);
},
@ -4995,6 +5034,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError();
}
@protected
RustStreamSink<List<String>> dco_decode_StreamSink_list_String_Sse(
dynamic raw,
) {
// Codec=Dco (DartCObject based), see doc to use other codecs
throw UnimplementedError();
}
@protected
String dco_decode_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@ -5782,8 +5829,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
UserConfig dco_decode_user_config(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 58)
throw Exception('unexpected arr length: expect 58 but see ${arr.length}');
if (arr.length != 59)
throw Exception('unexpected arr length: expect 59 but see ${arr.length}');
return UserConfig(
userId: dco_decode_i_64(arr[0]),
username: dco_decode_String(arr[1]),
@ -5845,9 +5892,10 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
passwordLessRecovery:
dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[53]),
fcmToken: dco_decode_opt_String(arr[54]),
currentSetupPage: dco_decode_opt_String(arr[55]),
skipSetupPages: dco_decode_bool(arr[56]),
hasZoomed: dco_decode_bool(arr[57]),
lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[55]),
currentSetupPage: dco_decode_opt_String(arr[56]),
skipSetupPages: dco_decode_bool(arr[57]),
hasZoomed: dco_decode_bool(arr[58]),
);
}
@ -5912,6 +5960,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
throw UnimplementedError('Unreachable ()');
}
@protected
RustStreamSink<List<String>> sse_decode_StreamSink_list_String_Sse(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
throw UnimplementedError('Unreachable ()');
}
@protected
String sse_decode_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -6938,6 +6994,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_passwordLessRecovery =
sse_decode_opt_box_autoadd_passwordless_recovery_config(deserializer);
var var_fcmToken = sse_decode_opt_String(deserializer);
var var_lastFcmWakeupAt = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_currentSetupPage = sse_decode_opt_String(deserializer);
var var_skipSetupPages = sse_decode_bool(deserializer);
var var_hasZoomed = sse_decode_bool(deserializer);
@ -7000,6 +7057,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
isBackupEnabled: var_isBackupEnabled,
passwordLessRecovery: var_passwordLessRecovery,
fcmToken: var_fcmToken,
lastFcmWakeupAt: var_lastFcmWakeupAt,
currentSetupPage: var_currentSetupPage,
skipSetupPages: var_skipSetupPages,
hasZoomed: var_hasZoomed,
@ -7175,6 +7233,23 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
void sse_encode_StreamSink_list_String_Sse(
RustStreamSink<List<String>> self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(
self.setupAndSerialize(
codec: SseCodec(
decodeSuccessData: sse_decode_list_String,
decodeErrorData: sse_decode_AnyhowException,
),
),
serializer,
);
}
@protected
void sse_encode_String(String self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -8112,6 +8187,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
serializer,
);
sse_encode_opt_String(self.fcmToken, serializer);
sse_encode_opt_box_autoadd_i_64(self.lastFcmWakeupAt, serializer);
sse_encode_opt_String(self.currentSetupPage, serializer);
sse_encode_bool(self.skipSetupPages, serializer);
sse_encode_bool(self.hasZoomed, serializer);

View file

@ -82,6 +82,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
RustStreamSink<ApiEvent> dco_decode_StreamSink_api_event_Sse(dynamic raw);
@protected
RustStreamSink<List<String>> dco_decode_StreamSink_list_String_Sse(
dynamic raw,
);
@protected
String dco_decode_String(dynamic raw);
@ -405,6 +410,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<List<String>> sse_decode_StreamSink_list_String_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@ -828,6 +838,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_list_String_Sse(
RustStreamSink<List<String>> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);

View file

@ -84,6 +84,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
RustStreamSink<ApiEvent> dco_decode_StreamSink_api_event_Sse(dynamic raw);
@protected
RustStreamSink<List<String>> dco_decode_StreamSink_list_String_Sse(
dynamic raw,
);
@protected
String dco_decode_String(dynamic raw);
@ -407,6 +412,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
RustStreamSink<List<String>> sse_decode_StreamSink_list_String_Sse(
SseDeserializer deserializer,
);
@protected
String sse_decode_String(SseDeserializer deserializer);
@ -830,6 +840,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_StreamSink_list_String_Sse(
RustStreamSink<List<String>> self,
SseSerializer serializer,
);
@protected
void sse_encode_String(String self, SseSerializer serializer);

View file

@ -158,6 +158,11 @@ class UserConfig {
bool isBackupEnabled;
PasswordlessRecoveryConfig? passwordLessRecovery;
String? fcmToken;
/// Unix seconds of the last opaque FCM/APNs wake-up that reached the native
/// notification worker. Recorded in Rust because Flutter is no longer
/// started for background delivery on either platform.
PlatformInt64? lastFcmWakeupAt;
String? currentSetupPage;
bool skipSetupPages;
bool hasZoomed;
@ -218,6 +223,7 @@ class UserConfig {
required this.isBackupEnabled,
this.passwordLessRecovery,
this.fcmToken,
this.lastFcmWakeupAt,
this.currentSetupPage,
required this.skipSetupPages,
required this.hasZoomed,
@ -280,6 +286,7 @@ class UserConfig {
isBackupEnabled.hashCode ^
passwordLessRecovery.hashCode ^
fcmToken.hashCode ^
lastFcmWakeupAt.hashCode ^
currentSetupPage.hashCode ^
skipSetupPages.hashCode ^
hasZoomed.hashCode;
@ -351,6 +358,7 @@ class UserConfig {
isBackupEnabled == other.isBackupEnabled &&
passwordLessRecovery == other.passwordLessRecovery &&
fcmToken == other.fcmToken &&
lastFcmWakeupAt == other.lastFcmWakeupAt &&
currentSetupPage == other.currentSetupPage &&
skipSetupPages == other.skipSetupPages &&
hasZoomed == other.hasZoomed;

View file

@ -1,12 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:camera/camera.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/utils/log.dart';
final int isolateCallbackId = Random().nextInt(0x7FFFFFFF);
class AppEnvironment {
static const _runtimeChannel = MethodChannel('eu.twonly/runtime_storage');
static late String cacheDir;
static late String supportDir;
@ -18,11 +21,66 @@ class AppEnvironment {
static Future<void> init() async {
if (_isInitialized) return;
cacheDir = (await getApplicationCacheDirectory()).path;
supportDir = (await getApplicationSupportDirectory()).path;
final privateSupportDir = (await getApplicationSupportDirectory()).path;
if (Platform.isIOS) {
final sharedSupportDir = await _runtimeChannel.invokeMethod<String>(
'runtimeSupportDirectory',
);
if (sharedSupportDir == null || sharedSupportDir.isEmpty) {
throw StateError('The iOS runtime App Group is unavailable.');
}
await _migrateToSharedSupportDirectory(
Directory(privateSupportDir),
Directory(sharedSupportDir),
);
supportDir = sharedSupportDir;
} else {
supportDir = privateSupportDir;
}
Log.init();
_isInitialized = true;
}
static Future<void> _migrateToSharedSupportDirectory(
Directory source,
Directory destination,
) async {
await destination.create(recursive: true);
final marker = File('${destination.path}/.runtime_storage_migrated_v1');
if (await marker.exists() || !await source.exists()) return;
await for (final entity in source.list(followLinks: false)) {
await _copyEntity(entity, destination.path);
}
await marker.writeAsString(
DateTime.now().toUtc().toIso8601String(),
flush: true,
);
}
static Future<void> _copyEntity(
FileSystemEntity entity,
String destinationDirectory,
) async {
final name = entity.uri.pathSegments.where((value) => value.isNotEmpty).last;
final destinationPath = '$destinationDirectory/$name';
if (entity is Directory) {
final destination = Directory(destinationPath);
await destination.create(recursive: true);
await for (final child in entity.list(followLinks: false)) {
await _copyEntity(child, destination.path);
}
return;
}
if (entity is! File) return;
final destination = File(destinationPath);
if (await destination.exists()) return;
final temporary = File('$destinationPath.migrating');
await entity.copy(temporary.path);
await temporary.rename(destination.path);
}
static void initTesting({String? customCacheDir, String? customSupportDir}) {
cacheDir = customCacheDir ?? '/tmp/twonly_cache';
supportDir = customSupportDir ?? '/tmp/twonly_support';

View file

@ -28,6 +28,7 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories.service.dart';
import 'package:twonly/src/services/migrations.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
@ -102,6 +103,7 @@ void main() async {
var storageError = await twonlyMinimumInitialization();
await FcmNotificationService.initStartup();
await setupPushNotification();
NativeNotificationService.init();
var userExists = false;

View file

@ -1,34 +1,73 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:logging/logging.dart';
import 'package:twonly/src/utils/log.dart';
/// Matches ANSI escape sequences (CSI sequences, caret notation like ^[[3m or \^[[3m).
final _ansiRegex = RegExp(r'(?:\x1B|\\?\^\[)\[[0-?]*[ -/]*[@-~]');
/// Matches the plain `ShortEventFormatter` output from `rust/src/log.rs`:
/// `HH:MM:SS LEVEL dir/file.rs:12 <fields> <message>`.
final _rustLogLine = RegExp(
r'^\d{2}:\d{2}:\d{2} (TRACE|DEBUG|INFO|WARN|ERROR) +(\S+:\d+) ?(.*)$',
dotAll: true,
);
/// Rust levels mapped onto the `logging` levels the Dart side already prints.
/// `SHOUT` is what [Log.error] uses, so a Rust `ERROR` reads the same as a Dart
/// one.
const Map<String, Level> _levels = {
'TRACE': Level.FINEST,
'DEBUG': Level.FINE,
'INFO': Level.INFO,
'WARN': Level.WARNING,
'ERROR': Level.SHOUT,
};
class LoggingCallbacks {
static Future<RustStreamSink<String>> getStreamSink() async {
final dartLogSink = RustStreamSink<String>();
// `stream` throws until flutter_rust_bridge has serialized the sink for
// Rust, which only happens once this function has returned. Poll for it
// instead of racing; buffered events are replayed on the first listen.
var attempts = 0;
Timer.periodic(const Duration(milliseconds: 100), (timer) {
attempts++;
try {
dartLogSink.stream.listen(
(log) {
if (log.contains('INFO ')) {
Log.info(log.split('INFO ')[1]);
} else if (log.contains('DEBUG ')) {
Log.info(log.split('DEBUG ')[1]);
} else if (kDebugMode &&
!Platform.environment.containsKey('FLUTTER_TEST')) {
// ignore: avoid_print
print(log);
}
},
);
dartLogSink.stream.listen(_handleRustLog);
timer.cancel();
} catch (e) {
// stream not yet initialized
} catch (_) {
// Stream not yet initialized.
if (attempts >= 100) {
timer.cancel();
Log.warn('Rust log sink never became available.');
}
}
});
return dartLogSink;
}
@visibleForTesting
static void handleRustLog(String log) => _handleRustLog(log);
static void _handleRustLog(String log) {
final sanitizedLog = log.replaceAll(_ansiRegex, '');
final match = _rustLogLine.firstMatch(sanitizedLog);
if (match == null) {
// Not a formatted event (panic output, a continuation line, ...).
Log.warn(sanitizedLog);
return;
}
// The level and the `file.rs:line` origin belong in the record itself, not
// repeated inside the message: the Dart call site here says nothing useful.
Log.forward(
level: _levels[match.group(1)] ?? Level.INFO,
source: match.group(2)!,
messageInput: match.group(3),
);
}
}

View file

@ -10,9 +10,6 @@ class SecureStorageKeys {
static const String userData = 'userData';
// Not required for backup...
static const String receivingPushKeys = 'push_keys_receiving';
static const String sendingPushKeys = 'push_keys_sending';
static const String lastFcmMessageTimestamp = 'last_fcm_message_timestamp';
static const String lastServerMessageTimestamp =
'last_server_message_timestamp';
}

View file

@ -0,0 +1,38 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:twonly/core/bridge/wrapper/app_database.dart';
import 'package:twonly/src/utils/log.dart';
/// Bridges Rust-side commits into Drift's stream query invalidation.
///
/// Rust owns the SQLite connection. Statements Drift issues go through
/// `openRustAppDatabase` and invalidate its query streams as usual, but every
/// write Rust performs on its own bypasses that executor entirely Drift never
/// learns about it, so `watch()` keeps serving stale rows and the UI does not
/// update until something else happens to touch the same table.
///
/// Rust already broadcasts the tables it commits to (`notify_committed`); this
/// forwards those batches into [GeneratedDatabase.notifyUpdates].
StreamSubscription<List<String>> listenToRustDatabaseChanges(
GeneratedDatabase db,
) {
return RustAppDatabase.changes().listen(
(tables) {
// An empty batch means Rust could not tell us precisely what changed
// (fresh subscription, or dropped notifications). Invalidate everything
// rather than leaving the UI stale.
final updates = tables.isEmpty
? db.allTables.map(TableUpdate.onTable).toSet()
: tables.map(TableUpdate.new).toSet();
db.notifyUpdates(updates);
},
onError: (Object error, StackTrace stackTrace) {
Log.error(
'Rust database change stream failed',
error: error,
stackTrace: stackTrace,
);
},
);
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/daos/groups.dao.dart';
@ -9,6 +11,7 @@ import 'package:twonly/src/database/daos/reactions.dao.dart';
import 'package:twonly/src/database/daos/receipts.dao.dart';
import 'package:twonly/src/database/daos/shortcuts.dao.dart';
import 'package:twonly/src/database/daos/user_discovery.dao.dart';
import 'package:twonly/src/database/rust_change_notifier.dart';
import 'package:twonly/src/database/rust_query_executor.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
@ -64,14 +67,27 @@ part 'twonly.db.g.dart';
],
)
class TwonlyDB extends _$TwonlyDB {
TwonlyDB([QueryExecutor? e])
: super(
e ?? openRustAppDatabase(),
);
TwonlyDB([QueryExecutor? e]) : super(e ?? openRustAppDatabase()) {
// Only the Rust-backed connection needs external change notifications.
// An explicit executor is a plain Drift-owned database (tests, and the
// legacy import in main.dart), where Drift already sees every write.
if (e == null) {
_rustChanges = listenToRustDatabaseChanges(this);
}
}
// ignore: matching_super_parameters
TwonlyDB.forTesting(DatabaseConnection super.connection);
StreamSubscription<List<String>>? _rustChanges;
@override
Future<void> close() async {
await _rustChanges?.cancel();
_rustChanges = null;
return super.close();
}
@override
int get schemaVersion => 25;

View file

@ -21,12 +21,12 @@ class TextMessage extends $pb.GeneratedMessage {
factory TextMessage({
$fixnum.Int64? userId,
$core.List<$core.int>? body,
$core.List<$core.int>? pushData,
$core.bool? wakeReceiver,
}) {
final result = create();
if (userId != null) result.userId = userId;
if (body != null) result.body = body;
if (pushData != null) result.pushData = pushData;
if (wakeReceiver != null) result.wakeReceiver = wakeReceiver;
return result;
}
@ -46,8 +46,7 @@ class TextMessage extends $pb.GeneratedMessage {
..aInt64(1, _omitFieldNames ? '' : 'userId')
..a<$core.List<$core.int>>(
2, _omitFieldNames ? '' : 'body', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'pushData', $pb.PbFieldType.OY)
..aOB(4, _omitFieldNames ? '' : 'wakeReceiver')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -87,14 +86,14 @@ class TextMessage extends $pb.GeneratedMessage {
@$pb.TagNumber(2)
void clearBody() => $_clearField(2);
@$pb.TagNumber(3)
$core.List<$core.int> get pushData => $_getN(2);
@$pb.TagNumber(3)
set pushData($core.List<$core.int> value) => $_setBytes(2, value);
@$pb.TagNumber(3)
$core.bool hasPushData() => $_has(2);
@$pb.TagNumber(3)
void clearPushData() => $_clearField(3);
@$pb.TagNumber(4)
$core.bool get wakeReceiver => $_getBF(2);
@$pb.TagNumber(4)
set wakeReceiver($core.bool value) => $_setBool(2, value);
@$pb.TagNumber(4)
$core.bool hasWakeReceiver() => $_has(2);
@$pb.TagNumber(4)
void clearWakeReceiver() => $_clearField(4);
}
class UploadRequest extends $pb.GeneratedMessage {

View file

@ -21,25 +21,17 @@ const TextMessage$json = {
'2': [
{'1': 'user_id', '3': 1, '4': 1, '5': 3, '10': 'userId'},
{'1': 'body', '3': 2, '4': 1, '5': 12, '10': 'body'},
{
'1': 'push_data',
'3': 3,
'4': 1,
'5': 12,
'9': 0,
'10': 'pushData',
'17': true
},
{'1': 'wake_receiver', '3': 4, '4': 1, '5': 8, '10': 'wakeReceiver'},
],
'8': [
{'1': '_push_data'},
'9': [
{'1': 3, '2': 4},
],
};
/// Descriptor for `TextMessage`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List textMessageDescriptor = $convert.base64Decode(
'CgtUZXh0TWVzc2FnZRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSEgoEYm9keRgCIAEoDFIEYm'
'9keRIgCglwdXNoX2RhdGEYAyABKAxIAFIIcHVzaERhdGGIAQFCDAoKX3B1c2hfZGF0YQ==');
'9keRIjCg13YWtlX3JlY2VpdmVyGAQgASgIUgx3YWtlUmVjZWl2ZXJKBAgDEAQ=');
@$core.Deprecated('Use uploadRequestDescriptor instead')
const UploadRequest$json = {

View file

@ -1140,98 +1140,6 @@ class EncryptedContent_ContactUpdate extends $pb.GeneratedMessage {
void clearDisplayName() => $_clearField(4);
}
class EncryptedContent_PushKeys extends $pb.GeneratedMessage {
factory EncryptedContent_PushKeys({
EncryptedContent_PushKeys_Type? type,
$fixnum.Int64? keyId,
$core.List<$core.int>? key,
$fixnum.Int64? createdAt,
}) {
final result = create();
if (type != null) result.type = type;
if (keyId != null) result.keyId = keyId;
if (key != null) result.key = key;
if (createdAt != null) result.createdAt = createdAt;
return result;
}
EncryptedContent_PushKeys._();
factory EncryptedContent_PushKeys.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory EncryptedContent_PushKeys.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'EncryptedContent.PushKeys',
createEmptyInstance: create)
..aE<EncryptedContent_PushKeys_Type>(1, _omitFieldNames ? '' : 'type',
enumValues: EncryptedContent_PushKeys_Type.values)
..aInt64(2, _omitFieldNames ? '' : 'keyId')
..a<$core.List<$core.int>>(
3, _omitFieldNames ? '' : 'key', $pb.PbFieldType.OY)
..aInt64(4, _omitFieldNames ? '' : 'createdAt')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedContent_PushKeys clone() => deepCopy();
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
EncryptedContent_PushKeys copyWith(
void Function(EncryptedContent_PushKeys) updates) =>
super.copyWith((message) => updates(message as EncryptedContent_PushKeys))
as EncryptedContent_PushKeys;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static EncryptedContent_PushKeys create() => EncryptedContent_PushKeys._();
@$core.override
EncryptedContent_PushKeys createEmptyInstance() => create();
@$core.pragma('dart2js:noInline')
static EncryptedContent_PushKeys getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<EncryptedContent_PushKeys>(create);
static EncryptedContent_PushKeys? _defaultInstance;
@$pb.TagNumber(1)
EncryptedContent_PushKeys_Type get type => $_getN(0);
@$pb.TagNumber(1)
set type(EncryptedContent_PushKeys_Type value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasType() => $_has(0);
@$pb.TagNumber(1)
void clearType() => $_clearField(1);
@$pb.TagNumber(2)
$fixnum.Int64 get keyId => $_getI64(1);
@$pb.TagNumber(2)
set keyId($fixnum.Int64 value) => $_setInt64(1, value);
@$pb.TagNumber(2)
$core.bool hasKeyId() => $_has(1);
@$pb.TagNumber(2)
void clearKeyId() => $_clearField(2);
@$pb.TagNumber(3)
$core.List<$core.int> get key => $_getN(2);
@$pb.TagNumber(3)
set key($core.List<$core.int> value) => $_setBytes(2, value);
@$pb.TagNumber(3)
$core.bool hasKey() => $_has(2);
@$pb.TagNumber(3)
void clearKey() => $_clearField(3);
@$pb.TagNumber(4)
$fixnum.Int64 get createdAt => $_getI64(3);
@$pb.TagNumber(4)
set createdAt($fixnum.Int64 value) => $_setInt64(3, value);
@$pb.TagNumber(4)
$core.bool hasCreatedAt() => $_has(3);
@$pb.TagNumber(4)
void clearCreatedAt() => $_clearField(4);
}
class EncryptedContent_FlameSync extends $pb.GeneratedMessage {
factory EncryptedContent_FlameSync({
$fixnum.Int64? flameCounter,
@ -1724,7 +1632,6 @@ class EncryptedContent extends $pb.GeneratedMessage {
EncryptedContent_ContactUpdate? contactUpdate,
EncryptedContent_ContactRequest? contactRequest,
EncryptedContent_FlameSync? flameSync,
EncryptedContent_PushKeys? pushKeys,
EncryptedContent_Reaction? reaction,
EncryptedContent_TextMessage? textMessage,
EncryptedContent_GroupCreate? groupCreate,
@ -1754,7 +1661,6 @@ class EncryptedContent extends $pb.GeneratedMessage {
if (contactUpdate != null) result.contactUpdate = contactUpdate;
if (contactRequest != null) result.contactRequest = contactRequest;
if (flameSync != null) result.flameSync = flameSync;
if (pushKeys != null) result.pushKeys = pushKeys;
if (reaction != null) result.reaction = reaction;
if (textMessage != null) result.textMessage = textMessage;
if (groupCreate != null) result.groupCreate = groupCreate;
@ -1813,8 +1719,6 @@ class EncryptedContent extends $pb.GeneratedMessage {
subBuilder: EncryptedContent_ContactRequest.create)
..aOM<EncryptedContent_FlameSync>(10, _omitFieldNames ? '' : 'flameSync',
subBuilder: EncryptedContent_FlameSync.create)
..aOM<EncryptedContent_PushKeys>(11, _omitFieldNames ? '' : 'pushKeys',
subBuilder: EncryptedContent_PushKeys.create)
..aOM<EncryptedContent_Reaction>(12, _omitFieldNames ? '' : 'reaction',
subBuilder: EncryptedContent_Reaction.create)
..aOM<EncryptedContent_TextMessage>(
@ -1976,208 +1880,197 @@ class EncryptedContent extends $pb.GeneratedMessage {
@$pb.TagNumber(10)
EncryptedContent_FlameSync ensureFlameSync() => $_ensure(8);
@$pb.TagNumber(11)
EncryptedContent_PushKeys get pushKeys => $_getN(9);
@$pb.TagNumber(11)
set pushKeys(EncryptedContent_PushKeys value) => $_setField(11, value);
@$pb.TagNumber(11)
$core.bool hasPushKeys() => $_has(9);
@$pb.TagNumber(11)
void clearPushKeys() => $_clearField(11);
@$pb.TagNumber(11)
EncryptedContent_PushKeys ensurePushKeys() => $_ensure(9);
@$pb.TagNumber(12)
EncryptedContent_Reaction get reaction => $_getN(10);
EncryptedContent_Reaction get reaction => $_getN(9);
@$pb.TagNumber(12)
set reaction(EncryptedContent_Reaction value) => $_setField(12, value);
@$pb.TagNumber(12)
$core.bool hasReaction() => $_has(10);
$core.bool hasReaction() => $_has(9);
@$pb.TagNumber(12)
void clearReaction() => $_clearField(12);
@$pb.TagNumber(12)
EncryptedContent_Reaction ensureReaction() => $_ensure(10);
EncryptedContent_Reaction ensureReaction() => $_ensure(9);
@$pb.TagNumber(13)
EncryptedContent_TextMessage get textMessage => $_getN(11);
EncryptedContent_TextMessage get textMessage => $_getN(10);
@$pb.TagNumber(13)
set textMessage(EncryptedContent_TextMessage value) => $_setField(13, value);
@$pb.TagNumber(13)
$core.bool hasTextMessage() => $_has(11);
$core.bool hasTextMessage() => $_has(10);
@$pb.TagNumber(13)
void clearTextMessage() => $_clearField(13);
@$pb.TagNumber(13)
EncryptedContent_TextMessage ensureTextMessage() => $_ensure(11);
EncryptedContent_TextMessage ensureTextMessage() => $_ensure(10);
@$pb.TagNumber(14)
EncryptedContent_GroupCreate get groupCreate => $_getN(12);
EncryptedContent_GroupCreate get groupCreate => $_getN(11);
@$pb.TagNumber(14)
set groupCreate(EncryptedContent_GroupCreate value) => $_setField(14, value);
@$pb.TagNumber(14)
$core.bool hasGroupCreate() => $_has(12);
$core.bool hasGroupCreate() => $_has(11);
@$pb.TagNumber(14)
void clearGroupCreate() => $_clearField(14);
@$pb.TagNumber(14)
EncryptedContent_GroupCreate ensureGroupCreate() => $_ensure(12);
EncryptedContent_GroupCreate ensureGroupCreate() => $_ensure(11);
@$pb.TagNumber(15)
EncryptedContent_GroupJoin get groupJoin => $_getN(13);
EncryptedContent_GroupJoin get groupJoin => $_getN(12);
@$pb.TagNumber(15)
set groupJoin(EncryptedContent_GroupJoin value) => $_setField(15, value);
@$pb.TagNumber(15)
$core.bool hasGroupJoin() => $_has(13);
$core.bool hasGroupJoin() => $_has(12);
@$pb.TagNumber(15)
void clearGroupJoin() => $_clearField(15);
@$pb.TagNumber(15)
EncryptedContent_GroupJoin ensureGroupJoin() => $_ensure(13);
EncryptedContent_GroupJoin ensureGroupJoin() => $_ensure(12);
@$pb.TagNumber(16)
EncryptedContent_GroupUpdate get groupUpdate => $_getN(14);
EncryptedContent_GroupUpdate get groupUpdate => $_getN(13);
@$pb.TagNumber(16)
set groupUpdate(EncryptedContent_GroupUpdate value) => $_setField(16, value);
@$pb.TagNumber(16)
$core.bool hasGroupUpdate() => $_has(14);
$core.bool hasGroupUpdate() => $_has(13);
@$pb.TagNumber(16)
void clearGroupUpdate() => $_clearField(16);
@$pb.TagNumber(16)
EncryptedContent_GroupUpdate ensureGroupUpdate() => $_ensure(14);
EncryptedContent_GroupUpdate ensureGroupUpdate() => $_ensure(13);
@$pb.TagNumber(17)
EncryptedContent_ResendGroupPublicKey get resendGroupPublicKey => $_getN(15);
EncryptedContent_ResendGroupPublicKey get resendGroupPublicKey => $_getN(14);
@$pb.TagNumber(17)
set resendGroupPublicKey(EncryptedContent_ResendGroupPublicKey value) =>
$_setField(17, value);
@$pb.TagNumber(17)
$core.bool hasResendGroupPublicKey() => $_has(15);
$core.bool hasResendGroupPublicKey() => $_has(14);
@$pb.TagNumber(17)
void clearResendGroupPublicKey() => $_clearField(17);
@$pb.TagNumber(17)
EncryptedContent_ResendGroupPublicKey ensureResendGroupPublicKey() =>
$_ensure(15);
$_ensure(14);
@$pb.TagNumber(18)
EncryptedContent_ErrorMessages get errorMessages => $_getN(16);
EncryptedContent_ErrorMessages get errorMessages => $_getN(15);
@$pb.TagNumber(18)
set errorMessages(EncryptedContent_ErrorMessages value) =>
$_setField(18, value);
@$pb.TagNumber(18)
$core.bool hasErrorMessages() => $_has(16);
$core.bool hasErrorMessages() => $_has(15);
@$pb.TagNumber(18)
void clearErrorMessages() => $_clearField(18);
@$pb.TagNumber(18)
EncryptedContent_ErrorMessages ensureErrorMessages() => $_ensure(16);
EncryptedContent_ErrorMessages ensureErrorMessages() => $_ensure(15);
@$pb.TagNumber(19)
EncryptedContent_AdditionalDataMessage get additionalDataMessage =>
$_getN(17);
$_getN(16);
@$pb.TagNumber(19)
set additionalDataMessage(EncryptedContent_AdditionalDataMessage value) =>
$_setField(19, value);
@$pb.TagNumber(19)
$core.bool hasAdditionalDataMessage() => $_has(17);
$core.bool hasAdditionalDataMessage() => $_has(16);
@$pb.TagNumber(19)
void clearAdditionalDataMessage() => $_clearField(19);
@$pb.TagNumber(19)
EncryptedContent_AdditionalDataMessage ensureAdditionalDataMessage() =>
$_ensure(17);
$_ensure(16);
@$pb.TagNumber(20)
EncryptedContent_TypingIndicator get typingIndicator => $_getN(18);
EncryptedContent_TypingIndicator get typingIndicator => $_getN(17);
@$pb.TagNumber(20)
set typingIndicator(EncryptedContent_TypingIndicator value) =>
$_setField(20, value);
@$pb.TagNumber(20)
$core.bool hasTypingIndicator() => $_has(18);
$core.bool hasTypingIndicator() => $_has(17);
@$pb.TagNumber(20)
void clearTypingIndicator() => $_clearField(20);
@$pb.TagNumber(20)
EncryptedContent_TypingIndicator ensureTypingIndicator() => $_ensure(18);
EncryptedContent_TypingIndicator ensureTypingIndicator() => $_ensure(17);
@$pb.TagNumber(21)
$core.List<$core.int> get senderUserDiscoveryVersion => $_getN(19);
$core.List<$core.int> get senderUserDiscoveryVersion => $_getN(18);
@$pb.TagNumber(21)
set senderUserDiscoveryVersion($core.List<$core.int> value) =>
$_setBytes(19, value);
$_setBytes(18, value);
@$pb.TagNumber(21)
$core.bool hasSenderUserDiscoveryVersion() => $_has(19);
$core.bool hasSenderUserDiscoveryVersion() => $_has(18);
@$pb.TagNumber(21)
void clearSenderUserDiscoveryVersion() => $_clearField(21);
@$pb.TagNumber(22)
EncryptedContent_UserDiscoveryRequest get userDiscoveryRequest => $_getN(20);
EncryptedContent_UserDiscoveryRequest get userDiscoveryRequest => $_getN(19);
@$pb.TagNumber(22)
set userDiscoveryRequest(EncryptedContent_UserDiscoveryRequest value) =>
$_setField(22, value);
@$pb.TagNumber(22)
$core.bool hasUserDiscoveryRequest() => $_has(20);
$core.bool hasUserDiscoveryRequest() => $_has(19);
@$pb.TagNumber(22)
void clearUserDiscoveryRequest() => $_clearField(22);
@$pb.TagNumber(22)
EncryptedContent_UserDiscoveryRequest ensureUserDiscoveryRequest() =>
$_ensure(20);
$_ensure(19);
@$pb.TagNumber(23)
EncryptedContent_UserDiscoveryUpdate get userDiscoveryUpdate => $_getN(21);
EncryptedContent_UserDiscoveryUpdate get userDiscoveryUpdate => $_getN(20);
@$pb.TagNumber(23)
set userDiscoveryUpdate(EncryptedContent_UserDiscoveryUpdate value) =>
$_setField(23, value);
@$pb.TagNumber(23)
$core.bool hasUserDiscoveryUpdate() => $_has(21);
$core.bool hasUserDiscoveryUpdate() => $_has(20);
@$pb.TagNumber(23)
void clearUserDiscoveryUpdate() => $_clearField(23);
@$pb.TagNumber(23)
EncryptedContent_UserDiscoveryUpdate ensureUserDiscoveryUpdate() =>
$_ensure(21);
$_ensure(20);
@$pb.TagNumber(24)
EncryptedContent_KeyVerificationProof get keyVerificationProof => $_getN(22);
EncryptedContent_KeyVerificationProof get keyVerificationProof => $_getN(21);
@$pb.TagNumber(24)
set keyVerificationProof(EncryptedContent_KeyVerificationProof value) =>
$_setField(24, value);
@$pb.TagNumber(24)
$core.bool hasKeyVerificationProof() => $_has(22);
$core.bool hasKeyVerificationProof() => $_has(21);
@$pb.TagNumber(24)
void clearKeyVerificationProof() => $_clearField(24);
@$pb.TagNumber(24)
EncryptedContent_KeyVerificationProof ensureKeyVerificationProof() =>
$_ensure(22);
$_ensure(21);
@$pb.TagNumber(25)
$core.bool get askForFriendPromotions => $_getBF(23);
$core.bool get askForFriendPromotions => $_getBF(22);
@$pb.TagNumber(25)
set askForFriendPromotions($core.bool value) => $_setBool(23, value);
set askForFriendPromotions($core.bool value) => $_setBool(22, value);
@$pb.TagNumber(25)
$core.bool hasAskForFriendPromotions() => $_has(23);
$core.bool hasAskForFriendPromotions() => $_has(22);
@$pb.TagNumber(25)
void clearAskForFriendPromotions() => $_clearField(25);
@$pb.TagNumber(26)
EncryptedContent_PasswordLessRecovery get passwordlessRecovery => $_getN(24);
EncryptedContent_PasswordLessRecovery get passwordlessRecovery => $_getN(23);
@$pb.TagNumber(26)
set passwordlessRecovery(EncryptedContent_PasswordLessRecovery value) =>
$_setField(26, value);
@$pb.TagNumber(26)
$core.bool hasPasswordlessRecovery() => $_has(24);
$core.bool hasPasswordlessRecovery() => $_has(23);
@$pb.TagNumber(26)
void clearPasswordlessRecovery() => $_clearField(26);
@$pb.TagNumber(26)
EncryptedContent_PasswordLessRecovery ensurePasswordlessRecovery() =>
$_ensure(24);
$_ensure(23);
@$pb.TagNumber(27)
EncryptedContent_PasswordLessRecoveryHeartbeat
get passwordlessRecoveryHeartbeat => $_getN(25);
get passwordlessRecoveryHeartbeat => $_getN(24);
@$pb.TagNumber(27)
set passwordlessRecoveryHeartbeat(
EncryptedContent_PasswordLessRecoveryHeartbeat value) =>
$_setField(27, value);
@$pb.TagNumber(27)
$core.bool hasPasswordlessRecoveryHeartbeat() => $_has(25);
$core.bool hasPasswordlessRecoveryHeartbeat() => $_has(24);
@$pb.TagNumber(27)
void clearPasswordlessRecoveryHeartbeat() => $_clearField(27);
@$pb.TagNumber(27)
EncryptedContent_PasswordLessRecoveryHeartbeat
ensurePasswordlessRecoveryHeartbeat() => $_ensure(25);
ensurePasswordlessRecoveryHeartbeat() => $_ensure(24);
}
const $core.bool _omitFieldNames =

View file

@ -169,25 +169,5 @@ class EncryptedContent_ContactUpdate_Type extends $pb.ProtobufEnum {
const EncryptedContent_ContactUpdate_Type._(super.value, super.name);
}
class EncryptedContent_PushKeys_Type extends $pb.ProtobufEnum {
static const EncryptedContent_PushKeys_Type REQUEST =
EncryptedContent_PushKeys_Type._(0, _omitEnumNames ? '' : 'REQUEST');
static const EncryptedContent_PushKeys_Type UPDATE =
EncryptedContent_PushKeys_Type._(1, _omitEnumNames ? '' : 'UPDATE');
static const $core.List<EncryptedContent_PushKeys_Type> values =
<EncryptedContent_PushKeys_Type>[
REQUEST,
UPDATE,
];
static final $core.List<EncryptedContent_PushKeys_Type?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 1);
static EncryptedContent_PushKeys_Type? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const EncryptedContent_PushKeys_Type._(super.value, super.name);
}
const $core.bool _omitEnumNames =
$core.bool.fromEnvironment('protobuf.omit_enum_names');

View file

@ -124,23 +124,13 @@ const EncryptedContent$json = {
'10': 'flameSync',
'17': true
},
{
'1': 'push_keys',
'3': 11,
'4': 1,
'5': 11,
'6': '.EncryptedContent.PushKeys',
'9': 11,
'10': 'pushKeys',
'17': true
},
{
'1': 'reaction',
'3': 12,
'4': 1,
'5': 11,
'6': '.EncryptedContent.Reaction',
'9': 12,
'9': 11,
'10': 'reaction',
'17': true
},
@ -150,7 +140,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.TextMessage',
'9': 13,
'9': 12,
'10': 'textMessage',
'17': true
},
@ -160,7 +150,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.GroupCreate',
'9': 14,
'9': 13,
'10': 'groupCreate',
'17': true
},
@ -170,7 +160,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.GroupJoin',
'9': 15,
'9': 14,
'10': 'groupJoin',
'17': true
},
@ -180,7 +170,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.GroupUpdate',
'9': 16,
'9': 15,
'10': 'groupUpdate',
'17': true
},
@ -190,7 +180,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.ResendGroupPublicKey',
'9': 17,
'9': 16,
'10': 'resendGroupPublicKey',
'17': true
},
@ -200,7 +190,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.ErrorMessages',
'9': 18,
'9': 17,
'10': 'errorMessages',
'17': true
},
@ -210,7 +200,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.AdditionalDataMessage',
'9': 19,
'9': 18,
'10': 'additionalDataMessage',
'17': true
},
@ -220,7 +210,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.TypingIndicator',
'9': 20,
'9': 19,
'10': 'typingIndicator',
'17': true
},
@ -230,7 +220,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.UserDiscoveryRequest',
'9': 21,
'9': 20,
'10': 'userDiscoveryRequest',
'17': true
},
@ -240,7 +230,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.UserDiscoveryUpdate',
'9': 22,
'9': 21,
'10': 'userDiscoveryUpdate',
'17': true
},
@ -250,7 +240,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.KeyVerificationProof',
'9': 23,
'9': 22,
'10': 'keyVerificationProof',
'17': true
},
@ -260,7 +250,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.PasswordLessRecovery',
'9': 24,
'9': 23,
'10': 'passwordlessRecovery',
'17': true
},
@ -270,7 +260,7 @@ const EncryptedContent$json = {
'4': 1,
'5': 11,
'6': '.EncryptedContent.PasswordLessRecoveryHeartbeat',
'9': 25,
'9': 24,
'10': 'passwordlessRecoveryHeartbeat',
'17': true
},
@ -289,7 +279,6 @@ const EncryptedContent$json = {
EncryptedContent_MediaUpdate$json,
EncryptedContent_ContactRequest$json,
EncryptedContent_ContactUpdate$json,
EncryptedContent_PushKeys$json,
EncryptedContent_FlameSync$json,
EncryptedContent_TypingIndicator$json,
EncryptedContent_UserDiscoveryRequest$json,
@ -310,7 +299,6 @@ const EncryptedContent$json = {
{'1': '_contact_update'},
{'1': '_contact_request'},
{'1': '_flame_sync'},
{'1': '_push_keys'},
{'1': '_reaction'},
{'1': '_text_message'},
{'1': '_group_create'},
@ -757,47 +745,6 @@ const EncryptedContent_ContactUpdate_Type$json = {
],
};
@$core.Deprecated('Use encryptedContentDescriptor instead')
const EncryptedContent_PushKeys$json = {
'1': 'PushKeys',
'2': [
{
'1': 'type',
'3': 1,
'4': 1,
'5': 14,
'6': '.EncryptedContent.PushKeys.Type',
'10': 'type'
},
{'1': 'key_id', '3': 2, '4': 1, '5': 3, '9': 0, '10': 'keyId', '17': true},
{'1': 'key', '3': 3, '4': 1, '5': 12, '9': 1, '10': 'key', '17': true},
{
'1': 'created_at',
'3': 4,
'4': 1,
'5': 3,
'9': 2,
'10': 'createdAt',
'17': true
},
],
'4': [EncryptedContent_PushKeys_Type$json],
'8': [
{'1': '_key_id'},
{'1': '_key'},
{'1': '_created_at'},
],
};
@$core.Deprecated('Use encryptedContentDescriptor instead')
const EncryptedContent_PushKeys_Type$json = {
'1': 'Type',
'2': [
{'1': 'REQUEST', '2': 0},
{'1': 'UPDATE', '2': 1},
],
};
@$core.Deprecated('Use encryptedContentDescriptor instead')
const EncryptedContent_FlameSync$json = {
'1': 'FlameSync',
@ -891,107 +838,102 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
'91cGRhdGUYCCABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGVICFINY29udGFj'
'dFVwZGF0ZYgBARJOCg9jb250YWN0X3JlcXVlc3QYCSABKAsyIC5FbmNyeXB0ZWRDb250ZW50Lk'
'NvbnRhY3RSZXF1ZXN0SAlSDmNvbnRhY3RSZXF1ZXN0iAEBEj8KCmZsYW1lX3N5bmMYCiABKAsy'
'Gy5FbmNyeXB0ZWRDb250ZW50LkZsYW1lU3luY0gKUglmbGFtZVN5bmOIAQESPAoJcHVzaF9rZX'
'lzGAsgASgLMhouRW5jcnlwdGVkQ29udGVudC5QdXNoS2V5c0gLUghwdXNoS2V5c4gBARI7Cghy'
'ZWFjdGlvbhgMIAEoCzIaLkVuY3J5cHRlZENvbnRlbnQuUmVhY3Rpb25IDFIIcmVhY3Rpb26IAQ'
'ESRQoMdGV4dF9tZXNzYWdlGA0gASgLMh0uRW5jcnlwdGVkQ29udGVudC5UZXh0TWVzc2FnZUgN'
'Ugt0ZXh0TWVzc2FnZYgBARJFCgxncm91cF9jcmVhdGUYDiABKAsyHS5FbmNyeXB0ZWRDb250ZW'
'50Lkdyb3VwQ3JlYXRlSA5SC2dyb3VwQ3JlYXRliAEBEj8KCmdyb3VwX2pvaW4YDyABKAsyGy5F'
'bmNyeXB0ZWRDb250ZW50Lkdyb3VwSm9pbkgPUglncm91cEpvaW6IAQESRQoMZ3JvdXBfdXBkYX'
'RlGBAgASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91cFVwZGF0ZUgQUgtncm91cFVwZGF0ZYgB'
'ARJiChdyZXNlbmRfZ3JvdXBfcHVibGljX2tleRgRIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUm'
'VzZW5kR3JvdXBQdWJsaWNLZXlIEVIUcmVzZW5kR3JvdXBQdWJsaWNLZXmIAQESSwoOZXJyb3Jf'
'bWVzc2FnZXMYEiABKAsyHy5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXNIElINZXJyb3'
'JNZXNzYWdlc4gBARJkChdhZGRpdGlvbmFsX2RhdGFfbWVzc2FnZRgTIAEoCzInLkVuY3J5cHRl'
'ZENvbnRlbnQuQWRkaXRpb25hbERhdGFNZXNzYWdlSBNSFWFkZGl0aW9uYWxEYXRhTWVzc2FnZY'
'gBARJRChB0eXBpbmdfaW5kaWNhdG9yGBQgASgLMiEuRW5jcnlwdGVkQ29udGVudC5UeXBpbmdJ'
'bmRpY2F0b3JIFFIPdHlwaW5nSW5kaWNhdG9yiAEBEmEKFnVzZXJfZGlzY292ZXJ5X3JlcXVlc3'
'QYFiABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlVzZXJEaXNjb3ZlcnlSZXF1ZXN0SBVSFHVzZXJE'
'aXNjb3ZlcnlSZXF1ZXN0iAEBEl4KFXVzZXJfZGlzY292ZXJ5X3VwZGF0ZRgXIAEoCzIlLkVuY3'
'J5cHRlZENvbnRlbnQuVXNlckRpc2NvdmVyeVVwZGF0ZUgWUhN1c2VyRGlzY292ZXJ5VXBkYXRl'
'iAEBEmEKFmtleV92ZXJpZmljYXRpb25fcHJvb2YYGCABKAsyJi5FbmNyeXB0ZWRDb250ZW50Lk'
'tleVZlcmlmaWNhdGlvblByb29mSBdSFGtleVZlcmlmaWNhdGlvblByb29miAEBEmAKFXBhc3N3'
'b3JkbGVzc19yZWNvdmVyeRgaIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUm'
'Vjb3ZlcnlIGFIUcGFzc3dvcmRsZXNzUmVjb3ZlcnmIAQESfAofcGFzc3dvcmRsZXNzX3JlY292'
'ZXJ5X2hlYXJ0YmVhdBgbIAEoCzIvLkVuY3J5cHRlZENvbnRlbnQuUGFzc3dvcmRMZXNzUmVjb3'
'ZlcnlIZWFydGJlYXRIGVIdcGFzc3dvcmRsZXNzUmVjb3ZlcnlIZWFydGJlYXSIAQEalgIKDUVy'
'cm9yTWVzc2FnZXMSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuRXJyb3JNZXNzYW'
'dlcy5UeXBlUgR0eXBlEiwKEnJlbGF0ZWRfcmVjZWlwdF9pZBgCIAEoCVIQcmVsYXRlZFJlY2Vp'
'cHRJZCKcAQoEVHlwZRI8CjhFUlJPUl9QUk9DRVNTSU5HX01FU1NBR0VfQ1JFQVRFRF9BQ0NPVU'
'5UX1JFUVVFU1RfSU5TVEVBRBAAEhgKFFVOS05PV05fTUVTU0FHRV9UWVBFEAISFwoTU0VTU0lP'
'Tl9PVVRfT0ZfU1lOQxADEiMKH0dST1VQX05PVF9GT1VORF9PUl9OT1RfQV9NRU1CRVIQBBqHAQ'
'oLR3JvdXBDcmVhdGUSGwoJc3RhdGVfa2V5GAMgASgMUghzdGF0ZUtleRIoChBncm91cF9wdWJs'
'aWNfa2V5GAQgASgMUg5ncm91cFB1YmxpY0tleRIiCgpncm91cF9uYW1lGAUgASgJSABSCWdyb3'
'VwTmFtZYgBAUINCgtfZ3JvdXBfbmFtZRo1CglHcm91cEpvaW4SKAoQZ3JvdXBfcHVibGljX2tl'
'eRgBIAEoDFIOZ3JvdXBQdWJsaWNLZXkaFgoUUmVzZW5kR3JvdXBQdWJsaWNLZXkayAIKC0dyb3'
'VwVXBkYXRlEioKEWdyb3VwX2FjdGlvbl90eXBlGAEgASgJUg9ncm91cEFjdGlvblR5cGUSMwoT'
'YWZmZWN0ZWRfY29udGFjdF9pZBgCIAEoA0gAUhFhZmZlY3RlZENvbnRhY3RJZIgBARIpCg5uZX'
'dfZ3JvdXBfbmFtZRgDIAEoCUgBUgxuZXdHcm91cE5hbWWIAQESVwombmV3X2RlbGV0ZV9tZXNz'
'YWdlc19hZnRlcl9taWxsaXNlY29uZHMYBCABKANIAlIibmV3RGVsZXRlTWVzc2FnZXNBZnRlck'
'1pbGxpc2Vjb25kc4gBAUIWChRfYWZmZWN0ZWRfY29udGFjdF9pZEIRCg9fbmV3X2dyb3VwX25h'
'bWVCKQonX25ld19kZWxldGVfbWVzc2FnZXNfYWZ0ZXJfbWlsbGlzZWNvbmRzGq8BCgtUZXh0TW'
'Vzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVyTWVzc2FnZUlkEhIKBHRl'
'eHQYAiABKAlSBHRleHQSHAoJdGltZXN0YW1wGAMgASgDUgl0aW1lc3RhbXASLQoQcXVvdGVfbW'
'Vzc2FnZV9pZBgEIAEoCUgAUg5xdW90ZU1lc3NhZ2VJZIgBAUITChFfcXVvdGVfbWVzc2FnZV9p'
'ZBrOAQoVQWRkaXRpb25hbERhdGFNZXNzYWdlEioKEXNlbmRlcl9tZXNzYWdlX2lkGAEgASgJUg'
'9zZW5kZXJNZXNzYWdlSWQSHAoJdGltZXN0YW1wGAIgASgDUgl0aW1lc3RhbXASEgoEdHlwZRgD'
'IAEoCVIEdHlwZRI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgEIAEoDEgAUhVhZGRpdGlvbm'
'FsTWVzc2FnZURhdGGIAQFCGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGmQKCFJlYWN0aW9u'
'EioKEXRhcmdldF9tZXNzYWdlX2lkGAEgASgJUg90YXJnZXRNZXNzYWdlSWQSFAoFZW1vamkYAi'
'ABKAlSBWVtb2ppEhYKBnJlbW92ZRgDIAEoCFIGcmVtb3ZlGr4CCg1NZXNzYWdlVXBkYXRlEjgK'
'BHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50Lk1lc3NhZ2VVcGRhdGUuVHlwZVIEdHlwZR'
'IvChFzZW5kZXJfbWVzc2FnZV9pZBgCIAEoCUgAUg9zZW5kZXJNZXNzYWdlSWSIAQESPQobbXVs'
'dGlwbGVfdGFyZ2V0X21lc3NhZ2VfaWRzGAMgAygJUhhtdWx0aXBsZVRhcmdldE1lc3NhZ2VJZH'
'MSFwoEdGV4dBgEIAEoCUgBUgR0ZXh0iAEBEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW1w'
'Ii0KBFR5cGUSCgoGREVMRVRFEAASDQoJRURJVF9URVhUEAESCgoGT1BFTkVEEAJCFAoSX3Nlbm'
'Rlcl9tZXNzYWdlX2lkQgcKBV90ZXh0GoUGCgVNZWRpYRIqChFzZW5kZXJfbWVzc2FnZV9pZBgB'
'IAEoCVIPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiABKA4yHC5FbmNyeXB0ZWRDb250ZW50Lk'
'1lZGlhLlR5cGVSBHR5cGUSRgodZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHMYAyABKANI'
'AFIaZGlzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQESNwoXcmVxdWlyZXNfYXV0aGVudGljYX'
'Rpb24YBCABKAhSFnJlcXVpcmVzQXV0aGVudGljYXRpb24SHAoJdGltZXN0YW1wGAUgASgDUgl0'
'aW1lc3RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgGIAEoCUgBUg5xdW90ZU1lc3NhZ2VJZIgBAR'
'IqCg5kb3dubG9hZF90b2tlbhgHIAEoDEgCUg1kb3dubG9hZFRva2VuiAEBEioKDmVuY3J5cHRp'
'b25fa2V5GAggASgMSANSDWVuY3J5cHRpb25LZXmIAQESKgoOZW5jcnlwdGlvbl9tYWMYCSABKA'
'xIBFINZW5jcnlwdGlvbk1hY4gBARIuChBlbmNyeXB0aW9uX25vbmNlGAogASgMSAVSD2VuY3J5'
'cHRpb25Ob25jZYgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgLIAEoDEgGUhVhZGRpdG'
'lvbmFsTWVzc2FnZURhdGGIAQEiPgoEVHlwZRIMCghSRVVQTE9BRBAAEgkKBUlNQUdFEAESCQoF'
'VklERU8QAhIHCgNHSUYQAxIJCgVBVURJTxAEQiAKHl9kaXNwbGF5X2xpbWl0X2luX21pbGxpc2'
'Vjb25kc0ITChFfcXVvdGVfbWVzc2FnZV9pZEIRCg9fZG93bmxvYWRfdG9rZW5CEQoPX2VuY3J5'
'cHRpb25fa2V5QhEKD19lbmNyeXB0aW9uX21hY0ITChFfZW5jcnlwdGlvbl9ub25jZUIaChhfYW'
'RkaXRpb25hbF9tZXNzYWdlX2RhdGEaqQEKC01lZGlhVXBkYXRlEjYKBHR5cGUYASABKA4yIi5F'
'bmNyeXB0ZWRDb250ZW50Lk1lZGlhVXBkYXRlLlR5cGVSBHR5cGUSKgoRdGFyZ2V0X21lc3NhZ2'
'VfaWQYAiABKAlSD3RhcmdldE1lc3NhZ2VJZCI2CgRUeXBlEgwKCFJFT1BFTkVEEAASCgoGU1RP'
'UkVEEAESFAoQREVDUllQVElPTl9FUlJPUhACGngKDkNvbnRhY3RSZXF1ZXN0EjkKBHR5cGUYAS'
'ABKA4yJS5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RSZXF1ZXN0LlR5cGVSBHR5cGUiKwoEVHlw'
'ZRILCgdSRVFVRVNUEAASCgoGUkVKRUNUEAESCgoGQUNDRVBUEAIapAIKDUNvbnRhY3RVcGRhdG'
'USOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZGF0ZS5UeXBlUgR0'
'eXBlEjcKFWF2YXRhcl9zdmdfY29tcHJlc3NlZBgCIAEoDEgAUhNhdmF0YXJTdmdDb21wcmVzc2'
'VkiAEBEh8KCHVzZXJuYW1lGAMgASgJSAFSCHVzZXJuYW1liAEBEiYKDGRpc3BsYXlfbmFtZRgE'
'IAEoCUgCUgtkaXNwbGF5TmFtZYgBASIfCgRUeXBlEgsKB1JFUVVFU1QQABIKCgZVUERBVEUQAU'
'IYChZfYXZhdGFyX3N2Z19jb21wcmVzc2VkQgsKCV91c2VybmFtZUIPCg1fZGlzcGxheV9uYW1l'
'GtkBCghQdXNoS2V5cxIzCgR0eXBlGAEgASgOMh8uRW5jcnlwdGVkQ29udGVudC5QdXNoS2V5cy'
'5UeXBlUgR0eXBlEhoKBmtleV9pZBgCIAEoA0gAUgVrZXlJZIgBARIVCgNrZXkYAyABKAxIAVID'
'a2V5iAEBEiIKCmNyZWF0ZWRfYXQYBCABKANIAlIJY3JlYXRlZEF0iAEBIh8KBFR5cGUSCwoHUk'
'VRVUVTVBAAEgoKBlVQREFURRABQgkKB19rZXlfaWRCBgoEX2tleUINCgtfY3JlYXRlZF9hdBqv'
'AQoJRmxhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDGZsYW1lQ291bnRlchI5ChlsYX'
'N0X2ZsYW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0RmxhbWVDb3VudGVyQ2hhbmdlEh8K'
'C2Jlc3RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcmNlX3VwZGF0ZRgEIAEoCFILZm'
'9yY2VVcGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5cGluZxgBIAEoCFIIaXNUeXBp'
'bmcSHQoKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFFVzZXJEaXNjb3ZlcnlSZXF1ZX'
'N0EicKD2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZlcnNpb24aMQoTVXNlckRpc2Nv'
'dmVyeVVwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZXMaPQoUS2V5VmVyaWZpY2F0aW'
'9uUHJvb2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1bGF0ZWRNYWMamwEKFFBhc3N3'
'b3JkTGVzc1JlY292ZXJ5EjUKE3JlY292ZXJ5U2VjcmV0U2hhcmUYASABKAxIAFITcmVjb3Zlcn'
'lTZWNyZXRTaGFyZYgBARIWCgZkZWxldGUYAiABKAhSBmRlbGV0ZRIcCgl0aHJlc2hvbGQYAyAB'
'KANSCXRocmVzaG9sZEIWChRfcmVjb3ZlcnlTZWNyZXRTaGFyZRozCh1QYXNzd29yZExlc3NSZW'
'NvdmVyeUhlYXJ0YmVhdBISCgRoYXNoGAEgASgMUgRoYXNoQgsKCV9ncm91cF9pZEIRCg9faXNf'
'ZGlyZWN0X2NoYXRCGQoXX3NlbmRlcl9wcm9maWxlX2NvdW50ZXJCIAoeX3NlbmRlcl91c2VyX2'
'Rpc2NvdmVyeV92ZXJzaW9uQhwKGl9hc2tfZm9yX2ZyaWVuZF9wcm9tb3Rpb25zQhEKD19tZXNz'
'YWdlX3VwZGF0ZUIICgZfbWVkaWFCDwoNX21lZGlhX3VwZGF0ZUIRCg9fY29udGFjdF91cGRhdG'
'VCEgoQX2NvbnRhY3RfcmVxdWVzdEINCgtfZmxhbWVfc3luY0IMCgpfcHVzaF9rZXlzQgsKCV9y'
'ZWFjdGlvbkIPCg1fdGV4dF9tZXNzYWdlQg8KDV9ncm91cF9jcmVhdGVCDQoLX2dyb3VwX2pvaW'
'5CDwoNX2dyb3VwX3VwZGF0ZUIaChhfcmVzZW5kX2dyb3VwX3B1YmxpY19rZXlCEQoPX2Vycm9y'
'X21lc3NhZ2VzQhoKGF9hZGRpdGlvbmFsX2RhdGFfbWVzc2FnZUITChFfdHlwaW5nX2luZGljYX'
'RvckIZChdfdXNlcl9kaXNjb3ZlcnlfcmVxdWVzdEIYChZfdXNlcl9kaXNjb3ZlcnlfdXBkYXRl'
'QhkKF19rZXlfdmVyaWZpY2F0aW9uX3Byb29mQhgKFl9wYXNzd29yZGxlc3NfcmVjb3ZlcnlCIg'
'ogX3Bhc3N3b3JkbGVzc19yZWNvdmVyeV9oZWFydGJlYXQ=');
'Gy5FbmNyeXB0ZWRDb250ZW50LkZsYW1lU3luY0gKUglmbGFtZVN5bmOIAQESOwoIcmVhY3Rpb2'
'4YDCABKAsyGi5FbmNyeXB0ZWRDb250ZW50LlJlYWN0aW9uSAtSCHJlYWN0aW9uiAEBEkUKDHRl'
'eHRfbWVzc2FnZRgNIAEoCzIdLkVuY3J5cHRlZENvbnRlbnQuVGV4dE1lc3NhZ2VIDFILdGV4dE'
'1lc3NhZ2WIAQESRQoMZ3JvdXBfY3JlYXRlGA4gASgLMh0uRW5jcnlwdGVkQ29udGVudC5Hcm91'
'cENyZWF0ZUgNUgtncm91cENyZWF0ZYgBARI/Cgpncm91cF9qb2luGA8gASgLMhsuRW5jcnlwdG'
'VkQ29udGVudC5Hcm91cEpvaW5IDlIJZ3JvdXBKb2luiAEBEkUKDGdyb3VwX3VwZGF0ZRgQIAEo'
'CzIdLkVuY3J5cHRlZENvbnRlbnQuR3JvdXBVcGRhdGVID1ILZ3JvdXBVcGRhdGWIAQESYgoXcm'
'VzZW5kX2dyb3VwX3B1YmxpY19rZXkYESABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlJlc2VuZEdy'
'b3VwUHVibGljS2V5SBBSFHJlc2VuZEdyb3VwUHVibGljS2V5iAEBEksKDmVycm9yX21lc3NhZ2'
'VzGBIgASgLMh8uRW5jcnlwdGVkQ29udGVudC5FcnJvck1lc3NhZ2VzSBFSDWVycm9yTWVzc2Fn'
'ZXOIAQESZAoXYWRkaXRpb25hbF9kYXRhX21lc3NhZ2UYEyABKAsyJy5FbmNyeXB0ZWRDb250ZW'
'50LkFkZGl0aW9uYWxEYXRhTWVzc2FnZUgSUhVhZGRpdGlvbmFsRGF0YU1lc3NhZ2WIAQESUQoQ'
'dHlwaW5nX2luZGljYXRvchgUIAEoCzIhLkVuY3J5cHRlZENvbnRlbnQuVHlwaW5nSW5kaWNhdG'
'9ySBNSD3R5cGluZ0luZGljYXRvcogBARJhChZ1c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0GBYgASgL'
'MiYuRW5jcnlwdGVkQ29udGVudC5Vc2VyRGlzY292ZXJ5UmVxdWVzdEgUUhR1c2VyRGlzY292ZX'
'J5UmVxdWVzdIgBARJeChV1c2VyX2Rpc2NvdmVyeV91cGRhdGUYFyABKAsyJS5FbmNyeXB0ZWRD'
'b250ZW50LlVzZXJEaXNjb3ZlcnlVcGRhdGVIFVITdXNlckRpc2NvdmVyeVVwZGF0ZYgBARJhCh'
'ZrZXlfdmVyaWZpY2F0aW9uX3Byb29mGBggASgLMiYuRW5jcnlwdGVkQ29udGVudC5LZXlWZXJp'
'ZmljYXRpb25Qcm9vZkgWUhRrZXlWZXJpZmljYXRpb25Qcm9vZogBARJgChVwYXNzd29yZGxlc3'
'NfcmVjb3ZlcnkYGiABKAsyJi5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5'
'SBdSFHBhc3N3b3JkbGVzc1JlY292ZXJ5iAEBEnwKH3Bhc3N3b3JkbGVzc19yZWNvdmVyeV9oZW'
'FydGJlYXQYGyABKAsyLy5FbmNyeXB0ZWRDb250ZW50LlBhc3N3b3JkTGVzc1JlY292ZXJ5SGVh'
'cnRiZWF0SBhSHXBhc3N3b3JkbGVzc1JlY292ZXJ5SGVhcnRiZWF0iAEBGpYCCg1FcnJvck1lc3'
'NhZ2VzEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkVycm9yTWVzc2FnZXMuVHlw'
'ZVIEdHlwZRIsChJyZWxhdGVkX3JlY2VpcHRfaWQYAiABKAlSEHJlbGF0ZWRSZWNlaXB0SWQinA'
'EKBFR5cGUSPAo4RVJST1JfUFJPQ0VTU0lOR19NRVNTQUdFX0NSRUFURURfQUNDT1VOVF9SRVFV'
'RVNUX0lOU1RFQUQQABIYChRVTktOT1dOX01FU1NBR0VfVFlQRRACEhcKE1NFU1NJT05fT1VUX0'
'9GX1NZTkMQAxIjCh9HUk9VUF9OT1RfRk9VTkRfT1JfTk9UX0FfTUVNQkVSEAQahwEKC0dyb3Vw'
'Q3JlYXRlEhsKCXN0YXRlX2tleRgDIAEoDFIIc3RhdGVLZXkSKAoQZ3JvdXBfcHVibGljX2tleR'
'gEIAEoDFIOZ3JvdXBQdWJsaWNLZXkSIgoKZ3JvdXBfbmFtZRgFIAEoCUgAUglncm91cE5hbWWI'
'AQFCDQoLX2dyb3VwX25hbWUaNQoJR3JvdXBKb2luEigKEGdyb3VwX3B1YmxpY19rZXkYASABKA'
'xSDmdyb3VwUHVibGljS2V5GhYKFFJlc2VuZEdyb3VwUHVibGljS2V5GsgCCgtHcm91cFVwZGF0'
'ZRIqChFncm91cF9hY3Rpb25fdHlwZRgBIAEoCVIPZ3JvdXBBY3Rpb25UeXBlEjMKE2FmZmVjdG'
'VkX2NvbnRhY3RfaWQYAiABKANIAFIRYWZmZWN0ZWRDb250YWN0SWSIAQESKQoObmV3X2dyb3Vw'
'X25hbWUYAyABKAlIAVIMbmV3R3JvdXBOYW1liAEBElcKJm5ld19kZWxldGVfbWVzc2FnZXNfYW'
'Z0ZXJfbWlsbGlzZWNvbmRzGAQgASgDSAJSIm5ld0RlbGV0ZU1lc3NhZ2VzQWZ0ZXJNaWxsaXNl'
'Y29uZHOIAQFCFgoUX2FmZmVjdGVkX2NvbnRhY3RfaWRCEQoPX25ld19ncm91cF9uYW1lQikKJ1'
'9uZXdfZGVsZXRlX21lc3NhZ2VzX2FmdGVyX21pbGxpc2Vjb25kcxqvAQoLVGV4dE1lc3NhZ2US'
'KgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBISCgR0ZXh0GAIgAS'
'gJUgR0ZXh0EhwKCXRpbWVzdGFtcBgDIAEoA1IJdGltZXN0YW1wEi0KEHF1b3RlX21lc3NhZ2Vf'
'aWQYBCABKAlIAFIOcXVvdGVNZXNzYWdlSWSIAQFCEwoRX3F1b3RlX21lc3NhZ2VfaWQazgEKFU'
'FkZGl0aW9uYWxEYXRhTWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVy'
'TWVzc2FnZUlkEhwKCXRpbWVzdGFtcBgCIAEoA1IJdGltZXN0YW1wEhIKBHR5cGUYAyABKAlSBH'
'R5cGUSOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYBCABKAxIAFIVYWRkaXRpb25hbE1lc3Nh'
'Z2VEYXRhiAEBQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRpkCghSZWFjdGlvbhIqChF0YX'
'JnZXRfbWVzc2FnZV9pZBgBIAEoCVIPdGFyZ2V0TWVzc2FnZUlkEhQKBWVtb2ppGAIgASgJUgVl'
'bW9qaRIWCgZyZW1vdmUYAyABKAhSBnJlbW92ZRq+AgoNTWVzc2FnZVVwZGF0ZRI4CgR0eXBlGA'
'EgASgOMiQuRW5jcnlwdGVkQ29udGVudC5NZXNzYWdlVXBkYXRlLlR5cGVSBHR5cGUSLwoRc2Vu'
'ZGVyX21lc3NhZ2VfaWQYAiABKAlIAFIPc2VuZGVyTWVzc2FnZUlkiAEBEj0KG211bHRpcGxlX3'
'RhcmdldF9tZXNzYWdlX2lkcxgDIAMoCVIYbXVsdGlwbGVUYXJnZXRNZXNzYWdlSWRzEhcKBHRl'
'eHQYBCABKAlIAVIEdGV4dIgBARIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVzdGFtcCItCgRUeX'
'BlEgoKBkRFTEVURRAAEg0KCUVESVRfVEVYVBABEgoKBk9QRU5FRBACQhQKEl9zZW5kZXJfbWVz'
'c2FnZV9pZEIHCgVfdGV4dBqFBgoFTWVkaWESKgoRc2VuZGVyX21lc3NhZ2VfaWQYASABKAlSD3'
'NlbmRlck1lc3NhZ2VJZBIwCgR0eXBlGAIgASgOMhwuRW5jcnlwdGVkQ29udGVudC5NZWRpYS5U'
'eXBlUgR0eXBlEkYKHWRpc3BsYXlfbGltaXRfaW5fbWlsbGlzZWNvbmRzGAMgASgDSABSGmRpc3'
'BsYXlMaW1pdEluTWlsbGlzZWNvbmRziAEBEjcKF3JlcXVpcmVzX2F1dGhlbnRpY2F0aW9uGAQg'
'ASgIUhZyZXF1aXJlc0F1dGhlbnRpY2F0aW9uEhwKCXRpbWVzdGFtcBgFIAEoA1IJdGltZXN0YW'
'1wEi0KEHF1b3RlX21lc3NhZ2VfaWQYBiABKAlIAVIOcXVvdGVNZXNzYWdlSWSIAQESKgoOZG93'
'bmxvYWRfdG9rZW4YByABKAxIAlINZG93bmxvYWRUb2tlbogBARIqCg5lbmNyeXB0aW9uX2tleR'
'gIIAEoDEgDUg1lbmNyeXB0aW9uS2V5iAEBEioKDmVuY3J5cHRpb25fbWFjGAkgASgMSARSDWVu'
'Y3J5cHRpb25NYWOIAQESLgoQZW5jcnlwdGlvbl9ub25jZRgKIAEoDEgFUg9lbmNyeXB0aW9uTm'
'9uY2WIAQESOwoXYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEYCyABKAxIBlIVYWRkaXRpb25hbE1l'
'c3NhZ2VEYXRhiAEBIj4KBFR5cGUSDAoIUkVVUExPQUQQABIJCgVJTUFHRRABEgkKBVZJREVPEA'
'ISBwoDR0lGEAMSCQoFQVVESU8QBEIgCh5fZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY29uZHNC'
'EwoRX3F1b3RlX21lc3NhZ2VfaWRCEQoPX2Rvd25sb2FkX3Rva2VuQhEKD19lbmNyeXB0aW9uX2'
'tleUIRCg9fZW5jcnlwdGlvbl9tYWNCEwoRX2VuY3J5cHRpb25fbm9uY2VCGgoYX2FkZGl0aW9u'
'YWxfbWVzc2FnZV9kYXRhGqkBCgtNZWRpYVVwZGF0ZRI2CgR0eXBlGAEgASgOMiIuRW5jcnlwdG'
'VkQ29udGVudC5NZWRpYVVwZGF0ZS5UeXBlUgR0eXBlEioKEXRhcmdldF9tZXNzYWdlX2lkGAIg'
'ASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghSRU9QRU5FRBAAEgoKBlNUT1JFRBABEh'
'QKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0UmVxdWVzdBI5CgR0eXBlGAEgASgOMiUu'
'RW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5UeXBlUgR0eXBlIisKBFR5cGUSCwoHUk'
'VRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGqQCCg1Db250YWN0VXBkYXRlEjgKBHR5'
'cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGUuVHlwZVIEdHlwZRI3Ch'
'VhdmF0YXJfc3ZnX2NvbXByZXNzZWQYAiABKAxIAFITYXZhdGFyU3ZnQ29tcHJlc3NlZIgBARIf'
'Cgh1c2VybmFtZRgDIAEoCUgBUgh1c2VybmFtZYgBARImCgxkaXNwbGF5X25hbWUYBCABKAlIAl'
'ILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCGAoWX2F2'
'YXRhcl9zdmdfY29tcHJlc3NlZEILCglfdXNlcm5hbWVCDwoNX2Rpc3BsYXlfbmFtZRqvAQoJRm'
'xhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDGZsYW1lQ291bnRlchI5ChlsYXN0X2Zs'
'YW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0RmxhbWVDb3VudGVyQ2hhbmdlEh8KC2Jlc3'
'RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcmNlX3VwZGF0ZRgEIAEoCFILZm9yY2VV'
'cGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5cGluZxgBIAEoCFIIaXNUeXBpbmcSHQ'
'oKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFFVzZXJEaXNjb3ZlcnlSZXF1ZXN0EicK'
'D2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZlcnNpb24aMQoTVXNlckRpc2NvdmVyeV'
'VwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZXMaPQoUS2V5VmVyaWZpY2F0aW9uUHJv'
'b2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1bGF0ZWRNYWMamwEKFFBhc3N3b3JkTG'
'Vzc1JlY292ZXJ5EjUKE3JlY292ZXJ5U2VjcmV0U2hhcmUYASABKAxIAFITcmVjb3ZlcnlTZWNy'
'ZXRTaGFyZYgBARIWCgZkZWxldGUYAiABKAhSBmRlbGV0ZRIcCgl0aHJlc2hvbGQYAyABKANSCX'
'RocmVzaG9sZEIWChRfcmVjb3ZlcnlTZWNyZXRTaGFyZRozCh1QYXNzd29yZExlc3NSZWNvdmVy'
'eUhlYXJ0YmVhdBISCgRoYXNoGAEgASgMUgRoYXNoQgsKCV9ncm91cF9pZEIRCg9faXNfZGlyZW'
'N0X2NoYXRCGQoXX3NlbmRlcl9wcm9maWxlX2NvdW50ZXJCIAoeX3NlbmRlcl91c2VyX2Rpc2Nv'
'dmVyeV92ZXJzaW9uQhwKGl9hc2tfZm9yX2ZyaWVuZF9wcm9tb3Rpb25zQhEKD19tZXNzYWdlX3'
'VwZGF0ZUIICgZfbWVkaWFCDwoNX21lZGlhX3VwZGF0ZUIRCg9fY29udGFjdF91cGRhdGVCEgoQ'
'X2NvbnRhY3RfcmVxdWVzdEINCgtfZmxhbWVfc3luY0ILCglfcmVhY3Rpb25CDwoNX3RleHRfbW'
'Vzc2FnZUIPCg1fZ3JvdXBfY3JlYXRlQg0KC19ncm91cF9qb2luQg8KDV9ncm91cF91cGRhdGVC'
'GgoYX3Jlc2VuZF9ncm91cF9wdWJsaWNfa2V5QhEKD19lcnJvcl9tZXNzYWdlc0IaChhfYWRkaX'
'Rpb25hbF9kYXRhX21lc3NhZ2VCEwoRX3R5cGluZ19pbmRpY2F0b3JCGQoXX3VzZXJfZGlzY292'
'ZXJ5X3JlcXVlc3RCGAoWX3VzZXJfZGlzY292ZXJ5X3VwZGF0ZUIZChdfa2V5X3ZlcmlmaWNhdG'
'lvbl9wcm9vZkIYChZfcGFzc3dvcmRsZXNzX3JlY292ZXJ5QiIKIF9wYXNzd29yZGxlc3NfcmVj'
'b3ZlcnlfaGVhcnRiZWF0');

View file

@ -4,7 +4,8 @@ package http_requests;
message TextMessage {
int64 user_id = 1;
bytes body = 2;
optional bytes push_data = 3;
reserved 3; // was: bytes push_data
bool wake_receiver = 4;
}
message UploadRequest {

View file

@ -710,7 +710,10 @@ Future<void> _createUploadRequest(MediaFileService media) async {
final messageOnSuccess = TextMessage()
..body = cipherText
..userId = Int64(groupMember.contactId);
..userId = Int64(groupMember.contactId)
// A media message is user visible, so the server may send the opaque
// FCM wake-up once the upload completes.
..wakeReceiver = true;
messagesOnSuccess.add(messageOnSuccess);
downloadTokens.add(downloadToken);

View file

@ -1,34 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
Future<void> customLocalPushNotification(String title, String msg) async {
final androidNotificationDetails = AndroidNotificationDetails(
'1',
'System',
channelDescription: 'System messages.',
importance: Importance.high,
priority: Priority.high,
styleInformation: BigTextStyleInformation(msg),
icon: 'ic_launcher_foreground',
);
const darwinNotificationDetails = DarwinNotificationDetails();
final notificationDetails = NotificationDetails(
android: androidNotificationDetails,
iOS: darwinNotificationDetails,
);
final id = Random.secure().nextInt(9999);
await flutterLocalNotificationsPlugin.show(
id,
title,
msg,
notificationDetails,
);
}

View file

@ -1,29 +0,0 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/log.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
SentryWidgetsFlutterBinding.ensureInitialized();
await AppEnvironment.init();
final isInitialized = await initBackgroundExecution();
await setupPushNotification();
Log.info('Handling a background message: ${message.messageId}');
await FcmNotificationService.handleRemoteMessage(message);
if (Platform.isAndroid) {
if (isInitialized) {
await backgroundFetch(lastExecutionInSecondsLimit: 3);
}
} else {
// make sure every thing run...
await Future.delayed(const Duration(milliseconds: 2000));
}
}

View file

@ -5,12 +5,8 @@ import 'package:firebase_app_installations/firebase_app_installations.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/fcm.background.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
@ -19,14 +15,14 @@ import '../../../firebase_options.dart';
// see more here: https://firebase.google.com/docs/cloud-messaging/flutter/receive?hl=de
class FcmNotificationService {
/// FCM is only an opaque wake-up transport now. Delivery is owned natively by
/// the iOS Notification Service Extension and by
/// `TwonlyFirebaseMessagingService` on Android, both of which call Rust
/// directly, so no Dart isolate or message listener is registered here.
static Future<void> initStartup() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
FirebaseMessaging.onMessage.listen(handleRemoteMessage);
}
static Future<void> initAfterUserLoaded() async {
@ -143,67 +139,6 @@ class FcmNotificationService {
}
}
static Future<void> handleRemoteMessage(RemoteMessage message) async {
Log.info('handleRemoteMessage received message: ${message.messageId}');
await _updateLastFcmMessageTimestamp();
if (!Platform.isAndroid) {
Log.error('Got message in Dart while on iOS');
}
if (message.notification != null && AppState.isAppInBackground) {
Log.error(
'Got notification but app is in background, so the SDK already have shown the message.',
);
return;
}
// In scenarios like Android Doze Mode or aggressive background restrictions, the OS may kill
// or heavily restrict network access, preventing the WebSocket from connecting in time.
// By parsing the FCM data payload offline, we can instantly display the notification, which also
// prevents FCM from penalizing/downgrading the app's data message priority for failing to show a notification.
// This is just a workarround until the new Rust decryption is enrolled fully.
final pushDataString = message.data['push_data'] as String?;
if (pushDataString != null) {
final apiState = await RustApi.connectionState();
if (apiState == ApiConnectionState.connected ||
apiState == ApiConnectionState.authenticating ||
apiState == ApiConnectionState.authenticated) {
Log.info('Got FCM message, but API is connected...');
} else {
Log.info('Trying to connect to the API in the background.');
if (await backgroundFetch()) {
return;
}
}
}
if (message.notification != null || message.data['title'] != null) {
final title =
message.notification?.title ?? message.data['title'] as String? ?? '';
final body =
message.notification?.body ?? message.data['body'] as String? ?? '';
await customLocalPushNotification(title, body);
}
}
static Future<void> _updateLastFcmMessageTimestamp() async {
const storage = FlutterSecureStorage();
final nowMs = DateTime.now().millisecondsSinceEpoch.toString();
try {
await storage.write(
key: SecureStorageKeys.lastFcmMessageTimestamp,
value: nowMs,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
Log.info('Updated last FCM message timestamp to $nowMs');
} catch (e) {
Log.error('Could not write last FCM message timestamp: $e');
}
}
static Future<void> updateLastServerMessageTimestamp() async {
const storage = FlutterSecureStorage();
final nowMs = DateTime.now().millisecondsSinceEpoch.toString();
@ -229,13 +164,6 @@ class FcmNotificationService {
}
const storage = FlutterSecureStorage();
try {
final lastFcmStr = await storage.read(
key: SecureStorageKeys.lastFcmMessageTimestamp,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
final lastServerStr = await storage.read(
key: SecureStorageKeys.lastServerMessageTimestamp,
iOptions: const IOSOptions(
@ -247,13 +175,12 @@ class FcmNotificationService {
final now = DateTime.now();
final threeDaysAgo = now.subtract(const Duration(days: 3));
DateTime? lastFcmTime;
if (lastFcmStr != null) {
final ms = int.tryParse(lastFcmStr);
if (ms != null) {
lastFcmTime = DateTime.fromMillisecondsSinceEpoch(ms);
}
}
// Recorded by the Rust notification worker, because neither platform
// starts Flutter for a background wake-up any more.
final lastFcmWakeup = userService.currentUser.lastFcmWakeupAt;
final lastFcmTime = lastFcmWakeup == null
? null
: DateTime.fromMillisecondsSinceEpoch(lastFcmWakeup * 1000);
if (lastFcmTime != null) {
Log.info(

View file

@ -0,0 +1,77 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'package:twonly/src/utils/log.dart';
/// Taps on notifications rendered natively (Android `MessagingStyle`) arrive
/// through a platform channel instead of the Firebase Messaging plugin, which
/// no longer owns background delivery.
///
/// Only the opaque conversation identifier crosses the channel; the Flutter
/// route is built here so the native layer stays free of routing knowledge.
class NativeNotificationService {
static const MethodChannel _channel = MethodChannel(
'eu.twonly/notificationTap',
);
static const String _conversationIdKey = 'conversation_id';
static const String _notificationIdsKey = 'notification_ids';
static final StreamController<String?> _taps =
StreamController<String?>.broadcast();
/// Emits the conversation id of every notification tapped while the app is
/// running. A `null` value means the notification had no specific
/// conversation and should only open the chats tab.
static Stream<String?> get taps => _taps.stream;
static void init() {
if (!Platform.isAndroid) return;
_channel.setMethodCallHandler((call) async {
if (call.method != 'onNotificationTapped') return;
_taps.add(_conversationIdOf(call.arguments));
});
}
/// Returns the tap that launched the app, or `null` when the app was not
/// started from a native notification. The result is consumed once.
static Future<({String? conversationId})?> consumeInitialTap() async {
if (!Platform.isAndroid) return null;
try {
final result = await _channel.invokeMapMethod<String, dynamic>(
'consumeInitialNotification',
);
if (result == null) return null;
return (conversationId: _conversationIdOf(result));
} catch (e) {
Log.error('Could not read the initial native notification: $e');
return null;
}
}
/// Withdraws notifications for messages that have just been opened. Native
/// notification identifiers are strings on iOS and stable hashes on Android,
/// so the conversion stays in the platform implementation.
static Future<void> cancelNotifications(
Iterable<String> notificationIds,
) async {
if (!Platform.isAndroid && !Platform.isIOS) return;
final ids = notificationIds.where((id) => id.isNotEmpty).toSet().toList();
if (ids.isEmpty) return;
try {
await _channel.invokeMethod<void>('cancelNotifications', {
_notificationIdsKey: ids,
});
} catch (e) {
Log.error('Could not withdraw opened-message notifications: $e');
}
}
static String? _conversationIdOf(Object? arguments) {
if (arguments is! Map) return null;
final conversationId = arguments[_conversationIdKey];
if (conversationId is! String || conversationId.isEmpty) return null;
return conversationId;
}
}

View file

@ -77,6 +77,17 @@ class Log {
final message = filterLogMessage('$messageInput');
Logger(_getCallerSourceCodeFilename()).fine(message, error, stackTrace);
}
/// Re-emits a record that was produced outside Dart, keeping the origin's
/// level and source location. Deriving either from the Dart call site would
/// only ever point back at the forwarding code.
static void forward({
required Level level,
required String source,
required Object? messageInput,
}) {
Logger(source).log(level, filterLogMessage('$messageInput'));
}
}
Future<String> loadLogFile() async {

View file

@ -15,7 +15,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/contact_labels.comp.dart';
@ -354,10 +354,6 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
List<GroupHistory> groupActions, {
bool reportOpened = false,
}) async {
if (reportOpened && _isViewActive()) {
unawaited(flutterLocalNotificationsPlugin.cancelAll());
}
for (final msg in newMessages) {
if (_animationState.hasReceivedFirstBatch &&
!_animationState.knownMessageIds.contains(msg.messageId) &&
@ -435,12 +431,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
_animationState.reportedOpenedMessageIds.addAll(
openedMessages[contactId]!,
);
unawaited(
RustApi.notifyMessagesOpened(
contactId: contactId,
messageIds: openedMessages[contactId]!,
),
);
unawaited(_reportMessagesOpened(contactId, openedMessages[contactId]!));
}
}
@ -472,6 +463,23 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
_updateGalleryItems(messages: storedMediaFiles);
}
Future<void> _reportMessagesOpened(
int contactId,
List<String> messageIds,
) async {
// Cancel once immediately for an already visible alert, and once after the
// durable outbox update to close the small race with a native push worker.
await NativeNotificationService.cancelNotifications(messageIds);
try {
await RustApi.notifyMessagesOpened(
contactId: contactId,
messageIds: messageIds,
);
} finally {
await NativeNotificationService.cancelNotifications(messageIds);
}
}
void _updateGalleryItems({List<Message>? messages, bool force = false}) {
final storedMediaMessages =
messages ??

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
@ -5,6 +7,7 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/visual/elements/better_text.element.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
@ -217,10 +220,7 @@ class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
_playerController.startPlayer();
if (widget.message.senderId != null &&
widget.message.openedAt == null) {
RustApi.notifyMessagesOpened(
contactId: widget.message.senderId!,
messageIds: [widget.message.messageId],
);
unawaited(_notifyMessageOpened());
}
}
setState(() {
@ -259,6 +259,21 @@ class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
],
);
}
Future<void> _notifyMessageOpened() async {
final senderId = widget.message.senderId;
if (senderId == null) return;
final messageIds = [widget.message.messageId];
await NativeNotificationService.cancelNotifications(messageIds);
try {
await RustApi.notifyMessagesOpened(
contactId: senderId,
messageIds: messageIds,
);
} finally {
await NativeNotificationService.cancelNotifications(messageIds);
}
}
}
String formatMsToMinSec(int milliseconds) {

View file

@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mutex/mutex.dart';
import 'package:screen_protector/screen_protector.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
@ -19,7 +18,7 @@ import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
@ -133,12 +132,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
final Mutex _messageUpdateLock = Mutex();
bool _isViewActive() {
if (!mounted) return false;
return !AppState.isAppInBackground &&
(ModalRoute.of(context)?.isCurrent ?? false);
}
Future<void> listenForUnopenedMedia(bool firstRun) async {
_subscription = twonlyDB.messagesDao
.watchMediaNotOpened(widget.group.groupId)
@ -256,10 +249,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
showSendTextMessageInput = false;
});
if (_isViewActive()) {
unawaited(flutterLocalNotificationsPlugin.cancelAll());
}
final stream = twonlyDB.mediaFilesDao.watchMedia(
allMediaFiles.first.mediaId!,
);
@ -416,10 +405,15 @@ class _MediaViewerViewState extends State<MediaViewerView> {
markAsOpenMessageIDs = messageIds;
}
await NativeNotificationService.cancelNotifications(markAsOpenMessageIDs);
try {
await RustApi.notifyMessagesOpened(
contactId: currentMessage!.senderId!,
messageIds: markAsOpenMessageIDs,
);
} finally {
await NativeNotificationService.cancelNotifications(markAsOpenMessageIDs);
}
}
Future<void> _setupVideoPlayer(MediaFileService mediaLocal) async {

View file

@ -14,6 +14,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/providers/routing.provider.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/native.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
@ -50,6 +51,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<int>? _homeViewPageIndexSub;
StreamSubscription<NotificationResponse>? _selectNotificationSub;
StreamSubscription<String?>? _nativeNotificationSub;
StreamSubscription<(String, MediaType)>? _sharedMediaSub;
static Uri? pendingSharedLink;
@ -113,6 +115,10 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
streamHomeViewPageIndex.add(0);
});
_nativeNotificationSub = NativeNotificationService.taps.listen(
_openNativeNotification,
);
_sharedLinkSub = streamSharedLink.stream.listen((uri) {
HomeViewState.pendingSharedLink = null;
_mainCameraController.setSharedLinkForPreview(uri);
@ -192,7 +198,20 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
});
}
void _openNativeNotification(String? conversationId) {
Log.info('Opened app from a native push notification tap.');
if (conversationId != null) {
routerProvider.go(Routes.chatsMessages(conversationId));
}
streamHomeViewPageIndex.add(0);
}
Future<void> _initAsync() async {
final initialNativeTap = await NativeNotificationService.consumeInitialTap();
if (initialNativeTap != null) {
_openNativeNotification(initialNativeTap.conversationId);
}
final notificationAppLaunchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
@ -245,6 +264,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
_onMessageOpenedAppSub?.cancel();
_homeViewPageIndexSub?.cancel();
_selectNotificationSub?.cancel();
_nativeNotificationSub?.cancel();
_disableCameraTimer?.cancel();
_mainCameraController.setState = null;
_mainCameraController.closeCamera();

View file

@ -93,6 +93,7 @@ class SetupView extends StatefulWidget {
class _SetupViewState extends State<SetupView> {
StreamSubscription<void>? _userUpdateStream;
late UserDiscoverySetupState state;
bool _setupDone = false;
@override
void initState() {
@ -102,16 +103,27 @@ class _SetupViewState extends State<SetupView> {
if (widget.onUpdate != null) {
_userUpdateStream = userService.onUserUpdated.listen((u) {
if (userService.currentUser.currentSetupPage == null) {
widget.onUpdate?.call();
_notifySetupDone();
}
});
}
}
/// Notifies the parent exactly once. Further user updates must not trigger
/// another callback, otherwise a parent popping this view would pop the
/// route below it as well.
void _notifySetupDone() {
if (_setupDone) return;
_setupDone = true;
unawaited(_userUpdateStream?.cancel());
_userUpdateStream = null;
widget.onUpdate?.call();
}
@override
void dispose() {
super.dispose();
_userUpdateStream?.cancel();
super.dispose();
}
@override
@ -184,7 +196,7 @@ class _SetupViewState extends State<SetupView> {
await UserService.update(
(u) => u.skipSetupPages = true,
);
widget.onUpdate?.call();
_notifySetupDone();
},
variant: MyButtonVariant.text,
child: Text(

View file

@ -13,14 +13,19 @@ class FinishSetupComp extends StatefulWidget {
class _FinishSetupCompState extends State<FinishSetupComp> {
Future<void> onTap() async {
await context.navPush(
SetupView(
// Captured before pushing so the callback never resolves the navigator
// through this widget's context, which lives below the pushed route.
final navigator = Navigator.of(context);
await navigator.push<void>(
MaterialPageRoute<void>(
builder: (_) => SetupView(
onUpdate: () {
if (mounted) {
Navigator.pop(context);
if (mounted && navigator.canPop()) {
navigator.pop();
}
},
),
),
);
}

View file

@ -14,7 +14,6 @@ class DeveloperInformationsView extends StatefulWidget {
}
class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
String? _lastFcmTimestamp;
String? _lastServerTimestamp;
@override
@ -26,13 +25,6 @@ class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
Future<void> _loadInformations({bool showFeedback = false}) async {
const storage = FlutterSecureStorage();
try {
final lastFcm = await storage.read(
key: SecureStorageKeys.lastFcmMessageTimestamp,
iOptions: const IOSOptions(
groupId: 'CN332ZUGRP.eu.twonly.shared',
accessibility: KeychainAccessibility.first_unlock,
),
);
final lastServer = await storage.read(
key: SecureStorageKeys.lastServerMessageTimestamp,
iOptions: const IOSOptions(
@ -42,7 +34,6 @@ class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
);
if (mounted) {
setState(() {
_lastFcmTimestamp = lastFcm;
_lastServerTimestamp = lastServer;
});
if (showFeedback) {
@ -56,6 +47,14 @@ class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
} catch (_) {}
}
String _formatFcmWakeup() {
final seconds = userService.currentUser.lastFcmWakeupAt;
if (seconds == null) return 'Never';
return DateTime.fromMillisecondsSinceEpoch(
seconds * 1000,
).toLocal().toString();
}
String _formatTimestamp(String? timestampStr) {
if (timestampStr == null) return 'Never';
final ms = int.tryParse(timestampStr);
@ -94,7 +93,7 @@ class _DeveloperInformationsViewState extends State<DeveloperInformationsView> {
const Divider(),
ListTile(
title: const Text('Last FCM Message'),
subtitle: Text(_formatTimestamp(_lastFcmTimestamp)),
subtitle: Text(_formatFcmWakeup()),
),
ListTile(
title: const Text('Last Server Message'),

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n promotion_id,\n public_id,\n from_contact_id,\n threshold,\n announcement_share,\n public_key_verified_timestamp\n FROM user_discovery_other_promotions\n WHERE public_id = ?\n ",
"query": "\n SELECT promotion_id, public_id, from_contact_id, threshold,\n announcement_share, public_key_verified_timestamp\n FROM user_discovery_other_promotions\n WHERE public_id = ?\n ",
"describe": {
"columns": [
{
@ -82,5 +82,5 @@
true
]
},
"hash": "229b83c3c0777a900e71c71d83920a2b93f5df34f29921263f0c960fe0a34ceb"
"hash": "0095795ef631e1c4a775c8984bbbe0ca0c546dcc3ef1a608527fa9c42484f763"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO contacts(user_id, username, accepted) VALUES (?, ?, 1)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "00e0c32ce73e5448c93de168ac43e7c46db5e3806197aeaf43850d7f8141f63d"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE messages SET opened_at = ?, opened_by_all = CASE WHEN NOT EXISTS(\n SELECT 1 FROM group_members gm\n WHERE gm.group_id = messages.group_id AND NOT EXISTS(\n SELECT 1 FROM message_actions ma\n WHERE ma.message_id = messages.message_id\n AND ma.contact_id = gm.contact_id AND ma.type = 'openedAt'\n )\n ) THEN ? ELSE NULL END\n WHERE message_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "02f92beaef5af931c37b62b9ebfbdcf0490b17343ba0e11427f572cc9c41b8b4"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = ? AND contact_id = ?)",
"describe": {
"columns": [
{
"name": "EXISTS(SELECT 1 FROM group_members WHERE group_id = ? AND contact_id = ?)",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "03c020a855d64733408f6cf11297505ecaa67c943819bcb3a5ed2a824e668ca6"
}

View file

@ -0,0 +1,86 @@
{
"db_name": "SQLite",
"query": "SELECT media_id, type AS media_type, download_token,\n encryption_key, encryption_mac, encryption_nonce\n FROM media_files WHERE media_id = ?",
"describe": {
"columns": [
{
"name": "media_id",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "media_id"
}
}
},
{
"name": "media_type",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "type"
}
}
},
{
"name": "download_token",
"ordinal": 2,
"type_info": "Blob",
"origin": {
"Table": {
"table": "media_files",
"name": "download_token"
}
}
},
{
"name": "encryption_key",
"ordinal": 3,
"type_info": "Blob",
"origin": {
"Table": {
"table": "media_files",
"name": "encryption_key"
}
}
},
{
"name": "encryption_mac",
"ordinal": 4,
"type_info": "Blob",
"origin": {
"Table": {
"table": "media_files",
"name": "encryption_mac"
}
}
},
{
"name": "encryption_nonce",
"ordinal": 5,
"type_info": "Blob",
"origin": {
"Table": {
"table": "media_files",
"name": "encryption_nonce"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
true,
true
]
},
"hash": "051214ce0e6182d160aa7d3f039009d0fb62c946560da627d7de41e57a0d289d"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT promotion\n FROM user_discovery_own_promotions\n WHERE version_id > ?\n ",
"query": "SELECT promotion FROM user_discovery_own_promotions\n WHERE contact_id = ? ORDER BY version_id DESC LIMIT 1",
"describe": {
"columns": [
{
@ -22,5 +22,5 @@
false
]
},
"hash": "1bf6e82ae32eff099bd28dc8e577247ebef69141bd7c6b887b5b11ed724e11b3"
"hash": "06bde3698b9256418111975b79dc581ea72a4f68f7dfd4ceb63bfac45aa031b3"
}

View file

@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "SELECT sender_id, content, is_deleted_from_sender FROM messages WHERE message_id = ?",
"describe": {
"columns": [
{
"name": "sender_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "messages",
"name": "sender_id"
}
}
},
{
"name": "content",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "messages",
"name": "content"
}
}
},
{
"name": "is_deleted_from_sender",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "messages",
"name": "is_deleted_from_sender"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
true,
false
]
},
"hash": "09817a671f97e3aaeffcf37f8018c231462700155ea0b6b1914a8432cfeb6dcf"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO messages(\n group_id,\n message_id,\n sender_id,\n type,\n content,\n quotes_message_id,\n created_at,\n ack_by_server\n ) VALUES (?, ?, ?, 'text', ?, ?, ?, CAST(strftime('%s', 'now') AS INTEGER))\n ON CONFLICT(message_id) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "0a000bb74996bb70072c0b0dbb3bd54b690ec20c817a2cb1a29b0ac0fcc884ad"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO user_discovery_user_relations (\n announced_user_id, from_contact_id, public_key_verified_timestamp\n ) VALUES (?, ?, ?)\n ON CONFLICT(announced_user_id, from_contact_id) DO UPDATE SET\n public_key_verified_timestamp = excluded.public_key_verified_timestamp\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "0a969e410875ea46fde7c9e7fa773b73da80d2a71736532a4bea6dd2efb43cbb"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM reactions WHERE message_id = ? AND sender_id = ? AND emoji = ?)",
"describe": {
"columns": [
{
"name": "EXISTS(SELECT 1 FROM reactions WHERE message_id = ? AND sender_id = ? AND emoji = ?)",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false
]
},
"hash": "0cf3d4227521bf00d715c1f66b595d9a4943283ab72635c63d184aab40dc19d5"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT display_name FROM contacts WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "display_name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "contacts",
"name": "display_name"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "0dbbfbbf830b11f3e15c85e9e6004196d69f202929f836ac0c0500ea57804dfc"
}

View file

@ -0,0 +1,50 @@
{
"db_name": "SQLite",
"query": "\n SELECT last_flame_counter_change, flame_counter, max_flame_counter\n FROM groups\n WHERE group_id = ?\n ",
"describe": {
"columns": [
{
"name": "last_flame_counter_change",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_flame_counter_change"
}
}
},
{
"name": "flame_counter",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "flame_counter"
}
}
},
{
"name": "max_flame_counter",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "max_flame_counter"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false
]
},
"hash": "0dd1dbaa350aa766bada77410aef0da125561fd388fad0b4943f124bc2e73600"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE messages SET content = ?, modified_at = ?\n WHERE message_id = ? AND sender_id = ? AND content IS NOT NULL",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "0e921f020426da5e0c76cd3c555f37d2abad38c2959ea971ae3085d51ad68ccb"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT user_id, recovery_contacts_secret_share FROM contacts\n WHERE recovery_contacts_secret_share IS NOT NULL\n AND (recovery_contacts_last_heartbeat IS NULL\n OR recovery_contacts_last_heartbeat <= ?)",
"describe": {
"columns": [
{
"name": "user_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "user_id"
}
}
},
{
"name": "recovery_contacts_secret_share",
"ordinal": 1,
"type_info": "Blob",
"origin": {
"Table": {
"table": "contacts",
"name": "recovery_contacts_secret_share"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "0ee2e00e444da6707674388c8126e806a8ce02bd0008cc5774ca2d4e11aa5002"
}

View file

@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT group_id, total_media_counter, last_flame_counter_change,\n last_flame_sync, flame_counter\n FROM groups WHERE last_flame_counter_change IS NOT NULL",
"describe": {
"columns": [
{
"name": "group_id",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "groups",
"name": "group_id"
}
}
},
{
"name": "total_media_counter",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "total_media_counter"
}
}
},
{
"name": "last_flame_counter_change",
"ordinal": 2,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_flame_counter_change"
}
}
},
{
"name": "last_flame_sync",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "last_flame_sync"
}
}
},
{
"name": "flame_counter",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "groups",
"name": "flame_counter"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
true,
true,
false
]
},
"hash": "10facb63e1057c6acfe8b60cfe5f6deb3535eed60977d917f57cae97bb220e30"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE contacts\n SET username = COALESCE(?, username),\n display_name = ?,\n avatar_svg_compressed = ?,\n sender_profile_counter = COALESCE(?, sender_profile_counter)\n WHERE user_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "117582dbddd40553a8a622ed65b24284d8fb91c141ba989e2e3f4846c93d2a4c"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO receipts(receipt_id, contact_id, message, contact_will_sends_receipt)\n VALUES (?, ?, ?, 0)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "121378634faf12e63bdd7e116c8a0b24a9e20e39c41e11be8e98b32f7d0e8f61"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT media_id FROM messages WHERE message_id = ? AND sender_id = ?",
"describe": {
"columns": [
{
"name": "media_id",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "messages",
"name": "media_id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "13e31c854a9086ef9fb5ae3a79ad5b915012fed2a79cf7275d74add38acff057"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT OR REPLACE INTO receipts(receipt_id, contact_id, message, contact_will_sends_receipt, wake_receiver)\n VALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "141c8905f5d1f47fdbd88a007115dda16edadf2e6f92e7b190e99925870bdc18"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(\n SELECT 1 FROM group_members\n WHERE group_id = ? AND contact_id = ? AND member_state != 'leftGroup'\n )",
"describe": {
"columns": [
{
"name": "EXISTS(\n SELECT 1 FROM group_members\n WHERE group_id = ? AND contact_id = ? AND member_state != 'leftGroup'\n )",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "1446c19ba57adcff2aeecf55e73196002e6e58ddd91f9fb7811fd43e67d956c8"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT media_id\n FROM messages\n WHERE message_id = ? AND sender_id = ?\n ",
"query": "\n SELECT media_id\n FROM messages\n WHERE message_id = ?\n ",
"describe": {
"columns": [
{
@ -16,11 +16,11 @@
}
],
"parameters": {
"Right": 2
"Right": 1
},
"nullable": [
true
]
},
"hash": "8edba8bc58b60b678cb1730572119bd0f1ba1f98232e9d61eb0b7c920a5526f0"
"hash": "18c5303b936b2615c4da0bd935b374fabd4973f6cbb5032d76a5d8824c5d2610"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT accepted, requested FROM contacts WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "accepted",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "accepted"
}
}
},
{
"name": "requested",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "requested"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "18c85d89372ea21ae6cc75750b1c2f8fac5d97ad75b1beb452176b36d3fbcaa7"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO media_files(media_id, type, download_state, upload_state) VALUES (?, 'image', 'ready', 'uploaded')",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "19645cdadf2646e2c3afb1f09dd509a62222baebc54743f3485368fdf27c167a"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT OR IGNORE INTO notification_outbox(\n event_id, notification_id, conversation_id, sender_id,\n message_id, kind, content, created_at\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "19af16bcf6c54900ed6032ce8ed7b5ef9d71456488805e343c2af10ad471b22a"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE receipts\n SET mark_for_retry = CAST(strftime('%s', 'now') AS INTEGER),\n retry_count = retry_count + 1,\n ack_by_server_at = NULL\n WHERE receipt_id = ? AND contact_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "1acafcad398cb31491dcee91a005eddd33a03d40afa505b7e0b5f59e2f731a20"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO user_discovery_user_relations (\n announced_user_id,\n from_contact_id,\n public_key_verified_timestamp\n ) VALUES (?, ?, ?)\n ON CONFLICT(announced_user_id, from_contact_id) DO UPDATE SET\n public_key_verified_timestamp = excluded.public_key_verified_timestamp\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "1be4155879419ae7cdcd7f8774ea5a6cce7ecb05e825095f4fffd6aaf4288d18"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT last_type_indicator FROM group_members WHERE group_id = ? AND contact_id = ?",
"describe": {
"columns": [
{
"name": "last_type_indicator",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "group_members",
"name": "last_type_indicator"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
true
]
},
"hash": "1d8a2e974208c2ca67e4fe792c4aedcaf5bb86dc24f09ed71069d3868df833ee"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT upload_state FROM media_files WHERE media_id = ?",
"describe": {
"columns": [
{
"name": "upload_state",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "media_files",
"name": "upload_state"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "1dad19878770f615fde37f4fa8ce4cb8254a723c4d353a2ccc1d9d224bb68971"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT recovery_last_heartbeat FROM contacts WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "recovery_last_heartbeat",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "recovery_last_heartbeat"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "1e27aa2db6de33b41610d8fe9394b7d54224d8717dd687516da0ced5268e4899"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(\n SELECT 1 FROM user_discovery_own_promotions\n WHERE contact_id = ? AND length(promotion) > 0\n )",
"describe": {
"columns": [
{
"name": "EXISTS(\n SELECT 1 FROM user_discovery_own_promotions\n WHERE contact_id = ? AND length(promotion) > 0\n )",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "1f22026fd1c3dc141303b1534b4d8fc497dc5fab1685aa8999f9ec9056b38df7"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE contacts SET recovery_secret_share = ?, recovery_is_trusted_friend = 1 WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "1f8ab52f86c62c865231b13253c61b6992a527e9fd1d89f074bed821fcb9095d"
}

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO received_receipts(receipt_id)\n VALUES (?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2136bd64733c0268d4da52cf36db8df3533d34a45008bddf61e1090ae1ace885"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE contacts SET requested = 1, deleted_by_user = 0 WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2138b7b197c5637580e0aa98d5e2d7e80b0cc1b660ab96ace53bb90ea5ff2968"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM api_outbox WHERE sequence_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "225cd55439cc89f0ee7bc53a0f19cf53a98ec2bdc32c82e43c6beef3bdc23d98"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE groups SET draft_message = 'draft text' WHERE group_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "23ce3aa70fcb31de3c6e6b1ba99750488d5d732b65ee326ca5b5a1626d43155c"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(*) FROM receipts WHERE contact_id = 9",
"describe": {
"columns": [
{
"name": "COUNT(*)",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "274f8f246a13f484fd1941bb0e570a06a7db075f26dfe0c943aefa0d66cff7aa"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO group_members(group_id, contact_id, member_state) VALUES (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "28663e0e60055c3bef26992fecdaed69a4ae46aa385d8927a62504f838987dfc"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO group_histories(group_history_id, group_id, contact_id, type)\n VALUES (?, ?, ?, 'addMember')",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "291bf3c6dfb80861e3a250243693beff0798bc51759ea525dae477bff2096585"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT contact_id FROM group_members WHERE group_id = ? AND group_public_key = ?",
"describe": {
"columns": [
{
"name": "contact_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "group_members",
"name": "contact_id"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "29bd81e76831e95b2a26531cbcf59139c1954d23158600ace4b314fcd4626992"
}

View file

@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "\n SELECT r.contact_id, r.message, r.message_id, r.contact_will_sends_receipt,\n r.wake_receiver, c.account_deleted, c.signal_version\n FROM receipts r\n JOIN contacts c ON c.user_id = r.contact_id\n WHERE r.receipt_id = ?\n ",
"describe": {
"columns": [
{
"name": "contact_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "receipts",
"name": "contact_id"
}
}
},
{
"name": "message",
"ordinal": 1,
"type_info": "Blob",
"origin": {
"Table": {
"table": "receipts",
"name": "message"
}
}
},
{
"name": "message_id",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "receipts",
"name": "message_id"
}
}
},
{
"name": "contact_will_sends_receipt",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "receipts",
"name": "contact_will_sends_receipt"
}
}
},
{
"name": "wake_receiver",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "receipts",
"name": "wake_receiver"
}
}
},
{
"name": "account_deleted",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "account_deleted"
}
}
},
{
"name": "signal_version",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "contacts",
"name": "signal_version"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
false,
false,
false,
false
]
},
"hash": "2a923146a880d7f8684495e93324a743ae58b3f32e01e5ad1fa5a304bb535ae5"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET type = ?, download_state = 'pending',\n requires_authentication = ?, display_limit_in_milliseconds = ?,\n download_token = ?, encryption_key = ?, encryption_mac = ?,\n encryption_nonce = ?, created_at = ? WHERE media_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "2b86ca80f38f47f115c8c8e6ce79343574e6ca435946c303dd8f35e68bfc57ef"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE contacts SET recovery_contacts_last_heartbeat = ? WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "2c4a9f54ba414124c446f52333a1c2dcf88c8ad4d4d96f746ce47532452ec19d"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE media_files SET download_state = 'reuploadRequested' WHERE media_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2cc4a73378cf37ff6e3dcbd761fec56686a8f9820a3bb2c489e94fb036de0786"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO group_members(group_id, contact_id, member_state)\n VALUES (?, ?, ?)\n ON CONFLICT(group_id, contact_id)\n DO UPDATE SET member_state = excluded.member_state\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "2e24484b81be3b3a726c018b26311d543af677422a69318c0e187c3cf7379bf7"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT contact_id FROM group_members\n WHERE group_id = ? AND (member_state IS NULL OR member_state != 'leftGroup')",
"describe": {
"columns": [
{
"name": "contact_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "group_members",
"name": "contact_id"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "2e5c2ccc9be071bcebcb9c8a7b0564e6ef21a38106bb9bc90c840ac1903b75d5"
}

View file

@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT group_name FROM groups WHERE group_id = ?",
"describe": {
"columns": [
{
"name": "group_name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "groups",
"name": "group_name"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "2ec060bf87e7c0a76c47be0010f9f9c37d20f2ea3ae1da27c757a5d32b60a1cc"
}

View file

@ -0,0 +1,21 @@
{
"db_name": "SQLite",
"query": "\n SELECT EXISTS(\n SELECT 1\n FROM key_verifications AS verification\n WHERE verification.contact_id = ?\n AND (\n verification.type != 'contactSharedByVerified'\n OR EXISTS(\n SELECT 1 FROM key_verifications AS verifier_verification\n WHERE verifier_verification.contact_id = verification.verified_by\n )\n )\n )\n ",
"describe": {
"columns": [
{
"name": "EXISTS(\n SELECT 1\n FROM key_verifications AS verification\n WHERE verification.contact_id = ?\n AND (\n verification.type != 'contactSharedByVerified'\n OR EXISTS(\n SELECT 1 FROM key_verifications AS verifier_verification\n WHERE verifier_verification.contact_id = verification.verified_by\n )\n )\n )",
"ordinal": 0,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "2f1854f10ac694a9dcabe133c32d986978bf168dccce04d461beeef220f8952c"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO message_histories(message_id, content, created_at)\n SELECT message_id, content, ? FROM messages\n WHERE message_id = ? AND sender_id = ? AND content IS NOT NULL",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "2fec92a566a99026de111e1b9b175719cb54932ea03bd754747407d3291f764d"
}

View file

@ -22,5 +22,5 @@
true
]
},
"hash": "5988df8ec10b625bbd16ddcd2dccad54f2b209e74849509c1905e0f106756c7d"
"hash": "345381ad177426da0800eaee0c04352e78377c106d7e156c07aef00858bc8bc8"
}

Some files were not shown because too many files have changed in this diff Show more