fixes database issues
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-05-30 23:02:58 +02:00
parent dc0ef25d73
commit 358f93979e
12 changed files with 300 additions and 169 deletions

View file

@ -1,5 +1,12 @@
# Changelog
## 0.2.25
- Improves: Smaller UI changes
- Fix: Migration issue that resulted in a corrupted backup mechanism
- Fix: Database issues causing messages to be lost or the database to be corrupted
- Fix: Permission view did not disappear after they were granted
## 0.2.23
- Improves: Smaller UI changes

View file

@ -92,14 +92,15 @@ class TwonlyDB extends _$TwonlyDB {
shareAcrossIsolates: true,
setup: (rawDb) {
rawDb
..execute('PRAGMA journal_mode=WAL;')
..execute('PRAGMA journal_mode=DELETE;')
..execute('PRAGMA synchronous=FULL;')
..execute('PRAGMA busy_timeout=5000;');
},
),
);
try {
if (userService.isUserCreated && userService.currentUser.enableDatabaseLogging) {
if (userService.isUserCreated &&
userService.currentUser.enableDatabaseLogging) {
return connection.interceptWith(DriftLoggingInterceptor());
}
} catch (_) {}

View file

@ -32,6 +32,13 @@ class UserService {
if (userDataMap != null) {
final userData = UserData.fromJson(userDataMap);
await RustKeyManager.setUserId(userId: userData.userId);
try {
// Ensure that the old userData is removed as it breaks the backup mechanism.
// This code can be removed when all users have updated to the latest version...
await SecureStorage.instance.delete(key: 'userData');
} catch (e) {
Log.error('Could not delete user data from SecureStorage: $e');
}
return userData;
}
@ -58,15 +65,20 @@ class UserService {
}
static Future<void> _migrateFromSecureStorage(UserData userData) async {
// Currently empty migration logic as requested, but we MUST store the data
await KeyValueStore.put('user', userData.toJson());
try {
await RustKeyManager.setUserId(userId: userData.userId);
} catch (e) {
Log.error('Could not set userId in RustKeyManager during migration: $e');
}
// Optional: Log migration
try {
await SecureStorage.instance.delete(key: 'userData');
} catch (e) {
Log.error('Could not delete user data from SecureStorage: $e');
}
Log.info('Migrated user data from SecureStorage to KeyValueStore');
}

View file

@ -63,24 +63,26 @@ class CameraPreviewControllerView extends StatefulWidget {
final bool hideControllers;
@override
State<CameraPreviewControllerView> createState() => _CameraPreviewControllerViewState();
State<CameraPreviewControllerView> createState() =>
_CameraPreviewControllerViewState();
}
class _CameraPreviewControllerViewState extends State<CameraPreviewControllerView> {
class _CameraPreviewControllerViewState
extends State<CameraPreviewControllerView> {
Future<bool>? _permissionsFuture;
@override
void initState() {
super.initState();
if (!AppState.hasCameraPermissions) {
_permissionsFuture = checkPermissions().then((hasPermission) {
if (hasPermission) {
if (hasPermission && mounted) {
setState(() {
AppState.hasCameraPermissions = true;
});
}
return hasPermission;
});
}
}
@override
Widget build(BuildContext context) {
@ -107,6 +109,10 @@ class _CameraPreviewControllerViewState extends State<CameraPreviewControllerVie
} else {
return PermissionHandlerView(
onSuccess: () {
setState(() {
AppState.hasCameraPermissions = true;
_permissionsFuture = Future.value(true);
});
widget.mainController.selectCamera(0, true);
},
);
@ -241,7 +247,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
Future<void> initAsync() async {
_hasAudioPermission = await Permission.microphone.isGranted;
if (!_hasAudioPermission && !userService.currentUser.requestedAudioPermission) {
if (!_hasAudioPermission &&
!userService.currentUser.requestedAudioPermission) {
await UserService.update((u) => u.requestedAudioPermission = true);
await requestMicrophonePermission();
}
@ -262,7 +269,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}
Future<void> updateScaleFactor(double newScale) async {
if (mc.selectedCameraDetails.scaleFactor == newScale || mc.cameraController == null) {
if (mc.selectedCameraDetails.scaleFactor == newScale ||
mc.cameraController == null) {
return;
}
await mc.cameraController?.setZoomLevel(
@ -345,7 +353,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
bool sharedFromGallery = false,
MediaType? mediaType,
}) async {
final type = mediaType ?? ((videoFilePath != null) ? MediaType.video : MediaType.image);
final type =
mediaType ??
((videoFilePath != null) ? MediaType.video : MediaType.image);
final mediaFileService = await initializeMediaUpload(
type,
userService.currentUser.defaultShowTime,
@ -386,7 +396,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
mainCameraController: mc,
previewLink: mc.sharedLinkForPreview,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return child;
},
transitionDuration: Duration.zero,
@ -418,13 +429,16 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
return false;
}
bool get isFront => mc.cameraController?.description.lensDirection == CameraLensDirection.front;
bool get isFront =>
mc.cameraController?.description.lensDirection ==
CameraLensDirection.front;
Future<void> onPanUpdate(dynamic details) async {
if (details == null) {
return;
}
if (mc.cameraController == null || !mc.cameraController!.value.isInitialized) {
if (mc.cameraController == null ||
!mc.cameraController!.value.isInitialized) {
return;
}
@ -553,7 +567,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}
Future<void> startVideoRecording() async {
if (mc.cameraController != null && mc.cameraController!.value.isRecordingVideo) {
if (mc.cameraController != null &&
mc.cameraController!.value.isRecordingVideo) {
return;
}
setState(() {
@ -573,7 +588,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
_currentTime = clock.now();
});
if (_videoRecordingStarted != null &&
_currentTime.difference(_videoRecordingStarted!).inSeconds >= maxVideoRecordingTime) {
_currentTime.difference(_videoRecordingStarted!).inSeconds >=
maxVideoRecordingTime) {
timer.cancel();
_videoRecordingTimer = null;
stopVideoRecording();
@ -610,7 +626,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
_videoRecordingLocked = false;
});
if (mc.cameraController == null || !mc.cameraController!.value.isRecordingVideo) {
if (mc.cameraController == null ||
!mc.cameraController!.value.isRecordingVideo) {
return;
}
@ -638,7 +655,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
@override
Widget build(BuildContext context) {
if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length || mc.cameraController == null) {
if (mc.selectedCameraDetails.cameraId >= AppEnvironment.cameras.length ||
mc.cameraController == null) {
return Container();
}
return StreamBuilder(
@ -662,7 +680,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
_baseScaleFactor = mc.selectedCameraDetails.scaleFactor;
});
// Get the position of the pointer
final renderBox = keyTriggerButton.currentContext!.findRenderObject()! as RenderBox;
final renderBox =
keyTriggerButton.currentContext!.findRenderObject()!
as RenderBox;
final localPosition = renderBox.globalToLocal(
details.globalPosition,
);
@ -698,18 +718,24 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
),
),
),
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null && !mc.isVideoRecording)
if (!mc.isSharePreviewIsShown &&
widget.sendToGroup != null &&
!mc.isVideoRecording)
ShowTitleText(
title: widget.sendToGroup!.groupName,
desc: context.lang.cameraPreviewSendTo,
),
if (!mc.isSharePreviewIsShown && mc.sharedLinkForPreview != null && !mc.isVideoRecording)
if (!mc.isSharePreviewIsShown &&
mc.sharedLinkForPreview != null &&
!mc.isVideoRecording)
ShowTitleText(
title: mc.sharedLinkForPreview?.host ?? '',
desc: 'Link',
isLink: true,
),
if (!mc.isSharePreviewIsShown && !mc.isVideoRecording && !widget.hideControllers)
if (!mc.isSharePreviewIsShown &&
!mc.isVideoRecording &&
!widget.hideControllers)
CameraTopActions(
selectedCameraDetails: mc.selectedCameraDetails,
hasAudioPermission: _hasAudioPermission,
@ -753,7 +779,8 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
videoRecordingStarted: _videoRecordingStarted,
maxVideoRecordingTime: maxVideoRecordingTime,
),
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null || widget.hideControllers)
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
widget.hideControllers)
Positioned(
left: 5,
top: 10,

View file

@ -1,4 +1,4 @@
// ignore_for_file: avoid_dynamic_calls
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
@ -24,31 +24,56 @@ Future<bool> checkPermissions() async {
return true;
}
class PermissionHandlerViewState extends State<PermissionHandlerView> {
class PermissionHandlerViewState extends State<PermissionHandlerView>
with WidgetsBindingObserver {
Timer? _timer;
bool _isSuccessTriggered = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
await _checkAndTriggerSuccess();
});
}
@override
void dispose() {
_timer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_checkAndTriggerSuccess();
}
}
Future<void> _checkAndTriggerSuccess() async {
if (_isSuccessTriggered) return;
try {
if (await checkPermissions()) {
_isSuccessTriggered = true;
_timer?.cancel();
// ignore: avoid_dynamic_calls
widget.onSuccess();
}
} catch (e) {
Log.error(e);
}
}
Future<Map<Permission, PermissionStatus>> permissionServices() async {
// try {
final statuses = await [
Permission.camera,
// Permission.microphone,
Permission.notification,
].request();
// } catch (e) {}
// You can request multiple permissions at once.
// if (statuses[Permission.microphone]!.isPermanentlyDenied) {
// openAppSettings();
// // setState(() {});
// } else {
// // if (statuses[Permission.microphone]!.isDenied) {
// // }
// }
if (statuses[Permission.camera]!.isPermanentlyDenied) {
await openAppSettings();
// setState(() {});
} else {
// if (statuses[Permission.camera]!.isDenied) {
// }
}
return statuses;
@ -75,6 +100,7 @@ class PermissionHandlerViewState extends State<PermissionHandlerView> {
try {
await permissionServices();
if (await checkPermissions()) {
// ignore: avoid_dynamic_calls
widget.onSuccess();
}
} catch (e) {

View file

@ -573,7 +573,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
),
),
),
onPressed: () async {
onPressed: () {
if (!showShortReactions) {
displayShortReactions();
} else {

View file

@ -67,11 +67,8 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
@override
Widget build(BuildContext context) {
return AnimatedSize(
return GestureDetector(
key: _targetKey,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
child: GestureDetector(
onTap: () async {
await sendReaction(widget.groupId, widget.messageId, widget.emoji);
widget.emojiKey.currentState?.spawn(
@ -81,12 +78,13 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
widget.hide();
},
child: SizedBox(
width: widget.show ? 40 : 10,
width: 40,
child: Center(
child: EmojiAnimationComp(
child: widget.show
? EmojiAnimationComp(
emoji: widget.emoji,
),
),
)
: const SizedBox.shrink(),
),
),
);

View file

@ -39,6 +39,7 @@ class ReactionButtons extends StatefulWidget {
class _ReactionButtonsState extends State<ReactionButtons> {
int selectedShortReaction = -1;
final GlobalKey _keyEmojiPicker = GlobalKey();
bool _renderAnimations = false;
List<String> selectedEmojis = EmojiAnimationComp.animatedIcons.keys
.toList()
@ -47,9 +48,28 @@ class _ReactionButtonsState extends State<ReactionButtons> {
@override
void initState() {
super.initState();
_renderAnimations = widget.show;
initAsync();
}
@override
void didUpdateWidget(ReactionButtons oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.show != oldWidget.show) {
if (widget.show) {
_renderAnimations = true;
} else {
Future.delayed(const Duration(milliseconds: 150), () {
if (mounted && !widget.show) {
setState(() {
_renderAnimations = false;
});
}
});
}
}
}
Future<void> initAsync() async {
if (userService.currentUser.preSelectedEmojies != null) {
selectedEmojis = userService.currentUser.preSelectedEmojies!;
@ -71,17 +91,17 @@ class _ReactionButtonsState extends State<ReactionButtons> {
? 50
: widget.mediaViewerDistanceFromBottom)
: widget.mediaViewerDistanceFromBottom - 20,
left: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2,
right: widget.show ? 0 : MediaQuery.sizeOf(context).width / 2,
left: 0,
right: 0,
curve: Curves.linearToEaseOut,
child: IgnorePointer(
ignoring: !widget.show,
child: AnimatedOpacity(
opacity: widget.show ? 1.0 : 0.0, // Fade in/out
duration: const Duration(milliseconds: 150),
child: Container(
color: widget.show ? Colors.black.withAlpha(0) : Colors.transparent,
padding: widget.show
? const EdgeInsets.symmetric(vertical: 32)
: null,
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
if (secondRowEmojis.isNotEmpty)
@ -94,7 +114,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
messageId: widget.messageId,
groupId: widget.groupId,
hide: widget.hide,
show: widget.show,
show: _renderAnimations,
emoji: emoji as String,
emojiKey: widget.emojiKey,
),
@ -111,7 +131,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
messageId: widget.messageId,
groupId: widget.groupId,
hide: widget.hide,
show: widget.show,
show: _renderAnimations,
emoji: emoji,
emojiKey: widget.emojiKey,
),
@ -159,6 +179,7 @@ class _ReactionButtonsState extends State<ReactionButtons> {
),
),
),
),
);
}
}

View file

@ -55,15 +55,29 @@ impl BackupArchive {
}
if is_db {
// To avoid write-lock conflicts with Dart (which has the live database open in write mode),
// we copy the database file first, then open the copy in write mode to perform the backup.
let temp_copy_path = backup_data_dir.join(format!("{}.temp_copy", file_name));
std::fs::copy(&file_path, &temp_copy_path)?;
let db = Database::new(
&file_path.display().to_string(),
&temp_copy_path.display().to_string(),
encryption_key.as_deref(),
false,
false, // Open the copy in write mode required for encrypted backups
)
.await?;
let backup_database_file = backup_data_dir.join(file_name).display().to_string();
db.create_backup(backup_database_file.as_str(), encryption_key.as_deref())
.await?;
// Close database connection to release file lock before removing it
drop(db);
remove_file(&temp_copy_path)?;
// Perform integrity check of the new database file
let backup_db =
Database::new(&backup_database_file, encryption_key.as_deref(), false).await?;
backup_db.check_integrity().await?;
} else {
let file_backup = backup_data_dir.join(file_name);
std::fs::copy(file_path, file_backup)?;

View file

@ -63,14 +63,14 @@ impl Context {
key_manager.store_to_keychain(&secure_storage)?;
let rust_db_path = database_dir.join("rust_db.sqlite");
let rust_db = Arc::new(
Database::new(
let rust_db = Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await?,
);
.await?;
rust_db.run_migrations().await?;
let rust_db = Arc::new(rust_db);
Ok(Context::from_standalone(TwonlyStandalone {
config,
@ -120,14 +120,14 @@ impl Context {
let mut rust_db_key = key_manager.main_key.get_database_key(DatabaseKey::RustDb);
let rust_db = Arc::new(
Database::new(
let rust_db = Database::new(
&rust_db_path.display().to_string(),
Some(rust_db_key.as_str()),
false,
)
.await?,
);
.await?;
rust_db.run_migrations().await?;
let rust_db = Arc::new(rust_db);
rust_db_key.zeroize();

View file

@ -26,10 +26,11 @@ impl Database {
let mut connect_options = format!("{db_url}?mode=rwc")
.parse::<SqliteConnectOptions>()?
.log_statements(log_statements_level)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Delete)
.foreign_keys(true)
.read_only(read_only)
.busy_timeout(Duration::from_millis(5000))
.pragma("synchronous", "FULL")
.pragma("recursive_triggers", "ON")
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
@ -43,15 +44,18 @@ impl Database {
.connect_with(connect_options)
.await?;
Ok(Self { pool })
}
pub(crate) async fn run_migrations(&self) -> Result<()> {
sqlx::migrate!("./src/database/migrations")
.run(&pool)
.run(&self.pool)
.await
.map_err(|e| {
tracing::error!("migration error: {:?}", e);
TwonlyError::Generic(format!("Migration error: {}", e))
})?;
Ok(Self { pool })
Ok(())
}
pub(crate) async fn create_backup(
@ -91,6 +95,22 @@ impl Database {
}
Ok(())
}
pub(crate) async fn check_integrity(&self) -> Result<()> {
let row: (String,) = sqlx::query_as("PRAGMA integrity_check")
.fetch_one(&self.pool)
.await
.map_err(|e| TwonlyError::Generic(format!("Integrity check query failed: {}", e)))?;
if row.0.to_lowercase() == "ok" {
Ok(())
} else {
Err(TwonlyError::Generic(format!(
"Database integrity check failed: {}",
row.0
)))
}
}
}
#[cfg(test)]
@ -109,6 +129,7 @@ mod tests {
// 1. Create and initialize database with key
let db = Database::new(&db_path, Some(key), false).await.unwrap();
db.run_migrations().await.unwrap();
ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await
.unwrap();
@ -137,6 +158,7 @@ mod tests {
let key = "secure_password";
let db = Database::new(&db_path, Some(key), false).await.unwrap();
db.run_migrations().await.unwrap();
ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await
.unwrap();
@ -165,6 +187,7 @@ mod tests {
let backup_path = dir.path().join("backup_plain.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap();
ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await
.unwrap();

View file

@ -71,6 +71,7 @@ macro_rules! generate_table_tests {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap();
let _id = $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
let all = $struct::$select_all_fn(&db.pool).await.unwrap();
@ -92,6 +93,7 @@ macro_rules! generate_test_select {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
db.run_migrations().await.unwrap();
$struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
let results = $struct::$select_fn(&db.pool, $($sel_arg),+).await.unwrap();