fix multiple smaller issues

This commit is contained in:
otsmr 2026-10-07 22:42:34 +02:00
parent 078676c8b9
commit 6d5a9c6a1f
35 changed files with 2066 additions and 356 deletions

View file

@ -57,6 +57,16 @@ guarantee a bounty currently :/
Currently there are still some core features and rewrites open I want to do involving a huge rewrite in Rust. Because of this, contributions are currently not wanted. You can still view the code (if you find any security issues please contact me!). Also, issues are currently closed. If you find a bug, please use the in-app option as there you can upload your debug log which helps a lot.
## Custom dependencies
Vendored packages are tracked in `dependencies.yaml`. Set `custom_changes: true`
on a dependency or an individual subpackage when it contains local modifications.
`dependencies.py` preserves these folders during normal and `--cache-only`
updates, and keeps their local versions and paths in the managed `pubspec.yaml`
sections. Update these packages manually and merge upstream changes with the
local modifications. Missing custom packages must be restored from the
`dependencies` repository.
## Signing Keys
When you download the app **via GitHub or F-Droid** you can verify the signing keys using for example the [AppVerifyer](https://github.com/soupslurpr/AppVerifier) and the following SHA-256 fingerprint of the signing certificate.

View file

@ -40,6 +40,7 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [appName: "twonly"]
}
signingConfigs {
@ -75,6 +76,8 @@ flutter {
}
dependencies {
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'junit:junit:4.12'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation platform('com.google.firebase:firebase-bom:34.9.0')
implementation 'com.google.firebase:firebase-messaging'

View file

@ -0,0 +1,121 @@
package eu.twonly.location
import android.Manifest
import android.content.Context
import android.content.Intent
import android.location.Criteria
import android.location.Location
import android.location.LocationManager
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/** Run on an emulator: the mock providers are removed after every test. */
@SdkSuppress(minSdkVersion = 31)
class NativeLocationTest {
companion object {
private var activityStarted = false
}
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext
private val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val providers = listOf("fused", LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
@Before
fun setUp() {
instrumentation.uiAutomation.grantRuntimePermission(context.packageName, Manifest.permission.ACCESS_COARSE_LOCATION)
instrumentation.uiAutomation.grantRuntimePermission(context.packageName, Manifest.permission.ACCESS_FINE_LOCATION)
if (!activityStarted) {
instrumentation.startActivitySync(
Intent(context, LocationTestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
activityStarted = true
}
instrumentation.uiAutomation.executeShellCommand(
"appops set ${context.packageName} android:mock_location allow",
).use { android.os.ParcelFileDescriptor.AutoCloseInputStream(it).readBytes() }
for (provider in providers) {
@Suppress("DEPRECATION")
manager.addTestProvider(provider, false, false, false, false, true, true, true,
Criteria.POWER_LOW, Criteria.ACCURACY_FINE)
manager.setTestProviderEnabled(provider, true)
}
}
@After
fun tearDown() {
for (provider in providers) {
runCatching { manager.removeTestProvider(provider) }
}
instrumentation.uiAutomation.executeShellCommand(
"appops set ${context.packageName} android:mock_location default",
).use { android.os.ParcelFileDescriptor.AutoCloseInputStream(it).readBytes() }
}
private fun fix(provider: String, ageMillis: Long = 0, accuracyMeters: Float = 10f): Location =
Location(provider).apply {
latitude = 52.52
longitude = 13.405
accuracy = accuracyMeters
time = System.currentTimeMillis() - ageMillis
elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() - ageMillis * 1_000_000
}
private fun assertFix(value: String?) {
assertNotNull("A recent precise fix should be returned", value)
val result = JSONArray(value!!)
assertEquals(52.52, result.getDouble(0), 0.000001)
assertEquals(13.405, result.getDouble(1), 0.000001)
assertEquals(10.0, result.getDouble(2), 0.000001)
}
@Test
fun recentGpsFixIsReturnedWithoutWaitingForAnUpdate() {
manager.setTestProviderLocation(LocationManager.GPS_PROVIDER, fix(LocationManager.GPS_PROVIDER))
assertFix(NativeLocation.current(500))
}
@Test
fun recentFusedFixIsReturnedWithoutWaitingForGps() {
manager.setTestProviderLocation("fused", fix("fused"))
assertFix(NativeLocation.current(500))
}
@Test
fun staleCachedFixIsNotStoredAsTheCaptureLocation() {
manager.setTestProviderLocation(LocationManager.GPS_PROVIDER, fix(LocationManager.GPS_PROVIDER, 240_000))
assertNull(NativeLocation.current(200))
}
@Test
fun liveFusedUpdateIsUsedWhenThereIsNoCachedFix() {
manager.setTestProviderEnabled(LocationManager.GPS_PROVIDER, false)
manager.setTestProviderEnabled(LocationManager.NETWORK_PROVIDER, false)
Handler(Looper.getMainLooper()).postDelayed({
manager.setTestProviderLocation("fused", fix("fused"))
}, 100)
assertFix(NativeLocation.current(2_000))
}
@Test
fun bestAvailableFixIsReturnedAtTheDeadline() {
manager.setTestProviderLocation(LocationManager.NETWORK_PROVIDER,
fix(LocationManager.NETWORK_PROVIDER, accuracyMeters = 100f))
val value = NativeLocation.current(200)
assertNotNull(value)
assertEquals(100.0, JSONArray(value!!).getDouble(2), 0.000001)
}
@Test
fun disabledProvidersReturnWithoutARequest() {
for (provider in providers) manager.setTestProviderEnabled(provider, false)
assertNull(NativeLocation.current(200))
}
}

View file

@ -5,6 +5,7 @@
-->
<uses-permission android:name="android.permission.INTERNET"/>
<application android:usesCleartextTraffic="true" >
<activity android:name="eu.twonly.location.LocationTestActivity" android:exported="false" />
<!-- // https://github.com/juliansteenbakker/mobile_scanner/issues/553 -->
<meta-data android:name="firebase_performance_collection_deactivated" android:value="true" />
<!-- <service

View file

@ -0,0 +1,6 @@
package eu.twonly.location
import android.app.Activity
/** Keeps native location instrumentation in the foreground without starting Flutter. */
class LocationTestActivity : Activity()

View file

@ -7,53 +7,89 @@ import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import eu.twonly.MyApplication
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/** One-shot precise location lookup called synchronously by Rust/JNI. */
object NativeLocation {
private const val TAG = "TwonlyLocation"
private const val TARGET_ACCURACY_METERS = 50f
private const val MAX_FIX_AGE_NANOS = 180_000_000_000L
@JvmStatic
@SuppressLint("MissingPermission")
fun current(timeoutMillis: Long): String? {
val context = MyApplication.instance
if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Memory location skipped: precise location permission is missing")
return null
}
val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
val finished = CountDownLatch(1)
val best = AtomicReference<Location?>(null)
val stopped = AtomicBoolean(false)
fun accept(location: Location) {
val age = SystemClock.elapsedRealtimeNanos() - location.elapsedRealtimeNanos
if (stopped.get() || !location.hasAccuracy() || !location.accuracy.isFinite() ||
location.accuracy < 0 || age !in 0..MAX_FIX_AGE_NANOS) return
val previous = best.get()
if (previous == null || location.accuracy < previous.accuracy) best.set(Location(location))
if (location.accuracy <= TARGET_ACCURACY_METERS) finished.countDown()
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
if (location.accuracy < 0) return
val previous = best.get()
if (previous == null || location.accuracy < previous.accuracy) best.set(location)
if (location.accuracy <= TARGET_ACCURACY_METERS) finished.countDown()
accept(location)
}
@Deprecated("Deprecated in Android")
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
}
val main = Handler(Looper.getMainLooper())
main.post {
val start = Runnable {
if (stopped.get()) return@Runnable
val providers = buildList {
// The fused provider may have a fix even when GPS cannot get
// a signal and the separate network provider is unavailable.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) add(LocationManager.FUSED_PROVIDER)
add(LocationManager.GPS_PROVIDER)
add(LocationManager.NETWORK_PROVIDER)
}.filter { runCatching { manager.isProviderEnabled(it) }.getOrDefault(false) }
for (provider in providers) {
runCatching { manager.getLastKnownLocation(provider) }
.onSuccess { location -> location?.let(::accept) }
.onFailure { Log.w(TAG, "Could not read cached $provider location", it) }
}
if (finished.count == 0L) return@Runnable
var requested = false
for (provider in listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)) {
if (!runCatching { manager.isProviderEnabled(provider) }.getOrDefault(false)) continue
for (provider in providers) {
runCatching {
manager.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper())
requested = true
}
Log.d(TAG, "Requested Memory location from $provider")
}.onFailure { Log.w(TAG, "Could not request $provider location", it) }
}
if (!requested) {
Log.w(TAG, "Memory location unavailable: no provider accepted the request")
finished.countDown()
}
if (!requested) finished.countDown()
}
finished.await(timeoutMillis.coerceAtLeast(0), TimeUnit.MILLISECONDS)
main.post { runCatching { manager.removeUpdates(listener) } }
main.post(start)
try {
finished.await(timeoutMillis.coerceAtLeast(0), TimeUnit.MILLISECONDS)
} finally {
stopped.set(true)
main.removeCallbacks(start)
main.post { runCatching { manager.removeUpdates(listener) } }
}
val location = best.get() ?: return null
return "[${location.latitude},${location.longitude},${location.accuracy}]"
}

@ -1 +1 @@
Subproject commit c3d085882368d7269956048bbb69fbd8a69a711c
Subproject commit 95853567e9ade7ebe27a2af17bb82c90f4cf8416

View file

@ -41,7 +41,39 @@ def get_default_branch(repo_path='.'):
return result.stdout.strip().split('/')[-1]
return 'main'
def get_package_version(package_path):
pubspec_path = os.path.join(package_path, "pubspec.yaml")
if os.path.exists(pubspec_path):
with open(pubspec_path, "r") as f:
ps = yaml.safe_load(f)
if ps and isinstance(ps, dict):
return ps.get("version", "any")
return "any"
def preserve_custom_package(pkg_name, out_dir):
out_path = os.path.join(out_dir, pkg_name)
if not os.path.isfile(os.path.join(out_path, "pubspec.yaml")):
raise FileNotFoundError(
f"Custom package not found: {out_path}. "
"Restore it from the dependencies repository; automatic updates are disabled."
)
print_yellow(f"Preserving {pkg_name}: custom_changes is enabled.")
return (pkg_name, get_package_version(out_path))
def integrate_package(folder_name, data, cache_dir, out_dir, cache_only=False):
packages_to_extract = data.get(
"subpackages", [{"name": folder_name, "path": data.get("path", "")}]
)
# Keep custom packages intact, including when copying from the cache.
# Read their local versions so managed pubspec entries are retained.
if data.get("custom_changes") or all(
pkg.get("custom_changes") for pkg in packages_to_extract
):
return [
preserve_custom_package(pkg["name"], out_dir)
for pkg in packages_to_extract
]
keep_list = ["lib", "LICENSE", "pubspec.yaml", "android", "ios", "darwin"]
if "keep" in data:
keep_list += [item.rstrip('/') for item in data['keep']]
@ -82,13 +114,11 @@ def integrate_package(folder_name, data, cache_dir, out_dir, cache_only=False):
results = [] # List of (pkg_name, version)
if "subpackages" in data:
packages_to_extract = data["subpackages"]
else:
packages_to_extract = [{"name": folder_name, "path": data.get("path", "")}]
for pkg in packages_to_extract:
pkg_name = pkg["name"]
if pkg.get("custom_changes"):
results.append(preserve_custom_package(pkg_name, out_dir))
continue
subpath = pkg.get("path", "")
package_src_path = os.path.join(cache_path, subpath) if subpath else cache_path
@ -115,12 +145,7 @@ def integrate_package(folder_name, data, cache_dir, out_dir, cache_only=False):
version = "any"
try:
pubspec_path = os.path.join(package_src_path, "pubspec.yaml")
if os.path.exists(pubspec_path):
with open(pubspec_path, "r") as f:
ps = yaml.safe_load(f)
if ps and isinstance(ps, dict):
version = ps.get("version", "any")
version = get_package_version(package_src_path)
except Exception as e:
print_yellow(f"Warning: Could not read version from {pkg_name}/pubspec.yaml")

View file

@ -12,6 +12,12 @@ dependencies:
blurhash_dart:
git: https://github.com/justacid/blurhash-dart.git
tag: v1.2.1
camera_avfoundation:
git: https://github.com/flutter/packages.git
tag: camera_avfoundation-v0.10.3
path: packages/camera/camera_avfoundation
custom_changes: true
custom_changes_description: Downscale and throttle rear-camera QR analysis frames.
exif:
git: https://github.com/bigflood/dartexif.git
dependencies:
@ -36,6 +42,8 @@ dependencies:
path: packages/video_player/video_player_avfoundation
- name: camera_android_camerax
path: packages/camera/camera_android_camerax
custom_changes: true
custom_changes_description: Use lower-resolution rear-camera QR analysis frames.
commit: b4f7d807a822a8ce327aae21353ac2217e928f6d
flutter_sharing_intent:
git: https://github.com/bhagat-techind/flutter_sharing_intent.git

View file

@ -0,0 +1,92 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
class BackupFileSize {
final String name;
/// None means the source file is absent and will be skipped by the backup.
final PlatformInt64? sizeBytes;
const BackupFileSize({
required this.name,
this.sizeBytes,
});
@override
int get hashCode => name.hashCode ^ sizeBytes.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BackupFileSize &&
runtimeType == other.runtimeType &&
name == other.name &&
sizeBytes == other.sizeBytes;
}
class BackupStorageInfo {
final PlatformInt64 databaseSizeBytes;
final PlatformInt64 freeSizeBytes;
final List<BackupTableSize> tables;
final List<BackupFileSize> files;
const BackupStorageInfo({
required this.databaseSizeBytes,
required this.freeSizeBytes,
required this.tables,
required this.files,
});
@override
int get hashCode =>
databaseSizeBytes.hashCode ^
freeSizeBytes.hashCode ^
tables.hashCode ^
files.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BackupStorageInfo &&
runtimeType == other.runtimeType &&
databaseSizeBytes == other.databaseSizeBytes &&
freeSizeBytes == other.freeSizeBytes &&
tables == other.tables &&
files == other.files;
}
class BackupTableSize {
final String name;
final PlatformInt64 rows;
final PlatformInt64 tableSizeBytes;
final PlatformInt64 indexSizeBytes;
const BackupTableSize({
required this.name,
required this.rows,
required this.tableSizeBytes,
required this.indexSizeBytes,
});
@override
int get hashCode =>
name.hashCode ^
rows.hashCode ^
tableSizeBytes.hashCode ^
indexSizeBytes.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BackupTableSize &&
runtimeType == other.runtimeType &&
name == other.name &&
rows == other.rows &&
tableSizeBytes == other.tableSizeBytes &&
indexSizeBytes == other.indexSizeBytes;
}

View file

@ -3,6 +3,7 @@
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import '../../backup/backup_archive.dart';
import '../../frb_generated.dart';
import '../../keys/backup_password_keys.dart';
import '../../lib.dart';
@ -23,6 +24,9 @@ class RustBackupArchive {
filePath: filePath,
);
static Future<BackupStorageInfo> storageInfo() => RustLib.instance.api
.crateBridgeWrapperBackupRustBackupArchiveStorageInfo();
@override
int get hashCode => 0;

View file

@ -4,6 +4,7 @@
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/server/prekeys.dart';
import 'backup/backup_archive.dart';
import 'bridge.dart';
import 'bridge/api.dart';
import 'bridge/callbacks.dart';
@ -90,7 +91,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => 926647182;
int get rustContentHash => -1385877172;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@ -645,6 +646,9 @@ abstract class RustLibApi extends BaseApi {
required String filePath,
});
Future<BackupStorageInfo>
crateBridgeWrapperBackupRustBackupArchiveStorageInfo();
Future<String?> crateBridgeWrapperBackupRustBackupIdentityGetBackupId();
Future<BackupPasswordKeys>
@ -5833,7 +5837,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
@override
Future<String?> crateBridgeWrapperBackupRustBackupIdentityGetBackupId() {
Future<BackupStorageInfo>
crateBridgeWrapperBackupRustBackupArchiveStorageInfo() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
@ -5845,6 +5850,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_backup_storage_info,
decodeErrorData: sse_decode_AnyhowException,
),
constMeta:
kCrateBridgeWrapperBackupRustBackupArchiveStorageInfoConstMeta,
argValues: [],
apiImpl: this,
),
);
}
TaskConstMeta
get kCrateBridgeWrapperBackupRustBackupArchiveStorageInfoConstMeta =>
const TaskConstMeta(
debugName: "rust_backup_archive_storage_info",
argNames: [],
);
@override
Future<String?> crateBridgeWrapperBackupRustBackupIdentityGetBackupId() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 149,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_opt_String,
decodeErrorData: null,
@ -5879,7 +5916,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 149,
funcId: 150,
port: port_,
);
},
@ -5912,7 +5949,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 150,
funcId: 151,
port: port_,
);
},
@ -5950,7 +5987,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 151,
funcId: 152,
port: port_,
);
},
@ -5987,7 +6024,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 152,
funcId: 153,
port: port_,
);
},
@ -6024,7 +6061,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 153,
funcId: 154,
port: port_,
);
},
@ -6062,7 +6099,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 154,
funcId: 155,
port: port_,
);
},
@ -6100,7 +6137,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 155,
funcId: 156,
port: port_,
);
},
@ -6133,7 +6170,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 156,
funcId: 157,
port: port_,
);
},
@ -6165,7 +6202,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 157,
funcId: 158,
port: port_,
);
},
@ -6200,7 +6237,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 158,
funcId: 159,
port: port_,
);
},
@ -6237,7 +6274,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 159,
funcId: 160,
port: port_,
);
},
@ -6270,7 +6307,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 160,
funcId: 161,
port: port_,
);
},
@ -6302,7 +6339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 161,
funcId: 162,
port: port_,
);
},
@ -6337,7 +6374,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 162,
funcId: 163,
port: port_,
);
},
@ -6376,7 +6413,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 163,
funcId: 164,
port: port_,
);
},
@ -6413,7 +6450,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 164,
funcId: 165,
port: port_,
);
},
@ -6443,7 +6480,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 165,
funcId: 166,
port: port_,
);
},
@ -6475,7 +6512,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 166,
funcId: 167,
port: port_,
);
},
@ -6510,7 +6547,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 167,
funcId: 168,
port: port_,
);
},
@ -6545,7 +6582,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 168,
funcId: 169,
port: port_,
);
},
@ -6576,7 +6613,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 169,
funcId: 170,
port: port_,
);
},
@ -6614,7 +6651,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 170,
funcId: 171,
port: port_,
);
},
@ -6649,7 +6686,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 171,
funcId: 172,
port: port_,
);
},
@ -6688,7 +6725,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 172,
funcId: 173,
port: port_,
);
},
@ -6723,7 +6760,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 173,
funcId: 174,
port: port_,
);
},
@ -6768,7 +6805,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 174,
funcId: 175,
port: port_,
);
},
@ -6811,7 +6848,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 175,
funcId: 176,
port: port_,
);
},
@ -6843,7 +6880,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 176,
funcId: 177,
port: port_,
);
},
@ -6878,7 +6915,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 177,
funcId: 178,
port: port_,
);
},
@ -6913,7 +6950,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 178,
funcId: 179,
port: port_,
);
},
@ -6948,7 +6985,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 179,
funcId: 180,
port: port_,
);
},
@ -6981,7 +7018,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 180,
funcId: 181,
)!;
},
codec: SseCodec(
@ -7021,7 +7058,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 181,
funcId: 182,
port: port_,
);
},
@ -7066,7 +7103,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 182,
funcId: 183,
port: port_,
);
},
@ -7096,7 +7133,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 183,
funcId: 184,
port: port_,
);
},
@ -7129,7 +7166,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 184,
funcId: 185,
port: port_,
);
},
@ -7164,7 +7201,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 185,
funcId: 186,
port: port_,
);
},
@ -7203,7 +7240,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 186,
funcId: 187,
)!;
},
codec: SseCodec(
@ -7403,6 +7440,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return ApiEventKind.values[raw as int];
}
@protected
BackupFileSize dco_decode_backup_file_size(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 2)
throw Exception('unexpected arr length: expect 2 but see ${arr.length}');
return BackupFileSize(
name: dco_decode_String(arr[0]),
sizeBytes: dco_decode_opt_box_autoadd_i_64(arr[1]),
);
}
@protected
BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@ -7415,6 +7464,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
BackupStorageInfo dco_decode_backup_storage_info(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 4)
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
return BackupStorageInfo(
databaseSizeBytes: dco_decode_i_64(arr[0]),
freeSizeBytes: dco_decode_i_64(arr[1]),
tables: dco_decode_list_backup_table_size(arr[2]),
files: dco_decode_list_backup_file_size(arr[3]),
);
}
@protected
BackupTableSize dco_decode_backup_table_size(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 4)
throw Exception('unexpected arr length: expect 4 but see ${arr.length}');
return BackupTableSize(
name: dco_decode_String(arr[0]),
rows: dco_decode_i_64(arr[1]),
tableSizeBytes: dco_decode_i_64(arr[2]),
indexSizeBytes: dco_decode_i_64(arr[3]),
);
}
@protected
bool dco_decode_bool(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@ -7766,6 +7843,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (raw as List<dynamic>).map(dco_decode_String).toList();
}
@protected
List<BackupFileSize> dco_decode_list_backup_file_size(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return (raw as List<dynamic>).map(dco_decode_backup_file_size).toList();
}
@protected
List<BackupTableSize> dco_decode_list_backup_table_size(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return (raw as List<dynamic>).map(dco_decode_backup_table_size).toList();
}
@protected
List<FrbAdditionalAccount> dco_decode_list_frb_additional_account(
dynamic raw,
@ -8591,6 +8680,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return ApiEventKind.values[inner];
}
@protected
BackupFileSize sse_decode_backup_file_size(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_name = sse_decode_String(deserializer);
var var_sizeBytes = sse_decode_opt_box_autoadd_i_64(deserializer);
return BackupFileSize(name: var_name, sizeBytes: var_sizeBytes);
}
@protected
BackupPasswordKeys sse_decode_backup_password_keys(
SseDeserializer deserializer,
@ -8604,6 +8701,38 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
BackupStorageInfo sse_decode_backup_storage_info(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_databaseSizeBytes = sse_decode_i_64(deserializer);
var var_freeSizeBytes = sse_decode_i_64(deserializer);
var var_tables = sse_decode_list_backup_table_size(deserializer);
var var_files = sse_decode_list_backup_file_size(deserializer);
return BackupStorageInfo(
databaseSizeBytes: var_databaseSizeBytes,
freeSizeBytes: var_freeSizeBytes,
tables: var_tables,
files: var_files,
);
}
@protected
BackupTableSize sse_decode_backup_table_size(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_name = sse_decode_String(deserializer);
var var_rows = sse_decode_i_64(deserializer);
var var_tableSizeBytes = sse_decode_i_64(deserializer);
var var_indexSizeBytes = sse_decode_i_64(deserializer);
return BackupTableSize(
name: var_name,
rows: var_rows,
tableSizeBytes: var_tableSizeBytes,
indexSizeBytes: var_indexSizeBytes,
);
}
@protected
bool sse_decode_bool(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -8999,6 +9128,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return ans_;
}
@protected
List<BackupFileSize> sse_decode_list_backup_file_size(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var len_ = sse_decode_i_32(deserializer);
var ans_ = <BackupFileSize>[];
for (var idx_ = 0; idx_ < len_; ++idx_) {
ans_.add(sse_decode_backup_file_size(deserializer));
}
return ans_;
}
@protected
List<BackupTableSize> sse_decode_list_backup_table_size(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var len_ = sse_decode_i_32(deserializer);
var ans_ = <BackupTableSize>[];
for (var idx_ = 0; idx_ < len_; ++idx_) {
ans_.add(sse_decode_backup_table_size(deserializer));
}
return ans_;
}
@protected
List<FrbAdditionalAccount> sse_decode_list_frb_additional_account(
SseDeserializer deserializer,
@ -10142,6 +10299,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(self.index, serializer);
}
@protected
void sse_encode_backup_file_size(
BackupFileSize self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(self.name, serializer);
sse_encode_opt_box_autoadd_i_64(self.sizeBytes, serializer);
}
@protected
void sse_encode_backup_password_keys(
BackupPasswordKeys self,
@ -10152,6 +10319,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_u_8_array_32(self.encryptionKey, serializer);
}
@protected
void sse_encode_backup_storage_info(
BackupStorageInfo self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_64(self.databaseSizeBytes, serializer);
sse_encode_i_64(self.freeSizeBytes, serializer);
sse_encode_list_backup_table_size(self.tables, serializer);
sse_encode_list_backup_file_size(self.files, serializer);
}
@protected
void sse_encode_backup_table_size(
BackupTableSize self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(self.name, serializer);
sse_encode_i_64(self.rows, serializer);
sse_encode_i_64(self.tableSizeBytes, serializer);
sse_encode_i_64(self.indexSizeBytes, serializer);
}
@protected
void sse_encode_bool(bool self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -10504,6 +10695,30 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_list_backup_file_size(
List<BackupFileSize> self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.length, serializer);
for (final item in self) {
sse_encode_backup_file_size(item, serializer);
}
}
@protected
void sse_encode_list_backup_table_size(
List<BackupTableSize> self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.length, serializer);
for (final item in self) {
sse_encode_backup_table_size(item, serializer);
}
}
@protected
void sse_encode_list_frb_additional_account(
List<FrbAdditionalAccount> self,

View file

@ -4,6 +4,7 @@
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api/server/prekeys.dart';
import 'backup/backup_archive.dart';
import 'bridge.dart';
import 'bridge/api.dart';
import 'bridge/callbacks.dart';
@ -86,9 +87,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ApiEventKind dco_decode_api_event_kind(dynamic raw);
@protected
BackupFileSize dco_decode_backup_file_size(dynamic raw);
@protected
BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw);
@protected
BackupStorageInfo dco_decode_backup_storage_info(dynamic raw);
@protected
BackupTableSize dco_decode_backup_table_size(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@ -207,6 +217,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<BackupFileSize> dco_decode_list_backup_file_size(dynamic raw);
@protected
List<BackupTableSize> dco_decode_list_backup_table_size(dynamic raw);
@protected
List<FrbAdditionalAccount> dco_decode_list_frb_additional_account(
dynamic raw,
@ -493,11 +509,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ApiEventKind sse_decode_api_event_kind(SseDeserializer deserializer);
@protected
BackupFileSize sse_decode_backup_file_size(SseDeserializer deserializer);
@protected
BackupPasswordKeys sse_decode_backup_password_keys(
SseDeserializer deserializer,
);
@protected
BackupStorageInfo sse_decode_backup_storage_info(
SseDeserializer deserializer,
);
@protected
BackupTableSize sse_decode_backup_table_size(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@ -644,6 +671,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<BackupFileSize> sse_decode_list_backup_file_size(
SseDeserializer deserializer,
);
@protected
List<BackupTableSize> sse_decode_list_backup_table_size(
SseDeserializer deserializer,
);
@protected
List<FrbAdditionalAccount> sse_decode_list_frb_additional_account(
SseDeserializer deserializer,
@ -995,12 +1032,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_api_event_kind(ApiEventKind self, SseSerializer serializer);
@protected
void sse_encode_backup_file_size(
BackupFileSize self,
SseSerializer serializer,
);
@protected
void sse_encode_backup_password_keys(
BackupPasswordKeys self,
SseSerializer serializer,
);
@protected
void sse_encode_backup_storage_info(
BackupStorageInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_backup_table_size(
BackupTableSize self,
SseSerializer serializer,
);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@ -1190,6 +1245,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_backup_file_size(
List<BackupFileSize> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_backup_table_size(
List<BackupTableSize> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_frb_additional_account(
List<FrbAdditionalAccount> self,

View file

@ -7,6 +7,7 @@
// ignore_for_file: argument_type_not_assignable
import 'api/server/prekeys.dart';
import 'backup/backup_archive.dart';
import 'bridge.dart';
import 'bridge/api.dart';
import 'bridge/callbacks.dart';
@ -88,9 +89,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ApiEventKind dco_decode_api_event_kind(dynamic raw);
@protected
BackupFileSize dco_decode_backup_file_size(dynamic raw);
@protected
BackupPasswordKeys dco_decode_backup_password_keys(dynamic raw);
@protected
BackupStorageInfo dco_decode_backup_storage_info(dynamic raw);
@protected
BackupTableSize dco_decode_backup_table_size(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@ -209,6 +219,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<BackupFileSize> dco_decode_list_backup_file_size(dynamic raw);
@protected
List<BackupTableSize> dco_decode_list_backup_table_size(dynamic raw);
@protected
List<FrbAdditionalAccount> dco_decode_list_frb_additional_account(
dynamic raw,
@ -495,11 +511,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ApiEventKind sse_decode_api_event_kind(SseDeserializer deserializer);
@protected
BackupFileSize sse_decode_backup_file_size(SseDeserializer deserializer);
@protected
BackupPasswordKeys sse_decode_backup_password_keys(
SseDeserializer deserializer,
);
@protected
BackupStorageInfo sse_decode_backup_storage_info(
SseDeserializer deserializer,
);
@protected
BackupTableSize sse_decode_backup_table_size(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@ -646,6 +673,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<BackupFileSize> sse_decode_list_backup_file_size(
SseDeserializer deserializer,
);
@protected
List<BackupTableSize> sse_decode_list_backup_table_size(
SseDeserializer deserializer,
);
@protected
List<FrbAdditionalAccount> sse_decode_list_frb_additional_account(
SseDeserializer deserializer,
@ -997,12 +1034,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_api_event_kind(ApiEventKind self, SseSerializer serializer);
@protected
void sse_encode_backup_file_size(
BackupFileSize self,
SseSerializer serializer,
);
@protected
void sse_encode_backup_password_keys(
BackupPasswordKeys self,
SseSerializer serializer,
);
@protected
void sse_encode_backup_storage_info(
BackupStorageInfo self,
SseSerializer serializer,
);
@protected
void sse_encode_backup_table_size(
BackupTableSize self,
SseSerializer serializer,
);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@ -1192,6 +1247,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_backup_file_size(
List<BackupFileSize> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_backup_table_size(
List<BackupTableSize> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_frb_additional_account(
List<FrbAdditionalAccount> self,

View file

@ -22,6 +22,13 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
// of this object.
// ignore: matching_super_parameters
GroupsDao(super.db);
// Former members stay stored so their messages and group history remain
// readable. A null state is a legacy member who is still in the group.
Expression<bool> _isActiveMember(GroupMembers member) =>
member.memberState.equals(MemberState.leftGroup.name).not() |
member.memberState.isNull();
Future<void> deleteGroup(String groupId) async {
await (delete(groups)..where((t) => t.groupId.equals(groupId))).go();
}
@ -37,10 +44,7 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
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()),
(t) => t.groupId.equals(groupId) & _isActiveMember(t),
))
.get();
}
@ -131,7 +135,10 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
),
])
..orderBy([OrderingTerm.desc(groupMembers.lastMessage)])
..where(groupMembers.groupId.equals(groupId)));
..where(
groupMembers.groupId.equals(groupId) &
_isActiveMember(groupMembers),
));
return query.map((row) => row.readTable(contacts)).get();
}
@ -145,25 +152,31 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
),
])
..orderBy([OrderingTerm.desc(groupMembers.lastMessage)])
..where(groupMembers.groupId.equals(groupId)));
..where(
groupMembers.groupId.equals(groupId) &
_isActiveMember(groupMembers),
));
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),
),
]);
(select(groupMembers)..where(
(t) => t.groupId.equals(groupId) & _isActiveMember(t),
))
.join([
leftOuterJoin(
contacts,
contacts.userId.equalsExp(groupMembers.contactId),
),
]);
return query
.map((row) => (row.readTable(contacts), row.readTable(groupMembers)))
.watch();
}
Stream<List<(Contact, GroupMember)>> watchAllGroupMembers() {
final query = select(groupMembers).join([
final query = (select(groupMembers)..where(_isActiveMember)).join([
innerJoin(contacts, contacts.userId.equalsExp(groupMembers.contactId)),
]);
return query
@ -207,8 +220,12 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
Stream<List<GroupMember>> watchTypingGroupMembers() {
return (select(
groupMembers,
)..where((member) => member.lastTypeIndicator.isNotNull())).watch();
groupMembers,
)..where(
(member) =>
member.lastTypeIndicator.isNotNull() & _isActiveMember(member),
))
.watch();
}
Stream<Group?> watchGroup(String groupId) {
@ -333,7 +350,8 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
),
])..where(
groups.isDirectChat.equals(false) &
groupMembers.contactId.equals(contactId),
groupMembers.contactId.equals(contactId) &
_isActiveMember(groupMembers),
);
return query.map((row) => row.readTable(groups)).watch();

View file

@ -78,6 +78,24 @@ String _avatarPngPathFor(Contact contact) {
}
class _AvatarIconState extends State<AvatarIcon> {
double get _avatarSize => 2 * (widget.fontSize ?? 20);
bool _isPhotoAvatar(String? path) => path?.endsWith('.webp') ?? false;
Widget _avatarImage(String path) {
final image = Image.file(
File(path),
width: _avatarSize,
height: _avatarSize,
errorBuilder: errorBuilder,
);
if (!_isPhotoAvatar(path)) return image;
return Transform.scale(
scale: (_avatarSize + 10) / _avatarSize,
child: image,
);
}
List<Contact> _avatarContacts = [];
Set<int> _contactsWithPngAvatar = {};
final Map<int, String> _resolvedAvatarPaths = {};
@ -175,11 +193,8 @@ class _AvatarIconState extends State<AvatarIcon> {
Widget getAvatarForContact(Contact contact) {
if (_contactsWithPngAvatar.contains(contact.userId)) {
return Image.file(
File(
_resolvedAvatarPaths[contact.userId] ?? _avatarPngPathFor(contact),
),
errorBuilder: errorBuilder,
return _avatarImage(
_resolvedAvatarPaths[contact.userId] ?? _avatarPngPathFor(contact),
);
}
// Deliberately no SVG fallback: the render kicked off by
@ -227,17 +242,8 @@ class _AvatarIconState extends State<AvatarIcon> {
}
Future<void> _updateMyAvatar() async {
final avatarSvg = userService.currentUser.avatarSvg;
if (avatarSvg == null) {
if (mounted) {
setState(() {
_myAvatarPath = null;
_avatarContacts = [];
});
}
return;
}
// Rust resolves photo avatars as well as public SVG avatars, including
// profiles that have only a photo avatar.
final path = await RustApi.currentUserAvatarPath();
if (mounted) {
@ -249,16 +255,13 @@ class _AvatarIconState extends State<AvatarIcon> {
@override
Widget build(BuildContext context) {
final proSize = (widget.fontSize == null) ? 40 : (widget.fontSize! * 2);
final proSize = _avatarSize;
Widget avatars = Container();
if (widget.myAvatar) {
if (_myAvatarPath != null) {
avatars = Image.file(
File(_myAvatarPath!),
errorBuilder: errorBuilder,
);
avatars = _avatarImage(_myAvatarPath!);
} else {
avatars = const SvgPicture(
AssetBytesLoader('assets/images/default_avatar.svg.vec'),
@ -320,16 +323,16 @@ class _AvatarIconState extends State<AvatarIcon> {
return Container(
constraints: BoxConstraints(
minHeight: 2 * (widget.fontSize ?? 20),
minWidth: 2 * (widget.fontSize ?? 20),
maxWidth: 2 * (widget.fontSize ?? 20),
maxHeight: 2 * (widget.fontSize ?? 20),
minHeight: proSize,
minWidth: proSize,
maxWidth: proSize,
maxHeight: proSize,
),
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
height: proSize as double,
height: proSize,
width: proSize,
color: widget.color,
child: Center(child: avatars),

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/loader/ripple.loader.dart';
@ -13,6 +14,8 @@ class ConnectionStatusComp extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!userService.currentUser.isDeveloper) return child;
final isConnected = context.watch<CustomChangeProvider>().isConnected;
return Stack(
children: [

View file

@ -145,7 +145,8 @@ class CameraPreviewView extends StatefulWidget {
State<CameraPreviewView> createState() => _CameraPreviewViewState();
}
class _CameraPreviewViewState extends State<CameraPreviewView> {
class _CameraPreviewViewState extends State<CameraPreviewView>
with WidgetsBindingObserver {
bool _galleryLoadedImageIsShown = false;
bool _showSelfieFlash = false;
double _basePanY = 0;
@ -164,10 +165,22 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
MainCameraController get mc => widget.mainCameraController;
StreamSubscription<HardwareButton>? androidVolumeDownSub;
StreamSubscription<void>? _userUpdates;
late bool _storeLocationInMemories;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_storeLocationInMemories = userService.currentUser.storeLocationInMemories;
_userUpdates = userService.onUserUpdated.listen((_) {
final enabled = userService.currentUser.storeLocationInMemories;
final wasEnabled = _storeLocationInMemories;
_storeLocationInMemories = enabled;
// The camera can remain mounted while settings are open, so enabling
// location does not necessarily cause a visibility change.
if (enabled && !wasEnabled) _prewarmMemoryLocation();
});
unawaited(VideoRecordingBudget.ensureLoaded());
initVolumeControl();
initAsync();
@ -200,6 +213,11 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _prewarmMemoryLocation();
}
void _checkAndInitCamera() {
if (widget.isVisible &&
mc.cameraController == null &&
@ -215,6 +233,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
unawaited(_userUpdates?.cancel());
_videoRecordingTimer?.cancel();
_deInitVolumeControl();
super.dispose();

View file

@ -32,11 +32,12 @@ import 'package:twonly/src/visual/views/camera/camera_preview_components/painter
import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
// A captured widget cannot contain more camera detail than the live preview.
// Full HD keeps the screenshot capture fast while avoiding the visible 720p
// upscale on most modern phone displays. Ultra-high/max would also make the
// continuously running barcode and face-detection image stream much heavier.
const ResolutionPreset _cameraResolutionPreset = ResolutionPreset.veryHigh;
// Ordinary photos retain the rear camera's 1080p preview. Selfie previews and
// face-filter streams use 720p.
ResolutionPreset cameraPreviewResolution(CameraLensDirection direction) =>
direction == CameraLensDirection.front
? ResolutionPreset.high
: ResolutionPreset.veryHigh;
class PreviewLink {
const PreviewLink({
@ -120,6 +121,7 @@ class MainCameraController {
Future<void>? _initializeFuture;
Future<void>? _pendingDisposal;
int _cameraSessionId = 0;
DateTime? _lastBarcodeFrame;
Future<void> closeCamera() async {
_cameraSessionId++;
@ -200,13 +202,24 @@ class MainCameraController {
}
selectedCameraDetails.isZoomAble = false;
_lastBarcodeFrame = null;
final currentController = cameraController;
if (currentController == null || !currentController.value.isInitialized) {
// Preserve an active recording's preset when switching lenses. Recreating
// its controller just to lower the selfie preview would end the video.
final resolution = isVideoRecording && currentController != null
? currentController.resolutionPreset
: cameraPreviewResolution(
AppEnvironment.cameras[cameraId].lensDirection,
);
if (currentController == null ||
!currentController.value.isInitialized ||
currentController.resolutionPreset != resolution) {
final controllerToDispose = cameraController;
cameraController = null;
setState?.call();
if (controllerToDispose != null) {
unawaited(controllerToDispose.dispose());
await controllerToDispose.dispose();
if (sessionId != _cameraSessionId) return;
}
final hasMic = await micPermissionFuture;
@ -214,7 +227,7 @@ class MainCameraController {
var controller = CameraController(
AppEnvironment.cameras[cameraId],
_cameraResolutionPreset,
resolution,
enableAudio: hasMic,
imageFormatGroup: Platform.isAndroid
? ImageFormatGroup.nv21
@ -234,7 +247,7 @@ class MainCameraController {
await controller.dispose();
controller = CameraController(
AppEnvironment.cameras[cameraId],
_cameraResolutionPreset,
resolution,
enableAudio: hasMic,
);
_initializeFuture = controller.initialize();
@ -457,16 +470,23 @@ class MainCameraController {
if (isVideoRecording || isSharePreviewIsShown) {
return;
}
final inputImage = _inputImageFromCameraImage(image);
if (inputImage == null) return;
// check if front camera is selected
if (cameraController?.description.lensDirection ==
CameraLensDirection.front) {
if (_currentFilterType != FaceFilterType.none) {
_processFaces(inputImage);
}
if (_currentFilterType == FaceFilterType.none || _isBusyFaces) return;
final inputImage = _inputImageFromCameraImage(image);
if (inputImage != null) unawaited(_processFaces(inputImage));
} else {
_processBarcode(inputImage);
if (_isBusy) return;
final now = clock.now();
if (_lastBarcodeFrame != null &&
now.difference(_lastBarcodeFrame!) <
const Duration(milliseconds: 150)) {
return;
}
_lastBarcodeFrame = now;
final inputImage = _inputImageFromCameraImage(image);
if (inputImage != null) unawaited(_processBarcode(inputImage));
}
}

View file

@ -166,6 +166,9 @@ class _ShareImageView extends State<ShareImageView> {
otherUsers.add(group);
}
}
otherUsers.sort(
(a, b) => b.lastMessageExchange.compareTo(a.lastMessageExchange),
);
setState(() {
_bestFriends = bestFriends;
@ -271,103 +274,119 @@ class _ShareImageView extends State<ShareImageView> {
left: 10,
right: 10,
),
child: ListView(
children: [
child: CustomScrollView(
restorationId: 'share_image_contact_selection',
slivers: [
if (_allGroups.isEmpty)
const EmptyChatListComp()
const SliverToBoxAdapter(child: EmptyChatListComp())
else ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: MyInput(
controller: searchUserName,
dense: true,
fontWeight: FontWeight.normal,
hintText: context.lang.shareImageSearchAllContacts,
prefixIcon: const Icon(Icons.search, size: 20),
onChanged: _filterUsers,
),
),
const SizedBox(height: 12),
if (!widget.sendToWidget) ...[
ContactGroupShortcutRow(
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
),
const SizedBox(height: 10),
StoryTargetSelector(
target: widget.storyTarget,
enabled: _storyPossible,
onChanged: () => setState(() {}),
onAudienceChanged: (chats) {
if (!mounted) return;
setState(() => _storyAudienceChats = chats);
},
),
// The next section's heading brings its own space.
const SizedBox(height: 4),
],
BestFriendsSelector(
groups: _pinnedContacts,
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImagePinnedContacts,
showSelectAll:
!widget.sendToWidget &&
!widget.mediaFileService.mediaFile.requiresAuthentication,
),
if (_pinnedContacts.isNotEmpty) const SizedBox(height: 10),
BestFriendsSelector(
groups: _bestFriends,
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImageBestFriends,
showSelectAll:
!widget.sendToWidget &&
!widget.mediaFileService.mediaFile.requiresAuthentication,
),
if (_bestFriends.isNotEmpty) const SizedBox(height: 10),
if (_otherUsers.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
SliverToBoxAdapter(
child: Column(
children: [
HeadLineComp(context.lang.shareImageAllUsers),
if (_allGroups.any((x) => x.archived))
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: MyInput(
controller: searchUserName,
dense: true,
fontWeight: FontWeight.normal,
hintText: context.lang.shareImageSearchAllContacts,
prefixIcon: const Icon(Icons.search, size: 20),
onChanged: _filterUsers,
),
),
const SizedBox(height: 12),
if (!widget.sendToWidget) ...[
ContactGroupShortcutRow(
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
),
const SizedBox(height: 10),
StoryTargetSelector(
target: widget.storyTarget,
enabled: _storyPossible,
onChanged: () => setState(() {}),
onAudienceChanged: (chats) {
if (!mounted) return;
setState(() => _storyAudienceChats = chats);
},
),
// The next section's heading brings its own space.
const SizedBox(height: 4),
],
BestFriendsSelector(
groups: _pinnedContacts,
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImagePinnedContacts,
showSelectAll:
!widget.sendToWidget &&
!widget
.mediaFileService
.mediaFile
.requiresAuthentication,
),
if (_pinnedContacts.isNotEmpty)
const SizedBox(height: 10),
BestFriendsSelector(
groups: _bestFriends,
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
title: context.lang.shareImageBestFriends,
showSelectAll:
!widget.sendToWidget &&
!widget
.mediaFileService
.mediaFile
.requiresAuthentication,
),
if (_bestFriends.isNotEmpty) const SizedBox(height: 10),
if (_otherUsers.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.lang.shareImageShowArchived,
style: const TextStyle(fontSize: 10),
),
Transform.scale(
scale: 0.75,
child: Checkbox.adaptive(
value: !hideArchivedUsers,
side: WidgetStateBorderSide.resolveWith(
(states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(width: 0);
}
return BorderSide(
color: Theme.of(
context,
).colorScheme.outline,
);
},
),
onChanged: (a) async {
hideArchivedUsers = !hideArchivedUsers;
await _filterUsers(lastQuery);
if (mounted) setState(() {});
},
HeadLineComp(context.lang.shareImageAllUsers),
if (_allGroups.any((x) => x.archived))
Row(
children: [
Text(
context.lang.shareImageShowArchived,
style: const TextStyle(fontSize: 10),
),
Transform.scale(
scale: 0.75,
child: Checkbox.adaptive(
value: !hideArchivedUsers,
side: WidgetStateBorderSide.resolveWith((
states,
) {
if (states.contains(
WidgetState.selected,
)) {
return const BorderSide(width: 0);
}
return BorderSide(
color: Theme.of(
context,
).colorScheme.outline,
);
}),
onChanged: (a) async {
hideArchivedUsers = !hideArchivedUsers;
await _filterUsers(lastQuery);
if (mounted) setState(() {});
},
),
),
],
),
),
],
),
],
),
),
if (_otherUsers.isNotEmpty)
UserList(
List.from(_otherUsers),
_otherUsers,
selectedGroupIds: widget.selectedGroupIds,
updateSelectedGroupIds: updateSelectedGroupIds,
),
@ -472,56 +491,52 @@ class UserList extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Step 1: Sort the users alphabetically
groups.sort(
(a, b) => b.lastMessageExchange.compareTo(a.lastMessageExchange),
);
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
restorationId: 'new_message_users_list',
itemCount: groups.length,
itemBuilder: (context, i) {
final group = groups[i];
return ListTile(
key: ValueKey(group.groupId),
title: Row(
children: [
Text(substringBy(group.groupName, 12)),
FlameCounterWidget(
groupId: group.groupId,
prefix: true,
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: groups.length,
(context, i) {
final group = groups[i];
return ListTile(
key: ValueKey(group.groupId),
title: Row(
children: [
Text(substringBy(group.groupName, 12)),
FlameCounterWidget(
groupId: group.groupId,
prefix: true,
),
],
),
leading: AvatarIcon(
group: group,
fontSize: 15,
),
trailing: Checkbox.adaptive(
value: selectedGroupIds.contains(group.groupId),
side: WidgetStateBorderSide.resolveWith(
(states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(width: 0);
}
return BorderSide(
color: Theme.of(context).colorScheme.outline,
);
},
),
],
),
leading: AvatarIcon(
group: group,
fontSize: 15,
),
trailing: Checkbox.adaptive(
value: selectedGroupIds.contains(group.groupId),
side: WidgetStateBorderSide.resolveWith(
(states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(width: 0);
}
return BorderSide(color: Theme.of(context).colorScheme.outline);
onChanged: (value) {
if (value == null) return;
updateSelectedGroupIds(group.groupId, value);
},
),
onChanged: (value) {
if (value == null) return;
updateSelectedGroupIds(group.groupId, value);
onTap: () {
updateSelectedGroupIds(
group.groupId,
!selectedGroupIds.contains(group.groupId),
);
},
),
onTap: () {
updateSelectedGroupIds(
group.groupId,
!selectedGroupIds.contains(group.groupId),
);
},
);
},
);
},
),
);
}
}

View file

@ -538,6 +538,8 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
// ---------------------------------------------------------------------------
void updateSelectedGroupIds(String groupId, bool checked) {
// ShareImageView rebuilds its own controls. Rebuilding this editor while it
// is covered by that route needlessly redraws the full media composition.
if (checked) {
if (media.requiresAuthentication) {
selectedGroupIds.clear();
@ -546,7 +548,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
} else {
selectedGroupIds.remove(groupId);
}
setState(() {});
}
Future<ScreenshotImageHelper?> storeImageAsOriginal() =>

View file

@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:twonly/core/backup/backup_archive.dart';
import 'package:twonly/core/bridge/wrapper/backup.dart';
import 'package:twonly/src/utils/misc.dart';
class DatabaseSizesDeveloperView extends StatefulWidget {
const DatabaseSizesDeveloperView({super.key});
@override
State<DatabaseSizesDeveloperView> createState() =>
_DatabaseSizesDeveloperViewState();
}
class _DatabaseSizesDeveloperViewState
extends State<DatabaseSizesDeveloperView> {
late Future<BackupStorageInfo> _storageInfo;
@override
void initState() {
super.initState();
_storageInfo = RustBackupArchive.storageInfo();
}
Future<void> _reload() async {
final future = RustBackupArchive.storageInfo();
setState(() => _storageInfo = future);
// FutureBuilder displays any failure, including on pull-to-refresh.
try {
await future;
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Database & Backup Sizes'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh sizes',
onPressed: _reload,
),
],
),
body: FutureBuilder<BackupStorageInfo>(
future: _storageInfo,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Could not load storage sizes.\n${snapshot.error}',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _reload,
child: const Text('Try again'),
),
],
),
),
);
}
final info = snapshot.requireData;
final tableBytes = info.tables.fold<int>(
0,
(total, table) => total + table.tableSizeBytes,
);
final indexBytes = info.tables.fold<int>(
0,
(total, table) => total + table.indexSizeBytes,
);
final fileBytes = info.files.fold<int>(
0,
(total, file) => total + (file.sizeBytes ?? 0),
);
final overhead =
info.databaseSizeBytes -
info.freeSizeBytes -
tableBytes -
indexBytes;
return RefreshIndicator(
onRefresh: _reload,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
const _SectionTitle('Main database · app_db.sqlite'),
_SizeTile(
title: 'Allocated database size',
bytes: info.databaseSizeBytes,
),
_SizeTile(title: 'Table data', bytes: tableBytes),
_SizeTile(title: 'Indexes', bytes: indexBytes),
_SizeTile(
title: 'Free pages',
subtitle: 'Allocated space available for reuse',
bytes: info.freeSizeBytes,
),
_SizeTile(
title: 'Schema and other overhead',
bytes: overhead,
),
const Divider(),
_SectionTitle('Tables · ${info.tables.length}'),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Largest first. Sizes include allocated table pages and '
'all indexes belonging to each table, including automatic '
'indexes. Empty tables can still occupy pages.',
),
),
for (final table in info.tables)
_SizeTile(
title: table.name,
subtitle:
'${table.rows} rows · '
'data ${formatBytes(table.tableSizeBytes)} · '
'indexes ${formatBytes(table.indexSizeBytes)}',
bytes: table.tableSizeBytes + table.indexSizeBytes,
),
const Divider(),
const _SectionTitle('Contacts & messages backup contents'),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Current local file sizes before backup. Database '
'snapshots, compression and encryption can change the '
'final archive size. Photos and videos use the separate '
'memories backup.',
),
),
_SizeTile(title: 'Total source files', bytes: fileBytes),
for (final file in info.files)
_SizeTile(
title: file.name,
subtitle: _fileDescription(file.name),
bytes: file.sizeBytes,
),
const ListTile(
title: Text('backup-manifest.json'),
subtitle: Text(
'File sizes, checksums and schema versions. '
'Generated when the backup is created.',
),
),
],
),
);
},
),
);
}
}
String _fileDescription(String name) => switch (name) {
'app_db.sqlite' => 'Main database, including the tables above',
'rust_db.sqlite' => 'Signal sessions, identities and encryption prekeys',
'twonly.sqlite' => 'Legacy database retained for migration compatibility',
'user_discovery_config.json' => 'User discovery configuration',
'user.json' => 'Account settings and preferences',
_ => 'Backup source file',
};
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
final String title;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
}
class _SizeTile extends StatelessWidget {
const _SizeTile({required this.title, required this.bytes, this.subtitle});
final String title;
final String? subtitle;
final int? bytes;
@override
Widget build(BuildContext context) {
final size = bytes;
final description = [
?subtitle,
if (size == null) 'Missing · skipped by backup',
].join('\n');
return ListTile(
title: Text(title),
subtitle: description.isEmpty ? null : Text(description),
trailing: size == null
? const Text('—')
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(formatBytes(size)),
Text(
'$size bytes',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}

View file

@ -25,6 +25,7 @@ import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
import 'package:twonly/src/visual/views/settings/developer/database_sizes_developer.view.dart';
import 'package:twonly/src/visual/views/settings/developer/home_widget_developer.view.dart';
import 'package:twonly/src/visual/views/settings/developer/user_discovery_developer.view.dart';
@ -405,6 +406,12 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
title: const Text('Informations'),
onTap: () => context.push(Routes.settingsDeveloperInformations),
),
ListTile(
title: const Text('Database & Backup Sizes'),
subtitle: const Text('Table sizes and backup contents'),
onTap: () =>
context.navPush(const DatabaseSizesDeveloperView()),
),
ListTile(
title: const Text('Show Retransmission Database'),
onTap: () => context.push(

View file

@ -190,12 +190,11 @@ packages:
source: path
version: "0.7.4+6"
camera_avfoundation:
dependency: transitive
dependency: "direct main"
description:
name: camera_avfoundation
sha256: "843247230a583e04742ea1434ce3f91e536760922fab67c72ce46aa34fac3e9d"
url: "https://pub.dev"
source: hosted
path: "dependencies/camera_avfoundation"
relative: true
source: path
version: "0.10.3"
camera_platform_interface:
dependency: transitive

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
version: 0.6.0+175
version: 0.6.0+178
environment:
sdk: ^3.11.0
@ -97,6 +97,7 @@ dependencies:
video_player_android: ^2.12.0
video_player_avfoundation: ^2.11.0
camera_android_camerax: ^0.7.4+6
camera_avfoundation: ^0.10.3
flutter_sharing_intent: ^2.0.4
hand_signature: ^3.1.0+2
hashlib: ^2.3.0
@ -143,6 +144,8 @@ dependency_overrides:
path: ./dependencies/video_player_avfoundation
camera_android_camerax:
path: ./dependencies/camera_android_camerax
camera_avfoundation:
path: ./dependencies/camera_avfoundation
flutter_sharing_intent:
path: ./dependencies/flutter_sharing_intent
hand_signature:

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT n.event_id, n.notification_id, n.conversation_id, n.sender_id,\n COALESCE(c.display_name, c.username) AS \"sender_name!: String\",\n c.avatar_svg_compressed, c.sender_profile_counter,\n g.group_name AS conversation_name,\n COALESCE(g.is_direct_chat, 0) AS \"is_direct_chat!: i64\",\n n.message_id, n.kind, n.content, n.created_at,\n target_message.type AS target_message_type,\n target_media.type AS target_media_type\n FROM notification_outbox n\n JOIN contacts c ON c.user_id = n.sender_id\n LEFT JOIN groups g ON g.group_id = n.conversation_id\n LEFT JOIN messages target_message ON target_message.message_id = n.message_id\n LEFT JOIN media_files target_media ON target_media.media_id = target_message.media_id\n WHERE n.delivered_at IS NULL AND n.cleared_at IS NULL\n ORDER BY n.created_at, n.event_id\n LIMIT ?\n ",
"query": "\n SELECT n.event_id, n.notification_id, n.conversation_id, n.sender_id,\n COALESCE(NULLIF(c.nick_name, ''), c.display_name, c.username) AS \"sender_name!: String\",\n g.group_name AS conversation_name,\n COALESCE(g.is_direct_chat, 0) AS \"is_direct_chat!: i64\",\n n.message_id, n.kind, n.content, n.created_at,\n target_message.type AS target_message_type,\n target_media.type AS target_media_type\n FROM notification_outbox n\n JOIN contacts c ON c.user_id = n.sender_id\n LEFT JOIN groups g ON g.group_id = n.conversation_id\n LEFT JOIN messages target_message ON target_message.message_id = n.message_id\n LEFT JOIN media_files target_media ON target_media.media_id = target_message.media_id\n WHERE n.delivered_at IS NULL AND n.cleared_at IS NULL\n ORDER BY n.created_at, n.event_id\n LIMIT ?\n ",
"describe": {
"columns": [
{
@ -50,34 +50,12 @@
{
"name": "sender_name!: String",
"ordinal": 4,
"type_info": "Text",
"type_info": "Null",
"origin": "Expression"
},
{
"name": "avatar_svg_compressed",
"ordinal": 5,
"type_info": "Blob",
"origin": {
"Table": {
"table": "contacts",
"name": "avatar_svg_compressed"
}
}
},
{
"name": "sender_profile_counter",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "contacts",
"name": "sender_profile_counter"
}
}
},
{
"name": "conversation_name",
"ordinal": 7,
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
@ -88,13 +66,13 @@
},
{
"name": "is_direct_chat!: i64",
"ordinal": 8,
"type_info": "Integer",
"ordinal": 6,
"type_info": "Null",
"origin": "Expression"
},
{
"name": "message_id",
"ordinal": 9,
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
@ -105,7 +83,7 @@
},
{
"name": "kind",
"ordinal": 10,
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
@ -116,7 +94,7 @@
},
{
"name": "content",
"ordinal": 11,
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
@ -127,7 +105,7 @@
},
{
"name": "created_at",
"ordinal": 12,
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
@ -138,7 +116,7 @@
},
{
"name": "target_message_type",
"ordinal": 13,
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
@ -149,7 +127,7 @@
},
{
"name": "target_media_type",
"ordinal": 14,
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
@ -167,18 +145,16 @@
false,
true,
false,
null,
false,
null,
true,
false,
true,
false,
true,
false,
true,
false,
true,
true
false
]
},
"hash": "7ee254f54718b616580ea6ca979679f8ec8065490cf0ffd0a8f44818c1f57167"
"hash": "6b0d6a1df75da706448489fd05157a51a9c093260171ec5b89934e3aee116f8e"
}

View file

@ -10,6 +10,7 @@ use crate::error::Result;
use crate::keys::{DatabaseKey, KeyManager};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{AssertSqlSafe, Row};
use std::collections::BTreeMap;
use std::fs::{remove_file, File};
use std::io::{copy, Cursor};
@ -52,7 +53,104 @@ struct BackupManifestFile {
sha256: String,
}
pub struct BackupStorageInfo {
pub database_size_bytes: i64,
pub free_size_bytes: i64,
pub tables: Vec<BackupTableSize>,
pub files: Vec<BackupFileSize>,
}
pub struct BackupTableSize {
pub name: String,
pub rows: i64,
pub table_size_bytes: i64,
pub index_size_bytes: i64,
}
pub struct BackupFileSize {
pub name: String,
/// None means the source file is absent and will be skipped by the backup.
pub size_bytes: Option<i64>,
}
impl BackupArchive {
/// Read current storage usage without creating or uploading a backup.
pub(crate) async fn storage_info(ctx: &Context) -> Result<BackupStorageInfo> {
let files = {
let keys = ctx.key_manager.lock().await;
Self::get_backup_files(ctx, &keys)?
.into_iter()
.map(|(name, directory, _, mut key)| {
key.zeroize();
let size_bytes = match directory.join(name).metadata() {
Ok(metadata) => Some(metadata.len() as i64),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
Ok(BackupFileSize {
name: name.to_owned(),
size_bytes,
})
})
.collect::<Result<Vec<_>>>()?
};
let database = ctx.app_db.read().await.clone();
let mut transaction = database.pool.begin().await?;
// SQLCipher returns page_size as TEXT for encrypted databases, unlike
// SQLite. Cast the table-valued pragma so both return an integer.
let page_size: i64 =
sqlx::query_scalar("SELECT CAST(page_size AS INTEGER) FROM pragma_page_size")
.fetch_one(&mut *transaction)
.await?;
let page_count: i64 = sqlx::query_scalar("PRAGMA page_count")
.fetch_one(&mut *transaction)
.await?;
let free_pages: i64 = sqlx::query_scalar("PRAGMA freelist_count")
.fetch_one(&mut *transaction)
.await?;
// Attribute each index (including SQLite's automatic indexes) to its
// owning table. dbstat includes overflow pages for large text/blobs.
let table_sizes = sqlx::query(
"SELECT t.name, \
COALESCE(SUM(CASE WHEN s.type = 'table' THEN d.pgsize ELSE 0 END), 0) AS table_bytes, \
COALESCE(SUM(CASE WHEN s.type = 'index' THEN d.pgsize ELSE 0 END), 0) AS index_bytes \
FROM sqlite_schema t \
LEFT JOIN sqlite_schema s ON s.tbl_name = t.name AND s.type IN ('table', 'index') \
LEFT JOIN dbstat d ON d.name = s.name \
WHERE t.type = 'table' AND t.name NOT LIKE 'sqlite_%' \
GROUP BY t.name ORDER BY table_bytes + index_bytes DESC, t.name",
)
.fetch_all(&mut *transaction)
.await?;
let mut tables = Vec::with_capacity(table_sizes.len());
for table in table_sizes {
let name: String = table.try_get("name")?;
let quoted_name = name.replace('"', "\"\"");
let rows = sqlx::query_scalar(AssertSqlSafe(format!(
"SELECT COUNT(*) FROM \"{quoted_name}\""
)))
.fetch_one(&mut *transaction)
.await?;
tables.push(BackupTableSize {
name,
rows,
table_size_bytes: table.try_get("table_bytes")?,
index_size_bytes: table.try_get("index_bytes")?,
});
}
transaction.commit().await?;
Ok(BackupStorageInfo {
database_size_bytes: page_count * page_size,
free_size_bytes: free_pages * page_size,
tables,
files,
})
}
#[allow(clippy::type_complexity)]
fn get_backup_files(
ctx: &Context,
@ -440,6 +538,98 @@ mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn storage_info_measures_pages_indexes_and_backup_sources() {
let temp_dir = tempdir().unwrap();
let ctx = Context::init_for_testing(
temp_dir.path().join("database"),
temp_dir.path().join("data"),
)
.await
.unwrap();
let data_dir = PathBuf::from(&ctx.config.data_dir);
std::fs::create_dir_all(data_dir.join("keyvalue")).unwrap();
std::fs::write(data_dir.join("keyvalue/user.json"), "{\"userId\":1}").unwrap();
std::fs::write(data_dir.join("user_discovery_config.json"), "{}").unwrap();
let database = ctx.app_db.read().await.clone();
// A quoted identifier exercises row counting; the large blobs require
// overflow pages, and UNIQUE creates an automatic index.
sqlx::query("CREATE TABLE \"size\"\"test\" (name TEXT UNIQUE, payload BLOB)")
.execute(&database.pool)
.await
.unwrap();
sqlx::query("CREATE INDEX size_test_payload ON \"size\"\"test\" (payload)")
.execute(&database.pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO \"size\"\"test\" VALUES ('first', zeroblob(100000)), ('second', zeroblob(100000))",
)
.execute(&database.pool)
.await
.unwrap();
let info = BackupArchive::storage_info(&ctx).await.unwrap();
let table = info.tables.iter().find(|t| t.name == "size\"test").unwrap();
assert_eq!(table.rows, 2);
assert!(table.table_size_bytes >= 200000);
assert!(table.index_size_bytes >= 200000);
let empty = info.tables.iter().find(|t| t.name == "contacts").unwrap();
assert_eq!(empty.rows, 0);
assert!(empty.table_size_bytes > 0);
assert!(info.tables.windows(2).all(|pair| {
pair[0].table_size_bytes + pair[0].index_size_bytes
>= pair[1].table_size_bytes + pair[1].index_size_bytes
}));
let used_bytes: i64 = info
.tables
.iter()
.map(|t| t.table_size_bytes + t.index_size_bytes)
.sum();
assert!(used_bytes + info.free_size_bytes <= info.database_size_bytes);
let main_file = info
.files
.iter()
.find(|f| f.name == APP_DATABASE_FILE)
.unwrap();
assert_eq!(main_file.size_bytes, Some(info.database_size_bytes));
assert_eq!(info.files.len(), 5);
for (name, size) in [
("twonly.sqlite", None),
("user.json", Some(12)),
("user_discovery_config.json", Some(2)),
] {
assert_eq!(
info.files
.iter()
.find(|f| f.name == name)
.unwrap()
.size_bytes,
size
);
}
assert!(!data_dir.join("temp_backup_dir").exists());
assert!(!data_dir.join("temp_backup.zip").exists());
sqlx::query("DELETE FROM \"size\"\"test\" WHERE 1")
.execute(&database.pool)
.await
.unwrap();
let after_delete = BackupArchive::storage_info(&ctx).await.unwrap();
assert_eq!(after_delete.database_size_bytes, info.database_size_bytes);
assert!(after_delete.free_size_bytes > info.free_size_bytes);
assert_eq!(
after_delete
.tables
.iter()
.find(|t| t.name == "size\"test")
.unwrap()
.rows,
0
);
}
#[tokio::test]
async fn test_backup_and_restore() {
let _ = pretty_env_logger::try_init();

View file

@ -6,6 +6,7 @@
use std::path::PathBuf;
use crate::backup::backup_archive::BackupArchive;
pub use crate::backup::backup_archive::{BackupFileSize, BackupStorageInfo, BackupTableSize};
use crate::backup::backup_identity::BackupIdentity;
use crate::bridge::get_twonly_flutter;
use crate::context::Context;
@ -73,6 +74,10 @@ impl RustBackupIdentity {
}
}
impl RustBackupArchive {
pub async fn storage_info() -> Result<BackupStorageInfo> {
BackupArchive::storage_info(Context::get_static()?).await
}
pub async fn create_backup_archive() -> Result<(String, String)> {
let ctx = Context::get_static()?;
let path = BackupArchive::create_backup(ctx).await?;

View file

@ -138,27 +138,12 @@ async fn import(
)))
.fetch_one(&mut *tx)
.await?;
let target_count: i64 = sqlx::query_scalar(AssertSqlSafe(format!(
r#"SELECT COUNT(*) FROM main."{table}""#
)))
.fetch_one(&mut *tx)
.await?;
if source_count != target_count {
return Err(TwonlyError::Generic(format!(
"Row count mismatch for {table}: source={source_count}, target={target_count}"
)));
}
let mismatch: Option<i64> = sqlx::query_scalar(AssertSqlSafe(format!(
r#"SELECT 1 FROM (SELECT {quoted} FROM main."{table}" EXCEPT SELECT {quoted} FROM legacy."{table}") LIMIT 1"#
)))
.fetch_optional(&mut *tx)
.await?;
let reverse_mismatch: Option<i64> = sqlx::query_scalar(AssertSqlSafe(format!(
r#"SELECT 1 FROM (SELECT {quoted} FROM legacy."{table}" EXCEPT SELECT {quoted} FROM main."{table}") LIMIT 1"#
)))
.fetch_optional(&mut *tx)
.await?;
if mismatch.is_some() || reverse_mismatch.is_some() {
if reverse_mismatch.is_some() {
return Err(TwonlyError::Generic(format!(
"Data mismatch while importing {table}"
)));

View file

@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 926647182;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1385877172;
// Section: executor
@ -5515,6 +5515,43 @@ fn wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_arch
})().await)
} })
}
fn wire__crate__bridge__wrapper__backup__rust_backup_archive_storage_info_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "rust_backup_archive_storage_info",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
(move || async move {
let output_ok =
crate::bridge::wrapper::backup::RustBackupArchive::storage_info()
.await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@ -7039,6 +7076,18 @@ impl SseDecode for crate::bridge::api::ApiEventKind {
}
}
impl SseDecode for crate::backup::backup_archive::BackupFileSize {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_name = <String>::sse_decode(deserializer);
let mut var_sizeBytes = <Option<i64>>::sse_decode(deserializer);
return crate::backup::backup_archive::BackupFileSize {
name: var_name,
size_bytes: var_sizeBytes,
};
}
}
impl SseDecode for crate::keys::backup_password_keys::BackupPasswordKeys {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -7051,6 +7100,40 @@ impl SseDecode for crate::keys::backup_password_keys::BackupPasswordKeys {
}
}
impl SseDecode for crate::backup::backup_archive::BackupStorageInfo {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_databaseSizeBytes = <i64>::sse_decode(deserializer);
let mut var_freeSizeBytes = <i64>::sse_decode(deserializer);
let mut var_tables =
<Vec<crate::backup::backup_archive::BackupTableSize>>::sse_decode(deserializer);
let mut var_files =
<Vec<crate::backup::backup_archive::BackupFileSize>>::sse_decode(deserializer);
return crate::backup::backup_archive::BackupStorageInfo {
database_size_bytes: var_databaseSizeBytes,
free_size_bytes: var_freeSizeBytes,
tables: var_tables,
files: var_files,
};
}
}
impl SseDecode for crate::backup::backup_archive::BackupTableSize {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_name = <String>::sse_decode(deserializer);
let mut var_rows = <i64>::sse_decode(deserializer);
let mut var_tableSizeBytes = <i64>::sse_decode(deserializer);
let mut var_indexSizeBytes = <i64>::sse_decode(deserializer);
return crate::backup::backup_archive::BackupTableSize {
name: var_name,
rows: var_rows,
table_size_bytes: var_tableSizeBytes,
index_size_bytes: var_indexSizeBytes,
};
}
}
impl SseDecode for bool {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -7357,6 +7440,32 @@ impl SseDecode for Vec<String> {
}
}
impl SseDecode for Vec<crate::backup::backup_archive::BackupFileSize> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = Vec::with_capacity(len_ as usize);
for idx_ in 0..len_ {
ans_.push(<crate::backup::backup_archive::BackupFileSize>::sse_decode(
deserializer,
));
}
return ans_;
}
}
impl SseDecode for Vec<crate::backup::backup_archive::BackupTableSize> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = Vec::with_capacity(len_ as usize);
for idx_ in 0..len_ {
ans_.push(<crate::backup::backup_archive::BackupTableSize>::sse_decode(deserializer));
}
return ans_;
}
}
impl SseDecode for Vec<crate::bridge::api::FrbAdditionalAccount> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -8510,43 +8619,44 @@ fn pde_ffi_dispatcher_primary_impl(
145 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len),
146 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len),
147 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
162 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
164 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
168 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
169 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
170 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
171 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
172 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
173 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
174 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
175 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
176 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len),
177 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
178 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
179 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
181 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
182 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
183 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
184 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
185 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
148 => wire__crate__bridge__wrapper__backup__rust_backup_archive_storage_info_impl(port, ptr, rust_vec_len, data_len),
149 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len),
150 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
151 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len),
152 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
153 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len),
154 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len),
155 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
156 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len),
157 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len),
158 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len),
159 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len),
160 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len),
161 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_local_credentials_impl(port, ptr, rust_vec_len, data_len),
162 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len),
163 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len),
164 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len),
165 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len),
166 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len),
167 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len),
168 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len),
169 => wire__crate__bridge__wrapper__signal__rust_signal_get_safety_number_impl(port, ptr, rust_vec_len, data_len),
170 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len),
171 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len),
172 => wire__crate__bridge__wrapper__signal__rust_signal_reset_contact_session_impl(port, ptr, rust_vec_len, data_len),
173 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len),
174 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len),
175 => wire__crate__bridge__webxdc__send_update_impl(port, ptr, rust_vec_len, data_len),
176 => wire__crate__bridge__webxdc__serve_impl(port, ptr, rust_vec_len, data_len),
177 => wire__crate__bridge__stories__story_audience_chats_impl(port, ptr, rust_vec_len, data_len),
178 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len),
179 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len),
180 => wire__crate__bridge__webxdc__updates_after_impl(port, ptr, rust_vec_len, data_len),
182 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len),
183 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len),
184 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len),
185 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len),
186 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -8565,12 +8675,12 @@ fn pde_ffi_dispatcher_sync_impl(
56 => {
wire__crate__bridge__api__rust_api_decode_avatar_svg_impl(ptr, rust_vec_len, data_len)
}
180 => wire__crate__bridge__user_config__user_config_api_clone_impl(
181 => wire__crate__bridge__user_config__user_config_api_clone_impl(
ptr,
rust_vec_len,
data_len,
),
186 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
187 => wire__crate__bridge__logging__write_log_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@ -8651,6 +8761,27 @@ impl flutter_rust_bridge::IntoIntoDart<crate::bridge::api::ApiEventKind>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::backup::backup_archive::BackupFileSize {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.name.into_into_dart().into_dart(),
self.size_bytes.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::backup::backup_archive::BackupFileSize
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::backup::backup_archive::BackupFileSize>
for crate::backup::backup_archive::BackupFileSize
{
fn into_into_dart(self) -> crate::backup::backup_archive::BackupFileSize {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::keys::backup_password_keys::BackupPasswordKeys {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@ -8672,6 +8803,52 @@ impl flutter_rust_bridge::IntoIntoDart<crate::keys::backup_password_keys::Backup
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::backup::backup_archive::BackupStorageInfo {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.database_size_bytes.into_into_dart().into_dart(),
self.free_size_bytes.into_into_dart().into_dart(),
self.tables.into_into_dart().into_dart(),
self.files.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::backup::backup_archive::BackupStorageInfo
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::backup::backup_archive::BackupStorageInfo>
for crate::backup::backup_archive::BackupStorageInfo
{
fn into_into_dart(self) -> crate::backup::backup_archive::BackupStorageInfo {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::backup::backup_archive::BackupTableSize {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.name.into_into_dart().into_dart(),
self.rows.into_into_dart().into_dart(),
self.table_size_bytes.into_into_dart().into_dart(),
self.index_size_bytes.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::backup::backup_archive::BackupTableSize
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::backup::backup_archive::BackupTableSize>
for crate::backup::backup_archive::BackupTableSize
{
fn into_into_dart(self) -> crate::backup::backup_archive::BackupTableSize {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::services::avatars::CustomAvatarInfo {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@ -9861,6 +10038,14 @@ impl SseEncode for crate::bridge::api::ApiEventKind {
}
}
impl SseEncode for crate::backup::backup_archive::BackupFileSize {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.name, serializer);
<Option<i64>>::sse_encode(self.size_bytes, serializer);
}
}
impl SseEncode for crate::keys::backup_password_keys::BackupPasswordKeys {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@ -9869,6 +10054,26 @@ impl SseEncode for crate::keys::backup_password_keys::BackupPasswordKeys {
}
}
impl SseEncode for crate::backup::backup_archive::BackupStorageInfo {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i64>::sse_encode(self.database_size_bytes, serializer);
<i64>::sse_encode(self.free_size_bytes, serializer);
<Vec<crate::backup::backup_archive::BackupTableSize>>::sse_encode(self.tables, serializer);
<Vec<crate::backup::backup_archive::BackupFileSize>>::sse_encode(self.files, serializer);
}
}
impl SseEncode for crate::backup::backup_archive::BackupTableSize {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.name, serializer);
<i64>::sse_encode(self.rows, serializer);
<i64>::sse_encode(self.table_size_bytes, serializer);
<i64>::sse_encode(self.index_size_bytes, serializer);
}
}
impl SseEncode for bool {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@ -10095,6 +10300,26 @@ impl SseEncode for Vec<String> {
}
}
impl SseEncode for Vec<crate::backup::backup_archive::BackupFileSize> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<crate::backup::backup_archive::BackupFileSize>::sse_encode(item, serializer);
}
}
}
impl SseEncode for Vec<crate::backup::backup_archive::BackupTableSize> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<crate::backup::backup_archive::BackupTableSize>::sse_encode(item, serializer);
}
}
}
impl SseEncode for Vec<crate::bridge::api::FrbAdditionalAccount> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {

View file

@ -570,7 +570,7 @@ pub async fn pending_batch(ctx: &Arc<Context>, locale: &str) -> Result<Notificat
PendingRow,
r#"
SELECT n.event_id, n.notification_id, n.conversation_id, n.sender_id,
COALESCE(c.display_name, c.username) AS "sender_name!: String",
COALESCE(NULLIF(c.nick_name, ''), c.display_name, c.username) AS "sender_name!: String",
g.group_name AS conversation_name,
COALESCE(g.is_direct_chat, 0) AS "is_direct_chat!: i64",
n.message_id, n.kind, n.content, n.created_at,

View file

@ -0,0 +1,144 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
void main() {
late TwonlyDB database;
setUp(() async {
database = TwonlyDB(NativeDatabase.memory());
for (final statement in [
"INSERT INTO contacts(user_id, username) VALUES (7, 'alice'), (8, 'bob'), (9, 'carol'), (10, 'former')",
"INSERT INTO groups(group_id, group_name) VALUES ('group', 'Friends'), ('other', 'Other')",
"INSERT INTO groups(group_id, group_name, is_direct_chat) VALUES ('direct', 'Alice', 1)",
// Legacy (null), normal and admin members must all remain visible.
'INSERT INTO group_members(group_id, contact_id, member_state, last_type_indicator) '
"VALUES ('group', 7, NULL, 1), ('group', 8, 'normal', 1), ('group', 9, 'admin', 1), ('group', 10, 'leftGroup', 1)",
"INSERT INTO group_members(group_id, contact_id) VALUES ('other', 7), ('direct', 7)",
'INSERT INTO messages(group_id, message_id, sender_id, type, content) '
"VALUES ('group', 'message', 7, 'text', 'Hello')",
'INSERT INTO group_histories(group_history_id, group_id, affected_contact_id, type) '
"VALUES ('removal', 'group', 7, 'removedMember')",
]) {
await database.customStatement(statement);
}
});
tearDown(() => database.close());
Future<void> setMemberState(MemberState state) =>
database.groupsDao.insertOrUpdateGroupMember(
GroupMembersCompanion(
groupId: const Value('group'),
contactId: const Value(7),
memberState: Value(state),
),
);
test(
'removed members leave active queries while history is retained',
() async {
expect(
(await database.groupsDao.getGroupContact(
'group',
)).map((c) => c.userId),
unorderedEquals([7, 8, 9]),
);
await setMemberState(MemberState.leftGroup);
expect(
(await database.groupsDao.getGroupContact(
'group',
)).map((c) => c.userId),
unorderedEquals([8, 9]),
);
expect(
(await database.groupsDao.getGroupNonLeftMembers(
'group',
)).map((m) => m.contactId),
unorderedEquals([8, 9]),
);
expect(
await database.groupsDao.getAllGroupMembers('group'),
hasLength(4),
);
expect(
(await database.select(database.messages).get()).single.content,
'Hello',
);
expect(
(await database.groupsDao.watchGroupActions('group').first)
.single
.affectedContactId,
7,
);
expect(
(await database.groupsDao.getGroupContact('other')).single.userId,
7,
);
expect(
(await database.groupsDao.getGroupContact('direct')).single.userId,
7,
);
},
);
final memberStreams = <String, Stream<List<int>> Function()>{
'member list': () => database.groupsDao
.watchGroupMembers('group')
.map((rows) => rows.map((row) => row.$1.userId).toList()),
'group contacts': () => database.groupsDao
.watchGroupContact('group')
.map((rows) => rows.map((row) => row.userId).toList()),
'chat list members': () => database.groupsDao.watchAllGroupMembers().map(
(rows) => rows
.where((row) => row.$2.groupId == 'group')
.map((row) => row.$1.userId)
.toList(),
),
'typing members': () => database.groupsDao.watchTypingGroupMembers().map(
(rows) => rows.map((row) => row.contactId).toList(),
),
};
for (final entry in memberStreams.entries) {
test('${entry.key} updates on removal and rejoining', () async {
final stream = StreamIterator(entry.value());
addTearDown(stream.cancel);
expect(await stream.moveNext(), isTrue);
expect(stream.current, unorderedEquals([7, 8, 9]));
final removal = stream.moveNext();
await setMemberState(MemberState.leftGroup);
expect(await removal, isTrue);
expect(stream.current, unorderedEquals([8, 9]));
final rejoining = stream.moveNext();
await setMemberState(MemberState.normal);
expect(await rejoining, isTrue);
expect(stream.current, unorderedEquals([7, 8, 9]));
});
}
test('mutual groups update when a member is removed', () async {
final stream = StreamIterator(
database.groupsDao.watchNonDirectGroupsForMember(7),
);
addTearDown(stream.cancel);
expect(await stream.moveNext(), isTrue);
expect(
stream.current.map((g) => g.groupId),
unorderedEquals(['group', 'other']),
);
final removal = stream.moveNext();
await setMemberState(MemberState.leftGroup);
expect(await removal, isTrue);
expect(stream.current.map((g) => g.groupId), ['other']);
});
}

View file

@ -0,0 +1,117 @@
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import yaml
SCRIPT_PATH = Path(__file__).resolve().parents[2] / "dependencies.py"
spec = importlib.util.spec_from_file_location("dependency_updater", SCRIPT_PATH)
updater = importlib.util.module_from_spec(spec)
spec.loader.exec_module(updater)
class CustomDependencyTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(self.temp_dir.cleanup)
self.root = Path(self.temp_dir.name)
self.cache = self.root / "cache"
self.output = self.root / "dependencies"
def create_package(self, root, name, version, contents):
package = root / name
(package / "lib").mkdir(parents=True)
(package / "pubspec.yaml").write_text(f"name: {name}\nversion: {version}\n")
(package / "lib" / "camera.dart").write_text(contents)
return package
def test_custom_package_is_preserved_without_cache_or_git(self):
package = self.create_package(self.output, "camera", "0.10.3", "custom code")
for cache_only in (False, True):
with self.subTest(cache_only=cache_only), patch.object(
updater.subprocess, "run"
) as git:
result = updater.integrate_package(
"camera", {"custom_changes": True},
self.cache, self.output, cache_only=cache_only,
)
self.assertEqual(result, [("camera", "0.10.3")])
self.assertEqual((package / "lib" / "camera.dart").read_text(), "custom code")
git.assert_not_called()
def test_custom_subpackage_is_preserved_while_other_package_updates(self):
custom = self.create_package(self.output, "camera", "0.7.4+6", "custom code")
self.create_package(self.output, "video", "1.0.0", "old code")
self.create_package(self.cache / "flutter_packages", "video", "2.0.0", "new code")
# The protected camera source is deliberately absent from the cache.
result = updater.integrate_package(
"flutter_packages",
{"subpackages": [
{"name": "camera", "path": "camera", "custom_changes": True},
{"name": "video", "path": "video"},
]},
self.cache, self.output, cache_only=True,
)
self.assertEqual(result, [("camera", "0.7.4+6"), ("video", "2.0.0")])
self.assertEqual((custom / "lib" / "camera.dart").read_text(), "custom code")
self.assertEqual((self.output / "video" / "lib" / "camera.dart").read_text(), "new code")
def test_all_custom_subpackages_need_no_cache_or_git(self):
self.create_package(self.output, "camera", "0.7.4+6", "custom code")
with patch.object(updater.subprocess, "run") as git:
result = updater.integrate_package(
"flutter_packages",
{"subpackages": [{"name": "camera", "custom_changes": True}]},
self.cache, self.output,
)
self.assertEqual(result, [("camera", "0.7.4+6")])
git.assert_not_called()
def test_missing_custom_package_fails_without_fetching_or_recreating_it(self):
with patch.object(updater.subprocess, "run") as git:
with self.assertRaisesRegex(FileNotFoundError, "Custom package not found"):
updater.integrate_package(
"camera", {"custom_changes": True}, self.cache, self.output,
)
git.assert_not_called()
self.assertFalse((self.output / "camera").exists())
def test_full_update_retains_custom_flags_and_managed_pubspec_entries(self):
self.create_package(self.output, "camera", "0.10.3", "custom code")
config = {
"cache": str(self.cache), "outdir": str(self.output),
"dependencies": {"camera": {"custom_changes": True}},
}
(self.root / "dependencies.yaml").write_text(yaml.safe_dump(config))
(self.root / "pubspec.yaml").write_text(
"dependencies:\n"
"## --- Start Managed Dependencies ---\n"
"## --- End Managed Dependencies ---\n"
"dependency_overrides:\n"
"## --- Start Managed Dependency Overrides ---\n"
"## --- End Managed Dependency Overrides ---\n"
)
previous_cwd = Path.cwd()
try:
os.chdir(self.root)
with patch("sys.argv", ["dependencies.py"]), patch.object(
updater.subprocess, "run"
) as git:
updater.main()
git.assert_not_called()
finally:
os.chdir(previous_cwd)
pubspec = yaml.safe_load((self.root / "pubspec.yaml").read_text())
self.assertEqual(pubspec["dependencies"]["camera"], "^0.10.3")
self.assertEqual(pubspec["dependency_overrides"]["camera"]["path"], str(self.output / "camera"))
saved_config = yaml.safe_load((self.root / "dependencies.yaml").read_text())
self.assertTrue(saved_config["dependencies"]["camera"]["custom_changes"])
self.assertEqual((self.output / "camera" / "lib" / "camera.dart").read_text(), "custom code")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,16 @@
import 'package:camera/camera.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
void main() {
test('selfie preview uses 720p while rear screenshot detail stays 1080p', () {
expect(
cameraPreviewResolution(CameraLensDirection.front),
ResolutionPreset.high,
);
expect(
cameraPreviewResolution(CameraLensDirection.back),
ResolutionPreset.veryHigh,
);
});
}

View file

@ -0,0 +1,88 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:twonly/core/frb_generated.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import '../../mocks/user_config.dart';
class _AvatarApi implements RustLibApi {
String? path;
@override
Future<String?> crateBridgeApiRustApiCurrentUserAvatarPath() async => path;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
final api = _AvatarApi();
final photoPath = File('assets/images/default_avatar.png').absolute.path;
setUpAll(() => RustLib.initMock(api: api));
tearDownAll(RustLib.dispose);
setUp(() async {
await locator.reset();
locator.registerSingleton<UserService>(UserService());
userService.currentUser = testUserConfig(
userId: 1,
username: 'me',
displayName: 'Me',
subscriptionPlan: 'Free',
currentSetupPage: null,
appVersion: 100,
);
api.path = null;
});
tearDown(() async {
await locator.reset();
});
Future<void> pumpAvatar(WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(home: AvatarIcon(myAvatar: true)),
);
await tester.pump();
}
void expectPhoto(WidgetTester tester) {
final image = tester.widget<Image>(find.byType(Image));
expect((image.image as FileImage).file.path, photoPath);
expect(find.byType(SvgPicture), findsNothing);
}
testWidgets('shows a photo avatar without a public SVG avatar', (
tester,
) async {
api.path = photoPath;
await pumpAvatar(tester);
expectPhoto(tester);
});
testWidgets('refreshes when a photo-only avatar is added or removed', (
tester,
) async {
await pumpAvatar(tester);
expect(find.byType(SvgPicture), findsOneWidget);
api.path = photoPath;
userService.triggerUserUpdate();
await tester.pump();
await tester.pump();
expectPhoto(tester);
api.path = null;
userService.triggerUserUpdate();
await tester.pump();
await tester.pump();
expect(find.byType(Image), findsNothing);
expect(find.byType(SvgPicture), findsOneWidget);
});
}