Merge pull request #393 from twonlyapp/dev
Some checks are pending
Publish on Github / build_and_publish (push) Waiting to run

- New: Groups can now collect flames as well
- New: Background execution to pre-load messages 
- New: Adds a link if the image contains a QR code
- Improve: Video compression with progress updates
- Improve: Show message "Flames restored"
- Improve: Show toast message if user was added via QR 
- Fix: Media file appears as a white square and is not listed.
- Fix: Issue with media files required to be reuploaded
- Fix: Problem during contact requests
- Fix: Problem with deleting a contact
- Fix: Problem with restoring from backup
- Fix: Issue with the log file
This commit is contained in:
Tobi 2026-03-15 12:07:53 +01:00 committed by GitHub
commit 5e759df55b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
137 changed files with 56909 additions and 78995 deletions

View file

@ -25,8 +25,11 @@ jobs:
- name: Cloning sub-repos
run: git submodule update --init --recursive
- name: Check flutter code
run: |
flutter pub get
flutter analyze
flutter test
- name: flutter pub get
run: flutter pub get
- name: flutter analyze
run: flutter analyze
- name: flutter test
run: flutter test

View file

@ -1,12 +1,27 @@
# Changelog
## 0.0.99
- New: Groups can now collect flames as well
- New: Background execution to pre-load messages
- New: Adds a link if the image contains a QR code
- Improve: Video compression with progress updates
- Improve: Show message "Flames restored"
- Improve: Show toast message if user was added via QR
- Fix: Media file appears as a white square and is not listed.
- Fix: Issue with media files required to be reuploaded
- Fix: Problem during contact requests
- Fix: Problem with deleting a contact
- Fix: Problem with restoring from backup
- Fix: Issue with the log file
## 0.0.96
Feature: Show link in chat if the saved media file contains one
Improve: Verification badge for groups
Improve: Huge reduction in app size
Fix: Crash on older devices when compressing a video
Fix: Problem with decrypting messages fixed
- Feature: Show link in chat if the saved media file contains one
- Improve: Verification badge for groups
- Improve: Huge reduction in app size
- Fix: Crash on older devices when compressing a video
- Fix: Problem with decrypting messages fixed
## 0.0.93

View file

@ -23,12 +23,12 @@ android {
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "1.8"
jvmTarget = "17"
}
defaultConfig {
@ -72,4 +72,5 @@ flutter {
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation 'com.otaliastudios:transcoder:0.11.0'
}

View file

@ -1,7 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<application
android:label="twonly"
android:name="${applicationName}"
android:name=".MyApplication"
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"

View file

@ -1,27 +1,14 @@
package eu.twonly
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.embedding.android.FlutterFragmentActivity
import android.view.KeyEvent
import dev.darttools.flutter_android_volume_keydown.FlutterAndroidVolumeKeydownPlugin.eventSink
import android.view.KeyEvent.KEYCODE_VOLUME_DOWN
import android.view.KeyEvent.KEYCODE_VOLUME_UP
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.OutputStream
class MainActivity : FlutterFragmentActivity() {
private val MEDIA_STORE_CHANNEL = "eu.twonly/mediaStore"
private lateinit var channel: MethodChannel
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KEYCODE_VOLUME_DOWN && eventSink != null) {
eventSink!!.success(true)
@ -37,80 +24,7 @@ class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MEDIA_STORE_CHANNEL)
channel.setMethodCallHandler {call, result ->
try {
if (call.method == "safeFileToDownload") {
val arguments = call.arguments<Map<String, String>>() as Map<String, String>
val sourceFile = arguments["sourceFile"]
if (sourceFile == null) {
result.success(false)
} else {
val context = applicationContext
val inputStream = FileInputStream(File(sourceFile))
val outputName = File(sourceFile).name.takeIf { it.isNotEmpty() } ?: "memories.zip"
val savedUri = saveZipToDownloads(context, outputName, inputStream)
if (savedUri != null) {
result.success(savedUri.toString())
} else {
result.error("SAVE_FAILED", "Could not save ZIP", null)
}
}
} else {
result.notImplemented()
}
} catch (e: Exception) {
result.error("EXCEPTION", e.message, null)
}
}
}
}
fun saveZipToDownloads(
context: Context,
fileName: String = "archive.zip",
sourceStream: InputStream
): android.net.Uri? {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/zip")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Files.getContentUri("external")
}
val uri = resolver.insert(collection, contentValues) ?: return null
try {
resolver.openOutputStream(uri).use { out: OutputStream? ->
requireNotNull(out) { "Unable to open output stream" }
sourceStream.use { input ->
input.copyTo(out)
}
out.flush()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
resolver.update(uri, done, null, null)
}
return uri
} catch (e: Exception) {
try { resolver.delete(uri, null, null) } catch (_: Exception) {}
return null
MediaStoreChannel.configure(flutterEngine, applicationContext)
VideoCompressionChannel.configure(flutterEngine, applicationContext)
}
}

View file

@ -0,0 +1,92 @@
package eu.twonly
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.io.OutputStream
object MediaStoreChannel {
private const val CHANNEL = "eu.twonly/mediaStore"
fun configure(flutterEngine: FlutterEngine, context: Context) {
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel.setMethodCallHandler { call, result ->
try {
if (call.method == "safeFileToDownload") {
val arguments = call.arguments<Map<String, String>>() as Map<String, String>
val sourceFile = arguments["sourceFile"]
if (sourceFile == null) {
result.success(false)
} else {
val inputStream = FileInputStream(File(sourceFile))
val outputName = File(sourceFile).name.takeIf { it.isNotEmpty() } ?: "memories.zip"
val savedUri = saveZipToDownloads(context, outputName, inputStream)
if (savedUri != null) {
result.success(savedUri.toString())
} else {
result.error("SAVE_FAILED", "Could not save ZIP", null)
}
}
} else {
result.notImplemented()
}
} catch (e: Exception) {
result.error("EXCEPTION", e.message, null)
}
}
}
private fun saveZipToDownloads(
context: Context,
fileName: String = "archive.zip",
sourceStream: InputStream
): android.net.Uri? {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/zip")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
}
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Files.getContentUri("external")
}
val uri = resolver.insert(collection, contentValues) ?: return null
try {
resolver.openOutputStream(uri).use { out: OutputStream? ->
requireNotNull(out) { "Unable to open output stream" }
sourceStream.use { input ->
input.copyTo(out)
}
out.flush()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }
resolver.update(uri, done, null, null)
}
return uri
} catch (e: Exception) {
try { resolver.delete(uri, null, null) } catch (_: Exception) {}
return null
}
}
}

View file

@ -0,0 +1,13 @@
package eu.twonly
import io.flutter.app.FlutterApplication
import dev.fluttercommunity.workmanager.WorkmanagerDebug
import dev.fluttercommunity.workmanager.LoggingDebugHandler
class MyApplication : FlutterApplication() {
override fun onCreate() {
super.onCreate()
// This enables the internal plugin logging to Logcat
WorkmanagerDebug.setCurrent(LoggingDebugHandler())
}
}

View file

@ -1,6 +0,0 @@
package eu.twonly
class MyMediaStorageProxy {
}

View file

@ -0,0 +1,107 @@
package eu.twonly
import android.content.Context
import android.media.MediaFormat
import android.os.Handler
import android.os.Looper
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import com.otaliastudios.transcoder.strategy.DefaultAudioStrategy
import com.otaliastudios.transcoder.strategy.DefaultVideoStrategy
import com.otaliastudios.transcoder.strategy.TrackStrategy
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
object VideoCompressionChannel {
private const val CHANNEL = "eu.twonly/videoCompression"
// Compression parameters defined natively (as requested)
private const val VIDEO_BITRATE = 2_000_000L // 2 Mbps
// Audio parameters defined natively
private const val AUDIO_BITRATE = 128_000L // 128 kbps
private const val AUDIO_SAMPLE_RATE = 44_100
private const val AUDIO_CHANNELS = 2
fun configure(flutterEngine: FlutterEngine, context: Context) {
val channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
channel.setMethodCallHandler { call, result ->
try {
if (call.method == "compressVideo") {
val arguments = call.arguments<Map<String, Any>>() ?: emptyMap()
val inputPath = arguments["input"] as? String
val outputPath = arguments["output"] as? String
if (inputPath == null || outputPath == null) {
result.error("INVALID_ARGS", "Input or output path missing", null)
return@setMethodCallHandler
}
val mainHandler = Handler(Looper.getMainLooper())
val baseVideoStrategy = DefaultVideoStrategy.Builder()
.keyFrameInterval(3f)
.bitRate(VIDEO_BITRATE)
.addResizer(com.otaliastudios.transcoder.resize.AtMostResizer(1920, 1080))
.build()
val trackStrategyClass = TrackStrategy::class.java
val hevcStrategy = java.lang.reflect.Proxy.newProxyInstance(
trackStrategyClass.classLoader,
arrayOf(trackStrategyClass)
) { _, method, args ->
val result = if (args != null) method.invoke(baseVideoStrategy, *args) else method.invoke(baseVideoStrategy)
if (method.name == "createOutputFormat" && result is MediaFormat) {
result.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_VIDEO_HEVC)
}
result
} as TrackStrategy
Transcoder.into(outputPath)
.addDataSource(inputPath)
.setVideoTrackStrategy(hevcStrategy)
.setAudioTrackStrategy(
DefaultAudioStrategy.builder()
.channels(AUDIO_CHANNELS)
.sampleRate(AUDIO_SAMPLE_RATE)
.bitRate(AUDIO_BITRATE)
.build()
)
.setListener(object : TranscoderListener {
override fun onTranscodeProgress(progress: Double) {
mainHandler.post {
val mappedProgress = (progress * 100).toInt()
channel.invokeMethod("onProgress", mapOf("progress" to mappedProgress))
}
}
override fun onTranscodeCompleted(successCode: Int) {
mainHandler.post {
result.success(outputPath)
}
}
override fun onTranscodeCanceled() {
mainHandler.post {
result.error("CANCELED", "Video compression canceled", null)
}
}
override fun onTranscodeFailed(exception: Throwable) {
mainHandler.post {
result.error("FAILED", exception.message, null)
}
}
})
.transcode()
} else {
result.notImplemented()
}
} catch (e: Exception) {
result.error("EXCEPTION", e.message, null)
}
}
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

@ -1 +1 @@
Subproject commit 33111edeb285db34edeb9fd21762825babe71ab0
Subproject commit 24d048b4abbe5c266b09965cc6f3ebdf83f97855

View file

@ -141,20 +141,20 @@ struct PushNotification: Sendable {
var kind: PushKind = .reaction
var messageID: String {
get {return _messageID ?? String()}
get {_messageID ?? String()}
set {_messageID = newValue}
}
/// Returns true if `messageID` has been explicitly set.
var hasMessageID: Bool {return self._messageID != nil}
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 {return _additionalContent ?? String()}
get {_additionalContent ?? String()}
set {_additionalContent = newValue}
}
/// Returns true if `additionalContent` has been explicitly set.
var hasAdditionalContent: Bool {return self._additionalContent != nil}
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}
@ -190,11 +190,11 @@ struct PushUser: Sendable {
var blocked: Bool = false
var lastMessageID: String {
get {return _lastMessageID ?? String()}
get {_lastMessageID ?? String()}
set {_lastMessageID = newValue}
}
/// Returns true if `lastMessageID` has been explicitly set.
var hasLastMessageID: Bool {return self._lastMessageID != nil}
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}

View file

@ -49,55 +49,55 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase (12.8.0):
- Firebase/Core (= 12.8.0)
- Firebase/Core (12.8.0):
- Firebase (12.9.0):
- Firebase/Core (= 12.9.0)
- Firebase/Core (12.9.0):
- Firebase/CoreOnly
- FirebaseAnalytics (~> 12.8.0)
- Firebase/CoreOnly (12.8.0):
- FirebaseCore (~> 12.8.0)
- Firebase/Messaging (12.8.0):
- FirebaseAnalytics (~> 12.9.0)
- Firebase/CoreOnly (12.9.0):
- FirebaseCore (~> 12.9.0)
- Firebase/Messaging (12.9.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.8.0)
- firebase_core (4.4.0):
- Firebase/CoreOnly (= 12.8.0)
- FirebaseMessaging (~> 12.9.0)
- firebase_core (4.5.0):
- Firebase/CoreOnly (= 12.9.0)
- Flutter
- firebase_messaging (16.1.1):
- Firebase/Messaging (= 12.8.0)
- firebase_messaging (16.1.2):
- Firebase/Messaging (= 12.9.0)
- firebase_core
- Flutter
- FirebaseAnalytics (12.8.0):
- FirebaseAnalytics/Default (= 12.8.0)
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
- FirebaseAnalytics (12.9.0):
- FirebaseAnalytics/Default (= 12.9.0)
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/Default (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
- GoogleAppMeasurement/Default (= 12.8.0)
- FirebaseAnalytics/Default (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleAppMeasurement/Default (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (12.8.0):
- FirebaseCoreInternal (~> 12.8.0)
- FirebaseCore (12.9.0):
- FirebaseCoreInternal (~> 12.9.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.8.0):
- FirebaseCoreInternal (12.9.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (12.9.0):
- FirebaseCore (~> 12.9.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
- FirebaseMessaging (12.9.0):
- FirebaseCore (~> 12.9.0)
- FirebaseInstallations (~> 12.9.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
@ -140,23 +140,23 @@ PODS:
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.8.0):
- GoogleAppMeasurement/Core (12.9.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Default (12.8.0):
- GoogleAppMeasurement/Default (12.9.0):
- GoogleAdsOnDeviceConversion (~> 3.2.0)
- GoogleAppMeasurement/Core (= 12.8.0)
- GoogleAppMeasurement/IdentitySupport (= 12.8.0)
- GoogleAppMeasurement/Core (= 12.9.0)
- GoogleAppMeasurement/IdentitySupport (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.8.0):
- GoogleAppMeasurement/Core (= 12.8.0)
- GoogleAppMeasurement/IdentitySupport (12.9.0):
- GoogleAppMeasurement/Core (= 12.9.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
@ -267,7 +267,7 @@ PODS:
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- no_screenshot (0.3.2-beta.3):
- no_screenshot (0.10.0):
- Flutter
- package_info_plus (0.4.5):
- Flutter
@ -276,7 +276,7 @@ PODS:
- pro_video_editor (0.0.1):
- Flutter
- PromisesObjC (2.4.0)
- restart_app (0.0.1):
- restart_app (1.7.3):
- Flutter
- SDWebImage (5.21.6):
- SDWebImage/Core (= 5.21.6)
@ -285,7 +285,7 @@ PODS:
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- Sentry/HybridSDK (8.56.2)
- sentry_flutter (9.13.0):
- sentry_flutter (9.14.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.56.2)
@ -326,11 +326,11 @@ PODS:
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- video_compress (0.3.0):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- workmanager_apple (0.0.1):
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@ -375,8 +375,8 @@ DEPENDENCIES:
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- SwiftProtobuf
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_compress (from `.symlinks/plugins/video_compress/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
SPEC REPOS:
trunk:
@ -484,10 +484,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_compress:
:path: ".symlinks/plugins/video_compress/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
workmanager_apple:
:path: ".symlinks/plugins/workmanager_apple/ios"
SPEC CHECKSUMS:
app_links: a754cbec3c255bd4bbb4d236ecc06f28cd9a7ce8
@ -501,14 +501,14 @@ SPEC CHECKSUMS:
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d
firebase_core: ee30637e6744af8e0c12a6a1e8a9718506ec2398
firebase_messaging: 343de01a8d3e18b60df0c6d37f7174c44ae38e02
FirebaseAnalytics: f20bbad8cb7f65d8a5eaefeb424ae8800a31bdfc
FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c
FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
FirebaseMessaging: 7f42cfd10ec64181db4e01b305a613791c8e782c
Firebase: 065f2bb395062046623036d8e6dc857bc2521d56
firebase_core: afac1aac13c931e0401c7e74ed1276112030efab
firebase_messaging: 7cb2727feb789751fc6936bcc8e08408970e2820
FirebaseAnalytics: cd7d01d352f3c237c9a0e31552c257cd0b0c0352
FirebaseCore: 428912f751178b06bef0a1793effeb4a5e09a9b8
FirebaseCoreInternal: b321eafae5362113bc182956fafc9922cfc77b72
FirebaseInstallations: 7b64ffd006032b2b019a59b803858df5112d9eaa
FirebaseMessaging: 7d6cdbff969127c4151c824fe432f0e301210f15
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
flutter_keyboard_visibility_temp_fork: 95b2d534bacf6ac62e7fcbe5c2a9e2c2a17ce06f
@ -521,7 +521,7 @@ SPEC CHECKSUMS:
google_mlkit_commons: a5e4ffae5bc59ea4c7b9025dc72cb6cb79dc1166
google_mlkit_face_detection: ee4b72cfae062b4c972204be955d83055a4bfd36
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
GoogleAppMeasurement: fce7c1c90640d2f9f5c56771f71deacb2ba3f98c
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
@ -538,16 +538,16 @@ SPEC CHECKSUMS:
MLKitFaceDetection: 32549f1e70e6e7731261bf9cea2b74095e2531cb
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
no_screenshot: 5e345998c43ffcad5d6834f249590483fcc037bd
no_screenshot: 03c8ac6586f9652cd45e3d12d74e5992256403ac
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
pro_video_editor: 44ef9a6d48dbd757ed428cf35396dd05f35c7830
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
restart_app: 9cda5378aacc5000e3f66ee76a9201534e7d3ecf
restart_app: 0714144901e260eae68f7afc2fc4aacc1a323ad2
SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7
sentry_flutter: dbed9a62ae39716b685a80140705c330d200d941
sentry_flutter: 841fa2fe08dc72eb95e2320b76e3f751f3400cf5
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
@ -556,8 +556,8 @@ SPEC CHECKSUMS:
SwiftProtobuf: c901f00a3e125dc33cac9b16824da85682ee47da
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_compress: f2133a07762889d67f0711ac831faa26f956980e
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
PODFILE CHECKSUM: ae041999f13ba7b2285ff9ad9bc688ed647bbcb7

View file

@ -21,6 +21,7 @@
D21FCEAB2D9F2B750088701D /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D21FCEA42D9F2B750088701D /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D25D4D1E2EF626E30029F805 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25D4D1D2EF626E30029F805 /* StoreKit.framework */; };
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 */; };
/* End PBXBuildFile section */
@ -107,6 +108,7 @@
D25D4D1D2EF626E30029F805 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
D25D4D702EFF41DB0029F805 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoCompressionChannel.swift; sourceTree = "<group>"; };
DC1EE71614E1B4F84D6FDC2D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
E190E82D9973B318A389650B /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
@ -235,6 +237,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
D2B2E0FE2F63819600E729C1 /* VideoCompressionChannel.swift */,
D24E27CC2F38ABC10055D9D1 /* RunnerRelease.entitlements */,
D25D4D802EFF437F0029F805 /* RunnerDebug.entitlements */,
D2265DD42D920142000D99BB /* Runner.entitlements */,
@ -624,6 +627,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D2B2E0FF2F63819600E729C1 /* VideoCompressionChannel.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);

View file

@ -4,6 +4,7 @@ import Foundation
import UIKit
import UserNotifications
import flutter_sharing_intent
import workmanager_apple
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
@ -12,6 +13,22 @@ import flutter_sharing_intent
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = self
if let registrar = self.registrar(forPlugin: "VideoCompressionChannel") {
VideoCompressionChannel.register(with: registrar.messenger())
}
WorkmanagerDebug.setCurrent(LoggingDebugHandler())
WorkmanagerPlugin.registerPeriodicTask(
withIdentifier: "eu.twonly.periodic_task",
frequency: NSNumber(value: 20 * 60)
)
WorkmanagerPlugin.registerBGProcessingTask(
withIdentifier: "eu.twonly.processing_task"
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

View file

@ -2,27 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
@ -43,6 +24,17 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>SharingMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FIREBASE_ANALYTICS_COLLECTION_ENABLED</key>
@ -51,10 +43,10 @@
<false/>
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Use your camera to make photos or videos and share them encrypted with your friends.</string>
<key>NSFaceIDUsageDescription</key>
@ -63,12 +55,39 @@
<string>Use your microphone to enable audio when making videos.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>twonly will save photos or videos to your library.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>eu.twonly.periodic_task</string>
<string>eu.twonly.processing_task</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
@ -89,19 +108,5 @@
</array>
<key>firebase_performance_collection_deactivated</key>
<true/>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>SharingMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
</dict>
</plist>

View file

@ -0,0 +1,253 @@
import Foundation
import Flutter
import AVFoundation
class VideoCompressionChannel {
private let channelName = "eu.twonly/videoCompression"
// Hold a strong reference so the instance isn't immediately deallocated
private static var activeInstance: VideoCompressionChannel?
static func register(with messenger: FlutterBinaryMessenger) {
let instance = VideoCompressionChannel()
activeInstance = instance
let channel = FlutterMethodChannel(name: instance.channelName, binaryMessenger: messenger)
print("[VideoCompressionChannel] Registered channel: \(instance.channelName)")
channel.setMethodCallHandler { [weak instance] (call: FlutterMethodCall, result: @escaping FlutterResult) in
print("[VideoCompressionChannel] Received method call: \(call.method)")
instance?.handle(call, result: result, channel: channel)
}
}
init() {}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult, channel: FlutterMethodChannel) {
if call.method == "compressVideo" {
guard let args = call.arguments as? [String: Any],
let inputPath = args["input"] as? String,
let outputPath = args["output"] as? String else {
print("[VideoCompressionChannel] Error: Missing input or output path in arguments")
result(FlutterError(code: "INVALID_ARGS", message: "Input or output path missing", details: nil))
return
}
print("[VideoCompressionChannel] Starting compressVideo from \(inputPath) to \(outputPath)")
compress(inputPath: inputPath, outputPath: outputPath, channel: channel, result: result)
} else {
print("[VideoCompressionChannel] Method not implemented: \(call.method)")
result(FlutterMethodNotImplemented)
}
}
func compress(inputPath: String, outputPath: String, channel: FlutterMethodChannel, result: @escaping FlutterResult) {
let inputURL = URL(fileURLWithPath: inputPath)
let outputURL = URL(fileURLWithPath: outputPath)
if FileManager.default.fileExists(atPath: outputURL.path) {
print("[VideoCompressionChannel] Removing existing file at output path")
try? FileManager.default.removeItem(at: outputURL)
}
let asset = AVAsset(url: inputURL)
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
print("[VideoCompressionChannel] Error: No video track found in asset")
result(FlutterError(code: "NO_VIDEO_TRACK", message: "Video track not found", details: nil))
return
}
let naturalSize = videoTrack.naturalSize
let transform = videoTrack.preferredTransform
let isPortrait = transform.a == 0 && abs(transform.b) == 1.0 && abs(transform.c) == 1.0 && transform.d == 0
let originalWidth = isPortrait ? naturalSize.height : naturalSize.width
let originalHeight = isPortrait ? naturalSize.width : naturalSize.height
let maxDimension: CGFloat = 1920.0
let minDimension: CGFloat = 1080.0
var targetWidth = originalWidth
var targetHeight = originalHeight
if targetWidth > maxDimension || targetHeight > maxDimension {
let widthRatio = maxDimension / targetWidth
let heightRatio = minDimension / targetHeight
let scaleFactor = min(widthRatio, heightRatio)
targetWidth *= scaleFactor
targetHeight *= scaleFactor
}
let targetBitrate = 3_000_000
do {
let reader = try AVAssetReader(asset: asset)
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
let videoSettings: [String: Any] = [
AVVideoCodecKey: AVVideoCodecType.hevc,
AVVideoWidthKey: Int(targetWidth),
AVVideoHeightKey: Int(targetHeight),
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: targetBitrate
]
]
let readerOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
])
let writerInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
writerInput.expectsMediaDataInRealTime = false
writerInput.transform = videoTrack.preferredTransform
guard writer.canAdd(writerInput) else {
result(FlutterError(code: "WRITER_ERROR", message: "Cannot add video writer input", details: nil))
return
}
guard reader.canAdd(readerOutput) else {
result(FlutterError(code: "READER_ERROR", message: "Cannot add video reader output", details: nil))
return
}
reader.add(readerOutput)
writer.add(writerInput)
// Audio processing (re-encode to AAC)
var audioReaderOutput: AVAssetReaderTrackOutput?
var audioWriterInput: AVAssetWriterInput?
if let audioTrack = asset.tracks(withMediaType: .audio).first {
let audioReaderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM
]
let aReaderOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioReaderSettings)
let audioWriterSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVNumberOfChannelsKey: 2,
AVSampleRateKey: 44100,
AVEncoderBitRateKey: 128000
]
let aWriterInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioWriterSettings)
aWriterInput.expectsMediaDataInRealTime = false
if reader.canAdd(aReaderOutput) && writer.canAdd(aWriterInput) {
reader.add(aReaderOutput)
writer.add(aWriterInput)
audioReaderOutput = aReaderOutput
audioWriterInput = aWriterInput
} else {
print("[VideoCompressionChannel] Warning: Cannot add audio tracks, proceeding without audio")
}
}
guard reader.startReading() else {
result(FlutterError(code: "READER_ERROR", message: "Cannot start reading: \(reader.error?.localizedDescription ?? "unknown error")", details: nil))
return
}
guard writer.startWriting() else {
result(FlutterError(code: "WRITER_ERROR", message: "Cannot start writing: \(writer.error?.localizedDescription ?? "unknown error")", details: nil))
return
}
writer.startSession(atSourceTime: .zero)
let duration = CMTimeGetSeconds(asset.duration)
let videoQueue = DispatchQueue(label: "videoQueue")
let audioQueue = DispatchQueue(label: "audioQueue")
let group = DispatchGroup()
// State tracking flag to avoid sending completed messages prematurely
var isVideoCompleted = false
var isAudioCompleted = audioWriterInput == nil
group.enter()
writerInput.requestMediaDataWhenReady(on: videoQueue) {
while writerInput.isReadyForMoreMediaData {
if reader.status != .reading {
if !isVideoCompleted {
isVideoCompleted = true
writerInput.markAsFinished()
group.leave()
}
return
}
if let sampleBuffer = readerOutput.copyNextSampleBuffer() {
let presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
let timeInSeconds = CMTimeGetSeconds(presentationTime)
if duration > 0 {
let progress = Int((timeInSeconds / duration) * 100)
DispatchQueue.main.async {
channel.invokeMethod("onProgress", arguments: ["progress": progress])
}
}
writerInput.append(sampleBuffer)
} else {
if !isVideoCompleted {
isVideoCompleted = true
writerInput.markAsFinished()
group.leave()
}
break
}
}
}
if let audioWriterInput = audioWriterInput, let audioReaderOutput = audioReaderOutput {
group.enter()
audioWriterInput.requestMediaDataWhenReady(on: audioQueue) {
while audioWriterInput.isReadyForMoreMediaData {
if reader.status != .reading {
if !isAudioCompleted {
isAudioCompleted = true
audioWriterInput.markAsFinished()
group.leave()
}
return
}
if let sampleBuffer = audioReaderOutput.copyNextSampleBuffer() {
audioWriterInput.append(sampleBuffer)
} else {
if !isAudioCompleted {
isAudioCompleted = true
audioWriterInput.markAsFinished()
group.leave()
}
break
}
}
}
}
group.notify(queue: .main) {
if reader.status == .completed {
writer.finishWriting {
if writer.status == .completed {
print("[VideoCompressionChannel] Compression completed successfully!")
result(outputPath)
} else {
print("[VideoCompressionChannel] Writer Error: \(writer.error?.localizedDescription ?? "Unknown error")")
result(FlutterError(code: "WRITER_ERROR", message: writer.error?.localizedDescription, details: nil))
}
}
} else {
writer.cancelWriting()
print("[VideoCompressionChannel] Reader Error: \(reader.error?.localizedDescription ?? "Unknown error")")
result(FlutterError(code: "READER_ERROR", message: reader.error?.localizedDescription, details: nil))
}
}
} catch {
print("[VideoCompressionChannel] Exception: \(error.localizedDescription)")
result(FlutterError(code: "COMPRESS_ERROR", message: error.localizedDescription, details: nil))
}
}
}

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
@ -188,7 +187,7 @@ class _AppMainWidgetState extends State<AppMainWidget> {
if (_showDatabaseMigration) {
child = const Center(child: Text('Please reinstall twonly.'));
} else if (_isUserCreated) {
if (gUser.twonlySafeBackup == null && !_skipBackup && kReleaseMode) {
if (gUser.twonlySafeBackup == null && !_skipBackup) {
child = SetupBackupView(
callBack: () {
_skipBackup = true;

View file

@ -31,7 +31,9 @@ void Function(SubscriptionPlan plan) globalCallbackUpdatePlan = (plan) {};
Map<String, VoidCallback> globalUserDataChangedCallBack = {};
bool globalIsAppInBackground = true;
bool globalIsInBackgroundTask = false;
bool globalAllowErrorTrackingViaSentry = false;
bool globalGotMessageFromServer = false;
late String globalApplicationCacheDirectory;
late String globalApplicationSupportDirectory;

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -16,10 +17,11 @@ import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/services/api/mediafiles/media_background.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/fcm.service.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/backup/create.backup.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
@ -27,6 +29,10 @@ import 'package:twonly/src/utils/storage.dart';
void main() async {
SentryWidgetsFlutterBinding.ensureInitialized();
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
await initFCMService();
final user = await getUser();
@ -45,12 +51,12 @@ void main() async {
}
unawaited(performTwonlySafeBackup());
unawaited(initializeBackgroundTaskManager());
} else {
Log.info('User is not yet register. Ensure all local data is removed.');
await deleteLocalUserData();
}
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
initLogger();
final settingsController = SettingsChangeProvider();

View file

@ -0,0 +1,45 @@
import 'package:flutter/services.dart';
import 'package:twonly/src/utils/log.dart';
abstract class VideoCompressionChannel {
static const MethodChannel _channel =
MethodChannel('eu.twonly/videoCompression');
static void Function(double)? _currentProgressCallback;
static bool _handlerSetup = false;
static void _setupProgressHandler() {
if (_handlerSetup) return;
_channel.setMethodCallHandler((call) async {
if (call.method == 'onProgress') {
// ignore: avoid_dynamic_calls
final progress = call.arguments['progress'] as int;
_currentProgressCallback?.call(progress / 100.0);
}
});
_handlerSetup = true;
}
static Future<String?> compressVideo({
required String inputPath,
required String outputPath,
void Function(double progress)? onProgress,
}) async {
try {
_setupProgressHandler();
_currentProgressCallback = onProgress;
await _channel.invokeMethod('compressVideo', {
'input': inputPath,
'output': outputPath,
});
return outputPath;
} on PlatformException catch (e) {
Log.error('Failed to compress video: $e');
return null;
} finally {
_currentProgressCallback = null;
}
}
}

View file

@ -0,0 +1,4 @@
class KeyValueKeys {
static const String lastPeriodicTaskExecution =
'last_periodic_task_execution';
}

View file

@ -110,7 +110,7 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
return query.map((row) => row.read(count)).watchSingle();
}
Stream<int?> watchContactsRequested() {
Stream<int?> watchContactsRequestedCount() {
final count = contacts.requested.count(distinct: true);
final query = selectOnly(contacts)
..where(

View file

@ -23,10 +23,11 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
GroupsDao(super.db);
Future<bool> isContactInGroup(int contactId, String groupId) async {
final entry = await (select(groupMembers)..where(
// ignore: require_trailing_commas
(t) => t.contactId.equals(contactId) & t.groupId.equals(groupId)))
.getSingleOrNull();
final entry =
await (select(groupMembers)..where(
(t) => t.contactId.equals(contactId) & t.groupId.equals(groupId),
))
.getSingleOrNull();
return entry != null;
}
@ -38,30 +39,31 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
String groupId,
GroupsCompanion updates,
) async {
await (update(groups)..where((c) => c.groupId.equals(groupId)))
.write(updates);
await (update(
groups,
)..where((c) => c.groupId.equals(groupId))).write(updates);
}
Future<List<GroupMember>> getGroupNonLeftMembers(String groupId) async {
return (select(groupMembers)
..where(
(t) =>
t.groupId.equals(groupId) &
(t.memberState.equals(MemberState.leftGroup.name).not() |
t.memberState.isNull()),
))
return (select(groupMembers)..where(
(t) =>
t.groupId.equals(groupId) &
(t.memberState.equals(MemberState.leftGroup.name).not() |
t.memberState.isNull()),
))
.get();
}
Future<List<GroupMember>> getAllGroupMembers(String groupId) async {
return (select(groupMembers)..where((t) => t.groupId.equals(groupId)))
.get();
return (select(
groupMembers,
)..where((t) => t.groupId.equals(groupId))).get();
}
Future<GroupMember?> getGroupMemberByPublicKey(Uint8List publicKey) async {
return (select(groupMembers)
..where((t) => t.groupPublicKey.equals(publicKey)))
.getSingleOrNull();
return (select(
groupMembers,
)..where((t) => t.groupPublicKey.equals(publicKey))).getSingleOrNull();
}
Future<Group?> createNewGroup(GroupsCompanion group) async {
@ -94,18 +96,16 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
int contactId,
GroupMembersCompanion updates,
) async {
await (update(groupMembers)
..where(
(c) => c.groupId.equals(groupId) & c.contactId.equals(contactId),
))
await (update(groupMembers)..where(
(c) => c.groupId.equals(groupId) & c.contactId.equals(contactId),
))
.write(updates);
}
Future<void> removeMember(String groupId, int contactId) async {
await (delete(groupMembers)
..where(
(c) => c.groupId.equals(groupId) & c.contactId.equals(contactId),
))
await (delete(groupMembers)..where(
(c) => c.groupId.equals(groupId) & c.contactId.equals(contactId),
))
.go();
}
@ -138,9 +138,9 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
Future<Group?> _insertGroup(GroupsCompanion group) async {
try {
await into(groups).insert(group);
return await (select(groups)
..where((t) => t.groupId.equals(group.groupId.value)))
.getSingle();
return await (select(
groups,
)..where((t) => t.groupId.equals(group.groupId.value))).getSingle();
} catch (e) {
Log.error('Could not insert group: $e');
return null;
@ -148,69 +148,71 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
}
Future<List<Contact>> getGroupContact(String groupId) async {
final query = (select(contacts).join([
leftOuterJoin(
groupMembers,
groupMembers.contactId.equalsExp(contacts.userId),
useColumns: false,
),
])
..orderBy([OrderingTerm.desc(groupMembers.lastMessage)])
..where(groupMembers.groupId.equals(groupId)));
final query =
(select(contacts).join([
leftOuterJoin(
groupMembers,
groupMembers.contactId.equalsExp(contacts.userId),
useColumns: false,
),
])
..orderBy([OrderingTerm.desc(groupMembers.lastMessage)])
..where(groupMembers.groupId.equals(groupId)));
return query.map((row) => row.readTable(contacts)).get();
}
Stream<List<Contact>> watchGroupContact(String groupId) {
final query = (select(contacts).join([
leftOuterJoin(
groupMembers,
groupMembers.contactId.equalsExp(contacts.userId),
useColumns: false,
),
])
..orderBy([OrderingTerm.desc(groupMembers.lastMessage)])
..where(groupMembers.groupId.equals(groupId)));
final query =
(select(contacts).join([
leftOuterJoin(
groupMembers,
groupMembers.contactId.equalsExp(contacts.userId),
useColumns: false,
),
])
..orderBy([OrderingTerm.desc(groupMembers.lastMessage)])
..where(groupMembers.groupId.equals(groupId)));
return query.map((row) => row.readTable(contacts)).watch();
}
Stream<List<(Contact, GroupMember)>> watchGroupMembers(String groupId) {
final query =
(select(groupMembers)..where((t) => t.groupId.equals(groupId))).join([
leftOuterJoin(
contacts,
contacts.userId.equalsExp(groupMembers.contactId),
),
]);
leftOuterJoin(
contacts,
contacts.userId.equalsExp(groupMembers.contactId),
),
]);
return query
.map((row) => (row.readTable(contacts), row.readTable(groupMembers)))
.watch();
}
Stream<List<Group>> watchGroupsForShareImage() {
return (select(groups)
..where(
(g) => g.leftGroup.equals(false) & g.deletedContent.equals(false),
))
return (select(groups)..where(
(g) => g.leftGroup.equals(false) & g.deletedContent.equals(false),
))
.watch();
}
Stream<List<GroupMember>> watchContactGroupMember(int contactId) {
return (select(groupMembers)
..where(
(g) => g.contactId.equals(contactId),
))
return (select(groupMembers)..where(
(g) => g.contactId.equals(contactId),
))
.watch();
}
Stream<Group?> watchGroup(String groupId) {
return (select(groups)..where((t) => t.groupId.equals(groupId)))
.watchSingleOrNull();
return (select(
groups,
)..where((t) => t.groupId.equals(groupId))).watchSingleOrNull();
}
Stream<Group?> watchDirectChat(int contactId) {
final groupId = getUUIDforDirectChat(contactId, gUser.userId);
return (select(groups)..where((t) => t.groupId.equals(groupId)))
.watchSingleOrNull();
return (select(
groups,
)..where((t) => t.groupId.equals(groupId))).watchSingleOrNull();
}
Stream<List<Group>> watchGroupsForChatList() {
@ -228,18 +230,18 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
}
Future<Group?> getGroup(String groupId) {
return (select(groups)..where((t) => t.groupId.equals(groupId)))
.getSingleOrNull();
return (select(
groups,
)..where((t) => t.groupId.equals(groupId))).getSingleOrNull();
}
Stream<int> watchFlameCounter(String groupId) {
return (select(groups)
..where(
(u) =>
u.groupId.equals(groupId) &
u.lastMessageReceived.isNotNull() &
u.lastMessageSend.isNotNull(),
))
return (select(groups)..where(
(u) =>
u.groupId.equals(groupId) &
u.lastMessageReceived.isNotNull() &
u.lastMessageSend.isNotNull(),
))
.watchSingleOrNull()
.asyncMap(getFlameCounterFromGroup);
}
@ -248,25 +250,28 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
return (select(groups)..where((t) => t.isDirectChat.equals(true))).get();
}
Future<List<Group>> getAllGroups() {
return select(groups).get();
}
Future<List<Group>> getAllNotJoinedGroups() {
return (select(groups)
..where(
(t) => t.joinedGroup.equals(false) & t.isDirectChat.equals(false),
))
return (select(groups)..where(
(t) => t.joinedGroup.equals(false) & t.isDirectChat.equals(false),
))
.get();
}
Future<List<GroupMember>> getAllGroupMemberWithoutPublicKey() async {
try {
final query = ((select(groupMembers)
..where((t) => t.groupPublicKey.isNull()))
.join([
leftOuterJoin(
groups,
groups.groupId.equalsExp(groupMembers.groupId),
),
])
..where(groups.isDirectChat.isNull()));
final query =
((select(groupMembers)..where((t) => t.groupPublicKey.isNull())).join(
[
leftOuterJoin(
groups,
groups.groupId.equalsExp(groupMembers.groupId),
),
],
)..where(groups.isDirectChat.isNull()));
return query.map((row) => row.readTable(groupMembers)).get();
} catch (e) {
Log.error(e);
@ -277,12 +282,11 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
Future<Group?> getDirectChat(int userId) async {
final query =
((select(groups)..where((t) => t.isDirectChat.equals(true))).join([
leftOuterJoin(
groupMembers,
groupMembers.groupId.equalsExp(groups.groupId),
),
])
..where(groupMembers.contactId.equals(userId)));
leftOuterJoin(
groupMembers,
groupMembers.groupId.equalsExp(groups.groupId),
),
])..where(groupMembers.contactId.equals(userId)));
return query.map((row) => row.readTable(groups)).getSingleOrNull();
}
@ -300,12 +304,11 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
String groupId,
DateTime newLastMessage,
) async {
await (update(groups)
..where(
(t) =>
t.groupId.equals(groupId) &
(t.lastMessageExchange.isSmallerThanValue(newLastMessage)),
))
await (update(groups)..where(
(t) =>
t.groupId.equals(groupId) &
(t.lastMessageExchange.isSmallerThanValue(newLastMessage)),
))
.write(GroupsCompanion(lastMessageExchange: Value(newLastMessage)));
}
}

View file

@ -22,5 +22,7 @@ class GroupsDaoManager {
$$GroupMembersTableTableManager(_db.attachedDatabase, _db.groupMembers);
$$GroupHistoriesTableTableManager get groupHistories =>
$$GroupHistoriesTableTableManager(
_db.attachedDatabase, _db.groupHistories);
_db.attachedDatabase,
_db.groupHistories,
);
}

View file

@ -121,6 +121,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
..where(
(t) => (t.uploadState.equals(UploadState.initialized.name) |
t.uploadState.equals(UploadState.uploadLimitReached.name) |
t.uploadState.equals(UploadState.uploading.name) |
t.uploadState.equals(UploadState.preprocessing.name)),
))
.get();

View file

@ -44,20 +44,23 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
}
Stream<List<Message>> watchMediaNotOpened(String groupId) {
final query = select(messages).join([
leftOuterJoin(mediaFiles, mediaFiles.mediaId.equalsExp(messages.mediaId)),
])
..where(
mediaFiles.downloadState
.equals(DownloadState.reuploadRequested.name)
.not() &
mediaFiles.type.equals(MediaType.audio.name).not() &
messages.openedAt.isNull() &
messages.groupId.equals(groupId) &
messages.mediaId.isNotNull() &
messages.senderId.isNotNull() &
messages.type.equals(MessageType.media.name),
);
final query =
select(messages).join([
leftOuterJoin(
mediaFiles,
mediaFiles.mediaId.equalsExp(messages.mediaId),
),
])..where(
mediaFiles.downloadState
.equals(DownloadState.reuploadRequested.name)
.not() &
mediaFiles.type.equals(MediaType.audio.name).not() &
messages.openedAt.isNull() &
messages.groupId.equals(groupId) &
messages.mediaId.isNotNull() &
messages.senderId.isNotNull() &
messages.type.equals(MessageType.media.name),
);
return query.map((row) => row.readTable(messages)).watch();
}
@ -70,8 +73,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
}
Stream<List<Message>> watchByGroupId(String groupId) {
return ((select(messages)
..where(
return ((select(messages)..where(
(t) =>
t.groupId.equals(groupId) &
(t.isDeletedFromSender.equals(true) |
@ -92,21 +94,22 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
contacts,
contacts.userId.equalsExp(groupMembers.contactId),
),
])
..where(groupMembers.groupId.equals(groupId)));
])..where(groupMembers.groupId.equals(groupId)));
return query
.map((row) => (row.readTable(groupMembers), row.readTable(contacts)))
.watch();
}
Stream<List<MessageAction>> watchMessageActionChanges(String messageId) {
return (select(messageActions)..where((t) => t.messageId.equals(messageId)))
.watch();
return (select(
messageActions,
)..where((t) => t.messageId.equals(messageId))).watch();
}
Stream<Message?> watchMessageById(String messageId) {
return (select(messages)..where((t) => t.messageId.equals(messageId)))
.watchSingleOrNull();
return (select(
messages,
)..where((t) => t.messageId.equals(messageId))).watchSingleOrNull();
}
Future<void> purgeMessageTable() async {
@ -114,35 +117,33 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
for (final group in allGroups) {
final deletionTime = clock.now().subtract(
Duration(
milliseconds: group.deleteMessagesAfterMilliseconds,
),
);
await (delete(messages)
..where(
(m) =>
m.groupId.equals(group.groupId) &
(m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true) |
m.mediaStored.equals(false)) &
(m.openedAt.isSmallerThanValue(deletionTime) |
(m.isDeletedFromSender.equals(true) &
m.createdAt.isSmallerThanValue(deletionTime))),
))
Duration(
milliseconds: group.deleteMessagesAfterMilliseconds,
),
);
await (delete(messages)..where(
(m) =>
m.groupId.equals(group.groupId) &
(m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true) |
m.mediaStored.equals(false)) &
(m.openedAt.isSmallerThanValue(deletionTime) |
(m.isDeletedFromSender.equals(true) &
m.createdAt.isSmallerThanValue(deletionTime))),
))
.go();
}
}
Future<void> openedAllTextMessages(String groupId) {
final updates = MessagesCompanion(openedAt: Value(clock.now()));
return (update(messages)
..where(
(t) =>
t.groupId.equals(groupId) &
t.senderId.isNotNull() &
t.openedAt.isNull() &
t.type.equals(MessageType.text.name),
))
return (update(messages)..where(
(t) =>
t.groupId.equals(groupId) &
t.senderId.isNotNull() &
t.openedAt.isNull() &
t.type.equals(MessageType.text.name),
))
.write(updates);
}
@ -158,29 +159,29 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
}
if (msg.mediaId != null && contactId != null) {
// contactId -> When a image is send to multiple and one message is delete the image should be still available...
await (delete(mediaFiles)..where((t) => t.mediaId.equals(msg.mediaId!)))
.go();
await (delete(
mediaFiles,
)..where((t) => t.mediaId.equals(msg.mediaId!))).go();
final mediaService = await MediaFileService.fromMediaId(msg.mediaId!);
if (mediaService != null) {
mediaService.fullMediaRemoval();
}
}
await (delete(messageHistories)
..where((t) => t.messageId.equals(messageId)))
.go();
await (delete(
messageHistories,
)..where((t) => t.messageId.equals(messageId))).go();
await (update(messages)
..where(
(t) => t.messageId.equals(messageId),
))
await (update(messages)..where(
(t) => t.messageId.equals(messageId),
))
.write(
const MessagesCompanion(
isDeletedFromSender: Value(true),
content: Value(null),
mediaId: Value(null),
),
);
const MessagesCompanion(
isDeletedFromSender: Value(true),
content: Value(null),
mediaId: Value(null),
),
);
}
Future<void> handleTextEdit(
@ -200,16 +201,15 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
createdAt: Value(timestamp),
),
);
await (update(messages)
..where(
(t) => t.messageId.equals(messageId),
))
await (update(messages)..where(
(t) => t.messageId.equals(messageId),
))
.write(
MessagesCompanion(
content: Value(text),
modifiedAt: Value(timestamp),
),
);
MessagesCompanion(
content: Value(text),
modifiedAt: Value(timestamp),
),
);
}
Future<void> handleMessagesOpened(
@ -232,8 +232,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
}
for (final messageId in messageIds) {
final isOpenedByAll =
await haveAllMembers(messageId, MessageActionType.openedAt);
final isOpenedByAll = await haveAllMembers(
messageId,
MessageActionType.openedAt,
);
final now = clock.now();
batch.update(
@ -271,17 +273,19 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
String messageId,
MessageActionType action,
) async {
final message =
await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull();
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (message == null) return true;
final members =
await twonlyDB.groupsDao.getGroupNonLeftMembers(message.groupId);
final members = await twonlyDB.groupsDao.getGroupNonLeftMembers(
message.groupId,
);
final actions = await (select(messageActions)
..where(
(t) => t.type.equals(action.name) & t.messageId.equals(messageId),
))
.get();
final actions =
await (select(messageActions)..where(
(t) => t.type.equals(action.name) & t.messageId.equals(messageId),
))
.get();
return members.length == actions.length;
}
@ -290,16 +294,18 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
String messageId,
MessagesCompanion updatedValues,
) async {
await (update(messages)..where((c) => c.messageId.equals(messageId)))
.write(updatedValues);
await (update(
messages,
)..where((c) => c.messageId.equals(messageId))).write(updatedValues);
}
Future<void> updateMessagesByMediaId(
String mediaId,
MessagesCompanion updatedValues,
) {
return (update(messages)..where((c) => c.mediaId.equals(mediaId)))
.write(updatedValues);
return (update(
messages,
)..where((c) => c.mediaId.equals(mediaId))).write(updatedValues);
}
Future<Message?> insertMessage(MessagesCompanion message) async {
@ -333,8 +339,9 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
);
}
return await (select(messages)..where((t) => t.rowId.equals(rowId)))
.getSingle();
return await (select(
messages,
)..where((t) => t.rowId.equals(rowId))).getSingle();
} catch (e) {
Log.error('Could not insert message: $e');
return null;
@ -342,11 +349,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
}
Future<MessageAction?> getLastMessageAction(String messageId) async {
return (((select(messageActions)
..where(
(t) => t.messageId.equals(messageId),
))
..orderBy([(t) => OrderingTerm.desc(t.actionAt)]))
return (((select(messageActions)..where(
(t) => t.messageId.equals(messageId),
))
..orderBy([(t) => OrderingTerm.desc(t.actionAt)]))
..limit(1))
.getSingleOrNull();
}
@ -373,8 +379,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
contacts,
contacts.userId.equalsExp(messageActions.contactId),
),
])
..where(messageActions.messageId.equals(messageId)));
])..where(messageActions.messageId.equals(messageId)));
return query
.map((row) => (row.readTable(messageActions), row.readTable(contacts)))
.watch();

View file

@ -31,10 +31,14 @@ class MessagesDaoManager {
$$ReactionsTableTableManager(_db.attachedDatabase, _db.reactions);
$$MessageHistoriesTableTableManager get messageHistories =>
$$MessageHistoriesTableTableManager(
_db.attachedDatabase, _db.messageHistories);
_db.attachedDatabase,
_db.messageHistories,
);
$$GroupMembersTableTableManager get groupMembers =>
$$GroupMembersTableTableManager(_db.attachedDatabase, _db.groupMembers);
$$MessageActionsTableTableManager get messageActions =>
$$MessageActionsTableTableManager(
_db.attachedDatabase, _db.messageActions);
_db.attachedDatabase,
_db.messageActions,
);
}

View file

@ -1,14 +1,18 @@
import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:hashlib/random.dart';
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/tables/receipts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/log.dart';
part 'receipts.dao.g.dart';
@DriftAccessor(tables: [Receipts, Messages, MessageActions, ReceivedReceipts])
@DriftAccessor(
tables: [Receipts, Messages, MessageActions, ReceivedReceipts, Contacts],
)
class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
// this constructor is required so that the main database can create an instance
// of this object.
@ -16,12 +20,13 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
ReceiptsDao(super.db);
Future<void> confirmReceipt(String receiptId, int fromUserId) async {
final receipt = await (select(receipts)
..where(
(t) =>
t.receiptId.equals(receiptId) & t.contactId.equals(fromUserId),
))
.getSingleOrNull();
final receipt =
await (select(receipts)..where(
(t) =>
t.receiptId.equals(receiptId) &
t.contactId.equals(fromUserId),
))
.getSingleOrNull();
if (receipt == null) return;
@ -33,34 +38,42 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
type: const Value(MessageActionType.ackByUserAt),
),
);
await handleMediaRelatedResponseFromReceiver(receipt.messageId!);
}
await (delete(receipts)
..where(
(t) =>
t.receiptId.equals(receiptId) & t.contactId.equals(fromUserId),
))
await (delete(receipts)..where(
(t) => t.receiptId.equals(receiptId) & t.contactId.equals(fromUserId),
))
.go();
}
Future<void> deleteReceipt(String receiptId) async {
await (delete(receipts)
..where(
(t) => t.receiptId.equals(receiptId),
))
await (delete(receipts)..where(
(t) => t.receiptId.equals(receiptId),
))
.go();
}
Future<void> purgeReceivedReceipts() async {
await (delete(receivedReceipts)
..where(
(t) => (t.createdAt.isSmallerThanValue(
clock.now().subtract(
const Duration(days: 25),
),
)),
))
await (delete(receivedReceipts)..where(
(t) => (t.createdAt.isSmallerThanValue(
clock.now().subtract(
const Duration(days: 25),
),
)),
))
.go();
final deletedContacts = await (select(
contacts,
)..where((t) => t.accountDeleted.equals(true))).get();
for (final contact in deletedContacts) {
await (delete(receipts)..where(
(t) => t.contactId.equals(contact.userId),
))
.go();
}
}
Future<Receipt?> insertReceipt(ReceiptsCompanion entry) async {
@ -72,8 +85,9 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
);
}
final id = await into(receipts).insert(insertEntry);
return await (select(receipts)..where((t) => t.rowId.equals(id)))
.getSingle();
return await (select(
receipts,
)..where((t) => t.rowId.equals(id))).getSingle();
} catch (e) {
// ignore error, receipts is already in the database...
return null;
@ -82,10 +96,9 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
Future<Receipt?> getReceiptById(String receiptId) async {
try {
return await (select(receipts)
..where(
(t) => t.receiptId.equals(receiptId),
))
return await (select(receipts)..where(
(t) => t.receiptId.equals(receiptId),
))
.getSingleOrNull();
} catch (e) {
Log.error(e);
@ -95,19 +108,20 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
Future<List<Receipt>> getReceiptsForRetransmission() async {
final markedRetriesTime = clock.now().subtract(
const Duration(
// give the server time to transmit all messages to the client
seconds: 20,
),
);
return (select(receipts)
..where(
(t) =>
t.ackByServerAt.isNull() |
t.markForRetry.isSmallerThanValue(markedRetriesTime) |
t.markForRetryAfterAccepted
.isSmallerThanValue(markedRetriesTime),
))
const Duration(
// give the server time to transmit all messages to the client
seconds: 20,
),
);
return (select(receipts)..where(
(t) =>
(t.ackByServerAt.isNull() |
t.markForRetry.isSmallerThanValue(markedRetriesTime) |
t.markForRetryAfterAccepted.isSmallerThanValue(
markedRetriesTime,
)) &
t.willBeRetriedByMediaUpload.equals(false),
))
.get();
}
@ -119,8 +133,9 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
String receiptId,
ReceiptsCompanion updates,
) async {
await (update(receipts)..where((c) => c.receiptId.equals(receiptId)))
.write(updates);
await (update(
receipts,
)..where((c) => c.receiptId.equals(receiptId))).write(updates);
}
Future<void> updateReceiptWidthUserId(
@ -128,31 +143,35 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
String receiptId,
ReceiptsCompanion updates,
) async {
await (update(receipts)
..where(
(c) =>
c.receiptId.equals(receiptId) & c.contactId.equals(fromUserId),
))
await (update(receipts)..where(
(c) => c.receiptId.equals(receiptId) & c.contactId.equals(fromUserId),
))
.write(updates);
}
Future<void> markMessagesForRetry(int contactId) async {
await (update(receipts)..where((c) => c.contactId.equals(contactId))).write(
ReceiptsCompanion(
markForRetry: Value(clock.now()),
),
);
await (update(receipts)..where(
(c) =>
c.contactId.equals(contactId) &
c.willBeRetriedByMediaUpload.equals(false),
))
.write(
ReceiptsCompanion(
markForRetry: Value(clock.now()),
),
);
}
Future<bool> isDuplicated(String receiptId) async {
return await (select(receivedReceipts)
..where((t) => t.receiptId.equals(receiptId)))
.getSingleOrNull() !=
return await (select(
receivedReceipts,
)..where((t) => t.receiptId.equals(receiptId))).getSingleOrNull() !=
null;
}
Future<void> gotReceipt(String receiptId) async {
await into(receivedReceipts)
.insert(ReceivedReceiptsCompanion(receiptId: Value(receiptId)));
await into(
receivedReceipts,
).insert(ReceivedReceiptsCompanion(receiptId: Value(receiptId)));
}
}

View file

@ -30,8 +30,12 @@ class ReceiptsDaoManager {
$$ReceiptsTableTableManager(_db.attachedDatabase, _db.receipts);
$$MessageActionsTableTableManager get messageActions =>
$$MessageActionsTableTableManager(
_db.attachedDatabase, _db.messageActions);
_db.attachedDatabase,
_db.messageActions,
);
$$ReceivedReceiptsTableTableManager get receivedReceipts =>
$$ReceivedReceiptsTableTableManager(
_db.attachedDatabase, _db.receivedReceipts);
_db.attachedDatabase,
_db.receivedReceipts,
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,9 @@ enum UploadState {
uploaded,
uploadLimitReached,
// File is to big to be uploaded
fileLimitReached,
// readyToUpload,
// uploadTaskStarted,
// receiverNotified,
@ -48,6 +51,8 @@ class MediaFiles extends Table {
BoolColumn get stored => boolean().withDefault(const Constant(false))();
BoolColumn get isDraftMedia => boolean().withDefault(const Constant(false))();
IntColumn get preProgressingProcess => integer().nullable()();
TextColumn get reuploadRequestedBy =>
text().map(IntListTypeConverter()).nullable()();

View file

@ -3,7 +3,7 @@ import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
enum MessageType { media, text, contacts }
enum MessageType { media, text, contacts, restoreFlameCounter }
@DataClassName('Message')
class Messages extends Table {

View file

@ -20,6 +20,9 @@ class Receipts extends Table {
BoolColumn get contactWillSendsReceipt =>
boolean().withDefault(const Constant(true))();
BoolColumn get willBeRetriedByMediaUpload =>
boolean().withDefault(const Constant(false))();
DateTimeColumn get markForRetry => dateTime().nullable()();
DateTimeColumn get markForRetryAfterAccepted => dateTime().nullable()();

View file

@ -54,15 +54,15 @@ part 'twonly.db.g.dart';
)
class TwonlyDB extends _$TwonlyDB {
TwonlyDB([QueryExecutor? e])
: super(
e ?? _openConnection(),
);
: super(
e ?? _openConnection(),
);
// ignore: matching_super_parameters
TwonlyDB.forTesting(DatabaseConnection super.connection);
@override
int get schemaVersion => 8;
int get schemaVersion => 10;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -131,6 +131,18 @@ class TwonlyDB extends _$TwonlyDB {
// ignore: experimental_member_use
await m.alterTable(TableMigration(schema.messageActions));
},
from8To9: (m, schema) async {
await m.addColumn(
schema.mediaFiles,
schema.mediaFiles.preProgressingProcess,
);
},
from9To10: (m, schema) async {
await m.addColumn(
schema.receipts,
schema.receipts.willBeRetriedByMediaUpload,
);
},
)(m, from, to);
},
);
@ -154,21 +166,20 @@ class TwonlyDB extends _$TwonlyDB {
}
Future<void> deleteDataForTwonlySafe() async {
await (delete(messages)
..where(
(t) => (t.mediaStored.equals(false) &
t.isDeletedFromSender.equals(false)),
))
await (delete(messages)..where(
(t) =>
(t.mediaStored.equals(false) &
t.isDeletedFromSender.equals(false)),
))
.go();
await update(messages).write(
const MessagesCompanion(
downloadToken: Value(null),
),
);
await (delete(mediaFiles)
..where(
(t) => (t.stored.equals(false)),
))
await (delete(mediaFiles)..where(
(t) => (t.stored.equals(false)),
))
.go();
await delete(receipts).go();
await delete(receivedReceipts).go();
@ -178,14 +189,13 @@ class TwonlyDB extends _$TwonlyDB {
senderProfileCounter: Value(0),
),
);
await (delete(signalPreKeyStores)
..where(
(t) => (t.createdAt.isSmallerThanValue(
clock.now().subtract(
const Duration(days: 25),
),
)),
))
await (delete(signalPreKeyStores)..where(
(t) => (t.createdAt.isSmallerThanValue(
clock.now().subtract(
const Duration(days: 25),
),
)),
))
.go();
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -64,7 +64,7 @@ import 'app_localizations_sv.dart';
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
@ -87,17 +87,17 @@ abstract class AppLocalizations {
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('de'),
Locale('en'),
Locale('sv')
Locale('sv'),
];
/// No description provided for @registerTitle.
@ -685,7 +685,7 @@ abstract class AppLocalizations {
/// No description provided for @settingsPrivacy.
///
/// In en, this message translates to:
/// **'Privacy'**
/// **'Privacy & Security'**
String get settingsPrivacy;
/// No description provided for @settingsPrivacyBlockUsers.
@ -2104,6 +2104,12 @@ abstract class AppLocalizations {
/// **'The upload limit has\nbeen reached. Upgrade to Pro\nor wait until tomorrow.'**
String get uploadLimitReached;
/// No description provided for @fileLimitReached.
///
/// In en, this message translates to:
/// **'Maximum file size\nexceeded'**
String get fileLimitReached;
/// No description provided for @retransmissionRequested.
///
/// In en, this message translates to:
@ -3033,6 +3039,24 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Unknown contact whose identity has not yet been verified.'**
String get verificationBadgeRedDesc;
/// No description provided for @chatEntryFlameRestored.
///
/// In en, this message translates to:
/// **'{count} flames restored'**
String chatEntryFlameRestored(Object count);
/// No description provided for @requestedUserToastText.
///
/// In en, this message translates to:
/// **'{username} was successfully requested.'**
String requestedUserToastText(Object username);
/// No description provided for @profileYourQrCode.
///
/// In en, this message translates to:
/// **'Your QR code'**
String get profileYourQrCode;
}
class _AppLocalizationsDelegate
@ -3064,8 +3088,9 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.');
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View file

@ -326,7 +326,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get settingsAppearance => 'Erscheinungsbild';
@override
String get settingsPrivacy => 'Datenschutz';
String get settingsPrivacy => 'Datenschutz & Sicherheit';
@override
String get settingsPrivacyBlockUsers => 'Benutzer blockieren';
@ -1116,6 +1116,9 @@ class AppLocalizationsDe extends AppLocalizations {
String get uploadLimitReached =>
'Das Upload-Limit wurde\nerreicht. Upgrade auf Pro\noder warte bis morgen.';
@override
String get fileLimitReached => 'Maximale Dateigröße\nerreicht';
@override
String get retransmissionRequested => 'Wird erneut versucht.';
@ -1695,4 +1698,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get verificationBadgeRedDesc =>
'Unbekannter Kontakt, dessen Identität bisher nicht verifiziert wurde.';
@override
String chatEntryFlameRestored(Object count) {
return '$count Flammen wiederhergestellt';
}
@override
String requestedUserToastText(Object username) {
return '$username wurde erfolgreich angefragt.';
}
@override
String get profileYourQrCode => 'Dein QR-Code';
}

View file

@ -322,7 +322,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsAppearance => 'Appearance';
@override
String get settingsPrivacy => 'Privacy';
String get settingsPrivacy => 'Privacy & Security';
@override
String get settingsPrivacyBlockUsers => 'Block users';
@ -1109,6 +1109,9 @@ class AppLocalizationsEn extends AppLocalizations {
String get uploadLimitReached =>
'The upload limit has\nbeen reached. Upgrade to Pro\nor wait until tomorrow.';
@override
String get fileLimitReached => 'Maximum file size\nexceeded';
@override
String get retransmissionRequested => 'Retransmission requested';
@ -1683,4 +1686,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get verificationBadgeRedDesc =>
'Unknown contact whose identity has not yet been verified.';
@override
String chatEntryFlameRestored(Object count) {
return '$count flames restored';
}
@override
String requestedUserToastText(Object username) {
return '$username was successfully requested.';
}
@override
String get profileYourQrCode => 'Your QR code';
}

View file

@ -322,7 +322,7 @@ class AppLocalizationsSv extends AppLocalizations {
String get settingsAppearance => 'Appearance';
@override
String get settingsPrivacy => 'Privacy';
String get settingsPrivacy => 'Privacy & Security';
@override
String get settingsPrivacyBlockUsers => 'Block users';
@ -1109,6 +1109,9 @@ class AppLocalizationsSv extends AppLocalizations {
String get uploadLimitReached =>
'The upload limit has\nbeen reached. Upgrade to Pro\nor wait until tomorrow.';
@override
String get fileLimitReached => 'Maximum file size\nexceeded';
@override
String get retransmissionRequested => 'Retransmission requested';
@ -1683,4 +1686,17 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get verificationBadgeRedDesc =>
'Unknown contact whose identity has not yet been verified.';
@override
String chatEntryFlameRestored(Object count) {
return '$count flames restored';
}
@override
String requestedUserToastText(Object username) {
return '$username was successfully requested.';
}
@override
String get profileYourQrCode => 'Your QR code';
}

@ -1 +1 @@
Subproject commit 6147155ce50caa97864d56e42e49a6f54702785d
Subproject commit 284c602b507e77addc8f21c4fc8a321f237cac1b

View file

@ -8,14 +8,16 @@ part of 'signal_identity.dart';
SignalIdentity _$SignalIdentityFromJson(Map<String, dynamic> json) =>
SignalIdentity(
identityKeyPairU8List: const Uint8ListConverter()
.fromJson(json['identityKeyPairU8List'] as String),
identityKeyPairU8List: const Uint8ListConverter().fromJson(
json['identityKeyPairU8List'] as String,
),
registrationId: (json['registrationId'] as num).toInt(),
);
Map<String, dynamic> _$SignalIdentityToJson(SignalIdentity instance) =>
<String, dynamic>{
'registrationId': instance.registrationId,
'identityKeyPairU8List':
const Uint8ListConverter().toJson(instance.identityKeyPairU8List),
'identityKeyPairU8List': const Uint8ListConverter().toJson(
instance.identityKeyPairU8List,
),
};

View file

@ -6,12 +6,13 @@ part of 'userdata.dart';
// JsonSerializableGenerator
// **************************************************************************
UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
userId: (json['userId'] as num).toInt(),
username: json['username'] as String,
displayName: json['displayName'] as String,
subscriptionPlan: json['subscriptionPlan'] as String? ?? 'Free',
)
UserData _$UserDataFromJson(Map<String, dynamic> json) =>
UserData(
userId: (json['userId'] as num).toInt(),
username: json['username'] as String,
displayName: json['displayName'] as String,
subscriptionPlan: json['subscriptionPlan'] as String? ?? 'Free',
)
..avatarSvg = json['avatarSvg'] as String?
..avatarJson = json['avatarJson'] as String?
..appVersion = (json['appVersion'] as num?)?.toInt() ?? 0
@ -25,7 +26,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
..todaysImageCounter = (json['todaysImageCounter'] as num?)?.toInt()
..themeMode =
$enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ??
ThemeMode.system
ThemeMode.system
..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt()
..requestedAudioPermission =
json['requestedAudioPermission'] as bool? ?? false
@ -38,9 +39,11 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
.toList()
..autoDownloadOptions =
(json['autoDownloadOptions'] as Map<String, dynamic>?)?.map(
(k, e) =>
MapEntry(k, (e as List<dynamic>).map((e) => e as String).toList()),
)
(k, e) => MapEntry(
k,
(e as List<dynamic>).map((e) => e as String).toList(),
),
)
..storeMediaFilesInGallery =
json['storeMediaFilesInGallery'] as bool? ?? false
..autoStoreAllSendUnlimitedMediaFiles =
@ -53,8 +56,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
..myBestFriendGroupId = json['myBestFriendGroupId'] as String?
..signalLastSignedPreKeyUpdated =
json['signalLastSignedPreKeyUpdated'] == null
? null
: DateTime.parse(json['signalLastSignedPreKeyUpdated'] as String)
? null
: DateTime.parse(json['signalLastSignedPreKeyUpdated'] as String)
..allowErrorTrackingViaSentry =
json['allowErrorTrackingViaSentry'] as bool? ?? false
..currentPreKeyIndexStart =
@ -75,7 +78,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
..twonlySafeBackup = json['twonlySafeBackup'] == null
? null
: TwonlySafeBackup.fromJson(
json['twonlySafeBackup'] as Map<String, dynamic>)
json['twonlySafeBackup'] as Map<String, dynamic>,
)
..askedForUserStudyPermission =
json['askedForUserStudyPermission'] as bool? ?? false
..userStudyParticipantsToken =
@ -85,52 +89,51 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
: DateTime.parse(json['lastUserStudyDataUpload'] as String);
Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'userId': instance.userId,
'username': instance.username,
'displayName': instance.displayName,
'avatarSvg': instance.avatarSvg,
'avatarJson': instance.avatarJson,
'appVersion': instance.appVersion,
'avatarCounter': instance.avatarCounter,
'isDeveloper': instance.isDeveloper,
'deviceId': instance.deviceId,
'subscriptionPlan': instance.subscriptionPlan,
'subscriptionPlanIdStore': instance.subscriptionPlanIdStore,
'lastImageSend': instance.lastImageSend?.toIso8601String(),
'todaysImageCounter': instance.todaysImageCounter,
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'defaultShowTime': instance.defaultShowTime,
'requestedAudioPermission': instance.requestedAudioPermission,
'showFeedbackShortcut': instance.showFeedbackShortcut,
'showShowImagePreviewWhenSending':
instance.showShowImagePreviewWhenSending,
'startWithCameraOpen': instance.startWithCameraOpen,
'preSelectedEmojies': instance.preSelectedEmojies,
'autoDownloadOptions': instance.autoDownloadOptions,
'storeMediaFilesInGallery': instance.storeMediaFilesInGallery,
'autoStoreAllSendUnlimitedMediaFiles':
instance.autoStoreAllSendUnlimitedMediaFiles,
'lastPlanBallance': instance.lastPlanBallance,
'additionalUserInvites': instance.additionalUserInvites,
'tutorialDisplayed': instance.tutorialDisplayed,
'myBestFriendGroupId': instance.myBestFriendGroupId,
'signalLastSignedPreKeyUpdated':
instance.signalLastSignedPreKeyUpdated?.toIso8601String(),
'allowErrorTrackingViaSentry': instance.allowErrorTrackingViaSentry,
'currentPreKeyIndexStart': instance.currentPreKeyIndexStart,
'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart,
'lastChangeLogHash': instance.lastChangeLogHash,
'hideChangeLog': instance.hideChangeLog,
'updateFCMToken': instance.updateFCMToken,
'nextTimeToShowBackupNotice':
instance.nextTimeToShowBackupNotice?.toIso8601String(),
'backupServer': instance.backupServer,
'twonlySafeBackup': instance.twonlySafeBackup,
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
'lastUserStudyDataUpload':
instance.lastUserStudyDataUpload?.toIso8601String(),
};
'userId': instance.userId,
'username': instance.username,
'displayName': instance.displayName,
'avatarSvg': instance.avatarSvg,
'avatarJson': instance.avatarJson,
'appVersion': instance.appVersion,
'avatarCounter': instance.avatarCounter,
'isDeveloper': instance.isDeveloper,
'deviceId': instance.deviceId,
'subscriptionPlan': instance.subscriptionPlan,
'subscriptionPlanIdStore': instance.subscriptionPlanIdStore,
'lastImageSend': instance.lastImageSend?.toIso8601String(),
'todaysImageCounter': instance.todaysImageCounter,
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'defaultShowTime': instance.defaultShowTime,
'requestedAudioPermission': instance.requestedAudioPermission,
'showFeedbackShortcut': instance.showFeedbackShortcut,
'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending,
'startWithCameraOpen': instance.startWithCameraOpen,
'preSelectedEmojies': instance.preSelectedEmojies,
'autoDownloadOptions': instance.autoDownloadOptions,
'storeMediaFilesInGallery': instance.storeMediaFilesInGallery,
'autoStoreAllSendUnlimitedMediaFiles':
instance.autoStoreAllSendUnlimitedMediaFiles,
'lastPlanBallance': instance.lastPlanBallance,
'additionalUserInvites': instance.additionalUserInvites,
'tutorialDisplayed': instance.tutorialDisplayed,
'myBestFriendGroupId': instance.myBestFriendGroupId,
'signalLastSignedPreKeyUpdated': instance.signalLastSignedPreKeyUpdated
?.toIso8601String(),
'allowErrorTrackingViaSentry': instance.allowErrorTrackingViaSentry,
'currentPreKeyIndexStart': instance.currentPreKeyIndexStart,
'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart,
'lastChangeLogHash': instance.lastChangeLogHash,
'hideChangeLog': instance.hideChangeLog,
'updateFCMToken': instance.updateFCMToken,
'nextTimeToShowBackupNotice': instance.nextTimeToShowBackupNotice
?.toIso8601String(),
'backupServer': instance.backupServer,
'twonlySafeBackup': instance.twonlySafeBackup,
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
'lastUserStudyDataUpload': instance.lastUserStudyDataUpload
?.toIso8601String(),
};
const _$ThemeModeEnumMap = {
ThemeMode.system: 'system',
@ -140,16 +143,18 @@ const _$ThemeModeEnumMap = {
TwonlySafeBackup _$TwonlySafeBackupFromJson(Map<String, dynamic> json) =>
TwonlySafeBackup(
backupId: (json['backupId'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
encryptionKey: (json['encryptionKey'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
)
backupId: (json['backupId'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
encryptionKey: (json['encryptionKey'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
)
..lastBackupSize = (json['lastBackupSize'] as num).toInt()
..backupUploadState =
$enumDecode(_$LastBackupUploadStateEnumMap, json['backupUploadState'])
..backupUploadState = $enumDecode(
_$LastBackupUploadStateEnumMap,
json['backupUploadState'],
)
..lastBackupDone = json['lastBackupDone'] == null
? null
: DateTime.parse(json['lastBackupDone'] as String);
@ -172,10 +177,10 @@ const _$LastBackupUploadStateEnumMap = {
};
BackupServer _$BackupServerFromJson(Map<String, dynamic> json) => BackupServer(
serverUrl: json['serverUrl'] as String,
retentionDays: (json['retentionDays'] as num).toInt(),
maxBackupBytes: (json['maxBackupBytes'] as num).toInt(),
);
serverUrl: json['serverUrl'] as String,
retentionDays: (json['retentionDays'] as num).toInt(),
maxBackupBytes: (json['maxBackupBytes'] as num).toInt(),
);
Map<String, dynamic> _$BackupServerToJson(BackupServer instance) =>
<String, dynamic>{

View file

@ -536,12 +536,14 @@ class Handshake_Authenticate extends $pb.GeneratedMessage {
$core.List<$core.int>? authToken,
$core.String? appVersion,
$fixnum.Int64? deviceId,
$core.bool? inBackground,
}) {
final result = create();
if (userId != null) result.userId = userId;
if (authToken != null) result.authToken = authToken;
if (appVersion != null) result.appVersion = appVersion;
if (deviceId != null) result.deviceId = deviceId;
if (inBackground != null) result.inBackground = inBackground;
return result;
}
@ -564,6 +566,7 @@ class Handshake_Authenticate extends $pb.GeneratedMessage {
2, _omitFieldNames ? '' : 'authToken', $pb.PbFieldType.OY)
..aOS(3, _omitFieldNames ? '' : 'appVersion')
..aInt64(4, _omitFieldNames ? '' : 'deviceId')
..aOB(5, _omitFieldNames ? '' : 'inBackground')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -624,6 +627,15 @@ class Handshake_Authenticate extends $pb.GeneratedMessage {
$core.bool hasDeviceId() => $_has(3);
@$pb.TagNumber(4)
void clearDeviceId() => $_clearField(4);
@$pb.TagNumber(5)
$core.bool get inBackground => $_getBF(4);
@$pb.TagNumber(5)
set inBackground($core.bool value) => $_setBool(4, value);
@$pb.TagNumber(5)
$core.bool hasInBackground() => $_has(4);
@$pb.TagNumber(5)
void clearInBackground() => $_clearField(5);
}
enum Handshake_Handshake {

View file

@ -229,10 +229,20 @@ const Handshake_Authenticate$json = {
'10': 'deviceId',
'17': true
},
{
'1': 'in_background',
'3': 5,
'4': 1,
'5': 8,
'9': 2,
'10': 'inBackground',
'17': true
},
],
'8': [
{'1': '_app_version'},
{'1': '_device_id'},
{'1': '_in_background'},
],
};
@ -254,10 +264,11 @@ final $typed_data.Uint8List handshakeDescriptor = $convert.base64Decode(
'QSFQoGaXNfaW9zGAggASgIUgVpc0lvcxIbCglsYW5nX2NvZGUYCSABKAlSCGxhbmdDb2RlEiIK'
'DXByb29mX29mX3dvcmsYCiABKANSC3Byb29mT2ZXb3JrQg4KDF9pbnZpdGVfY29kZRoSChBHZX'
'RBdXRoQ2hhbGxlbmdlGkMKDEdldEF1dGhUb2tlbhIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQS'
'GgoIcmVzcG9uc2UYAiABKAxSCHJlc3BvbnNlGqwBCgxBdXRoZW50aWNhdGUSFwoHdXNlcl9pZB'
'GgoIcmVzcG9uc2UYAiABKAxSCHJlc3BvbnNlGugBCgxBdXRoZW50aWNhdGUSFwoHdXNlcl9pZB'
'gBIAEoA1IGdXNlcklkEh0KCmF1dGhfdG9rZW4YAiABKAxSCWF1dGhUb2tlbhIkCgthcHBfdmVy'
'c2lvbhgDIAEoCUgAUgphcHBWZXJzaW9uiAEBEiAKCWRldmljZV9pZBgEIAEoA0gBUghkZXZpY2'
'VJZIgBAUIOCgxfYXBwX3ZlcnNpb25CDAoKX2RldmljZV9pZEILCglIYW5kc2hha2U=');
'VJZIgBARIoCg1pbl9iYWNrZ3JvdW5kGAUgASgISAJSDGluQmFja2dyb3VuZIgBAUIOCgxfYXBw'
'X3ZlcnNpb25CDAoKX2RldmljZV9pZEIQCg5faW5fYmFja2dyb3VuZEILCglIYW5kc2hha2U=');
@$core.Deprecated('Use applicationDataDescriptor instead')
const ApplicationData$json = {

View file

@ -91,6 +91,8 @@ class ErrorCode extends $pb.ProtobufEnum {
ErrorCode._(1034, _omitEnumNames ? '' : 'IPAPaymentExpired');
static const ErrorCode UserIsNotInFreePlan =
ErrorCode._(1035, _omitEnumNames ? '' : 'UserIsNotInFreePlan');
static const ErrorCode ForegroundSessionConnected =
ErrorCode._(1036, _omitEnumNames ? '' : 'ForegroundSessionConnected');
static const $core.List<ErrorCode> values = <ErrorCode>[
Unknown,
@ -131,6 +133,7 @@ class ErrorCode extends $pb.ProtobufEnum {
RegistrationDisabled,
IPAPaymentExpired,
UserIsNotInFreePlan,
ForegroundSessionConnected,
];
static final $core.Map<$core.int, ErrorCode> _byValue =

View file

@ -56,6 +56,7 @@ const ErrorCode$json = {
{'1': 'RegistrationDisabled', '2': 1033},
{'1': 'IPAPaymentExpired', '2': 1034},
{'1': 'UserIsNotInFreePlan', '2': 1035},
{'1': 'ForegroundSessionConnected', '2': 1036},
],
};
@ -77,4 +78,5 @@ final $typed_data.Uint8List errorCodeDescriptor = $convert.base64Decode(
'duZWRQcmVLZXkQgwgSEwoOVXNlcklkTm90Rm91bmQQhAgSFwoSVXNlcklkQWxyZWFkeVRha2Vu'
'EIUIEhcKEkFwcFZlcnNpb25PdXRkYXRlZBCGCBIYChNOZXdEZXZpY2VSZWdpc3RlcmVkEIcIEh'
'cKEkludmFsaWRQcm9vZk9mV29yaxCICBIZChRSZWdpc3RyYXRpb25EaXNhYmxlZBCJCBIWChFJ'
'UEFQYXltZW50RXhwaXJlZBCKCBIYChNVc2VySXNOb3RJbkZyZWVQbGFuEIsI');
'UEFQYXltZW50RXhwaXJlZBCKCBIYChNVc2VySXNOb3RJbkZyZWVQbGFuEIsIEh8KGkZvcmVncm'
'91bmRTZXNzaW9uQ29ubmVjdGVkEIwI');

View file

@ -10,9 +10,11 @@ message AdditionalMessageData {
enum Type {
LINK = 0;
CONTACTS = 1;
RESTORED_FLAME_COUNTER = 2;
}
Type type = 1;
optional string link = 2;
repeated SharedContact contacts = 3;
optional int64 restored_flame_counter = 4;
}

View file

@ -106,11 +106,14 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
AdditionalMessageData_Type? type,
$core.String? link,
$core.Iterable<SharedContact>? contacts,
$fixnum.Int64? restoredFlameCounter,
}) {
final result = create();
if (type != null) result.type = type;
if (link != null) result.link = link;
if (contacts != null) result.contacts.addAll(contacts);
if (restoredFlameCounter != null)
result.restoredFlameCounter = restoredFlameCounter;
return result;
}
@ -135,6 +138,7 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
..pc<SharedContact>(
3, _omitFieldNames ? '' : 'contacts', $pb.PbFieldType.PM,
subBuilder: SharedContact.create)
..aInt64(4, _omitFieldNames ? '' : 'restoredFlameCounter')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -180,6 +184,15 @@ class AdditionalMessageData extends $pb.GeneratedMessage {
@$pb.TagNumber(3)
$pb.PbList<SharedContact> get contacts => $_getList(2);
@$pb.TagNumber(4)
$fixnum.Int64 get restoredFlameCounter => $_getI64(3);
@$pb.TagNumber(4)
set restoredFlameCounter($fixnum.Int64 value) => $_setInt64(3, value);
@$pb.TagNumber(4)
$core.bool hasRestoredFlameCounter() => $_has(3);
@$pb.TagNumber(4)
void clearRestoredFlameCounter() => $_clearField(4);
}
const $core.bool _omitFieldNames =

View file

@ -19,15 +19,19 @@ class AdditionalMessageData_Type extends $pb.ProtobufEnum {
AdditionalMessageData_Type._(0, _omitEnumNames ? '' : 'LINK');
static const AdditionalMessageData_Type CONTACTS =
AdditionalMessageData_Type._(1, _omitEnumNames ? '' : 'CONTACTS');
static const AdditionalMessageData_Type RESTORED_FLAME_COUNTER =
AdditionalMessageData_Type._(
2, _omitEnumNames ? '' : 'RESTORED_FLAME_COUNTER');
static const $core.List<AdditionalMessageData_Type> values =
<AdditionalMessageData_Type>[
LINK,
CONTACTS,
RESTORED_FLAME_COUNTER,
];
static final $core.List<AdditionalMessageData_Type?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 1);
$pb.ProtobufEnum.$_initByValueList(values, 2);
static AdditionalMessageData_Type? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];

View file

@ -57,10 +57,20 @@ const AdditionalMessageData$json = {
'6': '.SharedContact',
'10': 'contacts'
},
{
'1': 'restored_flame_counter',
'3': 4,
'4': 1,
'5': 3,
'9': 1,
'10': 'restoredFlameCounter',
'17': true
},
],
'4': [AdditionalMessageData_Type$json],
'8': [
{'1': '_link'},
{'1': '_restored_flame_counter'},
],
};
@ -70,6 +80,7 @@ const AdditionalMessageData_Type$json = {
'2': [
{'1': 'LINK', '2': 0},
{'1': 'CONTACTS', '2': 1},
{'1': 'RESTORED_FLAME_COUNTER', '2': 2},
],
};
@ -77,5 +88,7 @@ const AdditionalMessageData_Type$json = {
final $typed_data.Uint8List additionalMessageDataDescriptor = $convert.base64Decode(
'ChVBZGRpdGlvbmFsTWVzc2FnZURhdGESLwoEdHlwZRgBIAEoDjIbLkFkZGl0aW9uYWxNZXNzYW'
'dlRGF0YS5UeXBlUgR0eXBlEhcKBGxpbmsYAiABKAlIAFIEbGlua4gBARIqCghjb250YWN0cxgD'
'IAMoCzIOLlNoYXJlZENvbnRhY3RSCGNvbnRhY3RzIh4KBFR5cGUSCAoETElOSxAAEgwKCENPTl'
'RBQ1RTEAFCBwoFX2xpbms=');
'IAMoCzIOLlNoYXJlZENvbnRhY3RSCGNvbnRhY3RzEjkKFnJlc3RvcmVkX2ZsYW1lX2NvdW50ZX'
'IYBCABKANIAVIUcmVzdG9yZWRGbGFtZUNvdW50ZXKIAQEiOgoEVHlwZRIICgRMSU5LEAASDAoI'
'Q09OVEFDVFMQARIaChZSRVNUT1JFRF9GTEFNRV9DT1VOVEVSEAJCBwoFX2xpbmtCGQoXX3Jlc3'
'RvcmVkX2ZsYW1lX2NvdW50ZXI=');

View file

@ -30,9 +30,9 @@ import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/api/server_messages.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/services/fcm.service.dart';
import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/group.services.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/signal/utils.signal.dart';
@ -46,6 +46,7 @@ import 'package:web_socket_channel/io.dart';
final lockConnecting = Mutex();
final lockRetransStore = Mutex();
final lockAuthentication = Mutex();
/// The ApiProvider is responsible for communicating with the server.
/// It handles errors and does automatically tries to reconnect on
@ -86,11 +87,14 @@ class ApiService {
// Function is called after the user is authenticated at the server
Future<void> onAuthenticated() async {
isAuthenticated = true;
await initFCMAfterAuthenticated();
globalCallbackConnectionState(isConnected: true);
if (!globalIsAppInBackground) {
if (globalIsInBackgroundTask) {
await retransmitRawBytes();
await tryTransmitMessages();
await tryDownloadAllMediaFiles();
} else if (!globalIsAppInBackground) {
unawaited(retransmitRawBytes());
unawaited(tryTransmitMessages());
unawaited(tryDownloadAllMediaFiles());
@ -124,6 +128,7 @@ class ApiService {
}
Future<void> startReconnectionTimer() async {
if (globalIsInBackgroundTask) return;
if (reconnectionTimer?.isActive ?? false) {
return;
}
@ -152,8 +157,9 @@ class ApiService {
if (connectivitySubscription != null) {
return;
}
connectivitySubscription =
Connectivity().onConnectivityChanged.listen((result) async {
connectivitySubscription = Connectivity().onConnectivityChanged.listen((
result,
) async {
if (!result.contains(ConnectivityResult.none)) {
await connect();
}
@ -330,7 +336,9 @@ class ApiService {
}
}
if (res.isError) {
Log.warn('Got error from server: ${res.error}');
if (res.error != ErrorCode.ForegroundSessionConnected) {
Log.warn('Got error from server: ${res.error}');
}
if (res.error == ErrorCode.AppVersionOutdated) {
globalCallbackAppIsOutdated();
Log.warn('App Version is OUTDATED.');
@ -348,7 +356,6 @@ class ApiService {
return Result.error(ErrorCode.InternalError);
}
if (res.error == ErrorCode.SessionNotAuthenticated) {
isAuthenticated = false;
if (authenticated) {
await authenticate();
if (isAuthenticated) {
@ -380,8 +387,9 @@ class ApiService {
Future<bool> tryAuthenticateWithToken(int userId) async {
const storage = FlutterSecureStorage();
final apiAuthToken =
await storage.read(key: SecureStorageKeys.apiAuthToken);
final apiAuthToken = await storage.read(
key: SecureStorageKeys.apiAuthToken,
);
final user = await getUser();
if (apiAuthToken != null && user != null) {
@ -395,6 +403,7 @@ class ApiService {
..userId = Int64(userId)
..appVersion = (await PackageInfo.fromPlatform()).version
..deviceId = Int64(user.deviceId)
..inBackground = globalIsInBackgroundTask
..authToken = base64Decode(apiAuthToken);
final handshake = Handshake()..authenticate = authenticate;
@ -404,12 +413,20 @@ class ApiService {
if (result.isSuccess) {
Log.info('websocket is authenticated');
unawaited(onAuthenticated());
isAuthenticated = true;
if (globalIsInBackgroundTask) {
await onAuthenticated();
} else {
unawaited(onAuthenticated());
}
return true;
}
if (result.isError) {
if (result.error != ErrorCode.AuthTokenNotValid) {
Log.error('got error while authenticating to the server: $result');
if (result.error != ErrorCode.AuthTokenNotValid &&
result.error != ErrorCode.ForegroundSessionConnected) {
Log.error(
'got error while authenticating to the server: ${result.error}',
);
return false;
}
}
@ -418,60 +435,66 @@ class ApiService {
}
Future<void> authenticate() async {
if (isAuthenticated) return;
if (await getSignalIdentity() == null) {
return;
}
return lockAuthentication.protect(() async {
if (isAuthenticated) return;
if (await getSignalIdentity() == null) {
return;
}
final userData = await getUser();
if (userData == null) return;
final userData = await getUser();
if (userData == null) return;
if (await tryAuthenticateWithToken(userData.userId)) {
return;
}
if (await tryAuthenticateWithToken(userData.userId)) {
return;
}
final handshake = Handshake()
..getAuthChallenge = Handshake_GetAuthChallenge();
final req = createClientToServerFromHandshake(handshake);
final handshake = Handshake()
..getAuthChallenge = Handshake_GetAuthChallenge();
final req = createClientToServerFromHandshake(handshake);
final result = await sendRequestSync(req, authenticated: false);
if (result.isError) {
Log.warn('could not request auth challenge', result);
return;
}
final result = await sendRequestSync(req, authenticated: false);
if (result.isError) {
Log.warn('could not request auth challenge', result);
return;
}
final challenge = result.value.authchallenge;
final challenge = result.value.authchallenge;
var privKey = (await getSignalIdentityKeyPair())?.getPrivateKey();
if (privKey == null) return;
final random = getRandomUint8List(32);
final signature = sign(privKey.serialize(), challenge as Uint8List, random);
privKey = null;
var privKey = (await getSignalIdentityKeyPair())?.getPrivateKey();
if (privKey == null) return;
final random = getRandomUint8List(32);
final signature = sign(
privKey.serialize(),
challenge as Uint8List,
random,
);
privKey = null;
final getAuthToken = Handshake_GetAuthToken()
..response = signature
..userId = Int64(userData.userId);
final getAuthToken = Handshake_GetAuthToken()
..response = signature
..userId = Int64(userData.userId);
final getauthtoken = Handshake()..getAuthToken = getAuthToken;
final getauthtoken = Handshake()..getAuthToken = getAuthToken;
final req2 = createClientToServerFromHandshake(getauthtoken);
final req2 = createClientToServerFromHandshake(getauthtoken);
final result2 = await sendRequestSync(req2, authenticated: false);
if (result2.isError) {
Log.error('could not send auth response: ${result2.error}');
return;
}
final result2 = await sendRequestSync(req2, authenticated: false);
if (result2.isError) {
Log.error('could not send auth response: ${result2.error}');
return;
}
final apiAuthToken = result2.value.authtoken as Uint8List;
final apiAuthTokenB64 = base64Encode(apiAuthToken);
final apiAuthToken = result2.value.authtoken as Uint8List;
final apiAuthTokenB64 = base64Encode(apiAuthToken);
const storage = FlutterSecureStorage();
await storage.write(
key: SecureStorageKeys.apiAuthToken,
value: apiAuthTokenB64,
);
const storage = FlutterSecureStorage();
await storage.write(
key: SecureStorageKeys.apiAuthToken,
value: apiAuthTokenB64,
);
await tryAuthenticateWithToken(userData.userId);
await tryAuthenticateWithToken(userData.userId);
});
}
Future<Result> register(
@ -490,8 +513,9 @@ class ApiService {
final register = Handshake_Register()
..username = username
..publicIdentityKey =
(await signalStore.getIdentityKeyPair()).getPublicKey().serialize()
..publicIdentityKey = (await signalStore.getIdentityKeyPair())
.getPublicKey()
.serialize()
..registrationId = Int64(signalIdentity.registrationId)
..signedPrekey = signedPreKey.getKeyPair().publicKey.serialize()
..signedPrekeySignature = signedPreKey.signature
@ -511,8 +535,10 @@ class ApiService {
}
Future<void> checkForDeletedUsernames() async {
final users = await twonlyDB.contactsDao
.getContactsByUsername('[deleted]', username2: '[Unknown]');
final users = await twonlyDB.contactsDao.getContactsByUsername(
'[deleted]',
username2: '[Unknown]',
);
for (final user in users) {
final userData = await getUserById(user.userId);
if (userData != null) {

View file

@ -18,7 +18,15 @@ Future<bool> handleNewContactRequest(int fromUserId) async {
.getContactByUserId(fromUserId)
.getSingleOrNull();
if (contact != null) {
if (contact.accepted) {
// Either the contact has accepted the fromUserId already: Then just blindly accept the request.
// Or the user has also requested fromUserId. This means that both user have requested each other (while been
// offline for example): In this case the contact can also be accepted blindly.
if (contact.accepted || (!contact.requested && !contact.deletedByUser)) {
if (!contact.accepted) {
// User has also requested the fromUserId, so mark the user as accepted.
await handleContactAccept(fromUserId);
}
// contact was already accepted, so just accept the request in the background.
await sendCipherText(
contact.userId,
@ -50,6 +58,28 @@ Future<bool> handleNewContactRequest(int fromUserId) async {
return true;
}
Future<void> handleContactAccept(int fromUserId) async {
await twonlyDB.contactsDao.updateContact(
fromUserId,
const ContactsCompanion(
requested: Value(false),
accepted: Value(true),
deletedByUser: Value(false),
),
);
final contact = await twonlyDB.contactsDao
.getContactByUserId(fromUserId)
.getSingleOrNull();
if (contact != null) {
await twonlyDB.groupsDao.createNewDirectChat(
fromUserId,
GroupsCompanion(
groupName: Value(getContactDisplayName(contact)),
),
);
}
}
Future<bool> handleContactRequest(
int fromUserId,
EncryptedContent_ContactRequest contactRequest,
@ -60,25 +90,7 @@ Future<bool> handleContactRequest(
return handleNewContactRequest(fromUserId);
case EncryptedContent_ContactRequest_Type.ACCEPT:
Log.info('Got a contact accept from $fromUserId');
await twonlyDB.contactsDao.updateContact(
fromUserId,
const ContactsCompanion(
requested: Value(false),
accepted: Value(true),
deletedByUser: Value(false),
),
);
final contact = await twonlyDB.contactsDao
.getContactByUserId(fromUserId)
.getSingleOrNull();
if (contact != null) {
await twonlyDB.groupsDao.createNewDirectChat(
fromUserId,
GroupsCompanion(
groupName: Value(getContactDisplayName(contact)),
),
);
}
await handleContactAccept(fromUserId);
case EncryptedContent_ContactRequest_Type.REJECT:
Log.info('Got a contact reject from $fromUserId');
await twonlyDB.contactsDao.updateContact(
@ -125,12 +137,12 @@ Future<void> handleContactUpdate(
}
Future<void> handleFlameSync(
int contactId,
String groupId,
EncryptedContent_FlameSync flameSync,
) async {
Log.info('Got a flameSync from $contactId');
Log.info('Got a flameSync for group $groupId');
final group = await twonlyDB.groupsDao.getDirectChat(contactId);
final group = await twonlyDB.groupsDao.getGroup(groupId);
if (group == null || group.lastFlameCounterChange == null) return;
var updates = GroupsCompanion(

View file

@ -4,7 +4,8 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
hide Message;
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/api/utils.dart';
@ -31,7 +32,7 @@ Future<void> handleMedia(
message.senderId != fromUserId ||
message.mediaId == null) {
Log.warn(
'Got reupload for a message that either does not exists or sender != fromUserId or not a media file',
'Got reupload from $fromUserId for a message that either does not exists (${message == null}) or senderId = ${message?.senderId}',
);
return;
}
@ -53,8 +54,9 @@ Future<void> handleMedia(
),
);
final mediaFile =
await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
message.mediaId!,
);
if (mediaFile != null) {
unawaited(startDownloadMedia(mediaFile, false));
@ -89,56 +91,64 @@ Future<void> handleMedia(
}
}
final mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
MediaFilesCompanion(
downloadState: const Value(DownloadState.pending),
type: Value(mediaType),
requiresAuthentication: Value(media.requiresAuthentication),
displayLimitInMilliseconds: Value(
displayLimitInMilliseconds,
),
downloadToken: Value(Uint8List.fromList(media.downloadToken)),
encryptionKey: Value(Uint8List.fromList(media.encryptionKey)),
encryptionMac: Value(Uint8List.fromList(media.encryptionMac)),
encryptionNonce: Value(Uint8List.fromList(media.encryptionNonce)),
createdAt: Value(fromTimestamp(media.timestamp)),
),
);
late MediaFile? mediaFile;
late Message? message;
if (mediaFile == null) {
Log.error('Could not insert media file into database');
return;
}
await twonlyDB.transaction(() async {
mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
MediaFilesCompanion(
downloadState: const Value(DownloadState.pending),
type: Value(mediaType),
requiresAuthentication: Value(media.requiresAuthentication),
displayLimitInMilliseconds: Value(
displayLimitInMilliseconds,
),
downloadToken: Value(Uint8List.fromList(media.downloadToken)),
encryptionKey: Value(Uint8List.fromList(media.encryptionKey)),
encryptionMac: Value(Uint8List.fromList(media.encryptionMac)),
encryptionNonce: Value(Uint8List.fromList(media.encryptionNonce)),
createdAt: Value(fromTimestamp(media.timestamp)),
),
);
final message = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
messageId: Value(media.senderMessageId),
senderId: Value(fromUserId),
groupId: Value(groupId),
mediaId: Value(mediaFile.mediaId),
type: Value(MessageType.media.name),
additionalMessageData: Value.absentIfNull(
media.hasAdditionalMessageData()
? Uint8List.fromList(media.additionalMessageData)
: null,
if (mediaFile == null) {
Log.error('Could not insert media file into database');
return;
}
message = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
messageId: Value(media.senderMessageId),
senderId: Value(fromUserId),
groupId: Value(groupId),
mediaId: Value(mediaFile!.mediaId),
type: Value(MessageType.media.name),
additionalMessageData: Value.absentIfNull(
media.hasAdditionalMessageData()
? Uint8List.fromList(media.additionalMessageData)
: null,
),
quotesMessageId: Value(
media.hasQuoteMessageId() ? media.quoteMessageId : null,
),
createdAt: Value(fromTimestamp(media.timestamp)),
),
quotesMessageId: Value(
media.hasQuoteMessageId() ? media.quoteMessageId : null,
),
createdAt: Value(fromTimestamp(media.timestamp)),
),
);
);
});
if (message != null) {
await twonlyDB.groupsDao
.increaseLastMessageExchange(groupId, fromTimestamp(media.timestamp));
Log.info('Inserted a new media message with ID: ${message.messageId}');
await twonlyDB.groupsDao.increaseLastMessageExchange(
groupId,
fromTimestamp(media.timestamp),
);
Log.info('Inserted a new media message with ID: ${message!.messageId}');
await incFlameCounter(
message.groupId,
message!.groupId,
true,
fromTimestamp(media.timestamp),
);
unawaited(startDownloadMedia(mediaFile, false));
unawaited(startDownloadMedia(mediaFile!, false));
}
}
@ -163,8 +173,9 @@ Future<void> handleMediaUpdate(
);
return;
}
final mediaFile =
await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
message.mediaId!,
);
if (mediaFile == null) {
Log.info(
'Got media file update, but media file was not found ${message.mediaId}',
@ -203,8 +214,9 @@ Future<void> handleMediaUpdate(
reuploadRequestedBy: Value(reuploadRequestedBy),
),
);
final mediaFileUpdated =
await MediaFileService.fromMediaId(mediaFile.mediaId);
final mediaFileUpdated = await MediaFileService.fromMediaId(
mediaFile.mediaId,
);
if (mediaFileUpdated != null) {
if (mediaFileUpdated.uploadRequestPath.existsSync()) {
mediaFileUpdated.uploadRequestPath.deleteSync();

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> handleReaction(
@ -17,6 +18,8 @@ Future<void> handleReaction(
reaction.remove,
);
await handleMediaRelatedResponseFromReceiver(reaction.targetMessageId);
if (!reaction.remove) {
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
}

View file

@ -19,8 +19,8 @@ import 'package:twonly/src/utils/misc.dart';
Future<void> tryDownloadAllMediaFiles({bool force = false}) async {
// This is called when WebSocket is newly connected, so allow all downloads to be restarted.
final mediaFiles =
await twonlyDB.mediaFilesDao.getAllMediaFilesPendingDownload();
final mediaFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingDownload();
for (final mediaFile in mediaFiles) {
if (await canMediaFileBeDownloaded(mediaFile)) {
@ -30,8 +30,9 @@ Future<void> tryDownloadAllMediaFiles({bool force = false}) async {
}
Future<bool> canMediaFileBeDownloaded(MediaFile mediaFile) async {
final messages =
await twonlyDB.messagesDao.getMessagesByMediaId(mediaFile.mediaId);
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
mediaFile.mediaId,
);
// Verify that the sender of the original image / message does still exists.
// If not delete the message as it can not be downloaded from the server anymore.
@ -56,8 +57,9 @@ Future<bool> canMediaFileBeDownloaded(MediaFile mediaFile) async {
return false;
}
final contact =
await twonlyDB.contactsDao.getContactById(messages.first.senderId!);
final contact = await twonlyDB.contactsDao.getContactById(
messages.first.senderId!,
);
if (contact == null || contact.accountDeleted) {
Log.info(
@ -98,23 +100,27 @@ Future<bool> isAllowedToDownload(MediaType type) async {
if (connectivityResult.contains(ConnectivityResult.mobile)) {
if (type == MediaType.video) {
if (options[ConnectivityResult.mobile.name]!
.contains(DownloadMediaTypes.video.name)) {
if (options[ConnectivityResult.mobile.name]!.contains(
DownloadMediaTypes.video.name,
)) {
return true;
}
} else if (options[ConnectivityResult.mobile.name]!
.contains(DownloadMediaTypes.image.name)) {
} else if (options[ConnectivityResult.mobile.name]!.contains(
DownloadMediaTypes.image.name,
)) {
return true;
}
}
if (connectivityResult.contains(ConnectivityResult.wifi)) {
if (type == MediaType.video) {
if (options[ConnectivityResult.wifi.name]!
.contains(DownloadMediaTypes.video.name)) {
if (options[ConnectivityResult.wifi.name]!.contains(
DownloadMediaTypes.video.name,
)) {
return true;
}
} else if (options[ConnectivityResult.wifi.name]!
.contains(DownloadMediaTypes.image.name)) {
} else if (options[ConnectivityResult.wifi.name]!.contains(
DownloadMediaTypes.image.name,
)) {
return true;
}
}
@ -230,8 +236,9 @@ Future<void> downloadFileFast(
String apiUrl,
File filePath,
) async {
final response =
await http.get(Uri.parse(apiUrl)).timeout(const Duration(seconds: 10));
final response = await http
.get(Uri.parse(apiUrl))
.timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
await filePath.writeAsBytes(response.bodyBytes);
@ -308,8 +315,10 @@ Future<void> handleEncryptedFile(String mediaId) async {
mac: Mac(mediaService.mediaFile.encryptionMac!),
);
final plaintextBytes =
await chacha20.decrypt(secretBox, secretKey: secretKeyData);
final plaintextBytes = await chacha20.decrypt(
secretBox,
secretKey: secretKeyData,
);
final rawMediaBytes = Uint8List.fromList(plaintextBytes);
@ -337,8 +346,8 @@ Future<void> handleEncryptedFile(String mediaId) async {
}
Future<void> makeMigrationToVersion91() async {
final messages =
await twonlyDB.mediaFilesDao.getAllMediaFilesReuploadRequested();
final messages = await twonlyDB.mediaFilesDao
.getAllMediaFilesReuploadRequested();
for (final message in messages) {
await requestMediaReupload(message.mediaId);
}

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:twonly/globals.dart';
@ -8,8 +8,8 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/backup/create.backup.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> initFileDownloader() async {
@ -74,30 +74,7 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
if (update.status == TaskStatus.complete) {
if (update.responseStatusCode == 200) {
Log.info('Upload of ${media.mediaId} success!');
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploaded),
),
);
/// As the messages where send in a bulk acknowledge all messages.
final messages =
await twonlyDB.messagesDao.getMessagesByMediaId(media.mediaId);
for (final message in messages) {
final contacts =
await twonlyDB.groupsDao.getGroupNonLeftMembers(message.groupId);
for (final contact in contacts) {
await twonlyDB.messagesDao.handleMessageAckByServer(
contact.contactId,
message.messageId,
clock.now(),
);
}
}
await markUploadAsSuccessful(media);
return;
}
Log.error(
@ -122,6 +99,20 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
'Background status $mediaId with status ${update.status} and ${update.responseStatusCode}. ',
);
if (update.status == TaskStatus.waitingToRetry) {
if (update.responseStatusCode == 401) {
// auth token is not valid, so either create a new task with a new token, or cancel task
final mediaService = MediaFileService(media);
await FileDownloader().cancelTaskWithId(update.task.taskId);
Log.info('Cancel task, already uploaded or will be reuploaded');
if (mediaService.mediaFile.uploadState != UploadState.uploaded) {
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
}
}
}
if (update.status == TaskStatus.failed ||
update.status == TaskStatus.canceled) {
Log.error(
@ -129,8 +120,11 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
);
final mediaService = MediaFileService(media);
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
// in case the media file is already uploaded to not reqtry
if (mediaService.mediaFile.uploadState != UploadState.uploaded) {
await mediaService.setUploadState(UploadState.uploading);
// In all other cases just try the upload again...
await startBackgroundMediaUpload(mediaService);
}
}
}

View file

@ -24,10 +24,13 @@ import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:workmanager/workmanager.dart' hide TaskStatus;
Future<void> finishStartedPreprocessing() async {
final mediaFiles =
await twonlyDB.mediaFilesDao.getAllMediaFilesPendingUpload();
final mediaFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingUpload();
Log.info('There are ${mediaFiles.length} media files pending');
for (final mediaFile in mediaFiles) {
if (mediaFile.isDraftMedia) {
@ -55,6 +58,50 @@ Future<void> finishStartedPreprocessing() async {
}
}
/// It can happen, that a media files is uploaded but not yet marked for been uploaded.
/// For example because the background_downloader plugin has not yet reported the finished upload.
/// In case the the message receipts or a reaction was received, mark the media file as been uploaded.
Future<void> handleMediaRelatedResponseFromReceiver(String messageId) async {
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (message == null || message.mediaId == null) return;
final media = await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!);
if (media == null) return;
if (media.uploadState != UploadState.uploaded) {
Log.info('Media was not yet marked as uploaded. Doing it now.');
await markUploadAsSuccessful(media);
}
}
Future<void> markUploadAsSuccessful(MediaFile media) async {
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
const MediaFilesCompanion(
uploadState: Value(UploadState.uploaded),
),
);
/// As the messages where send in a bulk acknowledge all messages.
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
media.mediaId,
);
for (final message in messages) {
final contacts = await twonlyDB.groupsDao.getGroupNonLeftMembers(
message.groupId,
);
for (final contact in contacts) {
await twonlyDB.messagesDao.handleMessageAckByServer(
contact.contactId,
message.messageId,
clock.now(),
);
}
}
}
Future<MediaFileService?> initializeMediaUpload(
MediaType type,
int? displayLimitInMilliseconds, {
@ -103,8 +150,9 @@ Future<void> insertMediaFileInMessagesTable(
groupId: Value(groupId),
mediaId: Value(mediaService.mediaFile.mediaId),
type: Value(MessageType.media.name),
additionalMessageData:
Value.absentIfNull(additionalData?.writeToBuffer()),
additionalMessageData: Value.absentIfNull(
additionalData?.writeToBuffer(),
),
),
);
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
@ -192,8 +240,9 @@ Future<void> _encryptMediaFiles(MediaFileService mediaService) async {
await mediaService.setEncryptedMac(Uint8List.fromList(secretBox.mac.bytes));
mediaService.encryptedPath
.writeAsBytesSync(Uint8List.fromList(secretBox.cipherText));
mediaService.encryptedPath.writeAsBytesSync(
Uint8List.fromList(secretBox.cipherText),
);
}
Future<void> _createUploadRequest(MediaFileService media) async {
@ -201,8 +250,9 @@ Future<void> _createUploadRequest(MediaFileService media) async {
final messagesOnSuccess = <TextMessage>[];
final messages =
await twonlyDB.messagesDao.getMessagesByMediaId(media.mediaFile.mediaId);
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
media.mediaFile.mediaId,
);
if (messages.isEmpty) {
// There where no user selected who should receive the image, so waiting with this step...
@ -210,8 +260,9 @@ Future<void> _createUploadRequest(MediaFileService media) async {
}
for (final message in messages) {
final groupMembers =
await twonlyDB.groupsDao.getGroupNonLeftMembers(message.groupId);
final groupMembers = await twonlyDB.groupsDao.getGroupNonLeftMembers(
message.groupId,
);
if (media.mediaFile.reuploadRequestedBy == null) {
await incFlameCounter(message.groupId, false, message.createdAt);
@ -220,8 +271,9 @@ Future<void> _createUploadRequest(MediaFileService media) async {
for (final groupMember in groupMembers) {
/// only send the upload to the users
if (media.mediaFile.reuploadRequestedBy != null) {
if (!media.mediaFile.reuploadRequestedBy!
.contains(groupMember.contactId)) {
if (!media.mediaFile.reuploadRequestedBy!.contains(
groupMember.contactId,
)) {
continue;
}
}
@ -260,8 +312,9 @@ Future<void> _createUploadRequest(MediaFileService media) async {
);
if (media.mediaFile.displayLimitInMilliseconds != null) {
notEncryptedContent.media.displayLimitInMilliseconds =
Int64(media.mediaFile.displayLimitInMilliseconds!);
notEncryptedContent.media.displayLimitInMilliseconds = Int64(
media.mediaFile.displayLimitInMilliseconds!,
);
}
final cipherText = await sendCipherText(
@ -299,6 +352,19 @@ Future<void> _createUploadRequest(MediaFileService media) async {
final uploadRequestBytes = uploadRequest.writeToBuffer();
if (uploadRequestBytes.length > 49_000_000) {
await media.setUploadState(UploadState.fileLimitReached);
await twonlyDB.messagesDao.updateMessagesByMediaId(
media.mediaFile.mediaId,
MessagesCompanion(
openedAt: Value(DateTime.now()),
ackByServer: Value(DateTime.now()),
),
);
return;
}
await media.uploadRequestPath.writeAsBytes(uploadRequestBytes);
}
@ -306,8 +372,9 @@ Mutex protectUpload = Mutex();
Future<void> _uploadUploadRequest(MediaFileService media) async {
await protectUpload.protect(() async {
final currentMedia =
await twonlyDB.mediaFilesDao.getMediaFileById(media.mediaFile.mediaId);
final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById(
media.mediaFile.mediaId,
);
if (currentMedia == null ||
currentMedia.uploadState == UploadState.backgroundUploadTaskStarted) {
@ -315,8 +382,9 @@ Future<void> _uploadUploadRequest(MediaFileService media) async {
return null;
}
final apiAuthTokenRaw = await const FlutterSecureStorage()
.read(key: SecureStorageKeys.apiAuthToken);
final apiAuthTokenRaw = await const FlutterSecureStorage().read(
key: SecureStorageKeys.apiAuthToken,
);
if (apiAuthTokenRaw == null) {
Log.error('api auth token not defined.');
@ -344,8 +412,9 @@ Future<void> _uploadUploadRequest(MediaFileService media) async {
final connectivityResult = await Connectivity().checkConnectivity();
if (!connectivityResult.contains(ConnectivityResult.mobile) &&
!connectivityResult.contains(ConnectivityResult.wifi)) {
if (globalIsInBackgroundTask ||
!connectivityResult.contains(ConnectivityResult.mobile) &&
!connectivityResult.contains(ConnectivityResult.wifi)) {
// no internet, directly put it into the background...
await FileDownloader().enqueue(task);
await media.setUploadState(UploadState.backgroundUploadTaskStarted);
@ -376,15 +445,30 @@ Future<void> uploadFileFastOrEnqueue(
);
try {
final workmanagerUniqueName =
'progressing_finish_uploads_${media.mediaFile.mediaId}';
await Workmanager().registerOneOffTask(
workmanagerUniqueName,
'eu.twonly.processing_task',
initialDelay: const Duration(minutes: 15),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
Log.info('Uploading fast: ${task.taskId}');
final response =
await requestMultipart.send().timeout(const Duration(seconds: 8));
final response = await requestMultipart.send();
var status = TaskStatus.failed;
if (response.statusCode == 200) {
status = TaskStatus.complete;
} else if (response.statusCode == 404) {
status = TaskStatus.notFound;
}
await Workmanager().cancelByUniqueName(workmanagerUniqueName);
await handleUploadStatusUpdate(
TaskStatusUpdate(
task,

View file

@ -79,8 +79,9 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
// ignore: parameter_assignments
receiptId = receipt.receiptId;
final contact =
await twonlyDB.contactsDao.getContactById(receipt.contactId);
final contact = await twonlyDB.contactsDao.getContactById(
receipt.contactId,
);
if (contact == null || contact.accountDeleted) {
Log.warn('Will not send message again as user does not exist anymore.');
await twonlyDB.receiptsDao.deleteReceipt(receiptId);
@ -99,8 +100,9 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
final message = pb.Message.fromBuffer(receipt.message)
..receiptId = receiptId;
final encryptedContent =
pb.EncryptedContent.fromBuffer(message.encryptedContent);
final encryptedContent = pb.EncryptedContent.fromBuffer(
message.encryptedContent,
);
final pushNotification = await getPushNotificationFromEncryptedContent(
receipt.contactId,
@ -111,8 +113,10 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
Uint8List? pushData;
if (pushNotification != null && receipt.retryCount <= 3) {
/// In case the message has to be resend more than three times, do not show a notification again...
pushData =
await encryptPushNotification(receipt.contactId, pushNotification);
pushData = await encryptPushNotification(
receipt.contactId,
pushNotification,
);
}
if (message.type == pb.Message_Type.TEST_NOTIFICATION) {
@ -331,7 +335,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
contactId: Value(contactId),
message: Value(response.writeToBuffer()),
messageId: Value(messageId),
ackByServerAt: Value(onlyReturnEncryptedData ? clock.now() : null),
willBeRetriedByMediaUpload: Value(onlyReturnEncryptedData),
),
);

View file

@ -26,6 +26,7 @@ import 'package:twonly/src/services/api/client2client/text_message.c2c.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/group.services.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
@ -62,6 +63,7 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
..response = response;
await apiService.sendResponse(ClientToServer()..v0 = v0);
globalGotMessageFromServer = true;
});
}
@ -94,6 +96,11 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
var retry = false;
if (message.hasPlaintextContent()) {
if (message.plaintextContent.hasDecryptionErrorMessage()) {
if (message.plaintextContent.decryptionErrorMessage.type ==
PlaintextContent_DecryptionErrorMessage_Type.PREKEY_UNKNOWN) {
// Get a new prekey from the server, and establish a new signal session.
await handleSessionResync(fromUserId);
}
Log.info(
'Got decryption error: ${message.plaintextContent.decryptionErrorMessage.type} for $receiptId',
);
@ -251,11 +258,6 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
return (null, null);
}
if (content.hasFlameSync()) {
await handleFlameSync(fromUserId, content.flameSync);
return (null, null);
}
if (content.hasPushKeys()) {
await handlePushKey(fromUserId, content.pushKeys);
return (null, null);
@ -340,6 +342,11 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage(
}
}
if (content.hasFlameSync()) {
await handleFlameSync(content.groupId, content.flameSync);
return (null, null);
}
if (content.hasGroupUpdate()) {
await handleGroupUpdate(
fromUserId,

View file

@ -81,22 +81,27 @@ Future<void> handleMediaError(MediaFile media) async {
);
}
Future<void> importSignalContactAndCreateRequest(
Future<bool> importSignalContactAndCreateRequest(
server.Response_UserData userdata,
) async {
if (await processSignalUserData(userdata)) {
// 1. Setup notifications keys with the other user
await setupNotificationWithUsers(
forceContact: userdata.userId.toInt(),
);
// 2. Then send user request
await sendCipherText(
userdata.userId.toInt(),
EncryptedContent(
contactRequest: EncryptedContent_ContactRequest(
type: EncryptedContent_ContactRequest_Type.REQUEST,
),
),
);
if (!await processSignalUserData(userdata)) {
return false;
}
// 1. Setup notifications keys with the other user
await setupNotificationWithUsers(
forceContact: userdata.userId.toInt(),
);
// 2. Then send user request
await sendCipherText(
userdata.userId.toInt(),
EncryptedContent(
contactRequest: EncryptedContent_ContactRequest(
type: EncryptedContent_ContactRequest_Type.REQUEST,
),
),
);
return true;
}

View file

@ -0,0 +1,130 @@
import 'dart:async';
import 'package:path_provider/path_provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/keyvalue.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:workmanager/workmanager.dart';
// ignore: unreachable_from_main
Future<void> initializeBackgroundTaskManager() async {
await Workmanager().initialize(callbackDispatcher);
await Workmanager().registerPeriodicTask(
'fetch_data_from_server',
'eu.twonly.periodic_task',
frequency: const Duration(minutes: 20),
initialDelay: const Duration(minutes: 5),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
}
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
switch (task) {
case 'eu.twonly.periodic_task':
if (await initBackgroundExecution()) {
await handlePeriodicTask();
}
case 'eu.twonly.processing_task':
if (await initBackgroundExecution()) {
await handleProcessingTask();
}
default:
Log.error('Unknown task was executed: $task');
}
return Future.value(true);
});
}
Future<bool> initBackgroundExecution() async {
SentryWidgetsFlutterBinding.ensureInitialized();
initLogger();
final user = await getUser();
if (user == null) return false;
gUser = user;
globalApplicationCacheDirectory = (await getApplicationCacheDirectory()).path;
globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path;
twonlyDB = TwonlyDB();
apiService = ApiService();
globalIsInBackgroundTask = true;
return true;
}
Future<bool> handlePeriodicTask() async {
final lastExecution =
await KeyValueStore.get(KeyValueKeys.lastPeriodicTaskExecution);
if (lastExecution == null || !lastExecution.containsKey('timestamp')) {
final lastExecutionTime = lastExecution?['timestamp'] as int?;
if (lastExecutionTime != null) {
final lastExecution =
DateTime.fromMillisecondsSinceEpoch(lastExecutionTime);
if (DateTime.now().difference(lastExecution).inMinutes < 2) {
Log.info(
'eu.twonly.periodic_task not executed as last execution was within the last two minutes.',
);
return true;
}
}
}
await KeyValueStore.put(KeyValueKeys.lastPeriodicTaskExecution, {
'timestamp': DateTime.now().millisecondsSinceEpoch,
});
Log.info('eu.twonly.periodic_task was called.');
final stopwatch = Stopwatch()..start();
if (!await apiService.connect()) {
Log.info('Could not connect to the api. Returning early.');
return false;
}
if (!apiService.isAuthenticated) {
Log.info('Api is not authenticated. Returning early.');
return false;
}
while (!globalGotMessageFromServer) {
if (stopwatch.elapsed.inSeconds >= 15) {
Log.info('No new message from the server after 15 seconds.');
break;
}
await Future.delayed(const Duration(milliseconds: 500));
}
if (globalGotMessageFromServer) {
Log.info('Received a server message from the server.');
}
await finishStartedPreprocessing();
await Future.delayed(const Duration(milliseconds: 2000));
await apiService.close(() {});
stopwatch.stop();
Log.info('eu.twonly.periodic_task finished after ${stopwatch.elapsed}.');
return true;
}
Future<void> handleProcessingTask() async {
Log.info('eu.twonly.processing_task was called.');
final stopwatch = Stopwatch()..start();
await finishStartedPreprocessing();
Log.info('eu.twonly.processing_task finished after ${stopwatch.elapsed}.');
}

View file

@ -5,7 +5,7 @@ import 'package:hashlib/hashlib.dart';
import 'package:http/http.dart' as http;
import 'package:twonly/globals.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart';
import 'package:twonly/src/services/backup/create.backup.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';

View file

@ -10,13 +10,12 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart';
import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart';
import 'package:twonly/src/services/backup/common.backup.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
@ -42,15 +41,16 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
Log.info('Starting new twonly Backup!');
final baseDir = (await getApplicationSupportDirectory()).path;
final baseDir = globalApplicationSupportDirectory;
final backupDir = Directory(join(baseDir, 'backup_twonly_safe/'));
await backupDir.create(recursive: true);
final backupDatabaseFile = File(join(backupDir.path, 'twonly.backup.sqlite'));
final backupDatabaseFileCleaned =
File(join(backupDir.path, 'twonly.backup.cleaned.sqlite'));
final backupDatabaseFileCleaned = File(
join(backupDir.path, 'twonly.backup.cleaned.sqlite'),
);
// copy database
final originalDatabase = File(join(baseDir, 'twonly.sqlite'));
@ -70,8 +70,9 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
await backupDB.deleteDataForTwonlySafe();
await backupDB
.customStatement('VACUUM INTO ?', [backupDatabaseFileCleaned.path]);
await backupDB.customStatement('VACUUM INTO ?', [
backupDatabaseFileCleaned.path,
]);
await backupDB.printTableSizes();
@ -80,10 +81,11 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
// ignore: inference_failure_on_collection_literal
final secureStorageBackup = {};
const storage = FlutterSecureStorage();
secureStorageBackup[SecureStorageKeys.signalIdentity] =
await storage.read(key: SecureStorageKeys.signalIdentity);
secureStorageBackup[SecureStorageKeys.signalSignedPreKey] =
await storage.read(key: SecureStorageKeys.signalSignedPreKey);
secureStorageBackup[SecureStorageKeys.signalIdentity] = await storage.read(
key: SecureStorageKeys.signalIdentity,
);
secureStorageBackup[SecureStorageKeys.signalSignedPreKey] = await storage
.read(key: SecureStorageKeys.signalSignedPreKey);
final userBackup = await getUser();
if (userBackup == null) return;
@ -117,13 +119,15 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
final backupHash = uint8ListToHex((await Sha256().hash(backupBytes)).bytes);
if (gUser.twonlySafeBackup!.lastBackupDone == null ||
gUser.twonlySafeBackup!.lastBackupDone!
.isAfter(clock.now().subtract(const Duration(days: 90)))) {
gUser.twonlySafeBackup!.lastBackupDone!.isAfter(
clock.now().subtract(const Duration(days: 90)),
)) {
force = true;
}
final lastHash =
await storage.read(key: SecureStorageKeys.twonlySafeLastBackupHash);
final lastHash = await storage.read(
key: SecureStorageKeys.twonlySafeLastBackupHash,
);
if (lastHash != null && !force) {
if (backupHash == lastHash) {
@ -155,8 +159,9 @@ Future<void> performTwonlySafeBackup({bool force = false}) async {
Log.info('Backup files created.');
final encryptedBackupBytesFile =
File(join(backupDir.path, 'twonly_safe.backup'));
final encryptedBackupBytesFile = File(
join(backupDir.path, 'twonly_safe.backup'),
);
await encryptedBackupBytesFile.writeAsBytes(encryptedBackupBytes);

View file

@ -8,23 +8,25 @@ import 'package:drift/drift.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart';
import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart';
import 'package:twonly/src/services/backup/common.backup.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
Future<void> recoverTwonlySafe(
Future<void> recoverBackup(
String username,
String password,
BackupServer? server,
) async {
final (backupId, encryptionKey) = await getMasterKey(password, username);
final backupServerUrl =
await getTwonlySafeBackupUrlFromServer(backupId, server);
final backupServerUrl = await getTwonlySafeBackupUrlFromServer(
backupId,
server,
);
if (backupServerUrl == null) {
Log.error('Could not create backup url');
@ -87,8 +89,9 @@ Future<void> handleBackupData(
plaintextBytes,
);
final baseDir = (await getApplicationSupportDirectory()).path;
final originalDatabase = File(join(baseDir, 'twonly.sqlite'));
final originalDatabase = File(
join(globalApplicationSupportDirectory, 'twonly.sqlite'),
);
await originalDatabase.writeAsBytes(backupContent.twonlyDatabase);
const storage = FlutterSecureStorage();

View file

@ -10,7 +10,7 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
Future<void> syncFlameCounters({String? forceForGroup}) async {
final groups = await twonlyDB.groupsDao.getAllDirectChats();
final groups = await twonlyDB.groupsDao.getAllGroups();
if (groups.isEmpty) return;
final maxMessageCounter = groups.map((x) => x.totalMediaCounter).max;
final bestFriend =
@ -37,14 +37,8 @@ Future<void> syncFlameCounters({String? forceForGroup}) async {
// only sync when flame counter is higher three or when they are bestFriends
if (flameCounter <= 2 && bestFriend.groupId != group.groupId) continue;
final groupMembers =
await twonlyDB.groupsDao.getGroupNonLeftMembers(group.groupId);
if (groupMembers.length != 1) {
continue; // flame sync is only done for groups of two
}
await sendCipherText(
groupMembers.first.contactId,
await sendCipherTextToGroup(
group.groupId,
EncryptedContent(
flameSync: EncryptedContent_FlameSync(
flameCounter: Int64(flameCounter),

View file

@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:pro_video_editor/pro_video_editor.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/channels/video_compression.channel.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/utils/log.dart';
import 'package:video_compress/video_compress.dart';
Future<void> compressImage(
File sourceFile,
@ -16,8 +17,6 @@ Future<void> compressImage(
) async {
final stopwatch = Stopwatch()..start();
// // ffmpeg -i input.png -vcodec libwebp -lossless 1 -preset default output.webp
try {
var compressedBytes = await FlutterImageCompress.compressWithFile(
sourceFile.path,
@ -74,37 +73,53 @@ Future<void> compressAndOverlayVideo(MediaFileService media) async {
try {
final task = VideoRenderData(
video: EditorVideo.file(media.originalPath),
// qualityPreset: VideoQualityPreset.p720High,
imageBytes: media.overlayImagePath.readAsBytesSync(),
enableAudio: !media.removeAudio,
);
final result = await ProVideoEditor.instance.renderVideo(task);
media.ffmpegOutputPath.writeAsBytesSync(result);
await ProVideoEditor.instance
.renderVideoToFile(media.ffmpegOutputPath.path, task);
MediaInfo? mediaInfo;
try {
mediaInfo = await VideoCompress.compressVideo(
media.ffmpegOutputPath.path,
quality: VideoQuality.Res640x480Quality,
includeAudio: true,
);
Log.info('Video has now size of ${mediaInfo!.filesize} bytes.');
} catch (e) {
Log.error('during video compression: $e');
}
if (Platform.isIOS ||
media.ffmpegOutputPath.statSync().size >= 10_000_000 ||
!kReleaseMode) {
String? compressedPath;
try {
compressedPath = await VideoCompressionChannel.compressVideo(
inputPath: media.ffmpegOutputPath.path,
outputPath: media.tempPath.path,
onProgress: (progress) async {
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaFile.mediaId,
MediaFilesCompanion(
preProgressingProcess: Value((progress * 100).toInt()),
),
);
},
);
} catch (e) {
Log.error('during video compression: $e');
}
if (mediaInfo == null) {
Log.error('Could not compress video using original video.');
// as a fall back use the non compressed version
media.ffmpegOutputPath.copySync(media.tempPath.path);
if (compressedPath == null) {
Log.error('Could not compress video using original video.');
// as a fall back use the non compressed version
media.ffmpegOutputPath.copySync(media.tempPath.path);
}
} else {
mediaInfo.file!.copySync(media.tempPath.path);
// In case the video is smaller than 10MB do not compress it...
media.ffmpegOutputPath.copySync(media.tempPath.path);
}
stopwatch.stop();
final sizeFrom = (media.ffmpegOutputPath.statSync().size / 1024 / 1024)
.toStringAsFixed(2);
final sizeTo =
(media.tempPath.statSync().size / 1024 / 1024).toStringAsFixed(2);
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to compress the video. Reduced from ${media.ffmpegOutputPath.statSync().size} to ${media.tempPath.statSync().size} bytes.',
'It took ${stopwatch.elapsedMilliseconds}ms to compress the video. Reduced from $sizeFrom to $sizeTo bytes.',
);
} catch (e) {
Log.error(e);

View file

@ -8,11 +8,12 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:twonly/globals.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/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
import '../../firebase_options.dart';
import '../../../firebase_options.dart';
// see more here: https://firebase.google.com/docs/cloud-messaging/flutter/receive?hl=de
@ -111,8 +112,15 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
initLogger();
// Log.info('Handling a background message: ${message.messageId}');
await handleRemoteMessage(message);
// make sure every thing run...
await Future.delayed(const Duration(milliseconds: 2000));
if (Platform.isAndroid) {
if (await initBackgroundExecution()) {
await handlePeriodicTask();
}
} else {
// make sure every thing run...
await Future.delayed(const Duration(milliseconds: 2000));
}
}
Future<void> handleRemoteMessage(RemoteMessage message) async {

View file

@ -1,12 +1,11 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/log.dart';
class KeyValueStore {
static Future<File> _getFilePath(String key) async {
final directory = await getApplicationSupportDirectory();
return File('${directory.path}/keyvalue/$key.json');
return File('$globalApplicationSupportDirectory/keyvalue/$key.json');
}
static Future<void> delete(String key) async {

View file

@ -1,8 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:mutex/mutex.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:twonly/globals.dart';
@ -10,7 +11,7 @@ void initLogger() {
// Logger.root.level = kReleaseMode ? Level.INFO : Level.ALL;
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) async {
await _writeLogToFile(record);
unawaited(_writeLogToFile(record));
if (!kReleaseMode) {
// ignore: avoid_print
print(
@ -66,77 +67,123 @@ class Log {
}
Future<String> loadLogFile() async {
final directory = await getApplicationSupportDirectory();
final logFile = File('${directory.path}/app.log');
return _protectFileAccess(() async {
final logFile = File('$globalApplicationSupportDirectory/app.log');
if (logFile.existsSync()) {
return logFile.readAsString();
} else {
return 'Log file does not exist.';
}
if (logFile.existsSync()) {
return logFile.readAsString();
} else {
return 'Log file does not exist.';
}
});
}
Future<String> readLast1000Lines() async {
final dir = await getApplicationSupportDirectory();
final file = File('${dir.path}/app.log');
if (!file.existsSync()) return '';
final all = await file.readAsLines();
final start = all.length > 1000 ? all.length - 1000 : 0;
return all.sublist(start).join('\n');
return _protectFileAccess(() async {
final file = File('$globalApplicationSupportDirectory/app.log');
if (!file.existsSync()) return '';
final all = await file.readAsLines();
final start = all.length > 1000 ? all.length - 1000 : 0;
return all.sublist(start).join('\n');
});
}
final Mutex _logMutex = Mutex();
Future<T> _protectFileAccess<T>(Future<T> Function() action) async {
return _logMutex.protect(() async {
final lockFile = File('$globalApplicationSupportDirectory/app.log.lock');
var lockAcquired = false;
while (!lockAcquired) {
try {
lockFile.createSync(exclusive: true);
lockAcquired = true;
} on FileSystemException catch (e) {
final isExists = e is PathExistsException || e.osError?.errorCode == 17;
if (!isExists) {
break;
}
try {
final stat = lockFile.statSync();
if (stat.type != FileSystemEntityType.notFound) {
final age = DateTime.now().difference(stat.modified).inMilliseconds;
if (age > 1000) {
lockFile.deleteSync();
continue;
}
}
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 50));
} catch (_) {
break;
}
}
try {
return await action();
} finally {
if (lockAcquired) {
try {
if (lockFile.existsSync()) {
lockFile.deleteSync();
}
} catch (_) {}
}
}
});
}
Future<void> _writeLogToFile(LogRecord record) async {
final directory = await getApplicationSupportDirectory();
final logFile = File('${directory.path}/app.log');
if (!logFile.existsSync()) {
logFile.createSync(recursive: true);
}
final logFile = File('$globalApplicationSupportDirectory/app.log');
final logMessage =
'${clock.now().toString().split(".")[0]} ${record.level.name} [twonly] ${record.loggerName} > ${record.message}\n';
final raf = await logFile.open(mode: FileMode.writeOnlyAppend);
try {
// Use FileLock.blockingExclusive to wait until the lock is available
await raf.lock(FileLock.blockingExclusive);
await raf.writeString(logMessage);
await raf.flush();
} catch (e) {
// ignore: avoid_print
print('Error during file access: $e');
} finally {
await raf.unlock();
await raf.close();
}
return _protectFileAccess(() async {
if (!logFile.existsSync()) {
logFile.createSync(recursive: true);
}
final raf = await logFile.open(mode: FileMode.writeOnlyAppend);
try {
await raf.writeString(logMessage);
await raf.flush();
} catch (e) {
// ignore: avoid_print
print('Error during file access: $e');
} finally {
await raf.close();
}
});
}
Future<void> cleanLogFile() async {
final directory = await getApplicationSupportDirectory();
final logFile = File('${directory.path}/app.log');
return _protectFileAccess(() async {
final logFile = File('$globalApplicationSupportDirectory/app.log');
if (logFile.existsSync()) {
final lines = await logFile.readAsLines();
if (logFile.existsSync()) {
final lines = await logFile.readAsLines();
if (lines.length <= 10000) return;
if (lines.length <= 10000) return;
final removeCount = lines.length - 10000;
final remaining = lines.sublist(removeCount, lines.length);
final removeCount = lines.length - 10000;
final remaining = lines.sublist(removeCount, lines.length);
final sink = logFile.openWrite()..writeAll(remaining, '\n');
await sink.close();
}
final sink = logFile.openWrite()..writeAll(remaining, '\n');
await sink.close();
}
});
}
Future<bool> deleteLogFile() async {
final directory = await getApplicationSupportDirectory();
final logFile = File('${directory.path}/app.log');
return _protectFileAccess(() async {
final logFile = File('$globalApplicationSupportDirectory/app.log');
if (logFile.existsSync()) {
await logFile.delete();
return true;
}
return false;
if (logFile.existsSync()) {
await logFile.delete();
return true;
}
return false;
});
}
String _getCallerSourceCodeFilename() {
@ -152,8 +199,11 @@ String _getCallerSourceCodeFilename() {
lineNumber = parts.last.split(':')[1]; // Extract the line number
} else {
final firstLine = stackTraceString.split('\n')[0];
fileName =
firstLine.split('/').last.split(':').first; // Extract the file name
fileName = firstLine
.split('/')
.last
.split(':')
.first; // Extract the file name
lineNumber = firstLine.split(':')[1]; // Extract the line number
}
lineNumber = lineNumber.replaceAll(')', '');

View file

@ -302,7 +302,9 @@ Color getMessageColorFromType(
) {
Color color;
if (message.type == MessageType.text.name) {
if (message.type == MessageType.restoreFlameCounter.name) {
color = Colors.orange;
} else if (message.type == MessageType.text.name) {
color = Colors.blueAccent;
} else if (mediaFile != null) {
if (mediaFile.requiresAuthentication) {

View file

@ -55,7 +55,7 @@ PublicProfile? parseQrCodeData(Uint8List rawBytes) {
return null;
}
Future<void> addNewContactFromPublicProfile(PublicProfile profile) async {
Future<bool> addNewContactFromPublicProfile(PublicProfile profile) async {
final userdata = Response_UserData(
userId: profile.userId,
publicIdentityKey: profile.publicIdentityKey,
@ -77,5 +77,8 @@ Future<void> addNewContactFromPublicProfile(PublicProfile profile) async {
),
);
if (added > 0) await importSignalContactAndCreateRequest(userdata);
if (added > 0) {
return importSignalContactAndCreateRequest(userdata);
}
return false;
}

View file

@ -21,12 +21,12 @@ Future<bool> isUserCreated() async {
}
Future<UserData?> getUser() async {
final userJson =
await const FlutterSecureStorage().read(key: SecureStorageKeys.userData);
if (userJson == null) {
return null;
}
try {
final userJson = await const FlutterSecureStorage()
.read(key: SecureStorageKeys.userData);
if (userJson == null) {
return null;
}
final userMap = jsonDecode(userJson) as Map<String, dynamic>;
final user = UserData.fromJson(userMap);
return user;

View file

@ -849,7 +849,21 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
onTap: () async {
c.isLoading = true;
widget.mainCameraController.setState();
await addNewContactFromPublicProfile(c.profile);
if (await addNewContactFromPublicProfile(
c.profile,
) &&
context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.lang.requestedUserToastText(
c.profile.username,
),
),
duration: const Duration(seconds: 8),
),
);
}
},
child: Container(
padding: const EdgeInsets.all(12),

View file

@ -6,7 +6,6 @@ enum FaceFilterType {
none,
dogBrown,
beardUpperLipGreen,
beardUpperLip,
}
extension FaceFilterTypeExtension on FaceFilterType {
@ -27,8 +26,6 @@ extension FaceFilterTypeExtension on FaceFilterType {
return Container();
case FaceFilterType.dogBrown:
return DogFilterPainter.getPreview();
case FaceFilterType.beardUpperLip:
return BeardFilterPainter.getPreview(this);
case FaceFilterType.beardUpperLipGreen:
return BeardFilterPainter.getPreview(this);
}

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
@ -25,19 +26,13 @@ import 'package:twonly/src/views/camera/camera_preview_components/painters/face_
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
class ScannedVerifiedContact {
ScannedVerifiedContact({
required this.contact,
required this.verificationOk,
});
ScannedVerifiedContact({required this.contact, required this.verificationOk});
Contact contact;
bool verificationOk;
}
class ScannedNewProfile {
ScannedNewProfile({
required this.profile,
this.isLoading = false,
});
ScannedNewProfile({required this.profile, this.isLoading = false});
PublicProfile profile;
bool isLoading;
}
@ -53,14 +48,15 @@ class MainCameraController {
String? scannedUrl;
GlobalKey zoomButtonKey = GlobalKey();
GlobalKey cameraPreviewKey = GlobalKey();
bool isSelectingFaceFilters = false;
bool isSelectingFaceFilters = false;
bool isSharePreviewIsShown = false;
bool isVideoRecording = false;
DateTime? timeSharedLinkWasSetWithQr;
Uri? sharedLinkForPreview;
void setSharedLinkForPreview(Uri url) {
void setSharedLinkForPreview(Uri? url) {
sharedLinkForPreview = url;
setState();
}
@ -92,9 +88,8 @@ class MainCameraController {
scannedUrl = null;
try {
await cameraController?.stopImageStream();
} catch (e) {
Log.warn(e);
}
// ignore: empty_catches
} catch (e) {}
final cameraControllerTemp = cameraController;
cameraController = null;
// prevents: CameraException(Disposed CameraController, buildPreview() was called on a disposed CameraController.)
@ -159,8 +154,9 @@ class MainCameraController {
}
}
await cameraController
?.lockCaptureOrientation(DeviceOrientation.portraitUp);
await cameraController?.lockCaptureOrientation(
DeviceOrientation.portraitUp,
);
await cameraController?.setFlashMode(
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
);
@ -169,7 +165,8 @@ class MainCameraController {
selectedCameraDetails.minAvailableZoom =
await cameraController?.getMinZoomLevel() ?? 1;
selectedCameraDetails
..isZoomAble = selectedCameraDetails.maxAvailableZoom !=
..isZoomAble =
selectedCameraDetails.maxAvailableZoom !=
selectedCameraDetails.minAvailableZoom
..cameraLoaded = true
..cameraId = cameraId;
@ -325,11 +322,23 @@ class MainCameraController {
);
customPaint = CustomPaint(painter: painter);
if (barcodes.isEmpty && timeSharedLinkWasSetWithQr != null) {
if (timeSharedLinkWasSetWithQr!.isAfter(
DateTime.now().subtract(const Duration(seconds: 2)),
)) {
setSharedLinkForPreview(null);
}
}
for (final barcode in barcodes) {
if (barcode.displayValue != null) {
if (barcode.displayValue!.startsWith('http://') ||
barcode.displayValue!.startsWith('https://')) {
scannedUrl = barcode.displayValue;
if (sharedLinkForPreview == null) {
timeSharedLinkWasSetWithQr = clock.now();
setSharedLinkForPreview(Uri.parse(scannedUrl!));
}
}
}
if (barcode.rawBytes == null) continue;
@ -338,16 +347,19 @@ class MainCameraController {
if (profile == null) continue;
final contact =
await twonlyDB.contactsDao.getContactById(profile.userId.toInt());
final contact = await twonlyDB.contactsDao.getContactById(
profile.userId.toInt(),
);
if (contact != null && contact.accepted) {
if (contactsVerified[contact.userId] == null) {
final storedPublicKey =
await getPublicKeyFromContact(contact.userId);
final storedPublicKey = await getPublicKeyFromContact(
contact.userId,
);
if (storedPublicKey != null) {
final verificationOk =
profile.publicIdentityKey.equals(storedPublicKey.toList());
final verificationOk = profile.publicIdentityKey.equals(
storedPublicKey.toList(),
);
contactsVerified[contact.userId] = ScannedVerifiedContact(
contact: contact,
verificationOk: verificationOk,
@ -365,8 +377,8 @@ class MainCameraController {
content: Text(
globalRootScaffoldMessengerKey.currentContext?.lang
.verifiedPublicKey(
getContactDisplayName(contact),
) ??
getContactDisplayName(contact),
) ??
'',
),
duration: const Duration(seconds: 6),
@ -408,7 +420,6 @@ class MainCameraController {
inputImage.metadata!.rotation,
cameraController!.description.lensDirection,
);
case FaceFilterType.beardUpperLip:
case FaceFilterType.beardUpperLipGreen:
painter = BeardFilterPainter(
_currentFilterType,

View file

@ -27,8 +27,6 @@ class BeardFilterPainter extends FaceFilterPainter {
static String getAssetPath(FaceFilterType beardType) {
switch (beardType) {
case FaceFilterType.beardUpperLip:
return 'assets/filters/beard_upper_lip.webp';
case FaceFilterType.beardUpperLipGreen:
return 'assets/filters/beard_upper_lip_green.webp';
case FaceFilterType.dogBrown:

View file

@ -33,7 +33,7 @@ class FilterSkeleton extends StatelessWidget {
child: Stack(
children: [
Positioned.fill(child: Container()),
if (child != null) child!,
?child,
],
),
);
@ -89,10 +89,11 @@ class _FilterLayerState extends State<FilterLayer> {
}
Future<void> initAsync() async {
final stickers = (await getStickerIndex())
.where((x) => x.imageSrc.contains('/imagefilter/'))
.toList()
..sortBy((x) => x.imageSrc);
final stickers =
(await getStickerIndex())
.where((x) => x.imageSrc.contains('/imagefilter/'))
.toList()
..sortBy((x) => x.imageSrc);
for (final sticker in stickers) {
pages.insert(pages.length - 1, ImageFilter(imagePath: sticker.imageSrc));

View file

@ -138,7 +138,7 @@ class _ChatListViewState extends State<ChatListView> {
actions: [
const FeedbackIconButton(),
StreamBuilder(
stream: twonlyDB.contactsDao.watchContactsRequested(),
stream: twonlyDB.contactsDao.watchContactsRequestedCount(),
builder: (context, snapshot) {
var count = 0;
if (snapshot.hasData && snapshot.data != null) {

View file

@ -1,38 +0,0 @@
import 'package:flutter/material.dart';
import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
class DemoUserCard extends StatelessWidget {
const DemoUserCard({super.key});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: isDarkMode(context) ? Colors.white : Colors.black,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Text(
'This is a Demo-Preview.',
textAlign: TextAlign.center,
style: TextStyle(
color: !isDarkMode(context) ? Colors.white : Colors.black,
fontSize: 18,
),
),
FilledButton(
onPressed: () async {
await deleteLocalUserData();
await Restart.restartApp(
notificationTitle: 'Demo-Mode exited.',
notificationBody: 'Click here to open the app again',
);
},
child: const Text('Register'),
),
],
),
);
}
}

View file

@ -26,15 +26,17 @@ class _ShareAdditionalViewState extends State<ShareAdditionalView> {
}
Future<void> openShareContactView() async {
final selectedContacts = await context.navPush(
SelectContactsView(
text: SelectedContactViewText(
title: context.lang.shareContactsTitle,
submitButton: (_, __) => context.lang.shareContactsSubmit,
submitIcon: FontAwesomeIcons.shareNodes,
),
),
) as List<int>?;
final selectedContacts =
await context.navPush(
SelectContactsView(
text: SelectedContactViewText(
title: context.lang.shareContactsTitle,
submitButton: (_, _) => context.lang.shareContactsSubmit,
submitIcon: FontAwesomeIcons.shareNodes,
),
),
)
as List<int>?;
if (selectedContacts != null && selectedContacts.isNotEmpty) {
await insertAndSendContactShareMessage(
widget.group.groupId,

View file

@ -10,9 +10,11 @@ import 'package:twonly/src/database/tables/messages.table.dart'
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/views/chats/chat_messages_components/chat_reaction_row.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_contacts.entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_flame_restored.entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_media_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_text_entry.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_unkown.entry.dart';
@ -78,6 +80,10 @@ class _ChatListEntryState extends State<ChatListEntry> {
if (mediaFiles != null) {
mediaService = MediaFileService(mediaFiles);
if (mounted) setState(() {});
} else {
Log.error(
'Media file not found for ${widget.message.messageId} => ${widget.message.mediaId}',
);
}
});
}
@ -132,6 +138,12 @@ class _ChatListEntryState extends State<ChatListEntry> {
);
}
if (widget.message.type == MessageType.restoreFlameCounter.name) {
return ChatFlameRestoredEntry(
message: widget.message,
);
}
return const ChatUnknownEntry();
}

View file

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/better_text.dart';
class ChatFlameRestoredEntry extends StatelessWidget {
const ChatFlameRestoredEntry({
required this.message,
super.key,
});
final Message message;
@override
Widget build(BuildContext context) {
AdditionalMessageData? data;
if (message.additionalMessageData != null) {
try {
data = AdditionalMessageData.fromBuffer(
message.additionalMessageData!,
);
} catch (e) {
data = null;
}
}
if (data == null || !data.hasRestoredFlameCounter()) {
return const SizedBox.shrink();
}
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(12),
),
child: BetterText(
text: context.lang
.chatEntryFlameRestored(data.restoredFlameCounter.toInt()),
textColor: isDarkMode(context) ? Colors.black : Colors.black,
),
);
}
}

View file

@ -101,8 +101,9 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
final mediaFile = message.mediaId == null
? null
: widget.mediaFiles
.firstWhereOrNull((t) => t.mediaId == message.mediaId);
: widget.mediaFiles.firstWhereOrNull(
(t) => t.mediaId == message.mediaId,
);
final color = getMessageColorFromType(message, mediaFile, context);
@ -144,8 +145,11 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
}
}
case MessageSendState.send:
icon =
FaIcon(FontAwesomeIcons.solidPaperPlane, size: 12, color: color);
icon = FaIcon(
FontAwesomeIcons.solidPaperPlane,
size: 12,
color: color,
);
text = context.lang.messageSendState_Send;
case MessageSendState.sending:
icon = getLoaderIcon(color);
@ -163,13 +167,20 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
context.lang.uploadLimitReached,
style: const TextStyle(fontSize: 9),
);
onTap = () => context.push(Routes.settingsSubscription);
}
if (mediaFile.uploadState == UploadState.preprocessing ||
mediaFile.uploadState == UploadState.initialized) {
if (mediaFile.uploadState == UploadState.initialized) {
text = context.lang.inProcess;
}
if (mediaFile.uploadState == UploadState.preprocessing) {
final progress = mediaFile.preProgressingProcess ?? 0;
if (progress > 0) {
text = '${context.lang.inProcess} ($progress%)';
} else {
text = context.lang.inProcess;
}
}
}
hasLoader = true;
@ -191,13 +202,28 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
}
if (mediaFile.downloadState == DownloadState.reuploadRequested) {
icon =
FaIcon(FontAwesomeIcons.clockRotateLeft, size: 12, color: color);
icon = FaIcon(
FontAwesomeIcons.clockRotateLeft,
size: 12,
color: color,
);
textWidget = Text(
context.lang.retransmissionRequested,
style: const TextStyle(fontSize: 9),
);
}
if (mediaFile.uploadState == UploadState.fileLimitReached) {
icon = FaIcon(
FontAwesomeIcons.triangleExclamation,
size: 12,
color: color,
);
textWidget = Text(
context.lang.fileLimitReached,
style: const TextStyle(fontSize: 9),
);
}
}
if (message.isDeletedFromSender) {
@ -220,10 +246,12 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
if (!widget.messages.any((t) => t.openedAt == null)) {
if (widget.lastReaction != null) {
/// No messages are still open, so check if the reaction is the last message received.
if (!widget.messages
.any((m) => m.createdAt.isAfter(widget.lastReaction!.createdAt))) {
if (EmojiAnimation.animatedIcons
.containsKey(widget.lastReaction!.emoji)) {
if (!widget.messages.any(
(m) => m.createdAt.isAfter(widget.lastReaction!.createdAt),
)) {
if (EmojiAnimation.animatedIcons.containsKey(
widget.lastReaction!.emoji,
)) {
icons = [
SizedBox(
height: 18,

View file

@ -113,6 +113,7 @@ class EmojiAnimation extends StatelessWidget {
'💯': '100.lottie',
'🎉': 'party-popper.lottie',
'🎊': 'confetti-ball.lottie',
'🎂': 'birthday-cake.json',
'🧡': 'orange-heart.lottie',
'💛': 'yellow-heart.lottie',
'💚': 'green-heart.lottie',

View file

@ -52,8 +52,7 @@ class _AvatarIconState extends State<AvatarIcon> {
super.dispose();
}
// ignore: strict_top_level_inference
Widget errorBuilder(_, __, ___) {
Widget errorBuilder(_, _, _) {
return const SvgPicture(
AssetBytesLoader('assets/images/default_avatar.svg.vec'),
);
@ -81,20 +80,20 @@ class _AvatarIconState extends State<AvatarIcon> {
groupStream = twonlyDB.groupsDao
.watchGroupContact(widget.group!.groupId)
.listen((contacts) {
_avatarContacts = [];
if (contacts.length == 1) {
if (contacts.first.avatarSvgCompressed != null) {
_avatarContacts.add(contacts.first);
}
} else {
for (final contact in contacts) {
if (contact.avatarSvgCompressed != null) {
_avatarContacts.add(contact);
_avatarContacts = [];
if (contacts.length == 1) {
if (contacts.first.avatarSvgCompressed != null) {
_avatarContacts.add(contacts.first);
}
} else {
for (final contact in contacts) {
if (contact.avatarSvgCompressed != null) {
_avatarContacts.add(contact);
}
}
}
}
}
setState(() {});
});
setState(() {});
});
} else if (widget.myAvatar) {
_globalUserDataCallBackId = 'avatar_${getRandomString(10)}';
globalUserDataChangedCallBack[_globalUserDataCallBackId!] = () {
@ -113,11 +112,11 @@ class _AvatarIconState extends State<AvatarIcon> {
contactStream = twonlyDB.contactsDao
.watchContact(widget.contactId!)
.listen((contact) {
if (contact != null && contact.avatarSvgCompressed != null) {
_avatarContacts = [contact];
setState(() {});
}
});
if (contact != null && contact.avatarSvgCompressed != null) {
_avatarContacts = [contact];
setState(() {});
}
});
}
if (mounted) setState(() {});
}

View file

@ -44,11 +44,7 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
group = await twonlyDB.groupsDao.getDirectChat(widget.contactId!);
groupId = group?.groupId;
} else if (groupId != null) {
// do not display the flame counter for groups
group = await twonlyDB.groupsDao.getGroup(groupId);
if (!(group?.isDirectChat ?? false)) {
return;
}
}
if (groupId != null && group != null) {
isBestFriend =
@ -67,19 +63,30 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
@override
Widget build(BuildContext context) {
if (flameCounter < 1) return Container();
var flameEmoji = '🔥';
if (isBestFriend) flameEmoji = '❤️‍🔥';
if (flameCounter == 100) flameEmoji = '💯';
if (flameCounter >= 365 && flameCounter % 365 == 0) {
flameEmoji = '🎂';
}
return Row(
children: [
if (widget.prefix) const SizedBox(width: 5),
if (widget.prefix) const Text(''),
if (widget.prefix) const SizedBox(width: 5),
Text(
flameCounter.toString(),
style: const TextStyle(fontSize: 13),
),
if (flameCounter != 100)
Text(
flameCounter.toString(),
style: const TextStyle(fontSize: 13),
),
SizedBox(
height: 15,
child: EmojiAnimation(
emoji: isBestFriend ? '❤️‍🔥' : '🔥',
emoji: flameEmoji,
),
),
],

View file

@ -74,7 +74,7 @@ class _ThreeRotatingDotsState extends State<ThreeRotatingDots>
height: size,
child: AnimatedBuilder(
animation: _animationController,
builder: (_, __) => Transform.translate(
builder: (_, _) => Transform.translate(
offset: Offset(0, size / 12),
child: Stack(
alignment: Alignment.center,
@ -110,7 +110,6 @@ class _ThreeRotatingDotsState extends State<ThreeRotatingDots>
),
/// Next 3 dots
_BuildDot.second(
controller: _animationController,
beginAngle: 0,
@ -217,9 +216,9 @@ class DrawDot extends StatelessWidget {
required double dotSize,
required this.color,
super.key,
}) : width = dotSize,
height = dotSize,
circular = true;
}) : width = dotSize,
height = dotSize,
circular = true;
const DrawDot.elliptical({
required this.width,

View file

@ -1,11 +1,18 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'
as pb;
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/flame.service.dart';
import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/log.dart';
@ -46,7 +53,8 @@ class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
}
Future<void> _restoreFlames() async {
if (!isUserAllowed(getCurrentPlan(), PremiumFeatures.RestoreFlames)) {
if (!isUserAllowed(getCurrentPlan(), PremiumFeatures.RestoreFlames) &&
kReleaseMode) {
await context.push(Routes.settingsSubscription);
return;
}
@ -60,7 +68,40 @@ class _MaxFlameListTitleState extends State<MaxFlameListTitle> {
lastFlameCounterChange: Value(clock.now()),
),
);
final addData = AdditionalMessageData(
type: AdditionalMessageData_Type.RESTORED_FLAME_COUNTER,
restoredFlameCounter: Int64(_group!.maxFlameCounter),
);
final message = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
groupId: Value(_groupId),
type: Value(MessageType.restoreFlameCounter.name),
additionalMessageData: Value(addData.writeToBuffer()),
),
);
if (message == null) {
Log.error('Could not insert message into database');
return;
}
final encryptedContent = pb.EncryptedContent(
additionalDataMessage: pb.EncryptedContent_AdditionalDataMessage(
senderMessageId: message.messageId,
additionalMessageData: addData.writeToBuffer(),
timestamp: Int64(message.createdAt.millisecondsSinceEpoch),
type: MessageType.restoreFlameCounter.name,
),
);
await syncFlameCounters(forceForGroup: _groupId);
await sendCipherTextToGroup(
_groupId,
encryptedContent,
messageId: message.messageId,
);
}
@override

View file

@ -29,7 +29,7 @@ class ContactView extends StatefulWidget {
class _ContactViewState extends State<ContactView> {
Contact? _contact;
bool _contactIsStillAGroupMember = true;
List<GroupMember> _memberOfGroups = [];
late StreamSubscription<Contact?> _contactSub;
late StreamSubscription<List<GroupMember>> _groupMemberSub;
@ -44,10 +44,8 @@ class _ContactViewState extends State<ContactView> {
});
_groupMemberSub = twonlyDB.groupsDao
.watchContactGroupMember(widget.userId)
.listen((update) {
setState(() {
_contactIsStillAGroupMember = update.isNotEmpty;
});
.listen((groups) async {
_memberOfGroups = groups;
});
super.initState();
}
@ -60,7 +58,18 @@ class _ContactViewState extends State<ContactView> {
}
Future<void> handleUserRemoveRequest(Contact contact) async {
if (_contactIsStillAGroupMember) {
var delete = true;
for (final groupM in _memberOfGroups) {
final group = await twonlyDB.groupsDao.getGroup(groupM.groupId);
if (group?.deletedContent ?? false) {
await twonlyDB.groupsDao.deleteGroup(group!.groupId);
} else {
delete = false;
}
}
if (!mounted) return;
if (!delete) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.lang.deleteUserErrorMessage),
@ -211,26 +220,6 @@ class _ContactViewState extends State<ContactView> {
setState(() {});
},
),
// BetterListTile(
// icon: FontAwesomeIcons.eraser,
// iconSize: 16,
// text: context.lang.deleteAllContactMessages,
// onTap: () async {
// final block = await showAlertDialog(
// context,
// context.lang.deleteAllContactMessages,
// context.lang.deleteAllContactMessagesBody(
// getContactDisplayName(contact),
// ),
// );
// if (block) {
// if (context.mounted) {
// await twonlyDB.messagesDao
// .deleteMessagesByContactId(contact.userId);
// }
// }
// },
// ),
BetterListTile(
icon: FontAwesomeIcons.flag,
text: context.lang.reportUser,

View file

@ -11,6 +11,7 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart';
import 'package:twonly/src/views/components/better_list_title.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/select_chat_deletion_time.comp.dart';
import 'package:twonly/src/views/components/verified_shield.dart';
import 'package:twonly/src/views/contact/contact.view.dart';
@ -181,6 +182,10 @@ class _GroupViewState extends State<GroupView> {
substringBy(_group!.groupName, 25),
style: const TextStyle(fontSize: 20),
),
FlameCounterWidget(
groupId: _group?.groupId,
prefix: true,
),
],
),
const SizedBox(height: 50),

View file

@ -93,7 +93,9 @@ class GroupMemberContextMenu extends StatelessWidget {
await twonlyDB.contactsDao.updateContact(
member.contactId,
const ContactsCompanion(
requested: Value(true),
accepted: Value(false),
requested: Value(false),
deletedByUser: Value(false),
),
);
await sendCipherText(

View file

@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:restart_app/restart_app.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/services/twonly_safe/restore.twonly_safe.dart';
import 'package:twonly/src/services/backup/restore.backup.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
@ -29,7 +29,7 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
});
try {
await recoverTwonlySafe(
await recoverBackup(
usernameCtrl.text,
passwordCtrl.text,
backupServer,
@ -38,6 +38,7 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
await Restart.restartApp(
notificationTitle: 'Backup successfully recovered.',
notificationBody: 'Click here to open the app again',
forceKill: true,
);
} catch (e) {
// in case something was already written from the backup...

View file

@ -140,6 +140,7 @@ class _AccountViewState extends State<AccountView> {
await Restart.restartApp(
notificationTitle: 'Account successfully deleted',
notificationBody: 'Click here to open the app again',
forceKill: true,
);
}
},

View file

@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart';
import 'package:twonly/src/services/backup/create.backup.dart';
import 'package:twonly/src/utils/misc.dart';
void Function() gUpdateBackupView = () {};

View file

@ -4,7 +4,7 @@ import 'package:flutter/services.dart' show rootBundle;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart';
import 'package:twonly/src/services/backup/common.backup.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';

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