From 10dadcee7c3c9aa2c21fc14ad4d9a44fe2d8e86d Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 24 Mar 2025 21:45:29 +0100 Subject: [PATCH] retransmission works --- ios/Runner.xcodeproj/project.pbxproj | 9 +- ios/Runner/Info.plist | 25 ++-- lib/src/components/zoom_selector.dart | 68 ++++++---- lib/src/providers/api/api.dart | 54 ++++---- lib/src/providers/api/media.dart | 158 +++++++++++++++++++++++- lib/src/providers/api_provider.dart | 1 + lib/src/providers/hive.dart | 6 +- lib/src/views/chats/chat_list_view.dart | 24 ++-- 8 files changed, 255 insertions(+), 90 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 30d564f..2dfafeb 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -477,13 +477,14 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = CN332ZUGRP; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.connect; + PRODUCT_BUNDLE_IDENTIFIER = eu.twonly; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -664,13 +665,14 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = CN332ZUGRP; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.connect; + PRODUCT_BUNDLE_IDENTIFIER = eu.twonly; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -687,13 +689,14 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = CN332ZUGRP; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.connect; + PRODUCT_BUNDLE_IDENTIFIER = eu.twonly; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index afc890d..292b5ca 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,16 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + To create photos that can be shared. + NSFaceIDUsageDescription + To protect others twonlies! + NSMicrophoneUsageDescription + To create videos that can be securely shared. + NSPhotoLibraryAddUsageDescription + Store photos in the gallery. + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,18 +53,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - - NSCameraUsageDescription - To create photos that can be shared. - NSMicrophoneUsageDescription - To create videos that can be securely shared. - NSPhotoLibraryAddUsageDescription - Store photos in the gallery. - NSFaceIDUsageDescription - To protect others twonlies! diff --git a/lib/src/components/zoom_selector.dart b/lib/src/components/zoom_selector.dart index efc5ff1..c3567fb 100644 --- a/lib/src/components/zoom_selector.dart +++ b/lib/src/components/zoom_selector.dart @@ -27,6 +27,19 @@ String beautifulZoomScale(double scale) { } class _CameraZoomButtonsState extends State { + bool showWideAngleZoom = false; + + @override + void initState() { + super.initState(); + initAsync(); + } + + Future initAsync() async { + showWideAngleZoom = (await widget.controller.getMinZoomLevel()) < 1; + setState(() {}); + } + @override Widget build(BuildContext context) { var zoomButtonStyle = TextButton.styleFrom( @@ -47,34 +60,37 @@ class _CameraZoomButtonsState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - TextButton( - style: zoomButtonStyle.copyWith( - foregroundColor: WidgetStateProperty.all( - (widget.scaleFactor < 1) ? Colors.yellow : Colors.white, + if (showWideAngleZoom) + TextButton( + style: zoomButtonStyle.copyWith( + foregroundColor: WidgetStateProperty.all( + (widget.scaleFactor < 1) ? Colors.yellow : Colors.white, + ), + ), + onPressed: () async { + var level = await widget.controller.getMinZoomLevel(); + widget.updateScaleFactor(level); + }, + child: FutureBuilder( + future: widget.controller.getMinZoomLevel(), + builder: (context, snap) { + if (snap.hasData) { + var minLevel = + beautifulZoomScale(snap.data!.toDouble()); + var currentLevel = + beautifulZoomScale(widget.scaleFactor); + return Text( + widget.scaleFactor < 1 + ? "${currentLevel}x" + : "${minLevel}x", + style: zoomTextStyle, + ); + } else { + return Text(""); + } + }, ), ), - onPressed: () async { - var level = await widget.controller.getMinZoomLevel(); - widget.updateScaleFactor(level); - }, - child: FutureBuilder( - future: widget.controller.getMinZoomLevel(), - builder: (context, snap) { - if (snap.hasData) { - var minLevel = beautifulZoomScale(snap.data!.toDouble()); - var currentLevel = beautifulZoomScale(widget.scaleFactor); - return Text( - widget.scaleFactor < 1 - ? "${currentLevel}x" - : "${minLevel}x", - style: zoomTextStyle, - ); - } else { - return Text(""); - } - }, - ), - ), TextButton( style: zoomButtonStyle.copyWith( foregroundColor: WidgetStateProperty.all( diff --git a/lib/src/providers/api/api.dart b/lib/src/providers/api/api.dart index 31d20b1..db6f93f 100644 --- a/lib/src/providers/api/api.dart +++ b/lib/src/providers/api/api.dart @@ -15,42 +15,32 @@ import 'package:twonly/src/utils/signal.dart' as SignalHelper; import 'package:twonly/src/utils/storage.dart'; Future tryTransmitMessages() async { - // List retransmit = - // await twonlyDatabase.getAllMessagesForRetransmitting(); + List retransmit = + await twonlyDatabase.messagesDao.getAllMessagesForRetransmitting(); - // if (retransmit.isEmpty) return; + if (retransmit.isEmpty) return; - // Logger("api.dart").info("try sending messages: ${retransmit.length}"); + Logger("api.dart").info("try sending messages: ${retransmit.length}"); - // Box box = await getMediaStorage(); - // for (int i = 0; i < retransmit.length; i++) { - // int msgId = retransmit[i].messageId; + Box box = await getMediaStorage(); + for (int i = 0; i < retransmit.length; i++) { + int msgId = retransmit[i].messageId; - // Uint8List? bytes = box.get("retransmit-$msgId-textmessage"); - // if (bytes != null) { - // Result resp = await apiProvider.sendTextMessage( - // retransmit[i].contactId, - // bytes, - // ); - - // if (resp.isSuccess) { - // await twonlyDatabase.updateMessageByMessageId( - // msgId, MessagesCompanion(acknowledgeByServer: Value(true))); - - // box.delete("retransmit-$msgId-textmessage"); - // } else { - // // in case of error do nothing. As the message is not removed the app will try again when relaunched - // } - // } - - // Uint8List? encryptedMedia = await box.get("retransmit-$msgId-media"); - // if (encryptedMedia != null) { - // MediaMessageContent content = - // MediaMessageContent.fromJson(jsonDecode(retransmit[i].contentJson!)); - // uploadMediaFile(msgId, retransmit[i].contactId, encryptedMedia, - // content.isRealTwonly, content.maxShowTime, retransmit[i].sendAt); - // } - // } + Uint8List? bytes = box.get("retransmit-$msgId-textmessage"); + if (bytes != null) { + Result resp = await apiProvider.sendTextMessage( + retransmit[i].contactId, + bytes, + ); + if (resp.isSuccess) { + await twonlyDatabase.messagesDao.updateMessageByMessageId( + msgId, MessagesCompanion(acknowledgeByServer: Value(true))); + box.delete("retransmit-$msgId-textmessage"); + } else { + // in case of error do nothing. As the message is not removed the app will try again when relaunched + } + } + } } // this functions ensures that the message is received by the server and in case of errors will try again later diff --git a/lib/src/providers/api/media.dart b/lib/src/providers/api/media.dart index a909246..69d0f91 100644 --- a/lib/src/providers/api/media.dart +++ b/lib/src/providers/api/media.dart @@ -2,6 +2,7 @@ import 'dart:collection'; import 'dart:convert'; import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart'; +import 'package:hive/hive.dart'; import 'package:logging/logging.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/app.dart'; @@ -37,11 +38,40 @@ Future tryDownloadAllMediaFiles() async { class Metadata { late List userIds; - late HashMap messageIds; - late Uint8List imageBytes; + late Map messageIds; late bool isRealTwonly; late int maxShowTime; late DateTime messageSendAt; + + Metadata(); + + Map toJson() { + // Convert Map to Map for JSON encoding + Map stringKeyMessageIds = + messageIds.map((key, value) => MapEntry(key.toString(), value)); + + return { + 'userIds': userIds, + 'messageIds': stringKeyMessageIds, + 'isRealTwonly': isRealTwonly, + 'maxShowTime': maxShowTime, + 'messageSendAt': messageSendAt.toIso8601String(), + }; + } + + factory Metadata.fromJson(Map json) { + Metadata state = Metadata(); + state.userIds = List.from(json['userIds']); + + // Convert Map to Map + state.messageIds = (json['messageIds'] as Map) + .map((key, value) => MapEntry(int.parse(key), value as int)); + + state.isRealTwonly = json['isRealTwonly']; + state.maxShowTime = json['maxShowTime']; + state.messageSendAt = DateTime.parse(json['messageSendAt']); + return state; + } } class PrepareState { @@ -50,11 +80,75 @@ class PrepareState { late List encryptionMac; late List encryptedBytes; late List encryptionNonce; + + PrepareState(); + + Map toJson() { + return { + 'sha2Hash': sha2Hash, + 'encryptionKey': encryptionKey, + 'encryptionMac': encryptionMac, + 'encryptedBytes': encryptedBytes, + 'encryptionNonce': encryptionNonce, + }; + } + + factory PrepareState.fromJson(Map json) { + PrepareState state = PrepareState(); + state.sha2Hash = List.from(json['sha2Hash']); + state.encryptionKey = List.from(json['encryptionKey']); + state.encryptionMac = List.from(json['encryptionMac']); + state.encryptedBytes = List.from(json['encryptedBytes']); + state.encryptionNonce = List.from(json['encryptionNonce']); + return state; + } } class UploadState { late List uploadToken; late List> downloadTokens; + + UploadState(); + + Map toJson() { + return { + 'uploadToken': uploadToken, + 'downloadTokens': downloadTokens, + }; + } + + factory UploadState.fromJson(Map json) { + UploadState state = UploadState(); + state.uploadToken = List.from(json['uploadToken']); + state.downloadTokens = List>.from( + json['downloadTokens'].map((token) => List.from(token)), + ); + return state; + } +} + +class States { + late Metadata metadata; + late PrepareState prepareState; + + States({ + required this.metadata, + required this.prepareState, + }); + + Map toJson() { + return { + 'metadata': metadata.toJson(), + 'prepareState': prepareState.toJson(), + }; + } + + factory States.fromJson(Map json) { + return States( + metadata: Metadata.fromJson(json['metadata']), + prepareState: PrepareState.fromJson(json['prepareState']), + ); + } } class ImageUploader { @@ -208,10 +302,11 @@ Future sendImage( metadata.userIds = userIds; metadata.isRealTwonly = isRealTwonly; metadata.maxShowTime = maxShowTime; - metadata.messageIds = HashMap(); + metadata.messageIds = {}; metadata.messageSendAt = DateTime.now(); - // store prepareState and metadata... + String stateId = prepareState.sha2Hash.toString(); + States states = States(metadata: metadata, prepareState: prepareState); // at this point it is safe inform the user about the process of sending the image.. for (final userId in metadata.userIds) { @@ -240,13 +335,66 @@ Future sendImage( } } + { + Box storage = await getMediaStorage(); + + String? mediaFilesJson = storage.get("mediaUploads"); + Map allMediaFiles = {}; + + if (mediaFilesJson != null) { + // allMediaFiles = jsonDecode(mediaFilesJson); + } + + allMediaFiles[stateId] = jsonEncode(states.toJson()); + storage.put("mediaUploads", jsonEncode(allMediaFiles)); + } + + uploadMediaState(stateId, prepareState, metadata); +} + +Future retransmitMediaFiles() async { + Box storage = await getMediaStorage(); + String? mediaFilesJson = storage.get("mediaUploads"); + + if (mediaFilesJson == null) { + return; + } + + Map allMediaFiles = jsonDecode(mediaFilesJson); + + for (final entry in allMediaFiles.entries) { + try { + String stateId = entry.key; + States states = States.fromJson(jsonDecode(entry.value)); + // upload one by one + await uploadMediaState(stateId, states.prepareState, states.metadata); + } catch (e) { + Logger("media.dart").shout(e); + } + } +} + +// if the upload failes this function is called again from the retransmitMediaFiles function which is +// called when the WebSocket is reconnected again. +Future uploadMediaState( + String stateId, PrepareState prepareState, Metadata metadata) async { final uploadState = await ImageUploader.uploadState(prepareState, metadata.userIds.length); if (uploadState == null) { return; } - // delete prepareState and store uploadState... + { + Box storage = await getMediaStorage(); + + String? mediaFilesJson = storage.get("mediaUploads"); + + if (mediaFilesJson != null) { + Map allMediaFiles = jsonDecode(mediaFilesJson); + allMediaFiles.remove(stateId); + storage.put("mediaUploads", jsonEncode(allMediaFiles)); + } + } final notifyState = await ImageUploader.notifyState(prepareState, uploadState, metadata); diff --git a/lib/src/providers/api_provider.dart b/lib/src/providers/api_provider.dart index 8817c65..64ff91f 100644 --- a/lib/src/providers/api_provider.dart +++ b/lib/src/providers/api_provider.dart @@ -74,6 +74,7 @@ class ApiProvider { if (!globalIsAppInBackground) { tryTransmitMessages(); + retransmitMediaFiles(); tryDownloadAllMediaFiles(); notifyContactsAboutProfileChange(); } diff --git a/lib/src/providers/hive.dart b/lib/src/providers/hive.dart index f0effa9..e0fcc00 100644 --- a/lib/src/providers/hive.dart +++ b/lib/src/providers/hive.dart @@ -25,6 +25,8 @@ Future getMediaStorage() async { var encryptionKey = base64Url.decode((await storage.read(key: 'hive_encryption_key'))!); - return await Hive.openBox('media_storage', - encryptionCipher: HiveAesCipher(encryptionKey)); + return await Hive.openBox( + 'media_storage', + encryptionCipher: HiveAesCipher(encryptionKey), + ); } diff --git a/lib/src/views/chats/chat_list_view.dart b/lib/src/views/chats/chat_list_view.dart index df0ad87..0ab9637 100644 --- a/lib/src/views/chats/chat_list_view.dart +++ b/lib/src/views/chats/chat_list_view.dart @@ -119,16 +119,22 @@ class _ChatListViewState extends State { .reduce((a, b) => a > b ? a : b); } - return ListView.builder( - restorationId: 'chat_list_view', - itemCount: contacts.length, - itemBuilder: (BuildContext context, int index) { - final user = contacts[index]; - return UserListItem( - user: user, - maxTotalMediaCounter: maxTotalMediaCounter, - ); + return RefreshIndicator( + onRefresh: () async { + await apiProvider.connect(); + await Future.delayed(Duration(seconds: 1)); }, + child: ListView.builder( + restorationId: 'chat_list_view', + itemCount: contacts.length, + itemBuilder: (BuildContext context, int index) { + final user = contacts[index]; + return UserListItem( + user: user, + maxTotalMediaCounter: maxTotalMediaCounter, + ); + }, + ), ); }, ),