mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-02 19:42:12 +00:00
fixes database issues
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
dc0ef25d73
commit
358f93979e
12 changed files with 300 additions and 169 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (_) {}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,23 +63,25 @@ 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) {
|
||||
_permissionsFuture = checkPermissions().then((hasPermission) {
|
||||
if (hasPermission && mounted) {
|
||||
setState(() {
|
||||
AppState.hasCameraPermissions = true;
|
||||
}
|
||||
return hasPermission;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return hasPermission;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -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,9 +396,10 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
mainCameraController: mc,
|
||||
previewLink: mc.sharedLinkForPreview,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return child;
|
||||
},
|
||||
transitionsBuilder:
|
||||
(context, animation, secondaryAnimation, child) {
|
||||
return child;
|
||||
},
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: 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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -573,7 +573,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
onPressed: () {
|
||||
if (!showShortReactions) {
|
||||
displayShortReactions();
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -67,26 +67,24 @@ 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(
|
||||
getGlobalOffset(_targetKey),
|
||||
widget.emoji,
|
||||
);
|
||||
widget.hide();
|
||||
},
|
||||
child: SizedBox(
|
||||
width: widget.show ? 40 : 10,
|
||||
child: Center(
|
||||
child: EmojiAnimationComp(
|
||||
emoji: widget.emoji,
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
await sendReaction(widget.groupId, widget.messageId, widget.emoji);
|
||||
widget.emojiKey.currentState?.spawn(
|
||||
getGlobalOffset(_targetKey),
|
||||
widget.emoji,
|
||||
);
|
||||
widget.hide();
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
child: Center(
|
||||
child: widget.show
|
||||
? EmojiAnimationComp(
|
||||
emoji: widget.emoji,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,91 +91,92 @@ 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: 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,
|
||||
child: Column(
|
||||
children: [
|
||||
if (secondRowEmojis.isNotEmpty)
|
||||
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: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
if (secondRowEmojis.isNotEmpty)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: secondRowEmojis
|
||||
.map(
|
||||
(emoji) => EmojiReactionWidget(
|
||||
messageId: widget.messageId,
|
||||
groupId: widget.groupId,
|
||||
hide: widget.hide,
|
||||
show: _renderAnimations,
|
||||
emoji: emoji as String,
|
||||
emojiKey: widget.emojiKey,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
if (secondRowEmojis.isNotEmpty) const SizedBox(height: 15),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: secondRowEmojis
|
||||
.map(
|
||||
(emoji) => EmojiReactionWidget(
|
||||
messageId: widget.messageId,
|
||||
groupId: widget.groupId,
|
||||
hide: widget.hide,
|
||||
show: widget.show,
|
||||
emoji: emoji as String,
|
||||
emojiKey: widget.emojiKey,
|
||||
children: [
|
||||
...firstRowEmojis.map(
|
||||
(emoji) => EmojiReactionWidget(
|
||||
messageId: widget.messageId,
|
||||
groupId: widget.groupId,
|
||||
hide: widget.hide,
|
||||
show: _renderAnimations,
|
||||
emoji: emoji,
|
||||
emojiKey: widget.emojiKey,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
key: _keyEmojiPicker,
|
||||
onTap: () async {
|
||||
final layer =
|
||||
// ignore: inference_failure_on_function_invocation
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: context.color.surface,
|
||||
builder: (context) {
|
||||
return const EmojiPickerBottom();
|
||||
},
|
||||
)
|
||||
as EmojiLayerData?;
|
||||
if (layer == null) return;
|
||||
await sendReaction(
|
||||
widget.groupId,
|
||||
widget.messageId,
|
||||
layer.text,
|
||||
);
|
||||
widget.emojiKey.currentState?.spawn(
|
||||
getGlobalOffset(_keyEmojiPicker),
|
||||
layer.text,
|
||||
);
|
||||
widget.hide();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.color.surfaceContainer.withAlpha(100),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const FaIcon(
|
||||
FontAwesomeIcons.ellipsisVertical,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (secondRowEmojis.isNotEmpty) const SizedBox(height: 15),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
...firstRowEmojis.map(
|
||||
(emoji) => EmojiReactionWidget(
|
||||
messageId: widget.messageId,
|
||||
groupId: widget.groupId,
|
||||
hide: widget.hide,
|
||||
show: widget.show,
|
||||
emoji: emoji,
|
||||
emojiKey: widget.emojiKey,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
key: _keyEmojiPicker,
|
||||
onTap: () async {
|
||||
final layer =
|
||||
// ignore: inference_failure_on_function_invocation
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: context.color.surface,
|
||||
builder: (context) {
|
||||
return const EmojiPickerBottom();
|
||||
},
|
||||
)
|
||||
as EmojiLayerData?;
|
||||
if (layer == null) return;
|
||||
await sendReaction(
|
||||
widget.groupId,
|
||||
widget.messageId,
|
||||
layer.text,
|
||||
);
|
||||
widget.emojiKey.currentState?.spawn(
|
||||
getGlobalOffset(_keyEmojiPicker),
|
||||
layer.text,
|
||||
);
|
||||
widget.hide();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.color.surfaceContainer.withAlpha(100),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const FaIcon(
|
||||
FontAwesomeIcons.ellipsisVertical,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
&rust_db_path.display().to_string(),
|
||||
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
|
||||
false,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let rust_db = Database::new(
|
||||
&rust_db_path.display().to_string(),
|
||||
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
|
||||
false,
|
||||
)
|
||||
.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(
|
||||
&rust_db_path.display().to_string(),
|
||||
Some(rust_db_key.as_str()),
|
||||
false,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
let rust_db = Database::new(
|
||||
&rust_db_path.display().to_string(),
|
||||
Some(rust_db_key.as_str()),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
rust_db.run_migrations().await?;
|
||||
let rust_db = Arc::new(rust_db);
|
||||
|
||||
rust_db_key.zeroize();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue