diff --git a/android/app/build.gradle b/android/app/build.gradle index 59b5306..6b71187 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,7 +33,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "eu.twonly.testing" + applicationId = "eu.twonly" multiDexEnabled true // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. @@ -53,7 +53,7 @@ android { buildTypes { debug { - applicationIdSuffix ".debug" + applicationIdSuffix ".testing" } release { signingConfig signingConfigs.release diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index 376ee0b..fc23916 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -497,11 +497,18 @@ class ApiService { return sendRequestSync(req); } - Future getUserData(String username) async { + Future getUserData(String username) async { final get = ApplicationData_GetUserByUsername()..username = username; final appData = ApplicationData()..getuserbyusername = get; final req = createClientToServerFromApplicationData(appData); - return sendRequestSync(req); + final res = await sendRequestSync(req); + if (res.isSuccess) { + final ok = res.value as server.Response_Ok; + if (ok.hasUserdata()) { + return ok.userdata; + } + } + return null; } Future getPlanBallance() async { diff --git a/lib/src/services/api/media_download.dart b/lib/src/services/api/media_download.dart index 66d8658..626ce0d 100644 --- a/lib/src/services/api/media_download.dart +++ b/lib/src/services/api/media_download.dart @@ -8,6 +8,7 @@ import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart'; import 'package:http/http.dart' as http; +import 'package:mutex/mutex.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:twonly/globals.dart'; @@ -20,11 +21,8 @@ import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/camera/share_image_editor_view.dart'; -Map downloadStartedForMediaReceived = {}; - Future tryDownloadAllMediaFiles({bool force = false}) async { // This is called when WebSocket is newly connected, so allow all downloads to be restarted. - downloadStartedForMediaReceived = {}; final messages = await twonlyDB.messagesDao.getAllMessagesPendingDownloading(); @@ -102,13 +100,15 @@ Future handleDownloadStatusUpdate(TaskStatusUpdate update) async { } Future handleDownloadStatusUpdateInternal( - int messageId, bool failed) async { + int messageId, + bool failed, +) async { if (failed) { Log.error('Download failed for $messageId'); final message = await twonlyDB.messagesDao .getMessageByMessageId(messageId) .getSingleOrNull(); - if (message != null) { + if (message != null && message.downloadState != DownloadState.downloaded) { await handleMediaError(message); } } else { @@ -117,18 +117,10 @@ Future handleDownloadStatusUpdateInternal( } } -Future startDownloadMedia(Message message, bool force, - {int retryCounter = 0}) async { +Mutex protectDownload = Mutex(); + +Future startDownloadMedia(Message message, bool force) async { if (message.contentJson == null) return; - if (downloadStartedForMediaReceived[message.messageId] != null && - retryCounter == 0) { - final started = downloadStartedForMediaReceived[message.messageId]!; - final elapsed = DateTime.now().difference(started); - if (elapsed <= const Duration(seconds: 60)) { - Log.error('Download already started...'); - return; - } - } final content = MessageContent.fromJson( message.kind, jsonDecode(message.contentJson!) as Map); @@ -157,16 +149,33 @@ Future startDownloadMedia(Message message, bool force, return; } - if (message.downloadState != DownloadState.downloaded) { + final isBlocked = await protectDownload.protect(() async { + final msg = await twonlyDB.messagesDao + .getMessageByMessageId(message.messageId) + .getSingleOrNull(); + + if (msg == null) return true; + + if (msg.downloadState != DownloadState.pending) { + Log.error( + '${message.messageId} is already downloaded or is downloading.'); + return true; + } + await twonlyDB.messagesDao.updateMessageByMessageId( message.messageId, const MessagesCompanion( downloadState: Value(DownloadState.downloading), ), ); - } - downloadStartedForMediaReceived[message.messageId] = DateTime.now(); + return false; + }); + + if (isBlocked) { + Log.info('Download for ${message.messageId} already started.'); + return; + } final downloadToken = uint8ListToHex(content.downloadToken!); diff --git a/lib/src/views/camera/camera_send_to_view.dart b/lib/src/views/camera/camera_send_to_view.dart index 4f7d9fa..37660e6 100644 --- a/lib/src/views/camera/camera_send_to_view.dart +++ b/lib/src/views/camera/camera_send_to_view.dart @@ -43,8 +43,14 @@ class CameraSendToViewState extends State { return cameraController; } + /// same function also in home.view.dart Future toggleSelectedCamera() async { - await cameraController?.dispose(); + if (cameraController == null) return; + // do not allow switching camera when recording + if (cameraController!.value.isRecordingVideo == true) { + return; + } + await cameraController!.dispose(); cameraController = null; await selectCamera((selectedCameraDetails.cameraId + 1) % 2, false, false); } diff --git a/lib/src/views/camera/image_editor/layers/filters/location_filter.dart b/lib/src/views/camera/image_editor/layers/filters/location_filter.dart index 529b1d4..c51b7ea 100644 --- a/lib/src/views/camera/image_editor/layers/filters/location_filter.dart +++ b/lib/src/views/camera/image_editor/layers/filters/location_filter.dart @@ -50,7 +50,7 @@ class _LocationFilterState extends State { for (final item in imageIndex) { if (item.imageSrc.contains('/cities/$normalizedCountry/')) { // Check if the item matches the normalized city - if (item.imageSrc.endsWith('$normalizedCity.png')) { + if (item.imageSrc.contains('$normalizedCity.')) { if (item.imageSrc.startsWith('/api/')) { _imageUrl = 'https://twonly.eu/${item.imageSrc}'; if (mounted) setState(() {}); diff --git a/lib/src/views/chats/add_new_user.view.dart b/lib/src/views/chats/add_new_user.view.dart index 43ab8c8..2bec5f7 100644 --- a/lib/src/views/chats/add_new_user.view.dart +++ b/lib/src/views/chats/add_new_user.view.dart @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_dynamic_calls - import 'dart:async'; import 'package:drift/drift.dart' hide Column; @@ -11,7 +9,6 @@ import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/model/json/message.dart'; -import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'; import 'package:twonly/src/model/protobuf/push_notification/push_notification.pbserver.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/api/utils.dart'; @@ -41,7 +38,11 @@ class _SearchUsernameView extends State { @override void initState() { super.initState(); - initStreams(); + contactsStream = twonlyDB.contactsDao + .watchNotAcceptedContacts() + .listen((update) => setState(() { + contacts = update; + })); } @override @@ -50,18 +51,9 @@ class _SearchUsernameView extends State { super.dispose(); } - void initStreams() { - contactsStream = - twonlyDB.contactsDao.watchNotAcceptedContacts().listen((update) { - setState(() { - contacts = update; - }); - }); - } - Future _addNewUser(BuildContext context) async { final user = await getUser(); - if (user == null || user.username == searchUserName.text) { + if (user == null || user.username == searchUserName.text || !mounted) { return; } @@ -69,70 +61,67 @@ class _SearchUsernameView extends State { _isLoading = true; }); - final res = await apiService.getUserData(searchUserName.text); + final userdata = await apiService.getUserData(searchUserName.text); + if (!context.mounted) return; - if (!context.mounted) { - return; - } - - if (res.isSuccess) { - final addUser = await showAlertDialog( - context, context.lang.userFound, context.lang.userFoundBody); - if (!addUser || !context.mounted) { - setState(() { - _isLoading = false; - }); - return; - } - - final added = await twonlyDB.contactsDao.insertContact( - ContactsCompanion( - username: Value(searchUserName.text), - userId: Value(res.value.userdata.userId.toInt() as int), - requested: const Value(false), - ), - ); - - if (added > 0) { - if (await createNewSignalSession( - res.value.userdata as Response_UserData)) { - // before notifying the other party, add - await setupNotificationWithUsers( - forceContact: res.value.userdata.userId.toInt() as int, - ); - await encryptAndSendMessageAsync( - null, - res.value.userdata.userId.toInt() as int, - MessageJson( - kind: MessageKind.contactRequest, - timestamp: DateTime.now(), - content: MessageContent(), - ), - pushNotification: PushNotification(kind: PushKind.contactRequest), - ); - } - } - } else { - await showAlertDialog(context, context.lang.searchUsernameNotFound, - context.lang.searchUsernameNotFoundBody(searchUserName.text)); - } setState(() { _isLoading = false; }); + + if (userdata == null) { + await showAlertDialog(context, context.lang.searchUsernameNotFound, + context.lang.searchUsernameNotFoundBody(searchUserName.text)); + return; + } + + final addUser = await showAlertDialog( + context, + context.lang.userFound, + context.lang.userFoundBody, + ); + + if (!addUser || !context.mounted) { + return; + } + + final added = await twonlyDB.contactsDao.insertContact( + ContactsCompanion( + username: Value(searchUserName.text), + userId: Value(userdata.userId.toInt()), + requested: const Value(false), + ), + ); + + if (added > 0) { + if (await createNewSignalSession(userdata)) { + // before notifying the other party, add + await setupNotificationWithUsers( + forceContact: userdata.userId.toInt(), + ); + await encryptAndSendMessageAsync( + null, + userdata.userId.toInt(), + MessageJson( + kind: MessageKind.contactRequest, + timestamp: DateTime.now(), + content: MessageContent(), + ), + pushNotification: PushNotification(kind: PushKind.contactRequest), + ); + } + } } InputDecoration getInputDecoration(String hintText) { - final primaryColor = - Theme.of(context).colorScheme.primary; // Get the primary color return InputDecoration( hintText: hintText, focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(9), - borderSide: BorderSide(color: primaryColor), + borderSide: BorderSide(color: context.color.primary), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: Theme.of(context).colorScheme.outline), + borderSide: BorderSide(color: context.color.outline), ), contentPadding: const EdgeInsets.symmetric(vertical: 15, horizontal: 20), ); @@ -146,8 +135,7 @@ class _SearchUsernameView extends State { ), body: SafeArea( child: Padding( - padding: - const EdgeInsets.only(bottom: 20, left: 10, top: 20, right: 10), + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10), child: Column( children: [ Padding( @@ -186,10 +174,7 @@ class _SearchUsernameView extends State { floatingActionButton: Padding( padding: const EdgeInsets.only(bottom: 30), child: FloatingActionButton( - foregroundColor: Colors.white, - onPressed: () { - if (!_isLoading) _addNewUser(context); - }, + onPressed: _isLoading ? null : () async => _addNewUser(context), child: _isLoading ? const Center(child: CircularProgressIndicator()) : const FaIcon(FontAwesomeIcons.magnifyingGlassPlus), @@ -199,17 +184,11 @@ class _SearchUsernameView extends State { } } -class ContactsListView extends StatefulWidget { +class ContactsListView extends StatelessWidget { const ContactsListView(this.contacts, {super.key}); - final List contacts; - @override - State createState() => _ContactsListViewState(); -} - -class _ContactsListViewState extends State { - List sendRequestActions(Contact contact) { + List sendRequestActions(BuildContext context, Contact contact) { return [ Tooltip( message: context.lang.searchUserNameArchiveUserTooltip, @@ -225,7 +204,7 @@ class _ContactsListViewState extends State { ]; } - List requestedActions(Contact contact) { + List requestedActions(BuildContext context, Contact contact) { return [ Tooltip( message: context.lang.searchUserNameBlockUserTooltip, @@ -272,9 +251,9 @@ class _ContactsListViewState extends State { @override Widget build(BuildContext context) { return ListView.builder( - itemCount: widget.contacts.length, + itemCount: contacts.length, itemBuilder: (context, index) { - final contact = widget.contacts[index]; + final contact = contacts[index]; final displayName = getContactDisplayName(contact); return ListTile( title: Text(displayName), @@ -282,8 +261,8 @@ class _ContactsListViewState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: contact.requested - ? requestedActions(contact) - : sendRequestActions(contact), + ? requestedActions(context, contact) + : sendRequestActions(context, contact), ), ); }, diff --git a/lib/src/views/home.view.dart b/lib/src/views/home.view.dart index 1009030..f796dc0 100644 --- a/lib/src/views/home.view.dart +++ b/lib/src/views/home.view.dart @@ -112,7 +112,10 @@ class HomeViewState extends State { } Future selectCamera( - int sCameraId, bool init, bool enableAudio) async { + int sCameraId, + bool init, + bool enableAudio, + ) async { final opts = await initializeCameraController( selectedCameraDetails, sCameraId, init, enableAudio); if (opts != null) { @@ -124,8 +127,14 @@ class HomeViewState extends State { return cameraController; } + /// same function also in camera_send_to_view Future toggleSelectedCamera() async { - await cameraController?.dispose(); + if (cameraController == null) return; + // do not allow switching camera when recording + if (cameraController!.value.isRecordingVideo == true) { + return; + } + await cameraController!.dispose(); cameraController = null; await selectCamera((selectedCameraDetails.cameraId + 1) % 2, false, false); }