From 29fc12c1f494e96fb8ba8ed9c525aae0117b3bfd Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 15 Jun 2024 14:19:35 +0200 Subject: [PATCH 001/131] --- 909 --- 6.4.0rc3 From a14f908f1158921407d8fe23065d78156fcb5a3b Mon Sep 17 00:00:00 2001 From: nautilusx Date: Tue, 18 Jun 2024 10:57:55 +0200 Subject: [PATCH 002/131] Update MLMAMPrefTableViewController.m Missing dot --- Monal/Classes/MLMAMPrefTableViewController.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/MLMAMPrefTableViewController.m b/Monal/Classes/MLMAMPrefTableViewController.m index 743435154b..f296fc627c 100644 --- a/Monal/Classes/MLMAMPrefTableViewController.m +++ b/Monal/Classes/MLMAMPrefTableViewController.m @@ -36,7 +36,7 @@ -(void) viewWillAppear:(BOOL)animated self.mamPref = [NSMutableArray new]; [self.mamPref addObject:@{@"Title":NSLocalizedString(@"Always archive", @""), @"Description":NSLocalizedString(@"All messages are archived by default.", @""), @"value":@"always"}]; [self.mamPref addObject:@{@"Title":NSLocalizedString(@"Never archive", @""), @"Description":NSLocalizedString(@"Messages never archived by default.", @""), @"value":@"never"}]; - [self.mamPref addObject:@{@"Title":NSLocalizedString(@"Only contacts", @""), @"Description":NSLocalizedString(@"Archive only if the contact is in contact list", @""), @"value":@"roster"}]; + [self.mamPref addObject:@{@"Title":NSLocalizedString(@"Only contacts", @""), @"Description":NSLocalizedString(@"Archive only if the contact is in contact list.", @""), @"value":@"roster"}]; } -(void) dealloc From ddd92195cd50e65e091922c7a9b1d5e4effd068c Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 12:51:11 +0200 Subject: [PATCH 003/131] Make EditGroupSubject heading translatable --- Monal/Classes/EditGroupSubject.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/EditGroupSubject.swift b/Monal/Classes/EditGroupSubject.swift index f90e1eee7c..0e2636d85c 100644 --- a/Monal/Classes/EditGroupSubject.swift +++ b/Monal/Classes/EditGroupSubject.swift @@ -39,7 +39,7 @@ struct EditGroupSubject: View { } } } - .navigationTitle("Group description") + .navigationTitle(Text("Group/Channel description")) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Abort") { From af1a8a2bd58997bc32d927cb6ad598239d9d5065 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 13:52:08 +0200 Subject: [PATCH 004/131] Add IBAN of monal bank account --- ReadMe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ReadMe.md b/ReadMe.md index b8ed7627d7..7728b3481d 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -29,7 +29,7 @@ Monal is developed by volunteers and community collaboration. The work which has - Donate via [GitHub Sponsors](https://github.com/sponsors/tmolitor-stud-tu) - Donate via [Libera Pay](https://liberapay.com/tmolitor) -- EU citizens can donate via SEPA, too. Just contact Thilo Molitor via mail to thilo@monal-im.org to get his IBAN. +- EU citizens can donate via SEPA, too. IBAN: DE66 5007 0371 0856 0419 01 Here you can read about further [support of the development](https://github.com/monal-im/Monal/issues/363)! From 31931a3c3ec4e6758a2c07240db9fa02386d6eb1 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 14:03:41 +0200 Subject: [PATCH 005/131] Make sure translations are picked up here --- Monal/Classes/GeneralSettings.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index 8466de48ff..c6c84be990 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -396,7 +396,7 @@ struct AttachmentSettings: View { Text("Load over wifi") } ) - Text("Load over WiFi up to: \(UInt(generalSettingsDefaultsDB.autodownloadFiletransfersWifiMaxSize/(1024*1024))) MiB") + Text("Load over WiFi up to: \(String(describing:UInt(generalSettingsDefaultsDB.autodownloadFiletransfersWifiMaxSize/(1024*1024)))) MiB") } Section { @@ -413,7 +413,7 @@ struct AttachmentSettings: View { Text("Load over Cellular") } ) - Text("Load over cellular up to: \(Int(generalSettingsDefaultsDB.autodownloadFiletransfersMobileMaxSize/(1024*1024))) MiB") + Text("Load over cellular up to: \(String(describing:UInt(generalSettingsDefaultsDB.autodownloadFiletransfersMobileMaxSize/(1024*1024)))) MiB") } Section(header: Text("Upload Settings")) { From 131c3a0eba184b66f3bc43d36d1cd14946cb731a Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 17:24:38 +0200 Subject: [PATCH 006/131] Add PromiseKit as cocoapod to make promises useable from objc --- Monal/Classes/ContactDetails.swift | 9 --------- Monal/Monal.xcodeproj/project.pbxproj | 17 ----------------- Monal/Podfile | 1 + Monal/Podfile.lock | 14 +++++++++++++- 4 files changed, 14 insertions(+), 27 deletions(-) diff --git a/Monal/Classes/ContactDetails.swift b/Monal/Classes/ContactDetails.swift index 6b16fc8798..b89e4d1e41 100644 --- a/Monal/Classes/ContactDetails.swift +++ b/Monal/Classes/ContactDetails.swift @@ -125,15 +125,6 @@ struct ContactDetails: View { .resizable() .frame(width: 24.0, height: 24.0) .accessibilityLabel((contact.mucType == "group") ? Text("Change Group Avatar") : Text("Change Channel Avatar")) -// .applyClosure { view in -// if #available(iOS 15, *) { -// view -// .symbolRenderingMode(.palette) -// .foregroundStyle(.primary, .secondary) -// } else { -// view.foregroundColor(.primary) -// } -// } }) .buttonStyle(.borderless) .offset(x: 8, y: -8) diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index 493d25f3f6..5ebe43a480 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -163,7 +163,6 @@ 84E231F32C16A9CE00735FB7 /* SVGView in Frameworks */ = {isa = PBXBuildFile; productRef = 84E231F22C16A9CE00735FB7 /* SVGView */; }; 84E55E7D2964424E003E191A /* ActiveChatsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 261A6284176C156500059090 /* ActiveChatsViewController.m */; }; 84E55E8029644279003E191A /* ActiveChatsViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = 84E55E7F2964426D003E191A /* ActiveChatsViewController.h */; }; - 84F194CE2C101A3E00F0A994 /* PromiseKit in Frameworks */ = {isa = PBXBuildFile; productRef = 84F194CD2C101A3E00F0A994 /* PromiseKit */; }; 84F194D12C15197200F0A994 /* FrameUp in Frameworks */ = {isa = PBXBuildFile; productRef = 84F194D02C15197200F0A994 /* FrameUp */; }; 84FC37552897521500634E3E /* snprintf.m in Sources */ = {isa = PBXBuildFile; fileRef = 84FC37542897521400634E3E /* snprintf.m */; }; 84FC37572897523500634E3E /* metamacros.h in Headers */ = {isa = PBXBuildFile; fileRef = 84FC37562897523500634E3E /* metamacros.h */; }; @@ -805,7 +804,6 @@ buildActionMask = 2147483647; files = ( 849ADF3F2BACF0360009BCD7 /* CocoaLumberjack in Frameworks */, - 84F194CE2C101A3E00F0A994 /* PromiseKit in Frameworks */, BE8B63D2491B1E5582965A8F /* Pods_monalxmpp.framework in Frameworks */, 849ADF412BACF0360009BCD7 /* CocoaLumberjackSwift in Frameworks */, 84E231F32C16A9CE00735FB7 /* SVGView in Frameworks */, @@ -1592,7 +1590,6 @@ 849ADF3E2BACF0360009BCD7 /* CocoaLumberjack */, 849ADF402BACF0360009BCD7 /* CocoaLumberjackSwift */, 849ADF422BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend */, - 84F194CD2C101A3E00F0A994 /* PromiseKit */, 84E231F22C16A9CE00735FB7 /* SVGView */, ); productName = monalxmpp; @@ -1745,7 +1742,6 @@ C1E1EC79286A025F0097EC74 /* XCRemoteSwiftPackageReference "SwiftSoup" */, 841898A82957712000FEC77D /* XCRemoteSwiftPackageReference "ViewExtractor" */, 849ADF3D2BACF0360009BCD7 /* XCRemoteSwiftPackageReference "cocoalumberjack" */, - 84F194CC2C101A3E00F0A994 /* XCRemoteSwiftPackageReference "PromiseKit" */, 84F194CF2C15197200F0A994 /* XCRemoteSwiftPackageReference "FrameUp" */, 84E231F12C16A9CE00735FB7 /* XCRemoteSwiftPackageReference "SVGView" */, ); @@ -4642,14 +4638,6 @@ minimumVersion = 1.0.6; }; }; - 84F194CC2C101A3E00F0A994 /* XCRemoteSwiftPackageReference "PromiseKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/mxcl/PromiseKit"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.1.2; - }; - }; 84F194CF2C15197200F0A994 /* XCRemoteSwiftPackageReference "FrameUp" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ryanlintott/FrameUp"; @@ -4706,11 +4694,6 @@ package = 84E231F12C16A9CE00735FB7 /* XCRemoteSwiftPackageReference "SVGView" */; productName = SVGView; }; - 84F194CD2C101A3E00F0A994 /* PromiseKit */ = { - isa = XCSwiftPackageProductDependency; - package = 84F194CC2C101A3E00F0A994 /* XCRemoteSwiftPackageReference "PromiseKit" */; - productName = PromiseKit; - }; 84F194D02C15197200F0A994 /* FrameUp */ = { isa = XCSwiftPackageProductDependency; package = 84F194CF2C15197200F0A994 /* XCRemoteSwiftPackageReference "FrameUp" */; diff --git a/Monal/Podfile b/Monal/Podfile index 7c601fd65c..e666a0c711 100644 --- a/Monal/Podfile +++ b/Monal/Podfile @@ -34,6 +34,7 @@ def monalxmpp pod 'WebRTC-lib' pod 'KSCrash', subspecs:['Recording', 'Reporting/Filters/Sets', 'Reporting/Filters/Tools', 'Reporting/Tools', 'Core'] signalDeps + pod "PromiseKit" end target 'shareSheet' do diff --git a/Monal/Podfile.lock b/Monal/Podfile.lock index d67b4df377..e5da8dc9af 100644 --- a/Monal/Podfile.lock +++ b/Monal/Podfile.lock @@ -41,6 +41,15 @@ PODS: - NotificationBannerSwift (3.2.1): - MarqueeLabel (~> 4.3.0) - SnapKit (~> 5.6.0) + - PromiseKit (8.1.1): + - PromiseKit/CorePromise (= 8.1.1) + - PromiseKit/Foundation (= 8.1.1) + - PromiseKit/UIKit (= 8.1.1) + - PromiseKit/CorePromise (8.1.1) + - PromiseKit/Foundation (8.1.1): + - PromiseKit/CorePromise + - PromiseKit/UIKit (8.1.1): + - PromiseKit/CorePromise - SAMKeychain (1.5.3) - SDWebImage (5.19.1): - SDWebImage/Core (= 5.19.1) @@ -66,6 +75,7 @@ DEPENDENCIES: - KSCrash/Reporting/Tools - MBProgressHUD (~> 1.2.0) - NotificationBannerSwift (~> 3.2.0) + - PromiseKit - SAMKeychain - SDWebImage - SignalProtocolC (from `https://github.com/monal-im/libsignal-protocol-c`, branch `master`) @@ -83,6 +93,7 @@ SPEC REPOS: - MarqueeLabel - MBProgressHUD - NotificationBannerSwift + - PromiseKit - SAMKeychain - SDWebImage - SnapKit @@ -114,6 +125,7 @@ SPEC CHECKSUMS: MarqueeLabel: 15e524a6762552bb279cb17438b8a94990269fb9 MBProgressHUD: 3ee5efcc380f6a79a7cc9b363dd669c5e1ae7406 NotificationBannerSwift: dce54ded532b26e30cd8e7f4d80e124a0f2ba7d1 + PromiseKit: d1be44b474e5acfa16adf007a1f49f104e10fead SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb SignalProtocolC: 8092866e45b663a6bc3e45a8d13bad2571dbf236 @@ -123,6 +135,6 @@ SPEC CHECKSUMS: TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 WebRTC-lib: bb973dd47acf5bc48d8a935a92ae836b70599bc1 -PODFILE CHECKSUM: f415f317e34fd11a687374f84ee8dfb14ebb29a6 +PODFILE CHECKSUM: 057f26bf53534fcd3805f905fef8635b6875a015 COCOAPODS: 1.15.2 From 631e3eb9644105d74837f55d2a7ecadb45f54ccf Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 17:29:18 +0200 Subject: [PATCH 007/131] Implement direct invites --- Monal/Classes/MLMucProcessor.m | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m index 5d41ca388c..3931bfba97 100644 --- a/Monal/Classes/MLMucProcessor.m +++ b/Monal/Classes/MLMucProcessor.m @@ -1233,22 +1233,13 @@ -(void) ping:(NSString*) roomJid withLastPing:(NSDate* _Nullable) lastPing -(void) inviteUser:(NSString*) jid inMuc:(NSString*) roomJid { - DDLogInfo(@"Inviting user '%@' to '%@' directly & indirectly", jid, roomJid); - - XMPPMessage* indirectInviteMsg = [[XMPPMessage alloc] initWithType:kMessageNormalType to:roomJid]; - [indirectInviteMsg addChildNode:[[MLXMLNode alloc] initWithElement:@"x" andNamespace:@"http://jabber.org/protocol/muc#user" withAttributes:@{} andChildren:@[ - [[MLXMLNode alloc] initWithElement:@"invite" withAttributes:@{ - @"to": jid - } andChildren:@[] andData:nil] - ] andData:nil]]; - [self->_account send:indirectInviteMsg]; - + DDLogInfo(@"Directly inviting user '%@' to '%@'...", jid, roomJid); XMPPMessage* directInviteMsg = [[XMPPMessage alloc] initWithType:kMessageNormalType to:jid]; [directInviteMsg addChildNode:[[MLXMLNode alloc] initWithElement:@"x" andNamespace:@"jabber:x:conference" withAttributes:@{ @"jid": roomJid } andChildren:@[] andData:nil]]; + [directInviteMsg setStoreHint]; [self->_account send:directInviteMsg]; - } -(void) setAffiliation:(NSString*) affiliation ofUser:(NSString*) jid inMuc:(NSString*) roomJid From 9b912f4fd655bd7c9740f77c4218badffb365020 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 17:34:07 +0200 Subject: [PATCH 008/131] Clean up some old XEP-0333 methods --- Monal/Classes/XMPPMessage.h | 1 - Monal/Classes/XMPPMessage.m | 5 ----- 2 files changed, 6 deletions(-) diff --git a/Monal/Classes/XMPPMessage.h b/Monal/Classes/XMPPMessage.h index 7691e168f1..dc9b80a6b8 100644 --- a/Monal/Classes/XMPPMessage.h +++ b/Monal/Classes/XMPPMessage.h @@ -39,7 +39,6 @@ FOUNDATION_EXPORT NSString* const kMessageHeadlineType; sets the receipt child element */ -(void) setReceipt:(NSString*) messageId; --(void) setChatmarkerReceipt:(NSString*) messageId; -(void) setDisplayed:(NSString*) messageId; -(void) setMDSDisplayed:(NSString*) stanzaId withStanzaIdBy:(NSString*) by; diff --git a/Monal/Classes/XMPPMessage.m b/Monal/Classes/XMPPMessage.m index eee696fc36..a91f8e85a3 100644 --- a/Monal/Classes/XMPPMessage.m +++ b/Monal/Classes/XMPPMessage.m @@ -108,11 +108,6 @@ -(void) setReceipt:(NSString*) messageId [self addChildNode:[[MLXMLNode alloc] initWithElement:@"received" andNamespace:@"urn:xmpp:receipts" withAttributes:@{@"id":messageId} andChildren:@[] andData:nil]]; } --(void) setChatmarkerReceipt:(NSString*) messageId -{ - [self addChildNode:[[MLXMLNode alloc] initWithElement:@"received" andNamespace:@"urn:xmpp:chat-markers:0" withAttributes:@{@"id":messageId} andChildren:@[] andData:nil]]; -} - -(void) setDisplayed:(NSString*) messageId { [self addChildNode:[[MLXMLNode alloc] initWithElement:@"displayed" andNamespace:@"urn:xmpp:chat-markers:0" withAttributes:@{@"id":messageId} andChildren:@[] andData:nil]]; From b7c2e91f817f089899f3f81f6f91eaa0645afa36 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 18:33:03 +0200 Subject: [PATCH 009/131] Clean up contact details for self chats --- Monal/Classes/ContactDetails.swift | 229 +++++++++++++++-------------- 1 file changed, 120 insertions(+), 109 deletions(-) diff --git a/Monal/Classes/ContactDetails.swift b/Monal/Classes/ContactDetails.swift index b89e4d1e41..4cbb3297f7 100644 --- a/Monal/Classes/ContactDetails.swift +++ b/Monal/Classes/ContactDetails.swift @@ -86,82 +86,84 @@ struct ContactDetails: View { Form { Section { VStack(spacing: 20) { - Image(uiImage: contact.avatar) - .resizable() - .scaledToFit() - .applyClosure {view in - if contact.isGroup { - if ownAffiliation == "owner" { - view.accessibilityLabel((contact.mucType == "group") ? Text("Change Group Avatar") : Text("Change Channel Avatar")) - .onTapGesture { - showImagePicker() - } - .addTopRight { - if contact.hasAvatar { - Button(action: { - showingRemoveAvatarConfirmation = true - }, label: { - Image(systemName: "xmark.circle.fill") - .resizable() - .frame(width: 24.0, height: 24.0) - .accessibilityLabel((contact.mucType == "group") ? Text("Remove Group Avatar") : Text("Remove Channel Avatar")) - .applyClosure { view in - if #available(iOS 15, *) { - view - .symbolRenderingMode(.palette) - .foregroundStyle(.white, .red) - } else { - view.foregroundColor(.red) + if !contact.isSelfChat { + Image(uiImage: contact.avatar) + .resizable() + .scaledToFit() + .applyClosure {view in + if contact.isGroup { + if ownAffiliation == "owner" { + view.accessibilityLabel((contact.mucType == "group") ? Text("Change Group Avatar") : Text("Change Channel Avatar")) + .onTapGesture { + showImagePicker() + } + .addTopRight { + if contact.hasAvatar { + Button(action: { + showingRemoveAvatarConfirmation = true + }, label: { + Image(systemName: "xmark.circle.fill") + .resizable() + .frame(width: 24.0, height: 24.0) + .accessibilityLabel((contact.mucType == "group") ? Text("Remove Group Avatar") : Text("Remove Channel Avatar")) + .applyClosure { view in + if #available(iOS 15, *) { + view + .symbolRenderingMode(.palette) + .foregroundStyle(.white, .red) + } else { + view.foregroundColor(.red) + } } - } - }) - .buttonStyle(.borderless) - .offset(x: 8, y: -8) - } else { - Button(action: { - showImagePicker() - }, label: { - Image(systemName: "pencil.circle.fill") - .resizable() - .frame(width: 24.0, height: 24.0) - .accessibilityLabel((contact.mucType == "group") ? Text("Change Group Avatar") : Text("Change Channel Avatar")) - }) - .buttonStyle(.borderless) - .offset(x: 8, y: -8) + }) + .buttonStyle(.borderless) + .offset(x: 8, y: -8) + } else { + Button(action: { + showImagePicker() + }, label: { + Image(systemName: "pencil.circle.fill") + .resizable() + .frame(width: 24.0, height: 24.0) + .accessibilityLabel((contact.mucType == "group") ? Text("Change Group Avatar") : Text("Change Channel Avatar")) + }) + .buttonStyle(.borderless) + .offset(x: 8, y: -8) + } } - } + } else { + view.accessibilityLabel((contact.mucType == "group") ? Text("Group Avatar") : Text("Channel Avatar")) + } } else { - view.accessibilityLabel((contact.mucType == "group") ? Text("Group Avatar") : Text("Channel Avatar")) + view.accessibilityLabel(Text("Avatar")) } - } else { - view.accessibilityLabel(Text("Avatar")) } - } - .frame(width: 150, height: 150, alignment: .center) - .shadow(radius: 7) - .sheet(isPresented:$showingImagePicker) { - ImagePicker(image:$inputImage) - } - .actionSheet(isPresented: $showingRemoveAvatarConfirmation) { - ActionSheet( - title: Text("Really remove avatar?"), - message: Text("This will remove the current avatar image and revert this group/channel to the default one."), - buttons: [ - .cancel(), - .destructive( - Text("Yes"), - action: { - performMucAction(account:account, mucJid:contact.contactJid, overlay:overlay, headlineView:Text("Removing avatar..."), descriptionView:Text("")) { - self.account.mucProcessor.publishAvatar(nil, forMuc: contact.contactJid) - }.catch { error in - errorAlert(title: Text("Error removing avatar!"), message: Text("\(String(describing:error))")) - hideLoadingOverlay(overlay) + .frame(width: 150, height: 150, alignment: .center) + .shadow(radius: 7) + .sheet(isPresented:$showingImagePicker) { + ImagePicker(image:$inputImage) + } + .actionSheet(isPresented: $showingRemoveAvatarConfirmation) { + ActionSheet( + title: Text("Really remove avatar?"), + message: Text("This will remove the current avatar image and revert this group/channel to the default one."), + buttons: [ + .cancel(), + .destructive( + Text("Yes"), + action: { + performMucAction(account:account, mucJid:contact.contactJid, overlay:overlay, headlineView:Text("Removing avatar..."), descriptionView:Text("")) { + self.account.mucProcessor.publishAvatar(nil, forMuc: contact.contactJid) + }.catch { error in + errorAlert(title: Text("Error removing avatar!"), message: Text("\(String(describing:error))")) + hideLoadingOverlay(overlay) + } } - } - ) - ] - ) - } + ) + ] + ) + } + } Button { UIPasteboard.general.setValue(contact.contactJid as String, forPasteboardType:UTType.utf8PlainText.identifier as String) @@ -177,7 +179,18 @@ struct ContactDetails: View { .accessibilityHint("Copies JID") } .buttonStyle(.borderless) - + +// //TODO: wait for account edit to become swiftui +// if contact.isSelfChat { +// Button { +// //TODO: open account edit +// } label: { +// Text("Open account settings") +// .accessibilityHint("Open account settings") +// } +// .buttonStyle(.borderless) +// } + //only show account jid if more than one is configured if MLXMPPManager.sharedInstance().connectedXMPP.count > 1 && !contact.isSelfChat { @@ -251,40 +264,42 @@ struct ContactDetails: View { // info/nondestructive buttons Section { - Button { - if contact.isGroup { - if !contact.isMuted && !contact.isMentionOnly { - contact.obj.toggleMentionOnly(true) - } else if !contact.isMuted && contact.isMentionOnly { - contact.obj.toggleMentionOnly(false) - contact.obj.toggleMute(true) + if !contact.isSelfChat { + Button { + if contact.isGroup { + if !contact.isMuted && !contact.isMentionOnly { + contact.obj.toggleMentionOnly(true) + } else if !contact.isMuted && contact.isMentionOnly { + contact.obj.toggleMentionOnly(false) + contact.obj.toggleMute(true) + } else { + contact.obj.toggleMentionOnly(false) + contact.obj.toggleMute(false) + } } else { - contact.obj.toggleMentionOnly(false) - contact.obj.toggleMute(false) - } - } else { - contact.obj.toggleMute(!contact.isMuted) - } - } label: { - if contact.isMuted { - Label { - contact.isGroup ? Text("Notifications disabled") : Text("Contact is muted") - } icon: { - Image(systemName: "bell.slash.fill") - .foregroundColor(.red) - } - } else if contact.isGroup && contact.isMentionOnly { - Label { - Text("Notify only when mentioned") - } icon: { - Image(systemName: "bell.badge") + contact.obj.toggleMute(!contact.isMuted) } - } else { - Label { - contact.isGroup ? Text("Notify on all messages") : Text("Contact is not muted") - } icon: { - Image(systemName: "bell.fill") - .foregroundColor(.green) + } label: { + if contact.isMuted { + Label { + contact.isGroup ? Text("Notifications disabled") : Text("Contact is muted") + } icon: { + Image(systemName: "bell.slash.fill") + .foregroundColor(.red) + } + } else if contact.isGroup && contact.isMentionOnly { + Label { + Text("Notify only when mentioned") + } icon: { + Image(systemName: "bell.badge") + } + } else { + Label { + contact.isGroup ? Text("Notify on all messages") : Text("Contact is not muted") + } icon: { + Image(systemName: "bell.fill") + .foregroundColor(.green) + } } } } @@ -363,12 +378,8 @@ struct ContactDetails: View { } #if !DISABLE_OMEMO - if !HelperTools.isContactBlacklisted(forEncryption:contact.obj) { - if !contact.isGroup { - NavigationLink(destination: LazyClosureView(OmemoKeys(contact: contact))) { - contact.isSelfChat ? Text("Own Encryption Keys") : Text("Encryption Keys") - } - } else if contact.mucType == "group" { + if !HelperTools.isContactBlacklisted(forEncryption:contact.obj) && !contact.isSelfChat { + if !contact.isGroup || contact.mucType == "group" { NavigationLink(destination: LazyClosureView(OmemoKeys(contact: contact))) { Text("Encryption Keys") } From 11d2d53544f4c2c8308a58317cecb7f59c00b7e9 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 18 Jun 2024 19:03:14 +0200 Subject: [PATCH 010/131] Implement image cropping when setting new muc avatar --- Monal/Classes/ContactDetails.swift | 33 +++++++++----- Monal/Classes/SwiftuiHelpers.swift | 73 ++++++++++++++++++++++++++---- Monal/Podfile | 2 +- Monal/Podfile.lock | 10 ++-- 4 files changed, 90 insertions(+), 28 deletions(-) diff --git a/Monal/Classes/ContactDetails.swift b/Monal/Classes/ContactDetails.swift index 4cbb3297f7..ab67fae0d9 100644 --- a/Monal/Classes/ContactDetails.swift +++ b/Monal/Classes/ContactDetails.swift @@ -140,9 +140,6 @@ struct ContactDetails: View { } .frame(width: 150, height: 150, alignment: .center) .shadow(radius: 7) - .sheet(isPresented:$showingImagePicker) { - ImagePicker(image:$inputImage) - } .actionSheet(isPresented: $showingRemoveAvatarConfirmation) { ActionSheet( title: Text("Really remove avatar?"), @@ -245,9 +242,6 @@ struct ContactDetails: View { } } .buttonStyle(.borderless) - .sheet(isPresented: $showingSheetEditSubject) { - LazyClosureView(EditGroupSubject(contact: contact)) - } } else { Text("Group subject:") } @@ -646,12 +640,27 @@ struct ContactDetails: View { } })) } - .onChange(of:inputImage) { _ in - performMucAction(account:account, mucJid:contact.contactJid, overlay:overlay, headlineView:Text("Uploading avatar..."), descriptionView:Text("")) { - self.account.mucProcessor.publishAvatar(inputImage, forMuc: contact.contactJid) - }.catch { error in - errorAlert(title: Text("Error changing avatar!"), message: Text("\(String(describing:error))")) - hideLoadingOverlay(overlay) + .sheet(isPresented: $showingSheetEditSubject) { + LazyClosureView(EditGroupSubject(contact: contact)) + } + .sheet(isPresented:$showingImagePicker) { + ImagePicker(image:$inputImage) + } + .sheet(isPresented: $inputImage.optionalMappedToBool()) { + ImageCropView(originalImage: inputImage!, configureBlock: { cropViewController in + cropViewController.aspectRatioPreset = .presetSquare + cropViewController.aspectRatioLockEnabled = true + cropViewController.aspectRatioPickerButtonHidden = true + cropViewController.resetAspectRatioEnabled = false + }, onCanceled: { + inputImage = nil + }) { (image, cropRect, angle) in + performMucAction(account:account, mucJid:contact.contactJid, overlay:overlay, headlineView:Text("Uploading avatar..."), descriptionView:Text("")) { + self.account.mucProcessor.publishAvatar(image, forMuc: contact.contactJid) + }.catch { error in + errorAlert(title: Text("Error changing avatar!"), message: Text("\(String(describing:error))")) + hideLoadingOverlay(overlay) + } } } .onChange(of:contact.avatar as UIImage) { _ in diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 1afde7b542..bb89ea0d6c 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -17,6 +17,7 @@ import PhotosUI import Combine import FLAnimatedImage import OrderedCollections +import CropViewController extension MLContact : Identifiable {} //make MLContact be usable in swiftui ForEach clauses @@ -221,6 +222,68 @@ func buildNotificationStateLabel(_ description: Text, isWorking: Bool) -> some V } } +//see https://github.com/CH3COOH/TOCropViewController/blob/issue/421/Swift/CropViewControllerSwiftUIExample/ImageCropView.swift +public struct ImageCropView: UIViewControllerRepresentable { + private let configureBlock: (CropViewController) -> Void + private let originalImage: UIImage + private let onCanceled: () -> Void + private let onImageCropped: (UIImage,CGRect,Int) -> Void + + @Environment(\.presentationMode) private var presentationMode + + public init(originalImage: UIImage, configureBlock: @escaping (CropViewController) -> Void, onCanceled: @escaping () -> Void, success onImageCropped: @escaping (UIImage,CGRect,Int) -> Void) { + self.originalImage = originalImage + self.configureBlock = configureBlock + self.onCanceled = onCanceled + self.onImageCropped = onImageCropped + } + + public func makeUIViewController(context: Context) -> CropViewController { + let cropController = CropViewController(image: originalImage) + cropController.delegate = context.coordinator + configureBlock(cropController) + return cropController + } + + public func updateUIViewController(_ uiViewController: CropViewController, context: Context) { + } + + public func makeCoordinator() -> Coordinator { + Coordinator( + onDismiss: { self.presentationMode.wrappedValue.dismiss() }, + onCanceled: self.onCanceled, + onImageCropped: self.onImageCropped + ) + } + + final public class Coordinator: NSObject, CropViewControllerDelegate { + private let onDismiss: () -> Void + private let onImageCropped: (UIImage,CGRect,Int) -> Void + private let onCanceled: () -> Void + + init(onDismiss: @escaping () -> Void, onCanceled: @escaping () -> Void, onImageCropped: @escaping (UIImage,CGRect,Int) -> Void) { + self.onDismiss = onDismiss + self.onImageCropped = onImageCropped + self.onCanceled = onCanceled + } + + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + self.onImageCropped(image, cropRect, angle) + self.onDismiss() + } + + public func cropViewController(_ cropViewController: CropViewController, didCropToCircularImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + self.onImageCropped(image, cropRect, angle) + self.onDismiss() + } + + public func cropViewController(_ cropViewController: CropViewController, didFinishCancelled cancelled: Bool) { + self.onCanceled() + self.onDismiss() + } + } +} + //see here for some ideas used herein: https://blog.logrocket.com/adding-gifs-ios-app-flanimatedimage-swiftui/#using-flanimatedimage-with-swift struct GIFViewer: UIViewRepresentable { typealias UIViewType = FLAnimatedImageView @@ -230,16 +293,6 @@ struct GIFViewer: UIViewRepresentable { let imageView = FLAnimatedImageView(frame:.zero) let animatedImage = FLAnimatedImage(animatedGIFData:data) imageView.animatedImage = animatedImage - //imageView.translatesAutoresizingMaskIntoConstraints = false - //imageView.contentMode = .scaleAspectFit - //imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - -// imageView.translatesAutoresizingMaskIntoConstraints = false -// imageView.layer.cornerRadius = 24 -// imageView.layer.masksToBounds = true -// imageView.setContentHuggingPriority(.required, for: .vertical) -// imageView.setContentHuggingPriority(.required, for: .horizontal) - return imageView } diff --git a/Monal/Podfile b/Monal/Podfile index e666a0c711..9b0b45f14d 100644 --- a/Monal/Podfile +++ b/Monal/Podfile @@ -18,7 +18,7 @@ def monal pod 'MBProgressHUD', '~> 1.2.0' pod 'SDWebImage' pod 'DZNEmptyDataSet' - pod 'TOCropViewController' + pod 'CropViewController' pod 'NotificationBannerSwift', '~> 3.2.0' pod 'FLAnimatedImage', '~> 1.0' end diff --git a/Monal/Podfile.lock b/Monal/Podfile.lock index e5da8dc9af..6919e361ba 100644 --- a/Monal/Podfile.lock +++ b/Monal/Podfile.lock @@ -1,5 +1,6 @@ PODS: - ASN1Decoder (1.10.0) + - CropViewController (2.7.4) - DZNEmptyDataSet (1.8.1) - FLAnimatedImage (1.0.17) - KSCrash/Core (1.17.0): @@ -61,11 +62,11 @@ PODS: - sqlite3/common (3.45.1) - sqlite3/perf-threadsafe (3.45.1): - sqlite3/common - - TOCropViewController (2.6.1) - WebRTC-lib (123.0.0) DEPENDENCIES: - ASN1Decoder + - CropViewController - DZNEmptyDataSet - FLAnimatedImage (~> 1.0) - KSCrash/Core @@ -81,12 +82,12 @@ DEPENDENCIES: - SignalProtocolC (from `https://github.com/monal-im/libsignal-protocol-c`, branch `master`) - SignalProtocolObjC (from `https://github.com/monal-im/SignalProtocol-ObjC.git`, branch `master`) - sqlite3/perf-threadsafe - - TOCropViewController - WebRTC-lib SPEC REPOS: trunk: - ASN1Decoder + - CropViewController - DZNEmptyDataSet - FLAnimatedImage - KSCrash @@ -98,7 +99,6 @@ SPEC REPOS: - SDWebImage - SnapKit - sqlite3 - - TOCropViewController - WebRTC-lib EXTERNAL SOURCES: @@ -119,6 +119,7 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: ASN1Decoder: 91cb1d781b5a178ea7375b2f1519e2bdaaa4c427 + CropViewController: 3489bbf95a3e11c654382b0bae08ac645cdf1b93 DZNEmptyDataSet: 9525833b9e68ac21c30253e1d3d7076cc828eaa7 FLAnimatedImage: bbf914596368867157cc71b38a8ec834b3eeb32b KSCrash: 593ec373759e4c1bce381421a627326a20d2dc66 @@ -132,9 +133,8 @@ SPEC CHECKSUMS: SignalProtocolObjC: 1beb46b1d35733e7ab96a919f88bac20ec771c73 SnapKit: e01d52ebb8ddbc333eefe2132acf85c8227d9c25 sqlite3: 73b7fc691fdc43277614250e04d183740cb15078 - TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 WebRTC-lib: bb973dd47acf5bc48d8a935a92ae836b70599bc1 -PODFILE CHECKSUM: 057f26bf53534fcd3805f905fef8635b6875a015 +PODFILE CHECKSUM: eb1329bed9beb1b2ebf1a1c250e9e314295e33c1 COCOAPODS: 1.15.2 From f97ea2ced8dd267eecac4f7e125e928cb8c893a9 Mon Sep 17 00:00:00 2001 From: Matthew Fennell <148400980+matthewrfennell@users.noreply.github.com> Date: Thu, 20 Jun 2024 00:04:42 +0100 Subject: [PATCH 011/131] Position lastMsgButton with constraints, fixes monal-im#921 This button was originally manually positioned using lastMsgButtonPositionConfigWithSize, and called on each state change (e.g. switching between chats, or rotating the screen). However, this caused problems in monal-im#921, since the inputContainerView values did not always reflect the reality of the sizes on the screen. Therefore, in this commit, we replace the manual calculation with a constraint-based approach, that lets Apple maintain the position for us. As a result, we only need to define the position once during initialisation, and can remove the other usages of lastMsgButtonPositionConfigWithSize. --- Monal/Classes/chatViewController.m | 40 +++++++++++++++++++----------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/Monal/Classes/chatViewController.m b/Monal/Classes/chatViewController.m index 6f5ee37c08..15f6eea31e 100644 --- a/Monal/Classes/chatViewController.m +++ b/Monal/Classes/chatViewController.m @@ -103,7 +103,8 @@ @interface chatViewController() context) { - } completion:^(id context) { - //[self lastMsgButtonPositionConfigWithSize:self.inputContainerView.bounds.size]; - [self lastMsgButtonPositionConfigWithSize:size]; - }]; } #pragma mark gestures From e4dcaef8b3c6c52e6c51f7450bec7657a64903bc Mon Sep 17 00:00:00 2001 From: Noman Ashraf Date: Sun, 26 May 2024 17:40:00 -0400 Subject: [PATCH 012/131] allow different times for Autodelete all messages feature, fixes #1053 --- Monal/Classes/DataLayer.h | 2 +- Monal/Classes/DataLayer.m | 29 +++++++++++--------- Monal/Classes/GeneralSettings.swift | 41 ++++++++++++++++++++++++++--- Monal/Classes/MLXMPPManager.m | 7 +++-- Monal/Classes/xmpp.m | 12 ++++++--- 5 files changed, 67 insertions(+), 24 deletions(-) diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h index 2fe0fbec21..f306e9f5c1 100644 --- a/Monal/Classes/DataLayer.h +++ b/Monal/Classes/DataLayer.h @@ -212,7 +212,7 @@ extern NSString* const kMessageTypeFiletransfer; -(void) clearMessages:(NSNumber*) accountNo; -(void) clearMessagesWithBuddy:(NSString*) buddy onAccount:(NSNumber*) accountNo; --(void) autodeleteAllMessagesAfter3Days; +-(void) autoDeleteMessagesAfterInterval:(NSTimeInterval)interval; -(void) deleteMessageHistory:(NSNumber *) messageNo; -(void) deleteMessageHistoryLocally:(NSNumber*) messageNo; -(void) updateMessageHistory:(NSNumber*) messageNo withText:(NSString*) newText; diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index e22fb7deef..8033a23d6d 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -1455,24 +1455,27 @@ -(void) clearMessagesWithBuddy:(NSString*) buddy onAccount:(NSNumber*) accountNo }]; } - --(void) autodeleteAllMessagesAfter3Days -{ +-(void)autoDeleteMessagesAfterInterval:(NSTimeInterval)interval { [self.db voidWriteTransaction:^{ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"]; - //3 days before now - NSString* pastDate = [dbFormatter stringFromDate:[[NSCalendar currentCalendar] dateByAddingUnit:NSCalendarUnitDay value:-3 toDate:[NSDate date] options:0]]; - //delete all transferred files old enough - NSArray* messageHistoryIDs = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE messageType=? AND timestamp0 { + generalSettingsDefaultsDB.AutodeleteInterval = minutes * 60 + } + showingCustomTimeSheet = false + } + } + .navigationBarTitle("Enter Custom Time", displayMode: .inline) + .navigationBarItems(trailing: Button("Cancel") { + showingCustomTimeSheet = false + }) + } + } + } } diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index f4095737a9..50a22ac3d2 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -85,8 +85,7 @@ -(void) defaultSettings [self upgradeBoolUserSettingsIfUnset:@"ShowURLPreview" toDefault:YES]; //upgrade message autodeletion - [self upgradeBoolUserSettingsIfUnset:@"AutodeleteAllMessagesAfter3Days" toDefault:NO]; - + [self upgradeIntegerUserSettingsIfUnset:@"AutodeleteInterval" toDefault:0]; //upgrade default omemo on [self upgradeBoolUserSettingsIfUnset:@"OMEMODefaultOn" toDefault:YES]; @@ -354,6 +353,10 @@ -(id) init while(YES) { for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP) [account updateIqHandlerTimeouts]; + NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; + if (autodeleteInterval > 0) { + [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; + } [NSThread sleepForTimeInterval:1]; } }); diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m index 2f9a58563b..d151b8a6aa 100644 --- a/Monal/Classes/xmpp.m +++ b/Monal/Classes/xmpp.m @@ -222,8 +222,10 @@ -(id) initWithServer:(nonnull MLXMPPServer*) server andIdentity:(nonnull MLXMPPI [self.pubsub registerForNode:@"urn:xmpp:mds:displayed:0" withHandler:$newHandler(MLPubSubProcessor, mdsHandler)]; //autodelete messages old enough (first invocation) - if([[HelperTools defaultsDB] boolForKey:@"AutodeleteAllMessagesAfter3Days"]) - [[DataLayer sharedInstance] autodeleteAllMessagesAfter3Days]; + NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; + if (autodeleteInterval > 0) { + [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; + } return self; } @@ -862,8 +864,10 @@ -(void) reinitLoginTimer -(void) connect { //autodelete messages old enough (second invocation) - if([[HelperTools defaultsDB] boolForKey:@"AutodeleteAllMessagesAfter3Days"]) - [[DataLayer sharedInstance] autodeleteAllMessagesAfter3Days]; + NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; + if (autodeleteInterval > 0) { + [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; + } if(_parseQueue.suspended) { From d63a6b744ac2ae3180151db57d7ae2d84546cf43 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Wed, 19 Jun 2024 20:42:52 +0200 Subject: [PATCH 013/131] Fix autodelete commit --- Monal/Classes/DataLayer.h | 3 +- Monal/Classes/DataLayer.m | 28 ++++++--- Monal/Classes/GeneralSettings.swift | 96 +++++++++++++++++++---------- Monal/Classes/HelperTools.m | 7 ++- Monal/Classes/MLXMPPManager.m | 30 +++++++-- Monal/Classes/MonalAppDelegate.m | 5 +- Monal/Classes/xmpp.m | 12 ---- 7 files changed, 117 insertions(+), 64 deletions(-) diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h index f306e9f5c1..3da15abe8b 100644 --- a/Monal/Classes/DataLayer.h +++ b/Monal/Classes/DataLayer.h @@ -43,6 +43,7 @@ extern NSString* const kMessageTypeFiletransfer; +(DataLayer*) sharedInstance; -(NSString* _Nullable) exportDB; -(void) createTransaction:(monal_void_block_t) block; +-(void) vacuum; //Roster -(NSString *) getRosterVersionForAccount:(NSNumber*) accountNo; @@ -212,7 +213,7 @@ extern NSString* const kMessageTypeFiletransfer; -(void) clearMessages:(NSNumber*) accountNo; -(void) clearMessagesWithBuddy:(NSString*) buddy onAccount:(NSNumber*) accountNo; --(void) autoDeleteMessagesAfterInterval:(NSTimeInterval)interval; +-(NSNumber*) autoDeleteMessagesAfterInterval:(NSTimeInterval)interval; -(void) deleteMessageHistory:(NSNumber *) messageNo; -(void) deleteMessageHistoryLocally:(NSNumber*) messageNo; -(void) updateMessageHistory:(NSNumber*) messageNo withText:(NSString*) newText; diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index 8033a23d6d..f82d49f914 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -157,6 +157,11 @@ -(void) createTransaction:(monal_void_block_t) block [self.db voidWriteTransaction:block]; } +-(void) vacuum +{ + return [self.db vacuum]; +} + #pragma mark account commands -(NSArray*) accountList @@ -1455,28 +1460,31 @@ -(void) clearMessagesWithBuddy:(NSString*) buddy onAccount:(NSNumber*) accountNo }]; } --(void)autoDeleteMessagesAfterInterval:(NSTimeInterval)interval { - [self.db voidWriteTransaction:^{ +-(NSNumber*) autoDeleteMessagesAfterInterval:(NSTimeInterval) interval +{ + return [self.db idWriteTransaction:^{ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"]; //interval before now NSDate* pastDate = [NSDate dateWithTimeIntervalSinceNow: -interval]; NSString* pastDateString = [dbFormatter stringFromDate:pastDate]; - // Select message history IDs for read messages before the specified date - NSArray* messageHistoryIDs = [self.db executeScalarReader:@"SELECT message_history_id FROM message_history WHERE unread=0 AND timestamp= 15 + autodeleteOptions[-1] = NSLocalizedString("Custom", comment:"Message autdelete time") + //check if we have a custom value and change picker value accordingly + if autodeleteOptions[autodeleteInterval] == nil { + _autodeleteIntervalSelection = State(wrappedValue:-1) + } + } else { + //check if we have a custom value, this should never happen because custom values should only be settable in ios >= 15 + if autodeleteOptions[autodeleteInterval] == nil { + //turn autodelete off int his case (sane value) + _autodeleteIntervalSelection = State(wrappedValue:0) + _autodeleteInterval = State(wrappedValue:0) + } + } + } + var body: some View { Form { Section(header: Text("Encryption")) { @@ -280,38 +311,41 @@ like hotel wifi, ugly mobile carriers etc. } Section(header: Text("On this device")) { - Picker("Autodelete all messages after", selection: $generalSettingsDefaultsDB.AutodeleteInterval) { - ForEach(autodeleteOptions.keys.sorted(), id: \.self) { key in - Text(autodeleteOptions[key, default: "Custom"]).tag(key) + VStack(alignment: .leading, spacing: 0) { + Picker("Autodelete all messages older than", selection: $autodeleteIntervalSelection) { + ForEach(autodeleteOptions.keys.sorted(), id: \.self) { key in + Text(autodeleteOptions[key]!).tag(key) + } } + if #available(iOS 15, *) { + //unknown interval or custom interval requested explicitly + if autodeleteOptions[autodeleteInterval] == nil || autodeleteIntervalSelection == -1 { + HStack { + Text("Custom Time: ") + Stepper(NSLocalizedString("\(String(describing:(max(1, autodeleteInterval / 3600)).formatted())) hours", comment:""), value: Binding( + get: { max(1, autodeleteInterval / 3600) /*clamp to 1 ... .max*/ }, + set: { autodeleteInterval = $0 * 3600 } + ), in: 1 ... .max) + } + } + } + Text("Be warned: Message will only be deleted on incoming pushes or if you open the app! This is especially true for shorter time intervals!").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) + Text("Also beware: You won't be able to load older history from your server, Monal will immediately delete it after fetching it!").foregroundColor(Color(UIColor.secondaryLabel)).font(.footnote) } } } .navigationBarTitle(Text("Security"), displayMode: .inline) - .onChange(of: generalSettingsDefaultsDB.AutodeleteInterval) { newValue in - if newValue == -1 { - showingCustomTimeSheet = true - } - } - .sheet(isPresented: $showingCustomTimeSheet) { - NavigationView { - Form { - TextField("Enter time in minutes", text: $customTimeString) - .keyboardType(.numberPad) - Button("Set") { - if let minutes = Int(customTimeString), minutes>0 { - generalSettingsDefaultsDB.AutodeleteInterval = minutes * 60 - } - showingCustomTimeSheet = false - } - } - .navigationBarTitle("Enter Custom Time", displayMode: .inline) - .navigationBarItems(trailing: Button("Cancel") { - showingCustomTimeSheet = false - }) + //save only when closing view to not delete messages while the user is selecting a (custom) value + .onDisappear { + if autodeleteIntervalSelection == -1 { + //make sure our custom value is stored clamped, too + autodeleteInterval = max(1, autodeleteInterval / 3600) + } else { + //copy over picker value if not set to custom + autodeleteInterval = autodeleteIntervalSelection } + generalSettingsDefaultsDB.AutodeleteInterval = autodeleteInterval } - } } diff --git a/Monal/Classes/HelperTools.m b/Monal/Classes/HelperTools.m index d271e2ac8a..d401a7f968 100644 --- a/Monal/Classes/HelperTools.m +++ b/Monal/Classes/HelperTools.m @@ -1712,8 +1712,9 @@ +(BOOL) isNotInFocus +(void) dispatchAsync:(BOOL) async reentrantOnQueue:(dispatch_queue_t _Nullable) queue withBlock:(monal_void_block_t) block { + dispatch_queue_t main_queue = dispatch_get_main_queue(); if(!queue) - queue = dispatch_get_main_queue(); + queue = main_queue; //apple docs say that enqueueing blocks for synchronous execution will execute this blocks in the thread the enqueueing came from //(e.g. the tread we are already in). @@ -1728,7 +1729,9 @@ +(void) dispatchAsync:(BOOL) async reentrantOnQueue:(dispatch_queue_t _Nullable) #pragma clang diagnostic ignored "-Wdeprecated-declarations" dispatch_queue_t current_queue = dispatch_get_current_queue(); #pragma clang diagnostic pop - if(current_queue == queue || (queue == dispatch_get_main_queue() && [NSThread isMainThread])) + if(queue == main_queue && [NSThread isMainThread]) + block(); + else if(current_queue == queue) block(); else { diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index 50a22ac3d2..2a7eb26bce 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -84,8 +84,16 @@ -(void) defaultSettings //upgrade url preview [self upgradeBoolUserSettingsIfUnset:@"ShowURLPreview" toDefault:YES]; - //upgrade message autodeletion - [self upgradeIntegerUserSettingsIfUnset:@"AutodeleteInterval" toDefault:0]; + //upgrade message autodeletion and migrate old "3 days" setting + NSNumber* oldAutodelete = [[HelperTools defaultsDB] objectForKey:@"AutodeleteAllMessagesAfter3Days"]; + if(oldAutodelete != nil && [oldAutodelete boolValue]) + { + [self upgradeIntegerUserSettingsIfUnset:@"AutodeleteInterval" toDefault:259200]; + [self removeObjectUserSettingsIfSet:@"AutodeleteAllMessagesAfter3Days"]; + } + else + [self upgradeIntegerUserSettingsIfUnset:@"AutodeleteInterval" toDefault:0]; + //upgrade default omemo on [self upgradeBoolUserSettingsIfUnset:@"OMEMODefaultOn" toDefault:YES]; @@ -353,10 +361,20 @@ -(id) init while(YES) { for(xmpp* account in [MLXMPPManager sharedInstance].connectedXMPP) [account updateIqHandlerTimeouts]; - NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; - if (autodeleteInterval > 0) { - [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; - } + + //needed to not crash the app with an obscure EXC_BREAKPOINT while deleting something in a currently open chat + //(triggered by [HelperTools dispatchAsync:reentrantOnQueue:withBlock:] in it's call to dispatch_get_current_queue()) + dispatch_async(dispatch_get_main_queue(), ^{ + NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; + if(autodeleteInterval > 0) + { + NSNumber* deletionCount = [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; + //make sure our ui updates after a deletion + if(deletionCount.integerValue > 0) + [[MLNotificationQueue currentQueue] postNotificationName:kMonalRefresh object:nil userInfo:nil]; + } + }); + [NSThread sleepForTimeInterval:1]; } }); diff --git a/Monal/Classes/MonalAppDelegate.m b/Monal/Classes/MonalAppDelegate.m index 3a189b2412..725a8a5054 100644 --- a/Monal/Classes/MonalAppDelegate.m +++ b/Monal/Classes/MonalAppDelegate.m @@ -1615,9 +1615,10 @@ -(void) handleBackgroundProcessingTask:(BGTask*) task } if(![[MLXMPPManager sharedInstance] hasConnectivity]) - { DDLogError(@"BGTASK has *no* connectivity? That's strange!"); - } + + //we are a bg processing task potentially having minutes of background time --> vacuum database + [[DataLayer sharedInstance] vacuum]; [self startBackgroundTimer:BGPROCESS_GRACEFUL_TIMEOUT]; @synchronized(self) { diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m index d151b8a6aa..a485292f91 100644 --- a/Monal/Classes/xmpp.m +++ b/Monal/Classes/xmpp.m @@ -221,12 +221,6 @@ -(id) initWithServer:(nonnull MLXMPPServer*) server andIdentity:(nonnull MLXMPPI //we support mds [self.pubsub registerForNode:@"urn:xmpp:mds:displayed:0" withHandler:$newHandler(MLPubSubProcessor, mdsHandler)]; - //autodelete messages old enough (first invocation) - NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; - if (autodeleteInterval > 0) { - [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; - } - return self; } @@ -863,12 +857,6 @@ -(void) reinitLoginTimer -(void) connect { - //autodelete messages old enough (second invocation) - NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; - if (autodeleteInterval > 0) { - [[DataLayer sharedInstance] autoDeleteMessagesAfterInterval:(NSTimeInterval)autodeleteInterval]; - } - if(_parseQueue.suspended) { DDLogWarn(@"Not trying to connect: parse queue frozen!"); From d623eeffa91db3484292e8d696573cb1958bc737 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 02:32:06 +0200 Subject: [PATCH 014/131] Harden sqlite --- Monal/Classes/MLSQLite.m | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/MLSQLite.m b/Monal/Classes/MLSQLite.m index 1a7d320adb..78068b3bc4 100644 --- a/Monal/Classes/MLSQLite.m +++ b/Monal/Classes/MLSQLite.m @@ -75,9 +75,7 @@ -(id) initWithFile:(NSString*) dbFile [HelperTools configureFileProtectionFor:[NSString stringWithFormat:@"%@-shm", _dbFile]]; if(sqlite3_open_v2([_dbFile UTF8String], &(self->_database), SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) == SQLITE_OK) - { DDLogInfo(@"Database opened: %@", _dbFile); - } else { //database error message @@ -107,12 +105,17 @@ -(id) initWithFile:(NSString*) dbFile //this uses the private api because we have no thread local instance added to the threadData dictionary yet and we don't use a transaction either (and public apis check both) //--> we must use the internal api because it does not call testThreadInstanceForQuery: testTransactionsForQuery: sqlite3_busy_timeout(self->_database, 2000); //set the busy time as early as possible to make sure the pragma states don't trigger a retry too often + sqlite3_db_config(self->_database, SQLITE_DBCONFIG_DEFENSIVE); while([self executeNonQuery:@"PRAGMA synchronous=NORMAL;" andArguments:@[] withException:NO] != YES) DDLogError(@"Database locked, while calling 'PRAGMA synchronous=NORMAL;', retrying..."); while([self executeNonQuery:@"PRAGMA truncate;" andArguments:@[] withException:NO] != YES) DDLogError(@"Database locked, while calling 'PRAGMA truncate;', retrying..."); while([self executeNonQuery:@"PRAGMA foreign_keys=on;" andArguments:@[] withException:NO] != YES) DDLogError(@"Database locked, while calling 'PRAGMA foreign_keys=on;', retrying..."); + //this seems to provide *slightly* better security + //see https://sqlite.org/pragma.html#pragma_trusted_schema + while([self executeNonQuery:@"PRAGMA trusted_schema = off;" andArguments:@[] withException:NO] != YES) + DDLogError(@"Database locked, while calling 'PRAGMA trusted_schema = off;', retrying..."); return self; } From 07ecfb1dc2351775197c5988769c57db06b5fe47 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 02:32:53 +0200 Subject: [PATCH 015/131] Clean up user defaults --- Monal/Classes/MLXMPPManager.m | 29 +---------------------------- Monal/Classes/SwiftHelpers.swift | 5 ++++- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index 2a7eb26bce..d2e64c672d 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -39,33 +39,7 @@ @implementation MLXMPPManager -(void) defaultSettings { - BOOL setDefaults = [[HelperTools defaultsDB] boolForKey:@"SetDefaults"]; - if(!setDefaults) - { - [[HelperTools defaultsDB] setBool:YES forKey:@"Sound"]; - [[HelperTools defaultsDB] setBool:NO forKey:@"ChatBackgrounds"]; - - // Privacy Settings - [[HelperTools defaultsDB] setBool:YES forKey:@"ShowGeoLocation"]; - [[HelperTools defaultsDB] setBool:YES forKey:@"SendLastUserInteraction"]; - [[HelperTools defaultsDB] setBool:YES forKey:@"SendLastChatState"]; - [[HelperTools defaultsDB] setBool:YES forKey:@"SendReceivedMarkers"]; - [[HelperTools defaultsDB] setBool:YES forKey:@"SendDisplayedMarkers"]; - [[HelperTools defaultsDB] setBool:YES forKey:@"ShowURLPreview"]; - - // Message Settings / Privacy - [[HelperTools defaultsDB] setInteger:NotificationPrivacySettingOptionDisplayNameAndMessage forKey:@"NotificationPrivacySetting"]; - - // udp logger - [[HelperTools defaultsDB] setBool:NO forKey:@"udpLoggerEnabled"]; - [[HelperTools defaultsDB] setObject:@"" forKey:@"udpLoggerHostname"]; - [[HelperTools defaultsDB] setObject:@"" forKey:@"udpLoggerPort"]; - [[HelperTools defaultsDB] setObject:@"" forKey:@"udpLoggerKey"]; - - [[HelperTools defaultsDB] setBool:YES forKey:@"SetDefaults"]; - [[HelperTools defaultsDB] synchronize]; - } - + [self upgradeBoolUserSettingsIfUnset:@"Sound" toDefault:YES]; [self upgradeObjectUserSettingsIfUnset:@"AlertSoundFile" toDefault:@"alert2"]; // upgrade ShowGeoLocation @@ -164,7 +138,6 @@ -(void) defaultSettings [self upgradeBoolUserSettingsIfUnset:@"useDnssecForAllConnections" toDefault:NO]; #endif - NSTimeZone* timeZone = [NSTimeZone localTimeZone]; DDLogVerbose(@"Current timezone name: '%@'...", [timeZone name]); if([[timeZone name] containsString:@"Europe"]) diff --git a/Monal/Classes/SwiftHelpers.swift b/Monal/Classes/SwiftHelpers.swift index 0ad13c020c..97e92bf171 100644 --- a/Monal/Classes/SwiftHelpers.swift +++ b/Monal/Classes/SwiftHelpers.swift @@ -242,7 +242,10 @@ public struct defaultsDB { ]) } } - set { container.set(newValue, forKey: key) } + set { + container.set(newValue, forKey: key) + container.synchronize() + } } public static subscript( From 1762bdc670d877482788941d0a76d5fdc4332973 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 02:35:43 +0200 Subject: [PATCH 016/131] Rename deleteMessageHistory: to retractMessageHistory: in DataLayer --- Monal/Classes/DataLayer.h | 2 +- Monal/Classes/DataLayer.m | 2 +- Monal/Classes/MLIQProcessor.m | 2 +- Monal/Classes/MLMessageProcessor.m | 4 ++-- Monal/Classes/chatViewController.m | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h index 3da15abe8b..88763fcda5 100644 --- a/Monal/Classes/DataLayer.h +++ b/Monal/Classes/DataLayer.h @@ -214,7 +214,7 @@ extern NSString* const kMessageTypeFiletransfer; -(void) clearMessages:(NSNumber*) accountNo; -(void) clearMessagesWithBuddy:(NSString*) buddy onAccount:(NSNumber*) accountNo; -(NSNumber*) autoDeleteMessagesAfterInterval:(NSTimeInterval)interval; --(void) deleteMessageHistory:(NSNumber *) messageNo; +-(void) retractMessageHistory:(NSNumber *) messageNo; -(void) deleteMessageHistoryLocally:(NSNumber*) messageNo; -(void) updateMessageHistory:(NSNumber*) messageNo withText:(NSString*) newText; -(NSNumber* _Nullable) getLMCHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from actualFrom:(NSString* _Nullable) actualFrom participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountNo; diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index f82d49f914..e929020652 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -1487,7 +1487,7 @@ -(NSNumber*) autoDeleteMessagesAfterInterval:(NSTimeInterval) interval }]; } --(void) deleteMessageHistory:(NSNumber*) messageNo +-(void) retractMessageHistory:(NSNumber*) messageNo { [self.db voidWriteTransaction:^{ [self.db executeNonQuery:@"PRAGMA secure_delete=on;"]; diff --git a/Monal/Classes/MLIQProcessor.m b/Monal/Classes/MLIQProcessor.m index ce67ab5d39..7ba0e8a6d5 100644 --- a/Monal/Classes/MLIQProcessor.m +++ b/Monal/Classes/MLIQProcessor.m @@ -746,7 +746,7 @@ +(BOOL) processRosterWithAccount:(xmpp*) account andIqNode:(XMPPIQ*) iqNode } DDLogInfo(@"Successfully moderated message in muc: %@", msg); - [[DataLayer sharedInstance] deleteMessageHistory:msg.messageDBId]; + [[DataLayer sharedInstance] retractMessageHistory:msg.messageDBId]; //update ui DDLogInfo(@"Sending out kMonalDeletedMessageNotice notification for historyId %@", msg.messageDBId); diff --git a/Monal/Classes/MLMessageProcessor.m b/Monal/Classes/MLMessageProcessor.m index 51141e8708..eaa1bd8469 100644 --- a/Monal/Classes/MLMessageProcessor.m +++ b/Monal/Classes/MLMessageProcessor.m @@ -452,7 +452,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag if(historyIdToRetract != nil) { - [[DataLayer sharedInstance] deleteMessageHistory:historyIdToRetract]; + [[DataLayer sharedInstance] retractMessageHistory:historyIdToRetract]; //update ui DDLogInfo(@"Sending out kMonalDeletedMessageNotice notification for historyId %@", historyIdToRetract); @@ -500,7 +500,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag ]; //...then retract this message (e.g. mark as retracted) - [[DataLayer sharedInstance] deleteMessageHistory:historyIdToRetract]; + [[DataLayer sharedInstance] retractMessageHistory:historyIdToRetract]; //update ui DDLogInfo(@"Sending out kMonalDeletedMessageNotice notification for historyId %@", historyIdToRetract); diff --git a/Monal/Classes/chatViewController.m b/Monal/Classes/chatViewController.m index 15f6eea31e..9ee5a98b83 100644 --- a/Monal/Classes/chatViewController.m +++ b/Monal/Classes/chatViewController.m @@ -2557,7 +2557,7 @@ -(UISwipeActionsConfiguration*) tableView:(UITableView*) tableView trailingSwipe if(!message.inbound) { [self.xmppAccount retractMessage:message]; - [[DataLayer sharedInstance] deleteMessageHistory:message.messageDBId]; + [[DataLayer sharedInstance] retractMessageHistory:message.messageDBId]; [message updateWithMessage:[[[DataLayer sharedInstance] messagesForHistoryIDs:@[message.messageDBId]] firstObject]]; //update table entry From 6c39f18a53ebe12e7387588978b764eab2cfacb0 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 02:45:46 +0200 Subject: [PATCH 017/131] Fix visibility of custom deletion stepper --- Monal/Classes/GeneralSettings.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index 908b266b8e..4d6dcba9cf 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -318,8 +318,8 @@ like hotel wifi, ugly mobile carriers etc. } } if #available(iOS 15, *) { - //unknown interval or custom interval requested explicitly - if autodeleteOptions[autodeleteInterval] == nil || autodeleteIntervalSelection == -1 { + //custom interval requested explicitly + if autodeleteIntervalSelection == -1 { HStack { Text("Custom Time: ") Stepper(NSLocalizedString("\(String(describing:(max(1, autodeleteInterval / 3600)).formatted())) hours", comment:""), value: Binding( From 41b71fb416eaecdf78ffb086eaae6e2a9f92204e Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 02:51:33 +0200 Subject: [PATCH 018/131] Bumo pods --- Monal/Podfile.lock | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Monal/Podfile.lock b/Monal/Podfile.lock index 6919e361ba..ccc61a9c07 100644 --- a/Monal/Podfile.lock +++ b/Monal/Podfile.lock @@ -3,26 +3,26 @@ PODS: - CropViewController (2.7.4) - DZNEmptyDataSet (1.8.1) - FLAnimatedImage (1.0.17) - - KSCrash/Core (1.17.0): + - KSCrash/Core (1.17.4): - KSCrash/Reporting/Filters/Basic - - KSCrash/Recording (1.17.0): - - KSCrash/Recording/Tools (= 1.17.0) - - KSCrash/Recording/Tools (1.17.0) - - KSCrash/Reporting/Filters/AppleFmt (1.17.0): + - KSCrash/Recording (1.17.4): + - KSCrash/Recording/Tools (= 1.17.4) + - KSCrash/Recording/Tools (1.17.4) + - KSCrash/Reporting/Filters/AppleFmt (1.17.4): - KSCrash/Recording - KSCrash/Reporting/Filters/Base - - KSCrash/Reporting/Filters/Base (1.17.0): + - KSCrash/Reporting/Filters/Base (1.17.4): - KSCrash/Recording - - KSCrash/Reporting/Filters/Basic (1.17.0): + - KSCrash/Reporting/Filters/Basic (1.17.4): - KSCrash/Recording - KSCrash/Reporting/Filters/Base - - KSCrash/Reporting/Filters/GZip (1.17.0): + - KSCrash/Reporting/Filters/GZip (1.17.4): - KSCrash/Recording - KSCrash/Reporting/Filters/Base - - KSCrash/Reporting/Filters/JSON (1.17.0): + - KSCrash/Reporting/Filters/JSON (1.17.4): - KSCrash/Recording - KSCrash/Reporting/Filters/Base - - KSCrash/Reporting/Filters/Sets (1.17.0): + - KSCrash/Reporting/Filters/Sets (1.17.4): - KSCrash/Recording - KSCrash/Reporting/Filters/AppleFmt - KSCrash/Reporting/Filters/Base @@ -30,12 +30,12 @@ PODS: - KSCrash/Reporting/Filters/GZip - KSCrash/Reporting/Filters/JSON - KSCrash/Reporting/Filters/Stringify - - KSCrash/Reporting/Filters/Stringify (1.17.0): + - KSCrash/Reporting/Filters/Stringify (1.17.4): - KSCrash/Recording - KSCrash/Reporting/Filters/Base - - KSCrash/Reporting/Filters/Tools (1.17.0): + - KSCrash/Reporting/Filters/Tools (1.17.4): - KSCrash/Recording - - KSCrash/Reporting/Tools (1.17.0): + - KSCrash/Reporting/Tools (1.17.4): - KSCrash/Recording - MarqueeLabel (4.3.2) - MBProgressHUD (1.2.0) @@ -52,17 +52,17 @@ PODS: - PromiseKit/UIKit (8.1.1): - PromiseKit/CorePromise - SAMKeychain (1.5.3) - - SDWebImage (5.19.1): - - SDWebImage/Core (= 5.19.1) - - SDWebImage/Core (5.19.1) + - SDWebImage (5.19.2): + - SDWebImage/Core (= 5.19.2) + - SDWebImage/Core (5.19.2) - SignalProtocolC (2.3.3) - SignalProtocolObjC (1.1.1): - SignalProtocolC (~> 2.3.3) - SnapKit (5.6.0) - - sqlite3/common (3.45.1) - - sqlite3/perf-threadsafe (3.45.1): + - "sqlite3/common (3.46.0+1)" + - "sqlite3/perf-threadsafe (3.46.0+1)": - sqlite3/common - - WebRTC-lib (123.0.0) + - WebRTC-lib (126.0.0) DEPENDENCIES: - ASN1Decoder @@ -122,18 +122,18 @@ SPEC CHECKSUMS: CropViewController: 3489bbf95a3e11c654382b0bae08ac645cdf1b93 DZNEmptyDataSet: 9525833b9e68ac21c30253e1d3d7076cc828eaa7 FLAnimatedImage: bbf914596368867157cc71b38a8ec834b3eeb32b - KSCrash: 593ec373759e4c1bce381421a627326a20d2dc66 + KSCrash: 158a0998f08ae7d4e54ef8a2da62d6e08b46d03a MarqueeLabel: 15e524a6762552bb279cb17438b8a94990269fb9 MBProgressHUD: 3ee5efcc380f6a79a7cc9b363dd669c5e1ae7406 NotificationBannerSwift: dce54ded532b26e30cd8e7f4d80e124a0f2ba7d1 PromiseKit: d1be44b474e5acfa16adf007a1f49f104e10fead SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c - SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb + SDWebImage: dfe95b2466a9823cf9f0c6d01217c06550d7b29a SignalProtocolC: 8092866e45b663a6bc3e45a8d13bad2571dbf236 SignalProtocolObjC: 1beb46b1d35733e7ab96a919f88bac20ec771c73 SnapKit: e01d52ebb8ddbc333eefe2132acf85c8227d9c25 - sqlite3: 73b7fc691fdc43277614250e04d183740cb15078 - WebRTC-lib: bb973dd47acf5bc48d8a935a92ae836b70599bc1 + sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 + WebRTC-lib: 7e2e15d90ebca6e08a1eb5d4afc365d70e4b95b0 PODFILE CHECKSUM: eb1329bed9beb1b2ebf1a1c250e9e314295e33c1 From dfb0155b780c761c11e7bc8d8ca5f67ab52ab4d9 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 03:02:38 +0200 Subject: [PATCH 019/131] Add comment explaining strange get_current_queue crash --- Monal/Classes/MLXMPPManager.m | 1 + 1 file changed, 1 insertion(+) diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index d2e64c672d..b1e9a0f902 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -336,6 +336,7 @@ -(id) init [account updateIqHandlerTimeouts]; //needed to not crash the app with an obscure EXC_BREAKPOINT while deleting something in a currently open chat + //the crash report then contains: message at /usr/lib/system/libdispatch.dylib: API MISUSE: Resurrection of an object //(triggered by [HelperTools dispatchAsync:reentrantOnQueue:withBlock:] in it's call to dispatch_get_current_queue()) dispatch_async(dispatch_get_main_queue(), ^{ NSInteger autodeleteInterval = [[HelperTools defaultsDB] integerForKey:@"AutodeleteInterval"]; From 774b2a36276004ec7cfa9380c5e943f332bfe643 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 21:49:08 +0200 Subject: [PATCH 020/131] Remove sqlite dbconfig because of crashes on some devices --- Monal/Classes/MLSQLite.m | 1 - 1 file changed, 1 deletion(-) diff --git a/Monal/Classes/MLSQLite.m b/Monal/Classes/MLSQLite.m index 78068b3bc4..386ed8f30e 100644 --- a/Monal/Classes/MLSQLite.m +++ b/Monal/Classes/MLSQLite.m @@ -105,7 +105,6 @@ -(id) initWithFile:(NSString*) dbFile //this uses the private api because we have no thread local instance added to the threadData dictionary yet and we don't use a transaction either (and public apis check both) //--> we must use the internal api because it does not call testThreadInstanceForQuery: testTransactionsForQuery: sqlite3_busy_timeout(self->_database, 2000); //set the busy time as early as possible to make sure the pragma states don't trigger a retry too often - sqlite3_db_config(self->_database, SQLITE_DBCONFIG_DEFENSIVE); while([self executeNonQuery:@"PRAGMA synchronous=NORMAL;" andArguments:@[] withException:NO] != YES) DDLogError(@"Database locked, while calling 'PRAGMA synchronous=NORMAL;', retrying..."); while([self executeNonQuery:@"PRAGMA truncate;" andArguments:@[] withException:NO] != YES) From 0ec702bc54d6ffa6aa58edbc1821be10ff964146 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 22:28:48 +0200 Subject: [PATCH 021/131] Rework release scripts to handle multiline content properly Also closes #1090 --- .github/workflows/beta.build-push.yml | 38 ++++++++++++++-- .github/workflows/develop-push.yml | 6 ++- .github/workflows/stable.build-push.yml | 55 ++++++++++++++--------- .github/workflows/update-translations.yml | 4 ++ 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index f0c49a4117..d12c7d51e1 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -28,8 +28,10 @@ jobs: with: clean: true submodules: true - - name: Fetch tags - run: git fetch --tags + fetch-depth: 100 + fetch-tags: true + show-progress: true + lfs: true - name: Checkout submodules run: git submodule update -f --init --remote - name: Get last build tag and increment it @@ -72,9 +74,14 @@ jobs: run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal - uses: actions/upload-artifact@v4 with: - name: monal-catalyst + name: monal-catalyst-zip path: Monal/build/app/Monal.zip if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: monal-catalyst-pkg + path: Monal/build/app/Monal.pkg + if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: monal-ios @@ -90,6 +97,31 @@ jobs: name: monal-ios-dsym path: Monal/build/ios_Monal.xcarchive/dSYMs if-no-files-found: error + - name: Extract version number and changelog from newest merge commit + id: releasenotes + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + - name: Release + uses: softprops/action-gh-release@v2 + with: + name: "${{ steps.releasenotes.outputs.name }}" + tag_name: "${{ steps.releasenotes.outputs.tag }}" + target_commitish: beta + generate_release_notes: false + body: "${{ steps.releasenotes.outputs.notes }}" + files: | + ./Monal/build/ipa/Monal.ipa + ./Monal/build/app/Monal.pkg + ./Monal/build/app/Monal.zip + fail_on_unmatched_files: true + token: ${{ secrets.GITHUB_TOKEN }} + draft: false + prerelease: true updateTranslations: name: Update Translations using Beta-Branch diff --git a/.github/workflows/develop-push.yml b/.github/workflows/develop-push.yml index 63c62120ca..aff248d8f8 100644 --- a/.github/workflows/develop-push.yml +++ b/.github/workflows/develop-push.yml @@ -28,8 +28,10 @@ jobs: with: clean: true submodules: true - - name: Fetch tags - run: git fetch --tags + fetch-depth: 100 + fetch-tags: true + show-progress: true + lfs: true - name: Checkout submodules run: git submodule update -f --init --remote - name: Import TURN secrets diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index 251e3234cb..73cfe4f7df 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -28,8 +28,10 @@ jobs: with: clean: true submodules: true - - name: Fetch tags - run: git fetch --tags + fetch-depth: 100 + fetch-tags: true + show-progress: true + lfs: true - name: Checkout submodules run: git submodule update -f --init --remote - name: Get last build tag and increment it @@ -73,27 +75,11 @@ jobs: run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id maccatalyst.G7YU7X7KRJ.SworIM # - name: Update xmpp.org client list with new timestamp # run: ./scripts/push_xmpp.org.sh - - name: Extract version number and changelog from newest merge commit - id: releasenotes - run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') - echo "tag=Build_iOS_$buildNumber" >> "$GITHUB_OUTPUT" - echo "name=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" >> "$GITHUB_OUTPUT" - echo "notes=$(git log -n 1 --merges --pretty=format:%b)" >> "$GITHUB_OUTPUT" - - name: Release - uses: softprops/action-gh-release@v2 + - uses: actions/upload-artifact@v4 with: - name: "Release ${{ steps.releasenotes.outputs.name }}" - tag_name: "${{ steps.releasenotes.outputs.tag }}" - target_commitish: stable - generate_release_notes: false - body: "${{ steps.releasenotes.outputs.notes }}" - files: | - ./Monal/build/ipa/Monal.ipa - ./Monal/build/app/Monal.zip - fail_on_unmatched_files: true - token: ${{ secrets.GITHUB_TOKEN }} - draft: false + name: monal-catalyst-zip + path: Monal/build/app/Monal.zip + if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: monal-catalyst-pkg @@ -114,3 +100,28 @@ jobs: # name: monal-ios-dsym # path: Monal/build/ios_Monal.xcarchive/dSYMs # if-no-files-found: error + - name: Extract version number and changelog from newest merge commit + id: releasenotes + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + - name: Release + uses: softprops/action-gh-release@v2 + with: + name: "${{ steps.releasenotes.outputs.name }}" + tag_name: "${{ steps.releasenotes.outputs.tag }}" + target_commitish: stable + generate_release_notes: false + body: "${{ steps.releasenotes.outputs.notes }}" + files: | + ./Monal/build/ipa/Monal.ipa + ./Monal/build/app/Monal.pkg + ./Monal/build/app/Monal.zip + fail_on_unmatched_files: true + token: ${{ secrets.GITHUB_TOKEN }} + draft: false + prerelease: false diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index a628f9ec12..fa3c998320 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -24,6 +24,10 @@ jobs: with: clean: true submodules: true + fetch-depth: 100 + fetch-tags: true + show-progress: true + lfs: true - name: Checkout submodules run: git submodule update -f --init --remote - name: Update translations From 5b8d7532dc656f9746688fcaf3cf4ae4cf098616 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 22 Jun 2024 15:40:27 +0200 Subject: [PATCH 022/131] Use fastlane to automatically push builds to testflight --- .github/workflows/beta.build-push.yml | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index d12c7d51e1..df43ebb704 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -53,12 +53,23 @@ jobs: run: ./scripts/build.sh - name: validate ios app run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - - name: push tag to beta repo + - name: Push beta tag to repo run: | buildNumber=$(git tag --sort="v:refname" |grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') git push origin Build_iOS_$buildNumber + - name: Extract version number and changelog from newest merge commit + id: releasenotes + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - name: Publish ios to appstore connect - run: xcrun altool --upload-app -f ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + #run: xcrun altool --upload-app -f ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + run: | + fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:"${{ steps.releasenotes.outputs.notes }}" ipa:"./Monal/build/ipa/Monal.ipa" distribute_external:true groups:"138111da-28b0-4f57-b870-ecfd90f48637","7309b303-b560-4cca-afa1-b5624e932c0b" reject_build_waiting_for_review:true submit_beta_review:true - name: Notarize catalyst run: xcrun notarytool submit ./Monal/build/app/Monal.zip --wait --team-id S8D843U34Y --key "/Users/ci/appstoreconnect/apiKey.p8" --key-id "$(cat /Users/ci/appstoreconnect/apiKeyId.txt)" --issuer "$(cat /Users/ci/appstoreconnect/apiIssuerId.txt)" - name: staple @@ -68,10 +79,12 @@ jobs: stapler validate "$APP_DIR" /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "../$APP_NAME.zip" cd ../../../.. - - name: upload new catalyst beta to monal-im.org + - name: Upload new catalyst beta to monal-im.org run: ./scripts/uploadNonAlpha.sh beta - name: Publish catalyst to appstore connect - run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal + #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal + run: | + fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:"${{ steps.releasenotes.outputs.notes }}" pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"8dad81f2-a83e-41f3-83d6-99626fcc538f","6e08cf63-829a-40ed-ac79-f38fc554d048" reject_build_waiting_for_review:true submit_beta_review:true - uses: actions/upload-artifact@v4 with: name: monal-catalyst-zip @@ -97,15 +110,6 @@ jobs: name: monal-ios-dsym path: Monal/build/ios_Monal.xcarchive/dSYMs if-no-files-found: error - - name: Extract version number and changelog from newest merge commit - id: releasenotes - run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') - echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - name: Release uses: softprops/action-gh-release@v2 with: From fc9be5438cf39ae9017e76dd07020eb6fb4ea850 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 22 Jun 2024 15:42:12 +0200 Subject: [PATCH 023/131] Bump version to 6.4.1 --- Monal/Monal.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index 5ebe43a480..01b05f5e64 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -2662,7 +2662,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.0; + MARKETING_VERSION = 6.4.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -3029,7 +3029,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.0; + MARKETING_VERSION = 6.4.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -3191,7 +3191,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.0; + MARKETING_VERSION = 6.4.1; ONLY_ACTIVE_ARCH = YES; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; RUN_CLANG_STATIC_ANALYZER = YES; @@ -3467,7 +3467,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.0; + MARKETING_VERSION = 6.4.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; RUN_CLANG_STATIC_ANALYZER = YES; SDKROOT = iphoneos; @@ -3820,7 +3820,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.0; + MARKETING_VERSION = 6.4.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -4234,7 +4234,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = NO; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.0; + MARKETING_VERSION = 6.4.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; RUN_CLANG_STATIC_ANALYZER = YES; SDKROOT = iphoneos; From 83ce24c4b3a4d7a71ebb682b2aad251fb76b3364 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 20 Jun 2024 22:28:48 +0200 Subject: [PATCH 024/131] Test release summary generator --- .github/workflows/test-release.yml | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/test-release.yml diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml new file mode 100644 index 0000000000..0c5c880054 --- /dev/null +++ b/.github/workflows/test-release.yml @@ -0,0 +1,32 @@ +# build a new stable release and push it to apple +name: test-release + +# Controls when the action will run. +on: + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + buildAndPublishStable: + # The type of runner that the job will run on + runs-on: self-hosted + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: Extract version number and changelog from newest merge commit + id: releasenotes + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes=$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" + - name: Release + uses: softprops/action-gh-release@v2 + with: + name: "Release ${{ steps.releasenotes.outputs.name }}" + tag_name: "${{ steps.releasenotes.outputs.tag }}" + target_commitish: stable + generate_release_notes: false + body: "${{ steps.releasenotes.outputs.notes }}" + token: ${{ secrets.GITHUB_TOKEN }} + draft: true From c83fd45fb280784bcfb9b52723982cbcd29a34b0 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Thu, 20 Jun 2024 21:48:38 +0100 Subject: [PATCH 025/131] Set target for contact view buttons, closes #942 When the target is not explicitly set, iOS will look up teh chain for something that can handle the action. This worked when the contacts view was initially loaded, but when the search view was opened and closed again, it caused the handler for these two buttons not to get picked up. By explicitly setting the target to self, we instruct the runtime that we are capable of handling the action, meaning it can still get triggered even when re-entering the view. --- Monal/Classes/ContactsViewController.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Monal/Classes/ContactsViewController.m b/Monal/Classes/ContactsViewController.m index 95339d6c64..01ed4ec13f 100644 --- a/Monal/Classes/ContactsViewController.m +++ b/Monal/Classes/ContactsViewController.m @@ -107,10 +107,12 @@ -(void) viewDidLoad UIBarButtonItem* addContact = [UIBarButtonItem new]; addContact.image = [UIImage systemImageNamed:@"person.fill.badge.plus"]; [addContact setAction:@selector(openAddContacts:)]; + [addContact setTarget:self]; UIBarButtonItem* createGroup = [[UIBarButtonItem alloc] init]; createGroup.image = [UIImage systemImageNamed:@"person.3.fill"]; [createGroup setAction:@selector(openCreateGroup:)]; + [createGroup setTarget:self]; self.navigationItem.rightBarButtonItems = [[NSArray alloc] initWithObjects:addContact, [[UIBarButtonItem alloc] init], createGroup, nil]; [self configureContactRequestsImage]; From da7e4a59b85f2e7c6aab7802f4776173f9576cb2 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Thu, 20 Jun 2024 21:50:54 +0100 Subject: [PATCH 026/131] Fix some whitespace in ContactsViewController --- Monal/Classes/ContactsViewController.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/ContactsViewController.m b/Monal/Classes/ContactsViewController.m index 01ed4ec13f..1eb37b853a 100644 --- a/Monal/Classes/ContactsViewController.m +++ b/Monal/Classes/ContactsViewController.m @@ -110,8 +110,8 @@ -(void) viewDidLoad [addContact setTarget:self]; UIBarButtonItem* createGroup = [[UIBarButtonItem alloc] init]; - createGroup.image = [UIImage systemImageNamed:@"person.3.fill"]; - [createGroup setAction:@selector(openCreateGroup:)]; + createGroup.image = [UIImage systemImageNamed:@"person.3.fill"]; + [createGroup setAction:@selector(openCreateGroup:)]; [createGroup setTarget:self]; self.navigationItem.rightBarButtonItems = [[NSArray alloc] initWithObjects:addContact, [[UIBarButtonItem alloc] init], createGroup, nil]; From 27171105a02ded63fe868ebbcd03d6c23c7d8a16 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Thu, 20 Jun 2024 21:51:22 +0100 Subject: [PATCH 027/131] Initialise right bar buttons with literal syntax --- Monal/Classes/ContactsViewController.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/ContactsViewController.m b/Monal/Classes/ContactsViewController.m index 1eb37b853a..7eb6ca5f28 100644 --- a/Monal/Classes/ContactsViewController.m +++ b/Monal/Classes/ContactsViewController.m @@ -113,7 +113,7 @@ -(void) viewDidLoad createGroup.image = [UIImage systemImageNamed:@"person.3.fill"]; [createGroup setAction:@selector(openCreateGroup:)]; [createGroup setTarget:self]; - self.navigationItem.rightBarButtonItems = [[NSArray alloc] initWithObjects:addContact, [[UIBarButtonItem alloc] init], createGroup, nil]; + self.navigationItem.rightBarButtonItems = @[addContact, [[UIBarButtonItem alloc] init], createGroup]; [self configureContactRequestsImage]; From 430457de4b678ed90b92581006cf1c035bf2b027 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 23 Jun 2024 21:32:46 +0200 Subject: [PATCH 028/131] Make sure to save name of newly created muc to db --- Monal/Classes/CreateGroupMenu.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Monal/Classes/CreateGroupMenu.swift b/Monal/Classes/CreateGroupMenu.swift index 12e9938a72..8cb54c522f 100644 --- a/Monal/Classes/CreateGroupMenu.swift +++ b/Monal/Classes/CreateGroupMenu.swift @@ -77,6 +77,7 @@ struct CreateGroupMenu: View { self.selectedAccount!.mucProcessor.addUIHandler({_data in let data = _data as! NSDictionary let success : Bool = data["success"] as! Bool; if success { + DataLayer.sharedInstance().setFullName(self.groupName, forContact:roomJid, andAccount:self.selectedAccount!.accountNo) self.selectedAccount!.mucProcessor.changeName(ofMuc: roomJid, to: self.groupName) for user in self.selectedContacts { self.selectedAccount!.mucProcessor.setAffiliation("member", ofUser: user.contactJid, inMuc: roomJid) From 087db19011c452cd2c2cdaebea1f64347b6cadf6 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 23 Jun 2024 21:33:31 +0200 Subject: [PATCH 029/131] Don't remove old participants from db while joining muc This fixes the display of omemo keys in mucs while joining (it showed an empty list before because it did not know which participants/members were in the muc) --- Monal/Classes/DataLayer.h | 2 +- Monal/Classes/DataLayer.m | 9 +++++---- Monal/Classes/MLMucProcessor.m | 5 ++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h index 88763fcda5..f571c8d221 100644 --- a/Monal/Classes/DataLayer.h +++ b/Monal/Classes/DataLayer.h @@ -110,7 +110,7 @@ extern NSString* const kMessageTypeFiletransfer; #pragma mark - MUC -(BOOL) initMuc:(NSString*) room forAccountId:(NSNumber*) accountNo andMucNick:(NSString* _Nullable) mucNick; --(void) cleanupMembersAndParticipantsListFor:(NSString*) room forAccountId:(NSNumber*) accountNo; +-(void) cleanupMembersAndParticipantsListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo; -(void) addMember:(NSDictionary*) member toMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; -(void) removeMember:(NSDictionary*) member fromMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; -(void) addParticipant:(NSDictionary*) participant toMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index e929020652..6842ae7ca0 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -938,7 +938,8 @@ -(BOOL) initMuc:(NSString*) room forAccountId:(NSNumber*) accountNo andMucNick:( nick = [self ownNickNameforMuc:room forAccount:accountNo]; MLAssert(nick != nil, @"Could not determine muc nick when adding muc"); - [self cleanupMembersAndParticipantsListFor:room forAccountId:accountNo]; + for(NSString* type in @[@"member", @"admin", @"owner"]) + [self cleanupMembersAndParticipantsListFor:room andType:type onAccountId:accountNo]; BOOL encrypt = NO; #ifndef DISABLE_OMEMO @@ -950,11 +951,11 @@ -(BOOL) initMuc:(NSString*) room forAccountId:(NSNumber*) accountNo andMucNick:( }]; } --(void) cleanupMembersAndParticipantsListFor:(NSString*) room forAccountId:(NSNumber*) accountNo +-(void) cleanupMembersAndParticipantsListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo { //clean up old muc data (will be refilled by incoming presences and/or disco queries) - [self.db executeNonQuery:@"DELETE FROM muc_participants WHERE account_id=? AND room=?;" andArguments:@[accountNo, room]]; - [self.db executeNonQuery:@"DELETE FROM muc_members WHERE account_id=? AND room=?;" andArguments:@[accountNo, room]]; + [self.db executeNonQuery:@"DELETE FROM muc_participants WHERE account_id=? AND room=? AND affiliation=?;" andArguments:@[accountNo, room, type]]; + [self.db executeNonQuery:@"DELETE FROM muc_members WHERE account_id=? AND room=? AND affiliation=?;" andArguments:@[accountNo, room, type]]; } -(void) addParticipant:(NSDictionary*) participant toMuc:(NSString*) room forAccountId:(NSNumber*) accountNo diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m index 3931bfba97..e4bfbf3a4d 100644 --- a/Monal/Classes/MLMucProcessor.m +++ b/Monal/Classes/MLMucProcessor.m @@ -1482,9 +1482,6 @@ -(void) publishAvatar:(UIImage* _Nullable) image forMuc:(NSString*) room if(join) { - DDLogInfo(@"Clearing muc participants and members tables for %@", iqNode.fromUser); - [[DataLayer sharedInstance] cleanupMembersAndParticipantsListFor:iqNode.fromUser forAccountId:_account.accountNo]; - //now try to join this room if requested [self sendJoinPresenceFor:iqNode.fromUser]; } @@ -1516,6 +1513,8 @@ -(void) sendJoinPresenceFor:(NSString*) room $$instance_handler(handleMembersList, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, type)) DDLogInfo(@"Got %@s list from %@...", type, iqNode.fromUser); + DDLogInfo(@"Clearing muc participants and members tables for type %@: %@", type, iqNode.fromUser); + [[DataLayer sharedInstance] cleanupMembersAndParticipantsListFor:iqNode.fromUser andType:type onAccountId:_account.accountNo]; [self handleMembersListUpdate:[iqNode find:@"{http://jabber.org/protocol/muc#admin}query/item@@"] forMuc:iqNode.fromUser]; [self logMembersOfMuc:iqNode.fromUser]; $$ From b1f88bf8dd72ddf818c449d80e688671281b2544 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 23 Jun 2024 21:34:59 +0200 Subject: [PATCH 030/131] Fix muc ui handler call when creating mucs --- Monal/Classes/MLMucProcessor.m | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m index e4bfbf3a4d..e0a806dc88 100644 --- a/Monal/Classes/MLMucProcessor.m +++ b/Monal/Classes/MLMucProcessor.m @@ -622,14 +622,15 @@ -(void) configureMuc:(NSString*) roomJid withMandatoryOptions:(NSDictionary*) ma @"roomJid": [NSString stringWithFormat:@"%@", roomJid], })); - [self callSuccessUIHandlerForMuc:iqNode.fromUser]; - + //don't call success handler if we are only "half-joined" (see comments below for what that means) if(joinOnSuccess) { //group is now properly configured and we are joined, but all the code handling a proper join was not run //--> join again to make sure everything is sane [self join:roomJid]; } + else + [self callSuccessUIHandlerForMuc:iqNode.fromUser]; $$ -(void) handleStatusCodes:(XMPPStanza*) node From 670dd67cb3439e5ea6b6f3a1e7887df5e8436102 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 23 Jun 2024 21:43:20 +0200 Subject: [PATCH 031/131] Fix calls from strangers privacy settings display When messages from strangers aren't allowed, the calls from strangers toggle should not only be disabled, but turned, off, too. When messages from strangers are allowed again, the calls from strangers toggle should return to its previos state. --- Monal/Classes/GeneralSettings.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index 4d6dcba9cf..008a045acd 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -378,7 +378,10 @@ struct PrivacySettings: View { Text("Accept incoming messages from strangers") Text("Allow contacts not in your contact list to contact you.") } - SettingsToggle(isOn: $generalSettingsDefaultsDB.allowCallsFromNonRosterContacts) { + SettingsToggle(isOn: Binding( + get: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts && generalSettingsDefaultsDB.allowNonRosterContacts }, + set: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts = $0 } + )) { Text("Accept incoming calls from strangers") Text("Allow contacts not in your contact list to call you.") }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts) From b94ef1a999d62dbf220046e5e2064741aea0c208 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 23 Jun 2024 21:55:08 +0200 Subject: [PATCH 032/131] Honor privacy settings for incoming muc invites Previously an incoming invite was ignored when the user was not at least marked as subscribedFrom in our roster. Now invites from unknown jids are accepted, if the privacy setting allowNonRosterContacts is turned on. --- Monal/Classes/MLMucProcessor.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m index e0a806dc88..93879be5d9 100644 --- a/Monal/Classes/MLMucProcessor.m +++ b/Monal/Classes/MLMucProcessor.m @@ -189,7 +189,7 @@ -(void) handleSentMessage:(NSNotification*) notification XMPPMessage* msg = notification.userInfo[@"message"]; NSString* callUiHandlerFor = nil; - //check if this is a direct invite (direct invites always follow indirect ones, so we don't have to check for indirect ones) + //check if this is a direct invite if([msg check:@"/{jabber:client}message/{jabber:x:conference}x@jid"]) callUiHandlerFor = [msg findFirst:@"/{jabber:client}message/{jabber:x:conference}x@jid"]; @@ -420,7 +420,7 @@ -(BOOL) processMessage:(XMPPMessage*) messageNode } MLContact* inviteFrom = [MLContact createContactFromJid:invitedMucJid andAccountNo:_account.accountNo]; DDLogInfo(@"Got mediated muc invite from %@ for %@...", inviteFrom, messageNode.fromUser); - if(!inviteFrom.isSubscribedFrom) + if(![[HelperTools defaultsDB] boolForKey: @"allowNonRosterContacts"] && !inviteFrom.isSubscribedFrom) { DDLogWarn(@"Ignoring invite from %@, this jid isn't at least marked as susbscribedFrom in our roster...", inviteFrom); return YES; //don't process this further @@ -439,7 +439,7 @@ -(BOOL) processMessage:(XMPPMessage*) messageNode MLContact* inviteFrom = [MLContact createContactFromJid:messageNode.fromUser andAccountNo:_account.accountNo]; DDLogInfo(@"Got direct muc invite from %@ for %@ --> joining...", inviteFrom, [messageNode findFirst:@"{jabber:x:conference}x@jid"]); - if(!inviteFrom.isSubscribedFrom) + if(![[HelperTools defaultsDB] boolForKey: @"allowNonRosterContacts"] && !inviteFrom.isSubscribedFrom) { DDLogWarn(@"Ignoring invite from %@, this jid isn't at least marked as susbscribedFrom in our roster...", inviteFrom); return YES; //don't process this further From 7de168501fa671ce9c8361c722251b1ac755152b Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 23 Jun 2024 23:30:41 +0200 Subject: [PATCH 033/131] Show muc nickname if group/channel participant is not in our roster --- Monal/Classes/ContactEntry.swift | 38 ++++++++++++++++++++++++++++---- Monal/Classes/MLContact.h | 1 + Monal/Classes/MemberList.swift | 6 ++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/Monal/Classes/ContactEntry.swift b/Monal/Classes/ContactEntry.swift index ce3ccea0c9..9cb3fc5aca 100644 --- a/Monal/Classes/ContactEntry.swift +++ b/Monal/Classes/ContactEntry.swift @@ -9,19 +9,33 @@ struct ContactEntry: View { let contact: ObservableKVOWrapper let selfnotesPrefix: Bool + let fallback: String? @ViewBuilder let additionalContent: () -> AdditionalContent - init(contact:ObservableKVOWrapper, selfnotesPrefix: Bool = true) where AdditionalContent == EmptyView { - self.init(contact:contact, selfnotesPrefix:selfnotesPrefix, additionalContent:{ EmptyView() }) + init(contact:ObservableKVOWrapper, selfnotesPrefix: Bool = true, fallback: String? = nil) where AdditionalContent == EmptyView { + self.init(contact:contact, selfnotesPrefix:selfnotesPrefix, fallback:fallback, additionalContent:{ EmptyView() }) + } + + init(contact:ObservableKVOWrapper, fallback: String?) where AdditionalContent == EmptyView { + self.init(contact:contact, selfnotesPrefix:true, fallback:fallback, additionalContent:{ EmptyView() }) } init(contact:ObservableKVOWrapper, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) { self.init(contact:contact, selfnotesPrefix:true, additionalContent:additionalContent) } + init(contact:ObservableKVOWrapper, fallback: String?, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) { + self.init(contact:contact, selfnotesPrefix:true, fallback:fallback, additionalContent:additionalContent) + } + init(contact:ObservableKVOWrapper, selfnotesPrefix: Bool, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) { + self.init(contact:contact, selfnotesPrefix:selfnotesPrefix, fallback:nil, additionalContent:additionalContent) + } + + init(contact:ObservableKVOWrapper, selfnotesPrefix: Bool, fallback: String?, @ViewBuilder additionalContent: @escaping () -> AdditionalContent) { self.contact = contact self.selfnotesPrefix = selfnotesPrefix + self.fallback = fallback self.additionalContent = additionalContent } @@ -33,9 +47,25 @@ struct ContactEntry: View { .frame(width: 40, height: 40, alignment: .center) VStack(alignment: .leading) { if selfnotesPrefix { - Text(contact.contactDisplayName as String) + // use the `let contactDisplayName` to make sure this view gets updated if the contact display name changes + // (the condition is never false, because contactDisplayName can not be nil) + if let contactDisplayName = (contact.contactDisplayName as String?) { + if let fallback = fallback { + Text(contact.obj.contactDisplayName(withFallback:fallback)) + } else { + Text(contactDisplayName) + } + } } else { - Text(contact.contactDisplayNameWithoutSelfnotesPrefix as String) + // use the `let contactDisplayNameWithoutSelfnotesPrefix` to make sure this view gets updated if the contact display name changes + // (the condition is never false, because contactDisplayNameWithoutSelfnotesPrefix can not be nil) + if let contactDisplayNameWithoutSelfnotesPrefix = (contact.contactDisplayNameWithoutSelfnotesPrefix as String?) { + if let fallback = fallback { + Text(contact.obj.contactDisplayName(withFallback:fallback, andSelfnotesPrefix:false)) + } else { + Text(contactDisplayNameWithoutSelfnotesPrefix) + } + } } additionalContent() Text(contact.contactJid as String) diff --git a/Monal/Classes/MLContact.h b/Monal/Classes/MLContact.h index 80a2b90b11..767f310ca2 100644 --- a/Monal/Classes/MLContact.h +++ b/Monal/Classes/MLContact.h @@ -95,6 +95,7 @@ FOUNDATION_EXPORT NSString* const kAskSubscribe; @property (nonatomic, readonly) NSString* contactDisplayNameWithoutSelfnotesPrefix; -(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName; +-(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName andSelfnotesPrefix:(BOOL) hasSelfnotesPrefix; -(void) updateWithContact:(MLContact*) contact; -(void) refresh; -(void) updateUnreadCount; diff --git a/Monal/Classes/MemberList.swift b/Monal/Classes/MemberList.swift index a00f412b35..41ba842119 100644 --- a/Monal/Classes/MemberList.swift +++ b/Monal/Classes/MemberList.swift @@ -21,6 +21,7 @@ struct MemberList: View { @State private var memberList: OrderedSet> @State private var affiliations: Dictionary, String> @State private var online: Dictionary, Bool> + @State private var nicknames: Dictionary, String> @State private var navigationActive: ObservableKVOWrapper? @State private var showAlert = false @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close")) @@ -35,6 +36,7 @@ struct MemberList: View { _memberList = State(wrappedValue:OrderedSet>()) _affiliations = State(wrappedValue:[:]) _online = State(wrappedValue:[:]) + _nicknames = State(wrappedValue:[:]) } func updateMemberlist() { @@ -42,12 +44,14 @@ struct MemberList: View { ownAffiliation = DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:self.muc.obj) ?? "none" affiliations.removeAll(keepingCapacity:true) online.removeAll(keepingCapacity:true) + nicknames.removeAll(keepingCapacity:true) for memberInfo in Array(DataLayer.sharedInstance().getMembersAndParticipants(ofMuc:self.muc.contactJid, forAccountId:account.accountNo)) { DDLogVerbose("Got member/participant entry: \(String(describing:memberInfo))") guard let jid = memberInfo["participant_jid"] as? String ?? memberInfo["member_jid"] as? String else { continue } let contact = ObservableKVOWrapper(MLContact.createContact(fromJid:jid, andAccountNo:account.accountNo)) + nicknames[contact] = memberInfo["room_nick"] as? String if !memberList.contains(contact) { continue } @@ -258,7 +262,7 @@ struct MemberList: View { if !contact.isSelfChat { HStack { HStack { - ContactEntry(contact:contact) { + ContactEntry(contact:contact, fallback:nicknames[contact]) { Text("Affiliation: \(mucAffiliationToString(affiliations[contact]))\(!(online[contact] ?? false) ? Text(" (offline)") : Text(""))") //.foregroundColor(Color(UIColor.secondaryLabel)) .font(.footnote) From cef50f3e4ea93b6bca061f614b79deb78f1f61ad Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 23 Jun 2024 23:32:59 +0200 Subject: [PATCH 034/131] Remove release test workflow --- .github/workflows/test-release.yml | 32 ------------------------------ 1 file changed, 32 deletions(-) delete mode 100644 .github/workflows/test-release.yml diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml deleted file mode 100644 index 0c5c880054..0000000000 --- a/.github/workflows/test-release.yml +++ /dev/null @@ -1,32 +0,0 @@ -# build a new stable release and push it to apple -name: test-release - -# Controls when the action will run. -on: - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - buildAndPublishStable: - # The type of runner that the job will run on - runs-on: self-hosted - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - - name: Extract version number and changelog from newest merge commit - id: releasenotes - run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') - echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "name=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "notes=$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" - - name: Release - uses: softprops/action-gh-release@v2 - with: - name: "Release ${{ steps.releasenotes.outputs.name }}" - tag_name: "${{ steps.releasenotes.outputs.tag }}" - target_commitish: stable - generate_release_notes: false - body: "${{ steps.releasenotes.outputs.notes }}" - token: ${{ secrets.GITHUB_TOKEN }} - draft: true From dc9133538cb145c6afad6e72452ab399efdbf38e Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 24 Jun 2024 01:16:25 +0200 Subject: [PATCH 035/131] Alert support muc of new beta release after build --- .github/workflows/beta.build-push.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index df43ebb704..78b2c09e43 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -15,6 +15,10 @@ jobs: buildAndPublishBeta: name: "Build and Publish Beta Release" runs-on: self-hosted + outputs: + release-tag: ${{ steps.releasenotes.outputs.tag }} + release-name: ${{ steps.releasenotes.outputs.name }} + release-changelog: ${{ steps.releasenotes.outputs.notes }} env: APP_NAME: "Monal" APP_DIR: "Monal.app" @@ -148,3 +152,20 @@ jobs: chmod +x ./scripts/updateLocalization.sh chmod +x ./scripts/xliff_extractor.py ./scripts/updateLocalization.sh BUILDSERVER + + notifyMuc: + name: Notify support MUC about new Betarelease + runs-on: ubuntu-latest + needs: [buildAndPublishBeta] + steps: + - name: Notify + uses: processone/xmpp-notifier@master + with: # Set the secrets as inputs + jid: ${{ secrets.BOT_JID }} + password: ${{ secrets.BOT_PASSWORD }} + server_host: ${{ secrets.BOT_SERVER }} + recipient: monal@chat.yax.im + recipient_is_room: true + message: | + New Betarelease: ${{ needs.buildAndPublishBeta.outputs.release-name }} + ${{ needs.buildAndPublishBeta.outputs.release-changelog }} From 3b6301fd816e7659e85500c94a67161b9def239e Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 24 Jun 2024 01:47:11 +0200 Subject: [PATCH 036/131] --- 912 --- 6.4.1b1 From e9cfcb31b7596b16f687edae2ed761451e3bc8a0 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 24 Jun 2024 03:09:39 +0200 Subject: [PATCH 037/131] Fix release name and changelog extraction --- .github/workflows/beta.build-push.yml | 11 +++++++---- .github/workflows/stable.build-push.yml | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 78b2c09e43..638c803941 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -66,14 +66,15 @@ jobs: run: | buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - name: Publish ios to appstore connect #run: xcrun altool --upload-app -f ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" run: | - fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:"${{ steps.releasenotes.outputs.notes }}" ipa:"./Monal/build/ipa/Monal.ipa" distribute_external:true groups:"138111da-28b0-4f57-b870-ecfd90f48637","7309b303-b560-4cca-afa1-b5624e932c0b" reject_build_waiting_for_review:true submit_beta_review:true + notes=$(printf '%s\n' "${{ steps.releasenotes.outputs.notes }}" | jq -sRr @sh) + fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:$notes ipa:"./Monal/build/ipa/Monal.ipa" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - name: Notarize catalyst run: xcrun notarytool submit ./Monal/build/app/Monal.zip --wait --team-id S8D843U34Y --key "/Users/ci/appstoreconnect/apiKey.p8" --key-id "$(cat /Users/ci/appstoreconnect/apiKeyId.txt)" --issuer "$(cat /Users/ci/appstoreconnect/apiIssuerId.txt)" - name: staple @@ -88,7 +89,8 @@ jobs: - name: Publish catalyst to appstore connect #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal run: | - fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:"${{ steps.releasenotes.outputs.notes }}" pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"8dad81f2-a83e-41f3-83d6-99626fcc538f","6e08cf63-829a-40ed-ac79-f38fc554d048" reject_build_waiting_for_review:true submit_beta_review:true + notes=$(printf '%s\n' "${{ steps.releasenotes.outputs.notes }}" | jq -sRr @sh) + fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:$notes pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - uses: actions/upload-artifact@v4 with: name: monal-catalyst-zip @@ -159,13 +161,14 @@ jobs: needs: [buildAndPublishBeta] steps: - name: Notify - uses: processone/xmpp-notifier@master + uses: monal-im/xmpp-notifier@master with: # Set the secrets as inputs jid: ${{ secrets.BOT_JID }} password: ${{ secrets.BOT_PASSWORD }} server_host: ${{ secrets.BOT_SERVER }} recipient: monal@chat.yax.im recipient_is_room: true + bot_alias: "Monal Release Bot" message: | New Betarelease: ${{ needs.buildAndPublishBeta.outputs.release-name }} ${{ needs.buildAndPublishBeta.outputs.release-changelog }} diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index 73cfe4f7df..b3fae8ea6f 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -105,7 +105,7 @@ jobs: run: | buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^\s*([^\s]+)\s+\(([^\s]+)\)$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" From d1a8cf2016712b34acc7de2e5d791ff44ac97d1b Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 02:30:49 +0200 Subject: [PATCH 038/131] Add logging to foregrounding/backgrounding in MLXMPPManager --- Monal/Classes/MLDelayableTimer.h | 13 +++++++++++++ Monal/Classes/MLDelayableTimer.m | 9 +++++++++ Monal/Classes/MLXMPPManager.m | 4 ++++ 3 files changed, 26 insertions(+) create mode 100644 Monal/Classes/MLDelayableTimer.h create mode 100644 Monal/Classes/MLDelayableTimer.m diff --git a/Monal/Classes/MLDelayableTimer.h b/Monal/Classes/MLDelayableTimer.h new file mode 100644 index 0000000000..f908da8b10 --- /dev/null +++ b/Monal/Classes/MLDelayableTimer.h @@ -0,0 +1,13 @@ +// +// MLDelayableTimer.h +// monalxmpp +// +// Created by admin on 24.06.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +#ifndef MLDelayableTimer_h +#define MLDelayableTimer_h + + +#endif /* MLDelayableTimer_h */ diff --git a/Monal/Classes/MLDelayableTimer.m b/Monal/Classes/MLDelayableTimer.m new file mode 100644 index 0000000000..4401e79c7c --- /dev/null +++ b/Monal/Classes/MLDelayableTimer.m @@ -0,0 +1,9 @@ +// +// MLDelayableTimer.m +// monalxmpp +// +// Created by admin on 24.06.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +#import diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index b1e9a0f902..1f5a508c71 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -395,6 +395,8 @@ -(void) noLongerInFocus -(void) nowBackgrounded { + DDLogInfo(@"App now backgrounded..."); + _isBackgrounded = YES; _isNotInFocus = YES; @@ -404,6 +406,8 @@ -(void) nowBackgrounded -(void) nowForegrounded { + DDLogInfo(@"App now foregrounded..."); + _isBackgrounded = NO; _isNotInFocus = NO; From 5e8d84e8f8c2434ee1d75688a681aa2eba68ccc9 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 04:04:20 +0200 Subject: [PATCH 039/131] Streamline applicationWillTerminate handling for faster completion We only have ~50ms to complete this before Apple kills us. --- Monal/Classes/MonalAppDelegate.m | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Monal/Classes/MonalAppDelegate.m b/Monal/Classes/MonalAppDelegate.m index 725a8a5054..b5dbc84b64 100644 --- a/Monal/Classes/MonalAppDelegate.m +++ b/Monal/Classes/MonalAppDelegate.m @@ -1235,15 +1235,11 @@ -(void) applicationWillTerminate:(UIApplication *)application _shutdownPending = YES; DDLogWarn(@"|~~| T E R M I N A T I N G |~~|"); [HelperTools scheduleBackgroundTask:YES]; //make sure delivery will be attempted, if needed (force as soon as possible) - DDLogInfo(@"|~~| 20%% |~~|"); - [self updateUnread]; - DDLogInfo(@"|~~| 40%% |~~|"); - [[HelperTools defaultsDB] synchronize]; - DDLogInfo(@"|~~| 60%% |~~|"); + DDLogInfo(@"|~~| 33%% |~~|"); [[MLXMPPManager sharedInstance] nowBackgrounded]; - DDLogInfo(@"|~~| 80%% |~~|"); + DDLogInfo(@"|~~| 66%% |~~|"); [HelperTools updateSyncErrorsWithDeleteOnly:NO andWaitForCompletion:YES]; - DDLogInfo(@"|~~| 100%% |~~|"); + DDLogInfo(@"|~~| 99%% |~~|"); [[MLXMPPManager sharedInstance] disconnectAll]; DDLogInfo(@"|~~| T E R M I N A T E D |~~|"); [DDLog flushLog]; From a5b4177292281eec68e0525b88faa1a6c8f9e196 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 03:42:49 +0200 Subject: [PATCH 040/131] Implement delayable timers and use them when frezing the parse queue This implementation allows timers to be paused and resumed. That allows us to suspend timers while the parse queue is frozen to not trigger unwanted behaviour while the queue is frozen. --- Monal/Classes/HelperTools.h | 13 ++- Monal/Classes/HelperTools.m | 69 ++++++------ Monal/Classes/MLConstants.h | 1 + Monal/Classes/MLDelayableTimer.h | 22 +++- Monal/Classes/MLDelayableTimer.m | 122 ++++++++++++++++++++- Monal/Classes/SwiftHelpers.swift | 1 + Monal/Classes/xmpp.m | 149 +++++++++++++++----------- Monal/Monal.xcodeproj/project.pbxproj | 8 ++ Monal/monalxmpp/module.modulemap | 4 + Monal/monalxmpp/monalxmpp.h | 1 + 10 files changed, 284 insertions(+), 106 deletions(-) diff --git a/Monal/Classes/HelperTools.h b/Monal/Classes/HelperTools.h index fa953819fa..e608499fb9 100644 --- a/Monal/Classes/HelperTools.h +++ b/Monal/Classes/HelperTools.h @@ -8,12 +8,17 @@ #import #import "MLConstants.h" +#import "MLDelayableTimer.h" #include "metamacros.h" -#define createTimer(timeout, handler, ...) createQueuedTimer(timeout, nil, handler, __VA_ARGS__) -#define createQueuedTimer(timeout, queue, handler, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([HelperTools startQueuedTimer:timeout withHandler:handler andCancelHandler:nil andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue])(_createQueuedTimer(timeout, queue, handler, __VA_ARGS__)) -#define _createQueuedTimer(timeout, queue, handler, cancelHandler, ...) [HelperTools startQueuedTimer:timeout withHandler:handler andCancelHandler:cancelHandler andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue] +#define createDelayableTimer(timeout, handler, ...) createDelayableQueuedTimer(timeout, nil, handler, __VA_ARGS__) +#define createDelayableQueuedTimer(timeout, queue, handler, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([HelperTools startDelayableQueuedTimer:timeout withHandler:handler andCancelHandler:nil andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue])(_createDelayableQueuedTimer(timeout, queue, handler, __VA_ARGS__)) +#define _createDelayableQueuedTimer(timeout, queue, handler, cancelHandler, ...) [HelperTools startDelayableQueuedTimer:timeout withHandler:handler andCancelHandler:cancelHandler andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue] + +#define createTimer(timeout, handler, ...) createQueuedTimer(timeout, nil, handler, __VA_ARGS__) +#define createQueuedTimer(timeout, queue, handler, ...) metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([HelperTools startQueuedTimer:timeout withHandler:handler andCancelHandler:nil andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue])(_createQueuedTimer(timeout, queue, handler, __VA_ARGS__)) +#define _createQueuedTimer(timeout, queue, handler, cancelHandler, ...) [HelperTools startQueuedTimer:timeout withHandler:handler andCancelHandler:cancelHandler andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__ onQueue:queue] #define MLAssert(check, text, ...) do { if(!(check)) { metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))([HelperTools MLAssertWithText:text andUserData:nil andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__];)([HelperTools MLAssertWithText:text andUserData:metamacro_head(__VA_ARGS__) andFile:(char*)__FILE__ andLine:__LINE__ andFunc:(char*)__func__];) while(YES); } } while(0) #define unreachable(...) do { metamacro_if_eq(0, metamacro_argcount(__VA_ARGS__))(MLAssert(NO, @"unreachable", __VA_ARGS__);)(MLAssert(NO, __VA_ARGS__);); } while(0) @@ -51,6 +56,7 @@ typedef NS_ENUM(NSUInteger, MLDefinedIdentifier) { typedef NS_ENUM(NSUInteger, MLRunLoopIdentifier) { MLRunLoopIdentifierNetwork, + MLRunLoopIdentifierTimer, }; void logException(NSException* exception); @@ -175,6 +181,7 @@ void swizzle(Class c, SEL orig, SEL new); +(CIImage*) createQRCodeFromString:(NSString*) input; //don't use these four directly, but via createTimer() makro ++(MLDelayableTimer*) startDelayableQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue; +(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue; +(NSString*) appBuildVersionInfoFor:(MLVersionType) type; diff --git a/Monal/Classes/HelperTools.m b/Monal/Classes/HelperTools.m index d401a7f968..1b56af12b2 100644 --- a/Monal/Classes/HelperTools.m +++ b/Monal/Classes/HelperTools.m @@ -57,6 +57,7 @@ #import "commithash.h" #import "MLContactSoftwareVersionInfo.h" #import "IPC.h" +#import "MLDelayableTimer.h" @import UserNotifications; @import CoreImage; @@ -70,6 +71,9 @@ @interface KSCrash() @property(nonatomic,readwrite,retain) NSString* basePath; @end +@interface MLDelayableTimer() +-(void) invalidate; +@end static char* _crashBundleName = "UnifiedReport"; static NSString* _processID; @@ -590,6 +594,7 @@ +(NSRunLoop*) getExtraRunloopWithIdentifier:(MLRunLoopIdentifier) identifier switch(identifier) { case MLRunLoopIdentifierNetwork: priority = DISPATCH_QUEUE_PRIORITY_BACKGROUND; name = "im.monal.runloop.networking"; break; + case MLRunLoopIdentifierTimer: priority = DISPATCH_QUEUE_PRIORITY_BACKGROUND; name = "im.monal.runloop.timer"; break; default: unreachable(@"unknown runloop identifier!"); } @@ -2255,8 +2260,8 @@ +(NSString*) generateDateTimeString:(NSDate*) datetime return [rfc3339DateFormatter stringFromDate:datetime]; } -//don't use this directly, but via createTimer() makro -+(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue +//don't use this directly, but via createDelayableTimer() makros ++(MLDelayableTimer*) startDelayableQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue { if(queue == nil) queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); @@ -2266,47 +2271,41 @@ +(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_ if([filePathComponents count]>1) fileStr = [NSString stringWithFormat:@"%@/%@", filePathComponents[[filePathComponents count]-2], filePathComponents[[filePathComponents count]-1]]; - if(timeout<=0.001) - { - //DDLogVerbose(@"Timer timeout is smaller than 0.001, dispatching handler directly."); + MLDelayableTimer* timer = [[MLDelayableTimer alloc] initWithHandler:^(MLDelayableTimer* timer){ if(handler) dispatch_async(queue, ^{ + DDLogDebug(@"calling handler for timer: %@", timer); handler(); }); - return ^{ }; //empty cancel block because this "timer" already triggered - } - - NSString* uuid = [[NSUUID UUID] UUIDString]; - - //DDLogDebug(@"setting up timer %@(%G)", uuid, timeout); - dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue); - dispatch_source_set_timer(timer, - dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout*NSEC_PER_SEC)), - DISPATCH_TIME_FOREVER, - (uint64_t) (0.1 * NSEC_PER_SEC)); //leeway of 100ms - - dispatch_source_set_event_handler(timer, ^{ - DDLogDebug(@"timer %@ %@(%G) triggered (created at %@:%d in %s)", timer, uuid, timeout, fileStr, line, func); - dispatch_source_cancel(timer); - if(handler) - handler(); - }); - - dispatch_source_set_cancel_handler(timer, ^{ - //DDLogDebug(@"timer %@ %@(%G) cancelled (created at %@:%d)", timer, uuid, timeout, fileName, line); + } andCancelHandler:^(MLDelayableTimer* timer){ if(cancelHandler) - cancelHandler(); - }); + dispatch_async(queue, ^{ + DDLogDebug(@"calling cancel block for timer: %@", timer); + cancelHandler(); + }); + } timeout:timeout tolerance:0.1 andDescription:[NSString stringWithFormat:@"created at %@:%d in %s", fileStr, line, func]]; - //start timer - DDLogDebug(@"starting timer %@ %@(%G) (created at %@:%d in %s)", timer, uuid, timeout, fileStr, line, func); - dispatch_resume(timer); + if(timeout < 0.001) + { + //DDLogVerbose(@"Timer timeout is smaller than 0.001, dispatching handler directly: %@", timer); + [timer invalidate]; + if(handler) + dispatch_async(queue, ^{ + handler(); + }); + return timer; //this timer is not added to a runloop and invalid because the handler already got called + } - //return block that can be used to cancel the timer + [timer start]; + return timer; +} + +//don't use this directly, but via createTimer() makros ++(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue +{ + MLDelayableTimer* timer = [self startDelayableQueuedTimer:timeout withHandler:handler andCancelHandler:cancelHandler andFile:file andLine:line andFunc:func onQueue:queue]; return ^{ - DDLogDebug(@"cancel block for timer %@ %@(%G) called (created at %@:%d in %s)", timer, uuid, timeout, fileStr, line, func); - if(!dispatch_source_testcancel(timer)) - dispatch_source_cancel(timer); + [timer cancel]; }; } diff --git a/Monal/Classes/MLConstants.h b/Monal/Classes/MLConstants.h index b6c26bd941..99d0a86651 100644 --- a/Monal/Classes/MLConstants.h +++ b/Monal/Classes/MLConstants.h @@ -64,6 +64,7 @@ static const DDLogLevel ddLogLevel = LOG_LEVEL_STDOUT; // #endif @class MLContact; +@class MLDelayableTimer; //some typedefs used throughout the project typedef void (^contactCompletion)(MLContact* _Nonnull selectedContact) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); diff --git a/Monal/Classes/MLDelayableTimer.h b/Monal/Classes/MLDelayableTimer.h index f908da8b10..2df5949eb2 100644 --- a/Monal/Classes/MLDelayableTimer.h +++ b/Monal/Classes/MLDelayableTimer.h @@ -2,12 +2,32 @@ // MLDelayableTimer.h // monalxmpp // -// Created by admin on 24.06.24. +// Created by Thilo Molitor on 24.06.24. // Copyright © 2024 monal-im.org. All rights reserved. // +#import + #ifndef MLDelayableTimer_h #define MLDelayableTimer_h +NS_ASSUME_NONNULL_BEGIN + +@class MLDelayableTimer; +typedef void (^monal_timer_block_t)(MLDelayableTimer* _Nonnull) NS_SWIFT_UNAVAILABLE("To be redefined in swift."); + +@interface MLDelayableTimer : NSObject + +-(instancetype) initWithHandler:(monal_timer_block_t) handler andCancelHandler:(monal_timer_block_t _Nullable) cancelHandler timeout:(NSTimeInterval) timeout tolerance:(NSTimeInterval) tolerance andDescription:(NSString* _Nullable) description; + +-(void) start; +-(void) trigger; +-(void) pause; +-(void) resume; +-(void) cancel; + +@end + +NS_ASSUME_NONNULL_END #endif /* MLDelayableTimer_h */ diff --git a/Monal/Classes/MLDelayableTimer.m b/Monal/Classes/MLDelayableTimer.m index 4401e79c7c..cb627dbea0 100644 --- a/Monal/Classes/MLDelayableTimer.m +++ b/Monal/Classes/MLDelayableTimer.m @@ -2,8 +2,126 @@ // MLDelayableTimer.m // monalxmpp // -// Created by admin on 24.06.24. +// Created by Thilo Molitor on 24.06.24. // Copyright © 2024 monal-im.org. All rights reserved. // -#import +#import "MLConstants.h" +#import "HelperTools.h" +#import "MLDelayableTimer.h" + +@interface MLDelayableTimer() +{ + NSTimer* _wrappedTimer; + monal_timer_block_t _Nullable _cancelHandler; + NSString* _Nullable _description; + NSTimeInterval _timeout; + NSTimeInterval _remainingTime; + NSUUID* _uuid; +} +@end + +@implementation MLDelayableTimer + +-(instancetype) initWithHandler:(monal_timer_block_t) handler andCancelHandler:(monal_timer_block_t _Nullable) cancelHandler timeout:(NSTimeInterval) timeout tolerance:(NSTimeInterval) tolerance andDescription:(NSString* _Nullable) description +{ + self = [super init]; + _wrappedTimer = [NSTimer timerWithTimeInterval:timeout repeats:NO block:^(NSTimer* _) { + handler(self); + }]; + _cancelHandler = cancelHandler; + _timeout = timeout; + _wrappedTimer.tolerance = tolerance; + _description = description; + _remainingTime = 0; + _uuid = [NSUUID UUID]; + return self; +} + +-(NSString*) description +{ + return [NSString stringWithFormat:@"%@(%G|%G) %@", [_uuid UUIDString], _timeout, _wrappedTimer.fireDate.timeIntervalSinceNow, _description]; +} + +-(void) start +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + unreachable(@"Could not start already fired timer!", @{@"timer": self}); + return; + } + DDLogDebug(@"Starting timer: %@", self); + [[HelperTools getExtraRunloopWithIdentifier:MLRunLoopIdentifierTimer] addTimer:_wrappedTimer forMode:NSRunLoopCommonModes]; + } +} + +-(void) trigger +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + unreachable(@"Could not trigger already fired timer!", @{@"timer": self}); + return; + } + DDLogDebug(@"Triggering timer: %@", self); + [_wrappedTimer fire]; + } +} + +-(void) pause +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + DDLogWarn(@"Tried to pause already fired timer: %@", self); + return; + } + DDLogDebug(@"Pausing timer: %@", self); + _remainingTime = _wrappedTimer.fireDate.timeIntervalSinceNow; + _wrappedTimer.fireDate = NSDate.distantFuture; //postpone timer virtually indefinitely + } +} + +-(void) resume +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + DDLogWarn(@"Tried to resume already fired timer: %@", self); + return; + } + DDLogDebug(@"Resuming timer: %@", self); + _wrappedTimer.fireDate = [NSDate dateWithTimeIntervalSinceNow:_remainingTime]; + _remainingTime = 0; + } +} + +-(void) cancel +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + DDLogWarn(@"Tried to cancel already fired timer: %@", self); + return; + } + DDLogDebug(@"Canceling timer: %@", self); + [self invalidate]; + } + _cancelHandler(self); +} + +-(void) invalidate +{ + @synchronized(self) { + if(!_wrappedTimer.valid) + { + unreachable(@"Could not invalidate already invalid timer!", @{@"timer": self}); + return; + } + //DDLogVerbose(@"Invalidating timer: %@", self); + [_wrappedTimer invalidate]; + } +} + +@end diff --git a/Monal/Classes/SwiftHelpers.swift b/Monal/Classes/SwiftHelpers.swift index 97e92bf171..0756b7dc18 100644 --- a/Monal/Classes/SwiftHelpers.swift +++ b/Monal/Classes/SwiftHelpers.swift @@ -31,6 +31,7 @@ let BGFETCH_DEFAULT_INTERVAL = HelperTools.getObjcDefinedValue(.BGFETCH_DEFAULT_ public typealias monal_void_block_t = @convention(block) () -> Void; public typealias monal_id_block_t = @convention(block) (AnyObject?) -> Void; +public typealias monal_timer_block_t = @convention(block) (MLDelayableTimer?) -> Void; //see https://stackoverflow.com/a/40629365/3528174 extension String: Error {} diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m index a485292f91..34b9b4be28 100644 --- a/Monal/Classes/xmpp.m +++ b/Monal/Classes/xmpp.m @@ -94,9 +94,9 @@ @interface xmpp() NSDate* _lastInteractionDate; //internal handlers and flags - monal_void_block_t _cancelLoginTimer; - monal_void_block_t _cancelPingTimer; - monal_void_block_t _cancelReconnectTimer; + MLDelayableTimer* _loginTimer; + MLDelayableTimer* _pingTimer; + MLDelayableTimer* _reconnectTimer; NSMutableArray* _timersToCancelOnDisconnect; NSMutableArray* _smacksAckHandler; NSMutableDictionary* _iqHandlers; @@ -494,7 +494,7 @@ -(BOOL) idle ) || ( //test if we are connected and idle (e.g. we're done with catchup and neither process any incoming stanzas nor trying to send anything) _catchupDone && - _cancelPingTimer == nil && + _pingTimer == nil && !unackedCount && ![_parseQueue operationCount] && //if something blocks the parse queue it is either an incoming stanza currently processed or waiting to be processed //[_receiveQueue operationCount] <= ([NSOperationQueue currentQueue]==_receiveQueue ? 1 : 0) && @@ -508,7 +508,7 @@ -(BOOL) idle "\t_accountState < kStateReconnecting = %@\n" "\t_reconnectInProgress = %@\n" "\t_catchupDone = %@\n" - "\t_cancelPingTimer = %@\n" + "\t_pingTimer = %@\n" "\t[self.unAckedStanzas count] = %lu\n" "\t[_parseQueue operationCount] = %lu\n" //"\t[_receiveQueue operationCount] = %lu\n" @@ -519,7 +519,7 @@ -(BOOL) idle bool2str(_accountState < kStateReconnecting), bool2str(_reconnectInProgress), bool2str(_catchupDone), - _cancelPingTimer == nil ? @"none" : @"running timer", + _pingTimer == nil ? @"none" : @"running timer", unackedCount, (unsigned long)[_parseQueue operationCount], //(unsigned long)[_receiveQueue operationCount], @@ -717,33 +717,47 @@ -(BOOL) parseQueueFrozen -(void) freezeParseQueue { - //don't do this in a block on the parse queue because the parse queue could potentially have a significant amount of blocks waiting - //to be synchronously dispatched to the receive queue and processed and we don't want to wait for all these stanzas to be processed - //and rather freeze the parse queue as soon as possible - _parseQueue.suspended = YES; - - //apparently setting _parseQueue.suspended = YES does return before the queue is actually suspended - //--> busy wait for _parseQueue.suspended == YES - [HelperTools busyWaitForOperationQueue:_parseQueue]; - MLAssert([self parseQueueFrozen] == YES, @"Parse queue not frozen after setting suspended to YES!"); - - //this has to be synchronous because we want to be sure no further stanzas are leaking from the parse queue - //into the receive queue once we leave this method - //--> wait for all blocks put into the receive queue by the parse queue right before it was frozen - [self dispatchOnReceiveQueue: ^{ - [HelperTools busyWaitForOperationQueue:self->_parseQueue]; - MLAssert([self parseQueueFrozen] == YES, @"Parse queue not frozen after setting suspended to YES (in receive queue)!"); - DDLogInfo(@"Parse queue is frozen now!"); - }]; + @synchronized(_parseQueue) { + //pause all timers before freezing the parse queue to not trigger timers that can not be handeld properly while frozen + [_loginTimer pause]; + [_pingTimer pause]; + [_reconnectTimer pause]; + + //don't do this in a block on the parse queue because the parse queue could potentially have a significant amount of blocks waiting + //to be synchronously dispatched to the receive queue and processed and we don't want to wait for all these stanzas to be processed + //and rather freeze the parse queue as soon as possible + _parseQueue.suspended = YES; + + //apparently setting _parseQueue.suspended = YES does return before the queue is actually suspended + //--> busy wait for _parseQueue.suspended == YES + [HelperTools busyWaitForOperationQueue:_parseQueue]; + MLAssert([self parseQueueFrozen] == YES, @"Parse queue not frozen after setting suspended to YES!"); + + //this has to be synchronous because we want to be sure no further stanzas are leaking from the parse queue + //into the receive queue once we leave this method + //--> wait for all blocks put into the receive queue by the parse queue right before it was frozen + [self dispatchOnReceiveQueue: ^{ + [HelperTools busyWaitForOperationQueue:self->_parseQueue]; + MLAssert([self parseQueueFrozen] == YES, @"Parse queue not frozen after setting suspended to YES (in receive queue)!"); + DDLogInfo(@"Parse queue is frozen now!"); + }]; + } } -(void) unfreezeParseQueue { - //this has to be synchronous because we want to be sure the parse queue is operating again once we leave this method - [self dispatchOnReceiveQueue: ^{ - self->_parseQueue.suspended = NO; - DDLogInfo(@"Parse queue is UNfrozen now!"); - }]; + @synchronized(_parseQueue) { + //this has to be synchronous because we want to be sure the parse queue is operating again once we leave this method + [self dispatchOnReceiveQueue: ^{ + self->_parseQueue.suspended = NO; + DDLogInfo(@"Parse queue is UNfrozen now!"); + }]; + + //resume all timers paused when freezing the parse queue + [_loginTimer resume]; + [_pingTimer resume]; + [_reconnectTimer resume]; + } } -(void) freezeSendQueue @@ -843,12 +857,12 @@ -(void) reinitLoginTimer return; //cancel old timer if existing and... - if(self->_cancelLoginTimer != nil) - self->_cancelLoginTimer(); + if(self->_loginTimer != nil) + [self->_loginTimer cancel]; //...replace it with new timer - self->_cancelLoginTimer = createTimer(CONNECT_TIMEOUT, (^{ + self->_loginTimer = createDelayableTimer(CONNECT_TIMEOUT, (^{ + self->_loginTimer = nil; [self dispatchAsyncOnReceiveQueue: ^{ - self->_cancelLoginTimer = nil; DDLogInfo(@"Login took too long, cancelling and trying to reconnect (potentially using another SRV record)"); [self reconnect]; }]; @@ -857,13 +871,19 @@ -(void) reinitLoginTimer -(void) connect { - if(_parseQueue.suspended) + if([self parseQueueFrozen]) { DDLogWarn(@"Not trying to connect: parse queue frozen!"); return; } [self dispatchAsyncOnReceiveQueue: ^{ + if([self parseQueueFrozen]) + { + DDLogWarn(@"Not trying to connect: parse queue frozen!"); + return; + } + [self->_parseQueue cancelAllOperations]; //throw away all parsed but not processed stanzas from old connections [self unfreezeParseQueue]; //make sure the parse queue is operational again //we don't want to loose outgoing messages by throwing away their receiveQueue operation adding them to the smacks queue etc. @@ -953,15 +973,15 @@ -(void) disconnectWithStreamError:(MLXMLNode* _Nullable) streamError andExplicit //this has to be synchronous because we want to wait for the disconnect to complete before continuingand unlocking the process in the NSE [self dispatchOnReceiveQueue: ^{ DDLogInfo(@"stopping running timers"); - if(self->_cancelLoginTimer) - self->_cancelLoginTimer(); //cancel running login timer - self->_cancelLoginTimer = nil; - if(self->_cancelPingTimer) - self->_cancelPingTimer(); //cancel running ping timer - self->_cancelPingTimer = nil; - if(self->_cancelReconnectTimer) - self->_cancelReconnectTimer(); - self->_cancelReconnectTimer = nil; + if(self->_loginTimer) + [self->_loginTimer cancel]; //cancel running login timer + self->_loginTimer = nil; + if(self->_pingTimer) + [self->_pingTimer cancel]; //cancel running ping timer + self->_pingTimer = nil; + if(self->_reconnectTimer) + [self->_reconnectTimer cancel]; //cancel running reconnect timer + self->_reconnectTimer = nil; @synchronized(self->_timersToCancelOnDisconnect) { for(monal_void_block_t timer in self->_timersToCancelOnDisconnect) timer(); @@ -1240,8 +1260,8 @@ -(void) reconnectWithStreamError:(MLXMLNode* _Nullable) streamError andWaitingTi [self disconnectWithStreamError:streamError andExplicitLogout:NO]; DDLogInfo(@"Trying to connect again in %G seconds...", wait); - self->_cancelReconnectTimer = createTimer(wait, (^{ - self->_cancelReconnectTimer = nil; + self->_reconnectTimer = createDelayableTimer(wait, (^{ + self->_reconnectTimer = nil; [self dispatchAsyncOnReceiveQueue: ^{ //there may be another connect/login operation in progress triggered from reachability or another timer if(self.accountState_cancelReconnectTimer = nil; + self->_reconnectTimer = nil; [self dispatchAsyncOnReceiveQueue: ^{ self->_reconnectInProgress = NO; }]; @@ -1467,16 +1487,16 @@ -(void) sendPing:(double) timeout DDLogInfo(@"ping attempted before logged in and bound, ignoring ping."); return; } - else if(self->_cancelPingTimer) + else if(self->_pingTimer) { DDLogInfo(@"ping already sent, ignoring second ping request."); return; } else if([self->_parseQueue operationCount] > 4) { - DDLogWarn(@"parseQueue overflow, delaying ping by 10 seconds."); + DDLogWarn(@"parseQueue overflow, delaying ping by 4 seconds."); @synchronized(self->_timersToCancelOnDisconnect) { - [self->_timersToCancelOnDisconnect addObject:createTimer(10.0, (^{ + [self->_timersToCancelOnDisconnect addObject:createTimer(4.0, (^{ DDLogDebug(@"ping delay expired, retrying ping."); [self sendPing:timeout]; }))]; @@ -1485,9 +1505,9 @@ -(void) sendPing:(double) timeout else { //start ping timer - self->_cancelPingTimer = createTimer(timeout, (^{ + self->_pingTimer = createDelayableTimer(timeout, (^{ + self->_pingTimer = nil; [self dispatchAsyncOnReceiveQueue: ^{ - self->_cancelPingTimer = nil; //check if someone already called reconnect or disconnect while we were waiting for the ping //(which was called while we still were >= kStateBound) if(self.accountState_cancelPingTimer) + if(self->_pingTimer) { - self->_cancelPingTimer(); //cancel timer (ping was successful) - self->_cancelPingTimer = nil; + [self->_pingTimer cancel]; //cancel timer (ping was successful) + self->_pingTimer = nil; } }; @@ -1526,8 +1546,7 @@ -(void) sendPing:(double) timeout [self sendIq:ping withResponseHandler:^(XMPPIQ* result __unused) { handler(); } andErrorHandler:^(XMPPIQ* error) { - if(error != nil) - handler(); + handler(); }]; } } @@ -1773,7 +1792,7 @@ -(void) processInput:(MLXMLNode*) parsedStanza withDelayedReplay:(BOOL) delayedR self->_catchupStanzaCounter++; //restart logintimer for every incoming stanza when not logged in (don't do anything without a running timer) - if(!delayedReplay && _cancelLoginTimer != nil && self->_accountState < kStateLoggedIn) + if(!delayedReplay && _loginTimer != nil && self->_accountState < kStateLoggedIn) [self reinitLoginTimer]; //only process most stanzas/nonzas after having a secure context @@ -2442,10 +2461,10 @@ -(void) processInput:(MLXMLNode*) parsedStanza withDelayedReplay:(BOOL) delayedR [[MLNotificationQueue currentQueue] postNotificationName:kMLIsLoggedInNotice object:self]; _usableServersList = [NSMutableArray new]; //reset list to start again with the highest SRV priority on next connect - if(_cancelLoginTimer) + if(_loginTimer) { - _cancelLoginTimer(); //we are now logged in --> cancel running login timer - _cancelLoginTimer = nil; + [self->_loginTimer cancel]; //we are now logged in --> cancel running login timer + _loginTimer = nil; } self->_loggedInOnce = YES; @@ -2653,10 +2672,10 @@ -(void) processInput:(MLXMLNode*) parsedStanza withDelayedReplay:(BOOL) delayedR self->_blockToCallOnTCPOpen = nil; //just to be sure but not strictly necessary self->_accountState = kStateLoggedIn; _usableServersList = [NSMutableArray new]; //reset list to start again with the highest SRV priority on next connect - if(_cancelLoginTimer) + if(_loginTimer) { - _cancelLoginTimer(); //we are now logged in --> cancel running login timer - _cancelLoginTimer = nil; + [self->_loginTimer cancel]; //we are now logged in --> cancel running login timer + _loginTimer = nil; } self->_loggedInOnce = YES; @@ -4675,7 +4694,7 @@ -(void)stream:(NSStream*) stream handleEvent:(NSStreamEvent) eventCode self->_streamHasSpace = NO; //restart logintimer when our output stream becomes readable (don't do anything without a running timer) - if(_cancelLoginTimer != nil && self->_accountState < kStateLoggedIn) + if(_loginTimer != nil && self->_accountState < kStateLoggedIn) [self reinitLoginTimer]; //we want this to be sync instead of async to make sure we are in kStateConnected before sending anything @@ -4865,7 +4884,7 @@ -(void) writeFromQueue } //restart logintimer for new write to our stream while not logged in (don't do anything without a running timer) - if(_cancelLoginTimer != nil && self->_accountState < kStateLoggedIn) + if(_loginTimer != nil && self->_accountState < kStateLoggedIn) [self reinitLoginTimer]; if(requestAck) diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index 01b05f5e64..5dc9d911d2 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -141,6 +141,8 @@ 842790852A32D16D005C18CC /* CallSounds in Resources */ = {isa = PBXBuildFile; fileRef = 842790842A32D16C005C18CC /* CallSounds */; }; 843AD3AB2AA55CE20036844D /* MLOgHtmlParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 843AD3AA2AA55CE20036844D /* MLOgHtmlParser.swift */; }; 8441EFF92921B53500E851E9 /* BackgroundSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8441EFF82921B53500E851E9 /* BackgroundSettings.swift */; }; + 844921EA2C29F9A000B99A9C /* MLDelayableTimer.m in Sources */ = {isa = PBXBuildFile; fileRef = 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */; }; + 844921EC2C29F9BE00B99A9C /* MLDelayableTimer.h in Headers */ = {isa = PBXBuildFile; fileRef = 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */; }; 844EEC6D28E718DB00CB5EF9 /* UIColor+Theme.m in Sources */ = {isa = PBXBuildFile; fileRef = 26D59D9220714F32006F1DEE /* UIColor+Theme.m */; }; 845D636B2AD4AEDA0066EFFB /* ImageViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845D636A2AD4AEDA0066EFFB /* ImageViewer.swift */; }; 845EFFBD2918721800C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; @@ -576,6 +578,8 @@ 842790842A32D16C005C18CC /* CallSounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CallSounds; sourceTree = ""; }; 843AD3AA2AA55CE20036844D /* MLOgHtmlParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MLOgHtmlParser.swift; path = Classes/MLOgHtmlParser.swift; sourceTree = ""; }; 8441EFF82921B53500E851E9 /* BackgroundSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundSettings.swift; sourceTree = ""; }; + 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLDelayableTimer.m; sourceTree = ""; }; + 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLDelayableTimer.h; sourceTree = ""; }; 845D636A2AD4AEDA0066EFFB /* ImageViewer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = ""; }; 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPlaceholder.swift; sourceTree = ""; }; 848717F1295ED64500B8D288 /* MLCall.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MLCall.m; path = Classes/MLCall.m; sourceTree = SOURCE_ROOT; }; @@ -1256,6 +1260,8 @@ 84C1CD4F2A8C764D007076ED /* SwiftHelpers.swift */, 84C1CD512A8F617F007076ED /* MLStreamRedirect.m */, 84C1CD532A8F6196007076ED /* MLStreamRedirect.h */, + 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */, + 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */, ); name = tools; sourceTree = ""; @@ -1489,6 +1495,7 @@ 54A22D2D26185E7E00B56EAD /* MLNotificationQueue.h in Headers */, 84E55E8029644279003E191A /* ActiveChatsViewController.h in Headers */, 541E4CC4254D369200FD7B28 /* MLPubSubProcessor.h in Headers */, + 844921EC2C29F9BE00B99A9C /* MLDelayableTimer.h in Headers */, 84C1CD542A8F6196007076ED /* MLStreamRedirect.h in Headers */, 389E298D25E901CA009A5268 /* MLAudioRecoderManager.h in Headers */, 541E4CBE254AA0B600FD7B28 /* MLHandler.h in Headers */, @@ -2188,6 +2195,7 @@ 26CC57C723A0892100ABB92A /* MLContact.m in Sources */, 540F625F24BA951E0008A6D8 /* HelperTools.m in Sources */, 38720923251EDE07001837EB /* MLXEPSlashMeHandler.m in Sources */, + 844921EA2C29F9A000B99A9C /* MLDelayableTimer.m in Sources */, 544656BB2534910D006B2953 /* XMPPDataForm.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Monal/monalxmpp/module.modulemap b/Monal/monalxmpp/module.modulemap index b8dca695ce..c190e04395 100644 --- a/Monal/monalxmpp/module.modulemap +++ b/Monal/monalxmpp/module.modulemap @@ -41,4 +41,8 @@ module MLCallPrivate { module HelperToolsPrivate { header "../Classes/HelperTools.h" export * +} +module MLDelayableTimerPrivate { + header "../Classes/MLDelayableTimer.h" + export * } \ No newline at end of file diff --git a/Monal/monalxmpp/monalxmpp.h b/Monal/monalxmpp/monalxmpp.h index 8c5b67e332..e42b4602c1 100644 --- a/Monal/monalxmpp/monalxmpp.h +++ b/Monal/monalxmpp/monalxmpp.h @@ -25,3 +25,4 @@ FOUNDATION_EXPORT const unsigned char monalxmppVersionString[]; #import "MLVoIPProcessor.h" #import "MLCall.h" #import "HelperTools.h" +#import "MLDelayableTimer.h" From 69e3dbdedbf6d95e0def2c3e010bec659742f4c7 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 06:57:13 +0200 Subject: [PATCH 041/131] Improve MemberList sorting speed --- Monal/Classes/ContactEntry.swift | 20 ++++++-------------- Monal/Classes/MLContact.m | 8 ++++---- Monal/Classes/MemberList.swift | 14 +++++++++++--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Monal/Classes/ContactEntry.swift b/Monal/Classes/ContactEntry.swift index 9cb3fc5aca..55069e7cc3 100644 --- a/Monal/Classes/ContactEntry.swift +++ b/Monal/Classes/ContactEntry.swift @@ -47,24 +47,16 @@ struct ContactEntry: View { .frame(width: 40, height: 40, alignment: .center) VStack(alignment: .leading) { if selfnotesPrefix { - // use the `let contactDisplayName` to make sure this view gets updated if the contact display name changes + // use the if to make sure this view gets updated if the contact display name changes // (the condition is never false, because contactDisplayName can not be nil) - if let contactDisplayName = (contact.contactDisplayName as String?) { - if let fallback = fallback { - Text(contact.obj.contactDisplayName(withFallback:fallback)) - } else { - Text(contactDisplayName) - } + if (contact.contactDisplayName as String?) != nil { + Text(contact.obj.contactDisplayName(withFallback:fallback)) } } else { - // use the `let contactDisplayNameWithoutSelfnotesPrefix` to make sure this view gets updated if the contact display name changes + // use the if to make sure this view gets updated if the contact display name changes // (the condition is never false, because contactDisplayNameWithoutSelfnotesPrefix can not be nil) - if let contactDisplayNameWithoutSelfnotesPrefix = (contact.contactDisplayNameWithoutSelfnotesPrefix as String?) { - if let fallback = fallback { - Text(contact.obj.contactDisplayName(withFallback:fallback, andSelfnotesPrefix:false)) - } else { - Text(contactDisplayNameWithoutSelfnotesPrefix) - } + if (contact.contactDisplayNameWithoutSelfnotesPrefix as String?) != nil { + Text(contact.obj.contactDisplayName(withFallback:fallback, andSelfnotesPrefix:false)) } } additionalContent() diff --git a/Monal/Classes/MLContact.m b/Monal/Classes/MLContact.m index c9be560dd8..157017d5e2 100644 --- a/Monal/Classes/MLContact.m +++ b/Monal/Classes/MLContact.m @@ -344,7 +344,7 @@ -(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName; -(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName andSelfnotesPrefix:(BOOL) hasSelfnotesPrefix { - DDLogVerbose(@"Calculating contact display name..."); + //DDLogVerbose(@"Calculating contact display name..."); NSString* displayName; if(!self.isSelfChat) { @@ -359,17 +359,17 @@ -(NSString*) contactDisplayNameWithFallback:(NSString* _Nullable) fallbackName a if(self.nickName && self.nickName.length > 0) { - DDLogVerbose(@"Using nickName: %@", self.nickName); + //DDLogVerbose(@"Using nickName: %@", self.nickName); displayName = self.nickName; } else if(self.fullName && self.fullName.length > 0) { - DDLogVerbose(@"Using fullName: %@", self.fullName); + //DDLogVerbose(@"Using fullName: %@", self.fullName); displayName = self.fullName; } else { - DDLogVerbose(@"Using fallback: %@", fallbackName); + //DDLogVerbose(@"Using fallback: %@", fallbackName); displayName = fallbackName; } } diff --git a/Monal/Classes/MemberList.swift b/Monal/Classes/MemberList.swift index 41ba842119..8d917f56c6 100644 --- a/Monal/Classes/MemberList.swift +++ b/Monal/Classes/MemberList.swift @@ -62,16 +62,22 @@ struct MemberList: View { online[contact] = false } } + //this is needed to improve sorting speed + var contactNames: [ObservableKVOWrapper:String] = [:] + for contact in memberList { + contactNames[contact] = contact.obj.contactDisplayName(withFallback:nicknames[contact], andSelfnotesPrefix:false) + } + //sort our member list memberList.sort { ( (online[$0]! ? 0 : 1), mucAffiliationToInt(affiliations[$0]), - ($0.contactDisplayNameWithoutSelfnotesPrefix as String), + (contactNames[$0]!), ($0.contactJid as String) ) < ( (online[$1]! ? 0 : 1), mucAffiliationToInt(affiliations[$1]), - ($1.contactDisplayNameWithoutSelfnotesPrefix as String), + (contactNames[$1]!), ($1.contactJid as String) ) } @@ -330,7 +336,9 @@ struct MemberList: View { .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalMucParticipantsAndMembersUpdated")).receive(on: RunLoop.main)) { notification in if let xmppAccount = notification.object as? xmpp, let contact = notification.userInfo?["contact"] as? MLContact { DDLogVerbose("Got muc participants/members update from account \(xmppAccount)...") - if contact == self.muc { + //only trigger update if we are either in a group type muc or have admin/owner priviledges + //all other cases will close this view anyways, it makes no sense to update everything directly before hiding thsi view + if contact == self.muc && (contact.mucType == "group" || ["owner", "admin"].contains(DataLayer.sharedInstance().getOwnAffiliation(inGroupOrChannel:self.muc.obj) ?? "none")) { updateMemberlist() } } From d9adb495121e69b0238621942508632523950b57 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 17:10:20 +0200 Subject: [PATCH 042/131] Fix race conditions when using weak container --- Monal/Classes/HelperTools.h | 2 +- Monal/Classes/MLContact.m | 5 +++-- Monal/Classes/MLXMLNode.m | 9 ++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Monal/Classes/HelperTools.h b/Monal/Classes/HelperTools.h index e608499fb9..85e25e5b7d 100644 --- a/Monal/Classes/HelperTools.h +++ b/Monal/Classes/HelperTools.h @@ -64,7 +64,7 @@ void swizzle(Class c, SEL orig, SEL new); //weak container holding an object as weak pointer (needed to not create retain circles in NSCache @interface WeakContainer : NSObject -@property (nonatomic, weak) id obj; +@property (atomic, weak) id obj; -(id) initWithObj:(id) obj; @end diff --git a/Monal/Classes/MLContact.m b/Monal/Classes/MLContact.m index 157017d5e2..3529ba613a 100644 --- a/Monal/Classes/MLContact.m +++ b/Monal/Classes/MLContact.m @@ -237,8 +237,9 @@ +(MLContact*) createContactFromJid:(NSString*) jid andAccountNo:(NSNumber*) acco @synchronized(_singletonCache) { if(_singletonCache[cacheKey] != nil) { - if(((WeakContainer*)_singletonCache[cacheKey]).obj != nil) - return ((WeakContainer*)_singletonCache[cacheKey]).obj; + MLContact* obj = ((WeakContainer*)_singletonCache[cacheKey]).obj; + if(obj != nil) + return obj; else [_singletonCache removeObjectForKey:cacheKey]; } diff --git a/Monal/Classes/MLXMLNode.m b/Monal/Classes/MLXMLNode.m index 0c31545d97..404f37ab76 100644 --- a/Monal/Classes/MLXMLNode.m +++ b/Monal/Classes/MLXMLNode.m @@ -365,13 +365,16 @@ -(NSArray*) find:(NSString* _Nonnull) queryString arguments:(va_list*) args #endif //return results from cache if possible + NSArray* cacheObj = nil; WeakContainer* cacheEntryContainer = [self.cache objectForKey:cacheKey]; - if(cacheEntryContainer != nil && cacheEntryContainer.obj != nil) + if(cacheEntryContainer != nil) + cacheObj = cacheEntryContainer.obj; + if(cacheObj != nil) { #ifdef DEBUG_XMLQueryLanguage - DDLogVerbose(@"Returning cached result: %@", (NSArray*)cacheEntryContainer.obj); + DDLogVerbose(@"Returning cached result: %@", cacheObj); #endif - return (NSArray*)cacheEntryContainer.obj; + return cacheObj; } #ifdef QueryStatistics From 62dbbada4e4d79da9d4b96f9c73f71364c7d832e Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 18:34:05 +0200 Subject: [PATCH 043/131] Fix deletion stepper localization --- Monal/Classes/GeneralSettings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index 008a045acd..387081c488 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -322,7 +322,7 @@ like hotel wifi, ugly mobile carriers etc. if autodeleteIntervalSelection == -1 { HStack { Text("Custom Time: ") - Stepper(NSLocalizedString("\(String(describing:(max(1, autodeleteInterval / 3600)).formatted())) hours", comment:""), value: Binding( + Stepper(String(format:NSLocalizedString("%@ hours", comment:""), String(describing:(max(1, autodeleteInterval / 3600)).formatted())), value: Binding( get: { max(1, autodeleteInterval / 3600) /*clamp to 1 ... .max*/ }, set: { autodeleteInterval = $0 * 3600 } ), in: 1 ... .max) From aea13ec736c3ac26591e7f9605eb39660dc17475 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 18:39:47 +0200 Subject: [PATCH 044/131] Fix message retraction in groupchats --- Monal/Classes/xmpp.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m index 34b9b4be28..6150d7e5f6 100644 --- a/Monal/Classes/xmpp.m +++ b/Monal/Classes/xmpp.m @@ -3321,7 +3321,7 @@ -(void) logStanza:(MLXMLNode*) stanza withPrefix:(NSString*) prefix -(void) retractMessage:(MLMessage*) msg { MLAssert([msg.accountId isEqual:self.accountNo], @"Can not retract message from one account on another account!", (@{@"self.accountNo": self.accountNo, @"msg": msg})); - XMPPMessage* messageNode = [[XMPPMessage alloc] initWithType:kMessageChatType to:msg.buddyName]; + XMPPMessage* messageNode = [[XMPPMessage alloc] initWithType:msg.isMuc ? kMessageGroupChatType : kMessageChatType to:msg.buddyName]; DDLogVerbose(@"Retracting message: %@", msg); //retraction From e29927d869cb179627e04c95887c3d83afd26d23 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 18:47:22 +0200 Subject: [PATCH 045/131] Fix commit 087db19 to not delete already known participants Member list iq results coming in after the presence flood did clear the participants table so that every member seemed offline. --- Monal/Classes/DataLayer.h | 3 ++- Monal/Classes/DataLayer.m | 12 ++++++++++-- Monal/Classes/MLMucProcessor.m | 10 ++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h index f571c8d221..201d0f0415 100644 --- a/Monal/Classes/DataLayer.h +++ b/Monal/Classes/DataLayer.h @@ -110,7 +110,8 @@ extern NSString* const kMessageTypeFiletransfer; #pragma mark - MUC -(BOOL) initMuc:(NSString*) room forAccountId:(NSNumber*) accountNo andMucNick:(NSString* _Nullable) mucNick; --(void) cleanupMembersAndParticipantsListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo; +-(void) cleanupParticipantsListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo; +-(void) cleanupMembersListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo; -(void) addMember:(NSDictionary*) member toMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; -(void) removeMember:(NSDictionary*) member fromMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; -(void) addParticipant:(NSDictionary*) participant toMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index 6842ae7ca0..602e8d7d48 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -939,7 +939,10 @@ -(BOOL) initMuc:(NSString*) room forAccountId:(NSNumber*) accountNo andMucNick:( MLAssert(nick != nil, @"Could not determine muc nick when adding muc"); for(NSString* type in @[@"member", @"admin", @"owner"]) - [self cleanupMembersAndParticipantsListFor:room andType:type onAccountId:accountNo]; + { + [self cleanupParticipantsListFor:room andType:type onAccountId:accountNo]; + [self cleanupMembersListFor:room andType:type onAccountId:accountNo]; + } BOOL encrypt = NO; #ifndef DISABLE_OMEMO @@ -951,10 +954,15 @@ -(BOOL) initMuc:(NSString*) room forAccountId:(NSNumber*) accountNo andMucNick:( }]; } --(void) cleanupMembersAndParticipantsListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo +-(void) cleanupParticipantsListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo { //clean up old muc data (will be refilled by incoming presences and/or disco queries) [self.db executeNonQuery:@"DELETE FROM muc_participants WHERE account_id=? AND room=? AND affiliation=?;" andArguments:@[accountNo, room, type]]; +} + +-(void) cleanupMembersListFor:(NSString*) room andType:(NSString*) type onAccountId:(NSNumber*) accountNo +{ + //clean up old muc data (will be refilled by incoming presences and/or disco queries) [self.db executeNonQuery:@"DELETE FROM muc_members WHERE account_id=? AND room=? AND affiliation=?;" andArguments:@[accountNo, room, type]]; } diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m index 93879be5d9..feb0218ba6 100644 --- a/Monal/Classes/MLMucProcessor.m +++ b/Monal/Classes/MLMucProcessor.m @@ -1483,6 +1483,12 @@ -(void) publishAvatar:(UIImage* _Nullable) image forMuc:(NSString*) room if(join) { + for(NSString* type in @[@"member", @"admin", @"owner"]) + { + DDLogInfo(@"Clearing muc participants table for type %@: %@", type, iqNode.fromUser); + [[DataLayer sharedInstance] cleanupParticipantsListFor:iqNode.fromUser andType:type onAccountId:_account.accountNo]; + } + //now try to join this room if requested [self sendJoinPresenceFor:iqNode.fromUser]; } @@ -1514,8 +1520,8 @@ -(void) sendJoinPresenceFor:(NSString*) room $$instance_handler(handleMembersList, account.mucProcessor, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSString*, type)) DDLogInfo(@"Got %@s list from %@...", type, iqNode.fromUser); - DDLogInfo(@"Clearing muc participants and members tables for type %@: %@", type, iqNode.fromUser); - [[DataLayer sharedInstance] cleanupMembersAndParticipantsListFor:iqNode.fromUser andType:type onAccountId:_account.accountNo]; + DDLogInfo(@"Clearing muc members table for type %@: %@", type, iqNode.fromUser); + [[DataLayer sharedInstance] cleanupMembersListFor:iqNode.fromUser andType:type onAccountId:_account.accountNo]; [self handleMembersListUpdate:[iqNode find:@"{http://jabber.org/protocol/muc#admin}query/item@@"] forMuc:iqNode.fromUser]; [self logMembersOfMuc:iqNode.fromUser]; $$ From 067d7b48c4b880c8d5ecdda45300d041f93fff6d Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 18:40:11 +0200 Subject: [PATCH 046/131] Implement occupant-ids and use them for retraction and LMC --- Monal/Classes/DataLayer.h | 7 ++-- Monal/Classes/DataLayer.m | 58 +++++++++++++++++------------ Monal/Classes/DataLayerMigrations.m | 13 +++++++ Monal/Classes/MLMessageProcessor.m | 22 ++++++++--- Monal/Classes/MLMucProcessor.m | 2 + 5 files changed, 71 insertions(+), 31 deletions(-) diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h index 201d0f0415..eb53f0e8b6 100644 --- a/Monal/Classes/DataLayer.h +++ b/Monal/Classes/DataLayer.h @@ -117,6 +117,7 @@ extern NSString* const kMessageTypeFiletransfer; -(void) addParticipant:(NSDictionary*) participant toMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; -(void) removeParticipant:(NSDictionary*) participant fromMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; -(NSDictionary* _Nullable) getParticipantForNick:(NSString*) nick inRoom:(NSString*) room forAccountId:(NSNumber*) accountNo; +-(NSDictionary* _Nullable) getParticipantForOccupant:(NSString*) occupant inRoom:(NSString*) room forAccountId:(NSNumber*) accountNo; -(NSArray*>*) getMembersAndParticipantsOfMuc:(NSString*) room forAccountId:(NSNumber*) accountNo; -(NSString* _Nullable) getOwnAffiliationInGroupOrChannel:(MLContact*) contact; -(NSString* _Nullable) getOwnRoleInGroupOrChannel:(MLContact*) contact; @@ -186,7 +187,7 @@ extern NSString* const kMessageTypeFiletransfer; /* adds a specified message to the database */ --(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) inbound forAccount:(NSNumber*) accountNo withBody:(NSString*) message actuallyfrom:(NSString*) actualfrom participantJid:(NSString*_Nullable) participantJid sent:(BOOL) sent unread:(BOOL) unread messageId:(NSString*) messageid serverMessageId:(NSString*) stanzaid messageType:(NSString*) messageType andOverrideDate:(NSDate*) messageDate encrypted:(BOOL) encrypted displayMarkerWanted:(BOOL) displayMarkerWanted usingHistoryId:(NSNumber* _Nullable) historyId checkForDuplicates:(BOOL) checkForDuplicates; +-(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) inbound forAccount:(NSNumber*) accountNo withBody:(NSString*) message actuallyfrom:(NSString*) actualfrom occupantId:(NSString* _Nullable) occupantId participantJid:(NSString*_Nullable) participantJid sent:(BOOL) sent unread:(BOOL) unread messageId:(NSString*) messageid serverMessageId:(NSString*) stanzaid messageType:(NSString*) messageType andOverrideDate:(NSDate*) messageDate encrypted:(BOOL) encrypted displayMarkerWanted:(BOOL) displayMarkerWanted usingHistoryId:(NSNumber* _Nullable) historyId checkForDuplicates:(BOOL) checkForDuplicates; /* Marks a message as sent. When the server acked it @@ -218,8 +219,8 @@ extern NSString* const kMessageTypeFiletransfer; -(void) retractMessageHistory:(NSNumber *) messageNo; -(void) deleteMessageHistoryLocally:(NSNumber*) messageNo; -(void) updateMessageHistory:(NSNumber*) messageNo withText:(NSString*) newText; --(NSNumber* _Nullable) getLMCHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from actualFrom:(NSString* _Nullable) actualFrom participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountNo; --(NSNumber* _Nullable) getRetractionHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from actualFrom:(NSString* _Nullable) actualFrom participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountNo; +-(NSNumber* _Nullable) getLMCHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from occupantId:(NSString* _Nullable) occupantId participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountNo; +-(NSNumber* _Nullable) getRetractionHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from participantJid:(NSString* _Nullable) participantJid occupantId:(NSString* _Nullable) occupantId andAccount:(NSNumber*) accountNo; -(NSNumber* _Nullable) getRetractionHistoryIDForModeratedStanzaId:(NSString*) stanzaId from:(NSString*) from andAccount:(NSNumber*) accountNo; -(NSDate* _Nullable) returnTimestampForQuote:(NSNumber*) historyID; diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index 602e8d7d48..5fa05a657a 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -973,7 +973,7 @@ -(void) addParticipant:(NSDictionary*) participant toMuc:(NSString*) room forAcc [self.db voidWriteTransaction:^{ //create entry if not already existing - [self.db executeNonQuery:@"INSERT OR IGNORE INTO muc_participants ('account_id', 'room', 'room_nick') VALUES(?, ?, ?);" andArguments:@[accountNo, room, participant[@"nick"]]]; + [self.db executeNonQuery:@"INSERT OR IGNORE INTO muc_participants ('account_id', 'room', 'room_nick', 'occupant_id') VALUES(?, ?, ?, ?);" andArguments:@[accountNo, room, participant[@"nick"], nilWrapper(participant[@"occupant_id"])]]; //update entry with optional fields (the first two fields are for members that are not just participants) if(participant[@"jid"]) @@ -1021,7 +1021,7 @@ -(void) removeMember:(NSDictionary*) member fromMuc:(NSString*) room forAccountI }]; } --(NSDictionary* _Nullable) getParticipantForNick:(NSString*) nick inRoom:(NSString*) room forAccountId:(NSString*) accountNo +-(NSDictionary* _Nullable) getParticipantForNick:(NSString*) nick inRoom:(NSString*) room forAccountId:(NSNumber*) accountNo { if(!nick || !room || accountNo == nil) return nil; @@ -1031,6 +1031,16 @@ -(NSDictionary* _Nullable) getParticipantForNick:(NSString*) nick inRoom:(NSStri }]; } +-(NSDictionary* _Nullable) getParticipantForOccupant:(NSString*) occupant inRoom:(NSString*) room forAccountId:(NSNumber*) accountNo +{ + if(!occupant || !occupant || accountNo == nil) + return nil; + return [self.db idReadTransaction:^{ + NSArray* result = [self.db executeReader:@"SELECT * FROM muc_participants WHERE account_id=? AND room=? AND occupant_id=?;" andArguments:@[accountNo, room, occupant]]; + return result.count > 0 ? result[0] : nil; + }]; +} + -(NSArray*>*) getMembersAndParticipantsOfMuc:(NSString*) room forAccountId:(NSNumber*) accountNo { if(!room || accountNo == nil) @@ -1244,7 +1254,7 @@ -(NSNumber*) getBiggestHistoryId }]; } --(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) inbound forAccount:(NSNumber*) accountNo withBody:(NSString*) message actuallyfrom:(NSString*) actualfrom participantJid:(NSString*) participantJid sent:(BOOL) sent unread:(BOOL) unread messageId:(NSString*) messageid serverMessageId:(NSString*) stanzaid messageType:(NSString*) messageType andOverrideDate:(NSDate*) messageDate encrypted:(BOOL) encrypted displayMarkerWanted:(BOOL) displayMarkerWanted usingHistoryId:(NSNumber* _Nullable) historyId checkForDuplicates:(BOOL) checkForDuplicates +-(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) inbound forAccount:(NSNumber*) accountNo withBody:(NSString*) message actuallyfrom:(NSString*) actualfrom occupantId:(NSString* _Nullable) occupantId participantJid:(NSString*_Nullable) participantJid sent:(BOOL) sent unread:(BOOL) unread messageId:(NSString*) messageid serverMessageId:(NSString*) stanzaid messageType:(NSString*) messageType andOverrideDate:(NSDate*) messageDate encrypted:(BOOL) encrypted displayMarkerWanted:(BOOL) displayMarkerWanted usingHistoryId:(NSNumber* _Nullable) historyId checkForDuplicates:(BOOL) checkForDuplicates; { if(!buddyName || !message) return nil; @@ -1282,14 +1292,14 @@ -(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) i if(historyId != nil) { DDLogVerbose(@"Inserting backwards with history id %@", historyId); - query = @"insert into message_history (message_history_id, account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, displayMarkerWanted, messageid, messageType, encrypted, stanzaid, participant_jid) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; - params = @[historyId, accountNo, buddyName, [NSNumber numberWithBool:inbound], dateString, message, actualfrom, [NSNumber numberWithBool:unread], [NSNumber numberWithBool:sent], [NSNumber numberWithBool:displayMarkerWanted], messageid?messageid:@"", messageType, [NSNumber numberWithBool:encrypted], stanzaid?stanzaid:@"", participantJid != nil ? participantJid : [NSNull null]]; + query = @"insert into message_history (message_history_id, account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, displayMarkerWanted, messageid, messageType, encrypted, stanzaid, participant_jid, occupant_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + params = @[historyId, accountNo, buddyName, [NSNumber numberWithBool:inbound], dateString, message, actualfrom, [NSNumber numberWithBool:unread], [NSNumber numberWithBool:sent], [NSNumber numberWithBool:displayMarkerWanted], messageid?messageid:@"", messageType, [NSNumber numberWithBool:encrypted], stanzaid?stanzaid:@"", nilWrapper(participantJid), nilWrapper(occupantId)]; } else { //we use autoincrement here instead of MAX(message_history_id) + 1 to be a little bit faster (but at the cost of "duplicated code") - query = @"insert into message_history (account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, displayMarkerWanted, messageid, messageType, encrypted, stanzaid, participant_jid) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; - params = @[accountNo, buddyName, [NSNumber numberWithBool:inbound], dateString, message, actualfrom, [NSNumber numberWithBool:unread], [NSNumber numberWithBool:sent], [NSNumber numberWithBool:displayMarkerWanted], messageid?messageid:@"", messageType, [NSNumber numberWithBool:encrypted], stanzaid?stanzaid:@"", participantJid != nil ? participantJid : [NSNull null]]; + query = @"insert into message_history (account_id, buddy_name, inbound, timestamp, message, actual_from, unread, sent, displayMarkerWanted, messageid, messageType, encrypted, stanzaid, participant_jid, occupant_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + params = @[accountNo, buddyName, [NSNumber numberWithBool:inbound], dateString, message, actualfrom, [NSNumber numberWithBool:unread], [NSNumber numberWithBool:sent], [NSNumber numberWithBool:displayMarkerWanted], messageid?messageid:@"", messageType, [NSNumber numberWithBool:encrypted], stanzaid?stanzaid:@"", nilWrapper(participantJid), nilWrapper(occupantId)]; } DDLogVerbose(@"%@ params:%@", query, params); BOOL success = [self.db executeNonQuery:query andArguments:params]; @@ -1527,30 +1537,32 @@ -(void) updateMessageHistory:(NSNumber*) messageNo withText:(NSString*) newText }]; } --(NSNumber* _Nullable) getLMCHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from actualFrom:(NSString* _Nullable) actualFrom participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountNo +-(NSNumber* _Nullable) getLMCHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from occupantId:(NSString* _Nullable) occupantId participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountNo { return [self.db idReadTransaction:^{ return [self.db executeScalar:@"SELECT M.message_history_id FROM message_history AS M INNER JOIN account AS A ON M.account_id=A.account_id INNER JOIN buddylist AS B on M.buddy_name = B.buddy_name AND M.account_id = B.account_id WHERE messageid=? AND M.account_id=? AND (\ (B.Muc=0 AND ((M.buddy_name=? AND M.inbound=1) OR ((A.username || '@' || A.domain)=? AND M.inbound=0))) OR \ - (\ - B.Muc=1 AND M.buddy_name=? AND M.actual_from=? AND (\ - M.participant_jid=? OR M.participant_jid IS NULL \ + (B.Muc=1 AND M.buddy_name=? AND (\ + (M.occupant_id=? AND M.occupant_id IS NOT NULL) OR \ + (M.participant_jid=? AND M.participant_jid IS NOT NULL) \ ) AND ( \ (M.actual_from=B.muc_nick AND M.inbound=0) OR \ (M.actual_from!=B.muc_nick AND M.inbound=1) \ ) \ ) \ - );" andArguments:@[messageid, accountNo, from, from, from, nilWrapper(actualFrom), nilWrapper(participantJid)]]; + );" andArguments:@[messageid, accountNo, from, from, from, nilWrapper(occupantId), nilWrapper(participantJid)]]; }]; } --(NSNumber* _Nullable) getRetractionHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from actualFrom:(NSString* _Nullable) actualFrom participantJid:(NSString* _Nullable) participantJid andAccount:(NSNumber*) accountNo +-(NSNumber* _Nullable) getRetractionHistoryIDForMessageId:(NSString*) messageid from:(NSString*) from participantJid:(NSString* _Nullable) participantJid occupantId:(NSString* _Nullable) occupantId andAccount:(NSNumber*) accountNo { return [self.db idReadTransaction:^{ return [self.db executeScalar:@"SELECT M.message_history_id FROM message_history AS M INNER JOIN account AS A ON M.account_id=A.account_id INNER JOIN buddylist AS B on M.buddy_name = B.buddy_name AND M.account_id = B.account_id WHERE M.account_id=? AND ( \ (B.Muc=0 AND M.messageid=? AND ((M.buddy_name=? AND M.inbound=1) OR ((A.username || '@' || A.domain)=? AND M.inbound=0))) OR \ - (B.Muc=1 AND M.stanzaid=? AND M.buddy_name=? AND (M.participant_jid=? OR (M.participant_jid IS NULL AND M.actual_from=?))) \ - );" andArguments:@[accountNo, messageid, from, from, messageid, from, nilWrapper(participantJid), nilWrapper(actualFrom)]]; + (B.Muc=1 AND M.stanzaid=? AND M.buddy_name=? AND ( \ + (M.participant_jid=? AND M.participant_jid IS NOT NULL) OR (M.occupant_id=? AND M.occupant_id IS NOT NULL)) \ + ) \ + );" andArguments:@[accountNo, messageid, from, from, messageid, from, nilWrapper(participantJid), nilWrapper(occupantId)]]; }]; } @@ -1600,7 +1612,7 @@ -(BOOL) checkLMCEligible:(NSNumber*) historyID encrypted:(BOOL) encrypted histor if(msg == nil) return NO; - //use the oldest 3 messages, if we are processing a MLhistory mam fetch, and the newest 3, if we are going forward in time + //only allow LMC if the correction message has the same encryption or better state as the original message if(historyBaseID != nil) { //only allow LMC for the 3 newest messages of this contact (or of us) @@ -1611,14 +1623,14 @@ -(BOOL) checkLMCEligible:(NSNumber*) historyID encrypted:(BOOL) encrypted histor ELSE 0 \ END \ FROM \ - (SELECT message_history_id, inbound, encrypted, messageType FROM message_history WHERE account_id=? AND buddy_name=? AND message_history_id"] && messageNode.fromResource) { ownNick = [[DataLayer sharedInstance] ownNickNameforMuc:messageNode.fromUser forAccount:account.accountNo]; @@ -322,8 +323,17 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag participantJid = [messageNode findFirst:@"//{http://jabber.org/protocol/muc#user}x/item@jid"]; if(![outerMessageNode check:@"{urn:xmpp:mam:2}result"] || participantJid == nil) { - NSDictionary* mucParticipant = [[DataLayer sharedInstance] getParticipantForNick:actualFrom inRoom:messageNode.fromUser forAccountId:account.accountNo]; - participantJid = mucParticipant ? mucParticipant[@"participant_jid"] : nil; + if([[account.mucProcessor getRoomFeaturesForMuc:messageNode.fromUser] containsObject:@"urn:xmpp:occupant-id:0"] && [messageNode check:@"{urn:xmpp:occupant-id:0}occupant-id@id"]) + { + occupantId = [messageNode findFirst:@"{urn:xmpp:occupant-id:0}occupant-id@id"]; + NSDictionary* mucParticipant = [[DataLayer sharedInstance] getParticipantForOccupant:occupantId inRoom:messageNode.fromUser forAccountId:account.accountNo]; + participantJid = mucParticipant ? mucParticipant[@"participant_jid"] : nil; + } + else + { + NSDictionary* mucParticipant = [[DataLayer sharedInstance] getParticipantForNick:actualFrom inRoom:messageNode.fromUser forAccountId:account.accountNo]; + participantJid = mucParticipant ? mucParticipant[@"participant_jid"] : nil; + } } //make sure this is not the full jid if(participantJid != nil) @@ -446,8 +456,8 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag } else { - //this checks for all spelled out in the business rules of XEP-0424 - historyIdToRetract = [[DataLayer sharedInstance] getRetractionHistoryIDForMessageId:idToRetract from:messageNode.fromUser actualFrom:actualFrom participantJid:participantJid andAccount:account.accountNo]; + //this checks for everything spelled out in the business rules of XEP-0424 + historyIdToRetract = [[DataLayer sharedInstance] getRetractionHistoryIDForMessageId:idToRetract from:messageNode.fromUser participantJid:participantJid occupantId:occupantId andAccount:account.accountNo]; } if(historyIdToRetract != nil) @@ -486,6 +496,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag forAccount:account.accountNo withBody:@"" actuallyfrom:actualFrom + occupantId:occupantId participantJid:participantJid sent:YES unread:NO @@ -568,7 +579,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag NSString* messageIdToReplace = [messageNode findFirst:@"{urn:xmpp:message-correct:0}replace@id"]; DDLogVerbose(@"Message id to LMC-replace: %@", messageIdToReplace); //this checks if this message is from the same jid as the message it tries to do the LMC for (e.g. inbound can only correct inbound and outbound only outbound) - historyId = [[DataLayer sharedInstance] getLMCHistoryIDForMessageId:messageIdToReplace from:messageNode.fromUser actualFrom:actualFrom participantJid:participantJid andAccount:account.accountNo]; + historyId = [[DataLayer sharedInstance] getLMCHistoryIDForMessageId:messageIdToReplace from:messageNode.fromUser occupantId:occupantId participantJid:participantJid andAccount:account.accountNo]; DDLogVerbose(@"History id to LMC-replace: %@", historyId); //now check if the LMC is allowed (we use historyIdToUse for MLhistory mam queries to only check LMC for the 3 messages coming before this ID in this converastion) //historyIdToUse will be nil, for messages going forward in time which means (check for the newest 3 messages in this conversation) @@ -588,6 +599,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag forAccount:account.accountNo withBody:[body copy] actuallyfrom:actualFrom + occupantId:occupantId participantJid:participantJid sent:YES unread:unread diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m index feb0218ba6..240d4cf42b 100644 --- a/Monal/Classes/MLMucProcessor.m +++ b/Monal/Classes/MLMucProcessor.m @@ -361,6 +361,8 @@ -(void) processPresence:(XMPPPresence*) presenceNode if(item[@"jid"]) item[@"jid"] = [HelperTools splitJid:item[@"jid"]][@"user"]; item[@"nick"] = presenceNode.fromResource; + if([_roomFeatures[presenceNode.fromUser] containsObject:@"urn:xmpp:occupant-id:0"]) + item[@"occupant_id"] = [presenceNode findFirst:@"{urn:xmpp:occupant-id:0}occupant-id@id"]; //handle participant updates if([presenceNode check:@"/"] || item[@"affiliation"] == nil) From 84f72405359f8f33cb82786de6faf13119fed971 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 20:20:13 +0200 Subject: [PATCH 047/131] Make sure to wait properly for notification state update --- Monal/Classes/MLMessageProcessor.m | 5 ++ Monal/Classes/MLNotificationManager.m | 84 ++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/Monal/Classes/MLMessageProcessor.m b/Monal/Classes/MLMessageProcessor.m index 4bfdcda51a..0c0427e3f3 100644 --- a/Monal/Classes/MLMessageProcessor.m +++ b/Monal/Classes/MLMessageProcessor.m @@ -571,6 +571,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag if(body) { + BOOL LMCReplaced = NO; NSNumber* historyId = nil; //handle LMC @@ -584,7 +585,10 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag //now check if the LMC is allowed (we use historyIdToUse for MLhistory mam queries to only check LMC for the 3 messages coming before this ID in this converastion) //historyIdToUse will be nil, for messages going forward in time which means (check for the newest 3 messages in this conversation) if(historyId != nil && [[DataLayer sharedInstance] checkLMCEligible:historyId encrypted:encrypted historyBaseID:historyIdToUse]) + { [[DataLayer sharedInstance] updateMessageHistory:historyId withText:body]; + LMCReplaced = YES; + } else historyId = nil; } @@ -675,6 +679,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag @"message": message, @"showAlert": @(showAlert), @"contact": possiblyUnknownContact, + @"LMCReplaced": @(LMCReplaced), }]; //try to automatically determine content type of filetransfers diff --git a/Monal/Classes/MLNotificationManager.m b/Monal/Classes/MLNotificationManager.m index 762165ba87..c033842e2a 100644 --- a/Monal/Classes/MLNotificationManager.m +++ b/Monal/Classes/MLNotificationManager.m @@ -24,6 +24,12 @@ @import AVFoundation; @import UniformTypeIdentifiers; +typedef NS_ENUM(NSUInteger, MLNotificationState) { + MLNotificationStateNone, + MLNotificationStatePending, + MLNotificationStateDelivered, +}; + @interface MLNotificationManager () @property (nonatomic, readonly) NotificationPrivacySettingOption notificationPrivacySetting; @end @@ -167,28 +173,76 @@ -(void) handleXMPPError:(NSNotification*) notification #pragma mark message signals --(void) handleFiletransferUpdate:(NSNotification*) notification +-(MLNotificationState) notificationStateForMessage:(MLMessage*) message { - xmpp* xmppAccount = notification.object; - MLMessage* message = [notification.userInfo objectForKey:@"message"]; NSString* idval = [self identifierWithMessage:message]; + __block MLNotificationState retval = MLNotificationStateNone; + NSCondition* condition = [NSCondition new]; + [condition lock]; + DDLogVerbose(@"Checking for 'pending' notification state for '%@'...", idval); [[UNUserNotificationCenter currentNotificationCenter] getPendingNotificationRequestsWithCompletionHandler:^(NSArray* requests) { for(UNNotificationRequest* request in requests) if([request.identifier isEqualToString:idval]) { - DDLogDebug(@"Already pending notification '%@', updating it...", idval); - [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:YES]; + DDLogDebug(@"Notification state pending for: %@", idval); + retval = MLNotificationStatePending; + [condition lock]; + [condition signal]; + [condition unlock]; + return; } + [condition lock]; + [condition signal]; + [condition unlock]; }]; + DDLogVerbose(@"Waiting for notification state check for '%@' to complete...", idval); + [condition wait]; + [condition unlock]; + + [condition lock]; + DDLogVerbose(@"Checking for 'delivered' notification state for '%@'...", idval); [[UNUserNotificationCenter currentNotificationCenter] getDeliveredNotificationsWithCompletionHandler:^(NSArray* notifications) { for(UNNotification* notification in notifications) if([notification.request.identifier isEqualToString:idval]) { - DDLogDebug(@"Already displayed notification '%@', updating it...", idval); - [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:NO]; + DDLogDebug(@"Notification state delivered for: %@", idval); + retval = MLNotificationStateDelivered; + [condition lock]; + [condition signal]; + [condition unlock]; + return; } + [condition lock]; + [condition signal]; + [condition unlock]; }]; + DDLogVerbose(@"Waiting for notification state check for '%@' to complete...", idval); + [condition wait]; + [condition unlock]; + + DDLogVerbose(@"Returning notification state for '%@': %@", idval, @(retval)); + return retval; +} + +-(void) handleFiletransferUpdate:(NSNotification*) notification +{ + xmpp* xmppAccount = notification.object; + MLMessage* message = [notification.userInfo objectForKey:@"message"]; + NSString* idval = [self identifierWithMessage:message]; + MLNotificationState state = [self notificationStateForMessage:message]; + if(state == MLNotificationStatePending || state == MLNotificationStateNone) + { + DDLogDebug(@"Already pending or unknown notification '%@', updating/posting it...", idval); + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:YES andLMCReplaced:NO]; + } + else if(state == MLNotificationStateDelivered) + { + DDLogDebug(@"Already displayed notification '%@', updating it...", idval); + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:NO andLMCReplaced:NO]; + } + else + unreachable(@"Unknown MLNotificationState!", @{@"state": @(state)}); } -(void) handleNewMessage:(NSNotification*) notification @@ -196,10 +250,11 @@ -(void) handleNewMessage:(NSNotification*) notification xmpp* xmppAccount = notification.object; MLMessage* message = [notification.userInfo objectForKey:@"message"]; BOOL showAlert = notification.userInfo[@"showAlert"] ? [notification.userInfo[@"showAlert"] boolValue] : NO; - [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:showAlert andSound:YES]; + BOOL LMCReplaced = notification.userInfo[@"LMCReplaced"] ? [notification.userInfo[@"LMCReplaced"] boolValue] : NO; + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:showAlert andSound:YES andLMCReplaced:LMCReplaced]; } --(void) internalMessageHandlerWithMessage:(MLMessage*) message andAccount:(xmpp*) xmppAccount showAlert:(BOOL) showAlert andSound:(BOOL) sound +-(void) internalMessageHandlerWithMessage:(MLMessage*) message andAccount:(xmpp*) xmppAccount showAlert:(BOOL) showAlert andSound:(BOOL) sound andLMCReplaced:(BOOL) LMCReplaced { if([message.messageType isEqualToString:kMessageTypeStatus]) return; @@ -231,6 +286,17 @@ -(void) internalMessageHandlerWithMessage:(MLMessage*) message andAccount:(xmpp* return; } + //check if we need to replace the still displayed notification or ignore this LMC + if(LMCReplaced) + { + MLNotificationState state = [self notificationStateForMessage:message]; + if(state == MLNotificationStateNone) + { + DDLogDebug(@"not showing notification for LMC: this notification was already removed earlier"); + return; + } + } + if([HelperTools isNotInFocus]) { DDLogVerbose(@"notification manager should show notification in background: %@", message.messageText); From 025cf1d3602f1cf25a69066ac1b0ff4e5d570eed Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 21:32:20 +0200 Subject: [PATCH 048/131] Make PromiseKit available to all objc targets --- Monal/Classes/MLConstants.h | 5 +++++ Monal/Podfile | 3 +++ Monal/Podfile.lock | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/MLConstants.h b/Monal/Classes/MLConstants.h index 99d0a86651..722c0f6aa4 100644 --- a/Monal/Classes/MLConstants.h +++ b/Monal/Classes/MLConstants.h @@ -21,6 +21,11 @@ static const DDLogLevel ddLogLevel = LOG_LEVEL_STDOUT; #import "MLLogFileManager.h" +@import PromiseKit; +#define PMKHangEnum(promise) (((NSNumber*)PMKHang(promise)).integerValue) +#define PMKHangBool(promise) (((NSNumber*)PMKHang(promise)).boolValue) +#define PMKHangInt(promise) (((NSNumber*)PMKHang(promise)).intValue) +#define PMKHangDouble(promise) (((NSNumber*)PMKHang(promise)).doubleValue) //configure app group constants #ifdef IS_ALPHA diff --git a/Monal/Podfile b/Monal/Podfile index 9b0b45f14d..fde6b9a1ee 100644 --- a/Monal/Podfile +++ b/Monal/Podfile @@ -21,6 +21,7 @@ def monal pod 'CropViewController' pod 'NotificationBannerSwift', '~> 3.2.0' pod 'FLAnimatedImage', '~> 1.0' + pod "PromiseKit" end def monalxmpp @@ -41,12 +42,14 @@ target 'shareSheet' do # Uncomment the next line if you're using Swift or would like to use dynamic frameworks use_frameworks! inhibit_all_warnings! + pod "PromiseKit" end target 'NotificationService' do # Uncomment the next line if you're using Swift or would like to use dynamic frameworks use_frameworks! inhibit_all_warnings! + pod "PromiseKit" end target 'Monal' do diff --git a/Monal/Podfile.lock b/Monal/Podfile.lock index ccc61a9c07..c8cdad1b0c 100644 --- a/Monal/Podfile.lock +++ b/Monal/Podfile.lock @@ -135,6 +135,6 @@ SPEC CHECKSUMS: sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 WebRTC-lib: 7e2e15d90ebca6e08a1eb5d4afc365d70e4b95b0 -PODFILE CHECKSUM: eb1329bed9beb1b2ebf1a1c250e9e314295e33c1 +PODFILE CHECKSUM: 5c99d737e50901754d89688ff4fdc8de7c97b322 COCOAPODS: 1.15.2 From c060f7af93012b9f00ff6d2dc4e9b9868ff703a5 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 21:32:53 +0200 Subject: [PATCH 049/131] Promisify notification state resolver --- Monal/Classes/MLNotificationManager.m | 119 +++++++++++++------------- 1 file changed, 58 insertions(+), 61 deletions(-) diff --git a/Monal/Classes/MLNotificationManager.m b/Monal/Classes/MLNotificationManager.m index c033842e2a..75ebb7a0c2 100644 --- a/Monal/Classes/MLNotificationManager.m +++ b/Monal/Classes/MLNotificationManager.m @@ -173,56 +173,47 @@ -(void) handleXMPPError:(NSNotification*) notification #pragma mark message signals --(MLNotificationState) notificationStateForMessage:(MLMessage*) message +-(AnyPromise*) notificationStateForMessage:(MLMessage*) message { NSString* idval = [self identifierWithMessage:message]; - __block MLNotificationState retval = MLNotificationStateNone; - NSCondition* condition = [NSCondition new]; - - [condition lock]; - DDLogVerbose(@"Checking for 'pending' notification state for '%@'...", idval); - [[UNUserNotificationCenter currentNotificationCenter] getPendingNotificationRequestsWithCompletionHandler:^(NSArray* requests) { - for(UNNotificationRequest* request in requests) - if([request.identifier isEqualToString:idval]) - { - DDLogDebug(@"Notification state pending for: %@", idval); - retval = MLNotificationStatePending; - [condition lock]; - [condition signal]; - [condition unlock]; - return; - } - [condition lock]; - [condition signal]; - [condition unlock]; - }]; - DDLogVerbose(@"Waiting for notification state check for '%@' to complete...", idval); - [condition wait]; - [condition unlock]; - - [condition lock]; - DDLogVerbose(@"Checking for 'delivered' notification state for '%@'...", idval); - [[UNUserNotificationCenter currentNotificationCenter] getDeliveredNotificationsWithCompletionHandler:^(NSArray* notifications) { - for(UNNotification* notification in notifications) - if([notification.request.identifier isEqualToString:idval]) - { - DDLogDebug(@"Notification state delivered for: %@", idval); - retval = MLNotificationStateDelivered; - [condition lock]; - [condition signal]; - [condition unlock]; - return; - } - [condition lock]; - [condition signal]; - [condition unlock]; - }]; - DDLogVerbose(@"Waiting for notification state check for '%@' to complete...", idval); - [condition wait]; - [condition unlock]; + NSMutableArray* promises = [NSMutableArray new]; + + [promises addObject:[AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + DDLogVerbose(@"Checking for 'pending' notification state for '%@'...", idval); + [[UNUserNotificationCenter currentNotificationCenter] getPendingNotificationRequestsWithCompletionHandler:^(NSArray* requests) { + for(UNNotificationRequest* request in requests) + if([request.identifier isEqualToString:idval]) + { + DDLogDebug(@"Notification state 'pending' for: %@", idval); + resolve(@(MLNotificationStatePending)); + return; + } + resolve(@(MLNotificationStateNone)); + }]; + }]]; + + [promises addObject:[AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + DDLogVerbose(@"Checking for 'delivered' notification state for '%@'...", idval); + [[UNUserNotificationCenter currentNotificationCenter] getDeliveredNotificationsWithCompletionHandler:^(NSArray* notifications) { + for(UNNotification* notification in notifications) + if([notification.request.identifier isEqualToString:idval]) + { + DDLogDebug(@"Notification state 'delivered' for: %@", idval); + resolve(@(MLNotificationStateDelivered)); + return; + } + resolve(@(MLNotificationStateNone)); + }]; + }]]; - DDLogVerbose(@"Returning notification state for '%@': %@", idval, @(retval)); - return retval; + + return PMKWhen(promises).then(^(NSArray* results) { + DDLogVerbose(@"Notification state check for '%@' completed...", idval); + for(NSNumber* entry in results) + if(entry.integerValue != MLNotificationStateNone) + return entry; + return @(MLNotificationStateNone); + }); } -(void) handleFiletransferUpdate:(NSNotification*) notification @@ -230,19 +221,22 @@ -(void) handleFiletransferUpdate:(NSNotification*) notification xmpp* xmppAccount = notification.object; MLMessage* message = [notification.userInfo objectForKey:@"message"]; NSString* idval = [self identifierWithMessage:message]; - MLNotificationState state = [self notificationStateForMessage:message]; - if(state == MLNotificationStatePending || state == MLNotificationStateNone) - { - DDLogDebug(@"Already pending or unknown notification '%@', updating/posting it...", idval); - [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:YES andLMCReplaced:NO]; - } - else if(state == MLNotificationStateDelivered) - { - DDLogDebug(@"Already displayed notification '%@', updating it...", idval); - [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:NO andLMCReplaced:NO]; - } - else - unreachable(@"Unknown MLNotificationState!", @{@"state": @(state)}); + //do this asynchronous on a background thread + [self notificationStateForMessage:message].thenInBackground(^(NSNumber* _state) { + MLNotificationState state = _state.integerValue; + if(state == MLNotificationStatePending || state == MLNotificationStateNone) + { + DDLogDebug(@"Already pending or unknown notification '%@', updating/posting it...", idval); + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:YES andLMCReplaced:NO]; + } + else if(state == MLNotificationStateDelivered) + { + DDLogDebug(@"Already displayed notification '%@', updating it...", idval); + [self internalMessageHandlerWithMessage:message andAccount:xmppAccount showAlert:YES andSound:NO andLMCReplaced:NO]; + } + else + unreachable(@"Unknown MLNotificationState!", @{@"state": @(state)}); + }); } -(void) handleNewMessage:(NSNotification*) notification @@ -289,7 +283,10 @@ -(void) internalMessageHandlerWithMessage:(MLMessage*) message andAccount:(xmpp* //check if we need to replace the still displayed notification or ignore this LMC if(LMCReplaced) { - MLNotificationState state = [self notificationStateForMessage:message]; + NSString* idval = [self identifierWithMessage:message]; + //wait synchronous for completion (needed for appex) + MLNotificationState state = PMKHangEnum([self notificationStateForMessage:message]); + DDLogVerbose(@"Notification state for '%@': %@", idval, @(state)); if(state == MLNotificationStateNone) { DDLogDebug(@"not showing notification for LMC: this notification was already removed earlier"); From 70019f55f387894b4964fdbe828dc0d0018a142a Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 21:37:36 +0200 Subject: [PATCH 050/131] Fix deadlock when deactivating account --- Monal/Classes/MonalAppDelegate.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/MonalAppDelegate.m b/Monal/Classes/MonalAppDelegate.m index b5dbc84b64..cc6f2ed251 100644 --- a/Monal/Classes/MonalAppDelegate.m +++ b/Monal/Classes/MonalAppDelegate.m @@ -351,7 +351,7 @@ -(void) updateUnread DDLogInfo(@"Updating unread called"); //make sure unread badge matches application badge NSNumber* unreadMsgCnt = [[DataLayer sharedInstance] countUnreadMessages]; - [HelperTools dispatchAsync:NO reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ + [HelperTools dispatchAsync:YES reentrantOnQueue:dispatch_get_main_queue() withBlock:^{ NSInteger unread = 0; if(unreadMsgCnt != nil) unread = [unreadMsgCnt integerValue]; From 4572603b7da6211a288e1115edd64b96aad71513 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 23:07:30 +0200 Subject: [PATCH 051/131] Update doap file and streamline implementation accordingly --- Monal/Classes/MLIQProcessor.m | 3 +- Monal/Classes/MLPubSub.m | 1 + Monal/Classes/XMPPEdit.m | 14 --- Monal/Classes/xmpp.m | 12 ++ monal.doap | 207 +++++++++++++++------------------- 5 files changed, 105 insertions(+), 132 deletions(-) diff --git a/Monal/Classes/MLIQProcessor.m b/Monal/Classes/MLIQProcessor.m index 7ba0e8a6d5..4b5e53fc61 100644 --- a/Monal/Classes/MLIQProcessor.m +++ b/Monal/Classes/MLIQProcessor.m @@ -649,8 +649,7 @@ +(BOOL) processRosterWithAccount:(xmpp*) account andIqNode:(XMPPIQ*) iqNode $$class_handler(handleSetMamPrefs, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode)) if([iqNode check:@"/"]) { - DDLogError(@"Setting MAM prefs returned an error: %@", [iqNode findFirst:@"error"]); - [HelperTools postError:NSLocalizedString(@"XMPP mam preferences error", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + DDLogError(@"Setting MAM prefs returned an error, ignoring: %@", [iqNode findFirst:@"error"]); return; } $$ diff --git a/Monal/Classes/MLPubSub.m b/Monal/Classes/MLPubSub.m index bb180b2742..ff554549d7 100644 --- a/Monal/Classes/MLPubSub.m +++ b/Monal/Classes/MLPubSub.m @@ -32,6 +32,7 @@ @implementation MLPubSub +(void) initialize { + //TODO: wait for servers to support pubsub#publish_node_full and set it at least for bookmarks2 _defaultOptions = @{ @"pubsub#notify_retract": @"true", @"pubsub#notify_delete": @"true" diff --git a/Monal/Classes/XMPPEdit.m b/Monal/Classes/XMPPEdit.m index 308a4d86aa..2fff9c5c26 100644 --- a/Monal/Classes/XMPPEdit.m +++ b/Monal/Classes/XMPPEdit.m @@ -12,7 +12,6 @@ #import "MLBlockedUsersTableViewController.h" #import "MLButtonCell.h" #import "MLImageManager.h" -#import "MLMAMPrefTableViewController.h" #import "MLPasswordChangeTableViewController.h" #import "MLServerDetails.h" #import "MLSwitchCell.h" @@ -49,7 +48,6 @@ enum kSettingsGeneralRows { SettingsChangePasswordRow, SettingsOmemoKeysRow, - SettingsMAMPreferencesRow, SettingsBlockedUsersRow, SettingsGeneralRowsCnt }; @@ -655,10 +653,6 @@ -(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NS [thecell initTapCell:NSLocalizedString(@"Encryption Keys (OMEMO)", @"")]; break; } - case SettingsMAMPreferencesRow: { - [thecell initTapCell:NSLocalizedString(@"Message Archive Preferences", @"")]; - break; - } case SettingsBlockedUsersRow: { [thecell initTapCell:NSLocalizedString(@"Blocked Users", @"")]; break; @@ -848,9 +842,6 @@ -(void) tableView:(UITableView*) tableView didSelectRowAtIndexPath:(NSIndexPath* [self showDetailViewController:ownOmemoKeysView sender:self]; break; } - case SettingsMAMPreferencesRow: - [self performSegueWithIdentifier:@"showMAMPref" sender:self]; - break; case SettingsBlockedUsersRow: [self performSegueWithIdentifier:@"showBlockedUsers" sender:self]; break; @@ -900,11 +891,6 @@ -(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender MLServerDetails* server= (MLServerDetails*)segue.destinationViewController; server.xmppAccount = [[MLXMPPManager sharedInstance] getConnectedAccountForID:self.accountNo]; } - else if([segue.identifier isEqualToString:@"showMAMPref"]) - { - MLMAMPrefTableViewController* mam = (MLMAMPrefTableViewController*)segue.destinationViewController; - mam.xmppAccount = [[MLXMPPManager sharedInstance] getConnectedAccountForID:self.accountNo]; - } else if([segue.identifier isEqualToString:@"showBlockedUsers"]) { xmpp* xmppAccount = [[MLXMPPManager sharedInstance] getConnectedAccountForID:self.accountNo]; diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m index 6150d7e5f6..2072513e2e 100644 --- a/Monal/Classes/xmpp.m +++ b/Monal/Classes/xmpp.m @@ -3219,6 +3219,17 @@ -(NSString* _Nullable) channelBindingToUse #pragma mark stanza handling +// -(AnyPromise*) sendIq:(XMPPIQ*) iq +// { +// return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { +// [self sendIq:iq withResponseHandler:^(XMPPIQ* response) { +// resolve(response); +// } andErrorHandler:^(XMPPIQ* error) { +// resolve(error); +// }]; +// }]; +// } + -(void) sendIq:(XMPPIQ*) iq withResponseHandler:(monal_iq_handler_t) resultHandler andErrorHandler:(monal_iq_handler_t) errorHandler { if(resultHandler || errorHandler) @@ -4037,6 +4048,7 @@ -(void) initSession [self queryDisco]; [self queryServerVersion]; [self purgeOfflineStorage]; + [self setMAMPrefs:@"always"]; //make sure we are able to do proper catchups [self sendPresence]; //this will trigger a replay of offline stanzas on prosody (no XEP-0013 support anymore 😡) //the offline messages will come in *after* we initialized the mam query, because the disco result comes in first //(and this is what triggers mam catchup) diff --git a/monal.doap b/monal.doap index 7ece02a1f2..df9a9f3c61 100644 --- a/monal.doap +++ b/monal.doap @@ -61,163 +61,155 @@ complete + 2.13.1 4.9 - XEP-0004: Data Forms wontfix - XEP-0027: Current Jabber OpenPGP Usage + Superseded by OX complete + 2.5.0 4.6 - XEP-0030: Service Discovery - - - - - - wontfix - XEP-0033: Extended Stanza Addressing partial + 1.34.6 5.0 - XEP-0045: Multi-User Chat wontfix - XEP-0047: In-Band Bytestreams complete + 1.2 5.0 - XEP-0048: Bookmarks wontfix - XEP-0049: Private XML Storage + Not used anymore, use PubSub/Pep complete - XEP-0054: vcard-temp (implemented only for MUC profiles) - - - - - - wontfix - XEP-0055: Jabber Search + 1.2 + 5.0 + Implemented only for MUC profiles complete + 1.0 4.8 - XEP-0059: Result Set Management. Used by other XEPs. partial + 1.26.0 4.9 - XEP-0060: Publish-Subscribe + Used mainly for Pep wontfix - XEP-0065: SOCKS5 Bytestreams + Use HTTP upload for filetransfers instead partial + 1.5 4.9 - XEP-0066: Out of Band Data + Used to mark XEP-0363 filetransfers only wontfix - XEP-0070: Verifying HTTP Requests via XMPP partial - XEP-0077: In-Band Registration + 2.4 + 4.7 complete + 1.1.4 4.9 - XEP-0084: User Avatar - complete + partial + 2.1 4.7 - XEP-0085: Chat State Notifications + Only typing notifications, use XEP-0319 to publish interactions complete - XEP-0092: Software Version + 1.1 + 5.0 wontfix - XEP-0107: User Mood complete + 1.6.0 4.7 - XEP-0115: Entity Capabilities - complete + partial + 1.1.1 + 5.0 XEP-0153: vCard-Based Avatars (implemented only for MUC profiles) @@ -225,92 +217,93 @@ planned - XEP-0158: CAPTCHA Forms - complete - XEP-0162: Best Practices for Roster and Subscription Management + partial + 0.2.1 + 5.4 complete + 1.2.2 4.9 - XEP-0163: Personal Eventing Protocol complete + 1.2.2 6.0 - XEP-0167: Jingle RTP Sessions complete + 1.1.1 6.0 - XEP-0176: Jingle ICE-UDP Transport Method complete + 1.1 4.9 - XEP-0172: User Nickname complete + 1.4.0 4.7 - XEP-0184: Message Receipts complete + 1.3 5.0 - XEP-0191: Blocking Command complete + 1.6.1 4.6 - XEP-0198: Stream Management complete + 2.0.1 4.7 - XEP-0199: XMPP Ping complete + 1.0.0 6.0 - XEP-0215: External Service Discovery complete + 1.1.1 4.9 XEP-0223: Persistent Storage of Private Data via PubSub @@ -319,29 +312,30 @@ wontfix - XEP-0234: Jingle File Transfer + Use HTTP filetransfer (XEP-0363) instead complete + 1.3 4.6 - XEP-0237: Roster Versioning complete + 1.0 4.9 - XEP-0245: The /me Command complete + 1.2 5.0 XEP-0249: Direct MUC Invitations @@ -350,197 +344,196 @@ wontfix - XEP-0260: Jingle SOCKS5 Bytestreams Transport Method + Use HTTP filetransfer (XEP-0363) instead wontfix - XEP-0261: Jingle In-Band Bytestreams Transport Method + Use HTTP filetransfer (XEP-0363) instead complete + 1.0.1 4.5 - XEP-0280: Message Carbons complete + 1.0.0 4.7 - XEP-0286: Mobile Considerations on LTE Networks complete + 1.0.2 6.0 - XEP-0293: Jingle RTP Feedback Negotiation complete + 1.1.2 6.0 - XEP-0294: Jingle RTP Header Extensions Negotiation complete + 0.3 5.1.1 - XEP-0305: XMPP Quickstart complete + 1.2.1 4.8 - XEP-0308: Last Message Correction complete + 1.1.1 4.8 - XEP-0313: Message Archive Management complete + 1.0.2 4.7 - XEP-0319: Last User Interaction in Presence complete + 1.0.0 6.0 - XEP-0320: Use of DTLS-SRTP in Jingle Sessions complete + 1.0.0 4.8 - XEP-0333: Displayed Markers complete + 1.0.0 6.0 - XEP-0338: Jingle Grouping Framework complete + 1.0.1 6.0 - XEP-0339: Source-Specific Media Attributes in Jingle complete + 1.0.0 4.7 - XEP-0352: Client State Indication complete - 6.0 0.6.0 - XEP-0353: Jingle Message Initiation + 6.0 complete + 0.4.1 4.8 - XEP-0357: Push Notifications complete + 0.7.0 4.8 - XEP-0359: Unique and Stable Stanza IDs complete + 1.1.0 4.9 - XEP-0363: HTTP File Upload complete + 1.1.0 4.6 - XEP-0368: SRV records for XMPP over TLS planned - XEP-0374: OpenPGP for XMPP Instant Messaging wontfix - XEP-0377: Spam Reporting (via XEP-0191) + Spam reporting done via XEP-0191 partial + 0.3.3 4.9 - XEP-0379: Pre-Authenticated Roster Subscription + No automatic approval if server does not support subscription pre-approval; No checking of tokens, if server does not do so (XEP-0401) complete + 0.4.0 5.1 - XEP-0380: Explicit Message Encryption complete + 0.3.0 4.8 - XEP-0384: OMEMO Encryption @@ -554,92 +547,76 @@ complete - 6.0 1.0.1 - XEP-0388: Extensible SASL Profile + 6.0 wontfix - XEP-0390: Entity Capabilities 2.0 complete + 1.0.0 5.1 - XEP-0392: Consistent Color Generation wontfix - XEP-0396: Jingle Encrypted Transports - OMEMO - - - - - - wontfix - XEP-0397: Instant Stream Resumption + Use HTTP filetransfer (XEP-0363) instead complete - XEP-0398: User Avatar to vCard-Based Avatars Conversion + 1.0.0 + 6.0 + Used for MUC avatars complete - 6.0 0.5.0 - XEP-0401: Ad-hoc Account Invitation Generation + 6.0 complete - 5.4 1.1.4 - XEP-0402: PEP Native Bookmarks - - - - - - wontfix - XEP-0409: IM Routing-NG + 5.4 complete + 1.1.0 5.0 - XEP-0410: MUC Self-Ping (Schrödinger's Chat) planned - XEP-0420: Stanza Content Encryption partial - XEP-0423 XMPP Compliance Suites 2020 + 1.0.1 + Check this XEP to see what's missing @@ -648,7 +625,6 @@ complete 6.3 0.4.1 - XEP-0424: Message Retraction @@ -657,70 +633,70 @@ complete 6.3 0.3.0 - XEP-0425: Moderated Message Retraction complete + 0.4.1 6.0 - XEP-0440: SASL Channel-Binding Type Capability complete + 0.2.0 4.8 - XEP-0441: Message Archive Management Preferences + Only to automatically turn on archiving if possible (setting: always) complete + 0.2.0 5.2 - XEP-0445: Pre-Authenticated In-Band Registration partial + 0.1.0 5.0 - XEP-0454: OMEMO Media sharing + No support for embedded thumbnails complete + 0.3.0 6.0 - XEP-0474: SASL SCRAM Downgrade Protection complete + 0.1.0 6.0 - XEP-0480: SASL Upgrade Tasks planned - XEP-0484: Fast Authentication Streamlining Tokens - + complete + 0.1.0 5.0 - XEP-0486: MUC Avatars @@ -729,8 +705,7 @@ complete 6.3 0.1.0 - XEP-0490: Message Displayed Synchronization - + \ No newline at end of file From eae2b18b07932d59959f3302347b16c80b2d7344 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 25 Jun 2024 23:59:05 +0200 Subject: [PATCH 052/131] Add "Beta" to pre-release name --- .github/workflows/beta.build-push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 638c803941..cd42bf5e19 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -66,7 +66,7 @@ jobs: run: | buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" From d9968909545478e864102dd5e8a93e905ce01cce Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Wed, 26 Jun 2024 02:18:47 +0200 Subject: [PATCH 053/131] Fix changelog submission to testflight via fastlane --- .github/workflows/beta.build-push.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index cd42bf5e19..7845a7abf2 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -72,9 +72,10 @@ jobs: echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - name: Publish ios to appstore connect #run: xcrun altool --upload-app -f ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + env: + PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes }} run: | - notes=$(printf '%s\n' "${{ steps.releasenotes.outputs.notes }}" | jq -sRr @sh) - fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:$notes ipa:"./Monal/build/ipa/Monal.ipa" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true + fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" ipa:"./Monal/build/ipa/Monal.ipa" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - name: Notarize catalyst run: xcrun notarytool submit ./Monal/build/app/Monal.zip --wait --team-id S8D843U34Y --key "/Users/ci/appstoreconnect/apiKey.p8" --key-id "$(cat /Users/ci/appstoreconnect/apiKeyId.txt)" --issuer "$(cat /Users/ci/appstoreconnect/apiIssuerId.txt)" - name: staple @@ -88,9 +89,10 @@ jobs: run: ./scripts/uploadNonAlpha.sh beta - name: Publish catalyst to appstore connect #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal + env: + PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes }} run: | - notes=$(printf '%s\n' "${{ steps.releasenotes.outputs.notes }}" | jq -sRr @sh) - fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" changelog:$notes pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true + fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - uses: actions/upload-artifact@v4 with: name: monal-catalyst-zip From 98d305203535731baf40f56e5726e908fba9ca75 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Wed, 26 Jun 2024 04:06:59 +0200 Subject: [PATCH 054/131] Downgrade WebRTC lib because of usage of Apple's private API --- Monal/Podfile | 7 ++++++- Monal/Podfile.lock | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Monal/Podfile b/Monal/Podfile index fde6b9a1ee..3c5aaac2db 100644 --- a/Monal/Podfile +++ b/Monal/Podfile @@ -31,8 +31,13 @@ def monalxmpp pod 'SAMKeychain' pod 'sqlite3/perf-threadsafe', inhibit_warnings: true pod 'ASN1Decoder' + #later versions of the webrtc lib trigger the following app review error: + #The app references non-public symbols in Contents/Frameworks/WebRTC.framework/Versions/A/WebRTC: + #_AVCaptureSessionInterruptionReasonKey, _AVCaptureSessionPresetInputPriority. + #If method names in your source code match the private Apple APIs listed above, altering your method names + #will help prevent this app from being flagged in future submissions. + pod 'WebRTC-lib', '~> 123.0' #pod 'GoogleWebRTC' - pod 'WebRTC-lib' pod 'KSCrash', subspecs:['Recording', 'Reporting/Filters/Sets', 'Reporting/Filters/Tools', 'Reporting/Tools', 'Core'] signalDeps pod "PromiseKit" diff --git a/Monal/Podfile.lock b/Monal/Podfile.lock index c8cdad1b0c..fed3e2c437 100644 --- a/Monal/Podfile.lock +++ b/Monal/Podfile.lock @@ -62,7 +62,7 @@ PODS: - "sqlite3/common (3.46.0+1)" - "sqlite3/perf-threadsafe (3.46.0+1)": - sqlite3/common - - WebRTC-lib (126.0.0) + - WebRTC-lib (123.0.0) DEPENDENCIES: - ASN1Decoder @@ -82,7 +82,7 @@ DEPENDENCIES: - SignalProtocolC (from `https://github.com/monal-im/libsignal-protocol-c`, branch `master`) - SignalProtocolObjC (from `https://github.com/monal-im/SignalProtocol-ObjC.git`, branch `master`) - sqlite3/perf-threadsafe - - WebRTC-lib + - WebRTC-lib (~> 123.0) SPEC REPOS: trunk: @@ -133,8 +133,8 @@ SPEC CHECKSUMS: SignalProtocolObjC: 1beb46b1d35733e7ab96a919f88bac20ec771c73 SnapKit: e01d52ebb8ddbc333eefe2132acf85c8227d9c25 sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 - WebRTC-lib: 7e2e15d90ebca6e08a1eb5d4afc365d70e4b95b0 + WebRTC-lib: bb973dd47acf5bc48d8a935a92ae836b70599bc1 -PODFILE CHECKSUM: 5c99d737e50901754d89688ff4fdc8de7c97b322 +PODFILE CHECKSUM: f766ee234cce3182eaa8a645d3fa1e41666094d2 COCOAPODS: 1.15.2 From 6f17360d9045d3f40a0bf27bc946e8f4d064ef6d Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Sun, 30 Jun 2024 12:04:51 +0100 Subject: [PATCH 055/131] Remove unused init from AddTopLevelNavigation --- Monal/Classes/SwiftuiHelpers.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index bb89ea0d6c..c1c0f99f1e 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -513,10 +513,6 @@ struct AddTopLevelNavigation: View { self.build = build self.delegate = delegate } - init(withDelegate delegate: SheetDismisserProtocol, andClosure build: @escaping () -> Content) { - self.build = build - self.delegate = delegate - } var body: some View { NavigationView { build() From 1c50bef0a224366f32b9a9a28808935f1517cf41 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Sun, 30 Jun 2024 12:05:37 +0100 Subject: [PATCH 056/131] Remove sheet dismisser from makeOwnOmemoKeyView The SheetDismisserProtocol was not used, so there is no need for a corresponding DismissAction. --- Monal/Classes/SwiftuiHelpers.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index c1c0f99f1e..1fda2714f2 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -615,9 +615,7 @@ class SwiftuiInterface : NSObject { @objc func makeOwnOmemoKeyView(_ ownContact: MLContact?) -> UIViewController { - let delegate = SheetDismisserProtocol() let host = UIHostingController(rootView:AnyView(EmptyView())) - delegate.host = host if(ownContact == nil) { host.rootView = AnyView(OmemoKeys(contact: nil)) } else { From 8ac87d927ea757ce8b3515a5a09232dfa7aa0c11 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Sun, 30 Jun 2024 12:07:39 +0100 Subject: [PATCH 057/131] Remove sheet dismisser from ContactRequestsMenu The SheetDismisserProtocol was not used, so there is no need for a corresponding DismissAction. --- Monal/Classes/ContactRequestsMenu.swift | 7 ++----- Monal/Classes/SwiftuiHelpers.swift | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Monal/Classes/ContactRequestsMenu.swift b/Monal/Classes/ContactRequestsMenu.swift index 040d3283af..023caeda9f 100644 --- a/Monal/Classes/ContactRequestsMenu.swift +++ b/Monal/Classes/ContactRequestsMenu.swift @@ -63,7 +63,6 @@ struct ContactRequestsMenuEntry: View { } struct ContactRequestsMenu: View { - var delegate: SheetDismisserProtocol @State private var pendingRequests: [MLContact] var body: some View { @@ -95,15 +94,13 @@ struct ContactRequestsMenu: View { } } - init(delegate: SheetDismisserProtocol) { - self.delegate = delegate + init() { self.pendingRequests = DataLayer.sharedInstance().allContactRequests() as! [MLContact] } } struct ContactRequestsMenu_Previews: PreviewProvider { - static var delegate = SheetDismisserProtocol() static var previews: some View { - ContactRequestsMenu(delegate: delegate) + ContactRequestsMenu() } } diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 1fda2714f2..f7691d1907 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -673,7 +673,7 @@ class SwiftuiInterface : NSObject { case "LogIn": host.rootView = AnyView(UIKitWorkaround(WelcomeLogIn(delegate:delegate))) case "ContactRequests": - host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactRequestsMenu(delegate: delegate))) + host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactRequestsMenu())) case "CreateGroup": host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: CreateGroupMenu(delegate: delegate))) case "ChatPlaceholder": From 9419c19fbdc7777a3aa8cd33e0884f9ea017e942 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Sun, 30 Jun 2024 12:08:32 +0100 Subject: [PATCH 058/131] Remove sheet dismisser from AccountPicker The SheetDismisserProtocol was not used, so there is no need for a corresponding DismissAction. --- Monal/Classes/AccountPicker.swift | 7 ++----- Monal/Classes/SwiftuiHelpers.swift | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Monal/Classes/AccountPicker.swift b/Monal/Classes/AccountPicker.swift index 2b321cdc9c..d05b455a12 100644 --- a/Monal/Classes/AccountPicker.swift +++ b/Monal/Classes/AccountPicker.swift @@ -7,7 +7,6 @@ // struct AccountPicker: View { - let delegate: SheetDismisserProtocol let contacts: [MLContact] let callType: MLCallType #if IS_ALPHA @@ -18,8 +17,7 @@ struct AccountPicker: View { let appLogoId = "AppLogo" #endif - init(delegate:SheetDismisserProtocol, contacts:[MLContact], callType: MLCallType) { - self.delegate = delegate + init(contacts:[MLContact], callType: MLCallType) { self.contacts = contacts self.callType = callType } @@ -63,8 +61,7 @@ struct AccountPicker: View { } struct AccountPicker_Previews: PreviewProvider { - static var delegate = SheetDismisserProtocol() static var previews: some View { - AccountPicker(delegate:delegate, contacts:[MLContact.makeDummyContact(0)], callType:.audio) + AccountPicker(contacts:[MLContact.makeDummyContact(0)], callType:.audio) } } diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index f7691d1907..40b26399cc 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -582,7 +582,7 @@ class SwiftuiInterface : NSObject { let delegate = SheetDismisserProtocol() let host = UIHostingController(rootView:AnyView(EmptyView())) delegate.host = host - host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:AccountPicker(delegate:delegate, contacts:contacts, callType:MLCallType(rawValue: callType)!))) + host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:AccountPicker(contacts:contacts, callType:MLCallType(rawValue: callType)!))) return host } From 136f11fd76a878a4b2c5651500afae66134b6706 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 1 Jul 2024 20:22:39 +0200 Subject: [PATCH 059/131] Deactivate message search shortcut --- Monal/Classes/chatViewController.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/chatViewController.m b/Monal/Classes/chatViewController.m index 9ee5a98b83..72bfaf8740 100644 --- a/Monal/Classes/chatViewController.m +++ b/Monal/Classes/chatViewController.m @@ -2923,7 +2923,7 @@ -(void) commandIPressed:(UIKeyCommand*)keyCommand // Open search ViewController -(void) commandFPressed:(UIKeyCommand*)keyCommand { - [self showSeachButtonAction]; + //[self showSeachButtonAction]; } // List of custom hardware key commands From 7833bc2f54572eac64a457d54ed13e4e7e101789 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 1 Jul 2024 21:07:19 +0200 Subject: [PATCH 060/131] Make sure ios/macos only changelog entries aren't pushed to apple --- .github/workflows/beta.build-push.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 7845a7abf2..c95b89b666 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -59,16 +59,27 @@ jobs: run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - name: Push beta tag to repo run: | - buildNumber=$(git tag --sort="v:refname" |grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') git push origin Build_iOS_$buildNumber - name: Extract version number and changelog from newest merge commit id: releasenotes run: | buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + + echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + + echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - name: Publish ios to appstore connect #run: xcrun altool --upload-app -f ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" From 98de6faffe540be3ba0b1fa9f8e5a48017f9664d Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Thu, 4 Jul 2024 03:04:24 +0200 Subject: [PATCH 061/131] Use fastlane for stable releases, too This uses our new mail2webhook python script installed on the mailserver to trigger a workflow posting release information once the iOS build reaches the appstore. --- .github/workflows/beta.build-push.yml | 40 ++++---- .github/workflows/publish-stable-release.yml | 96 ++++++++++++++++++++ .github/workflows/stable.build-push.yml | 70 +++++++++++--- scripts/mail2webhook.py | 78 ++++++++++++++++ 4 files changed, 254 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/publish-stable-release.yml create mode 100644 scripts/mail2webhook.py diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index c95b89b666..da2feffead 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -18,7 +18,7 @@ jobs: outputs: release-tag: ${{ steps.releasenotes.outputs.tag }} release-name: ${{ steps.releasenotes.outputs.name }} - release-changelog: ${{ steps.releasenotes.outputs.notes }} + release-notes: ${{ steps.releasenotes.outputs.notes }} env: APP_NAME: "Monal" APP_DIR: "Monal.app" @@ -65,26 +65,34 @@ jobs: id: releasenotes run: | buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + mkdir -p /Users/ci/releases + OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" + touch "$OUTPUT_FILE" + echo "OUTPUT_FILE=$OUTPUT_FILE" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "version=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + + cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" - name: Publish ios to appstore connect #run: xcrun altool --upload-app -f ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" env: - PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes }} + PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes_ios }} run: | fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" ipa:"./Monal/build/ipa/Monal.ipa" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - name: Notarize catalyst @@ -101,7 +109,7 @@ jobs: - name: Publish catalyst to appstore connect #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal env: - PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes }} + PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes_macos }} run: | fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - uses: actions/upload-artifact@v4 @@ -183,5 +191,5 @@ jobs: recipient_is_room: true bot_alias: "Monal Release Bot" message: | - New Betarelease: ${{ needs.buildAndPublishBeta.outputs.release-name }} - ${{ needs.buildAndPublishBeta.outputs.release-changelog }} + ${{ needs.buildAndPublishBeta.outputs.release-name }} was released + ${{ needs.buildAndPublishBeta.outputs.release-notes }} diff --git a/.github/workflows/publish-stable-release.yml b/.github/workflows/publish-stable-release.yml new file mode 100644 index 0000000000..c05348d385 --- /dev/null +++ b/.github/workflows/publish-stable-release.yml @@ -0,0 +1,96 @@ +name: Publish release +on: + repository_dispatch: + types: [distribution] +jobs: + extractChangelog: + runs-on: self-hosted + outputs: + release-buildNumber: ${{ steps.releasenotes.outputs.buildNumber }} + release-tag: ${{ steps.releasenotes.outputs.tag }} + release-version: ${{ steps.releasenotes.outputs.version }} + release-name: ${{ steps.releasenotes.outputs.name }} + release-notes: ${{ steps.releasenotes.outputs.notes }} + release-notes_ios: ${{ steps.releasenotes.outputs.notes_ios }} + release-notes_macos: ${{ steps.releasenotes.outputs.notes_macos }} + # create release only if the ios app made it to the appstore and ignore the macos appstore state + if: github.event.client_payload.Platform == 'iOS' + steps: + # - run: | + # echo ${{ github.event.client_payload.AppName }} + # echo ${{ github.event.client_payload.Platform }} + # echo ${{ github.event.client_payload.AppVersionNumber }} + - name: Load release info + id: releasenotes + run: | + buildNumber="$(fastlane run app_store_build_number api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" app_identifier:"G7YU7X7KRJ.SworIM" live:false version:"${{ github.event.client_payload.AppVersionNumber }}" 2>&1 | tee /dev/stderr | grep Result | sed -E 's/^.*Result: ([0-9]+).*$/\1/g')" + mkdir -p /Users/ci/releases + OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" + touch "$OUTPUT_FILE" + cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" + + promoteDraftRelease: + name: Promote draft release to live release + runs-on: ubuntu-latest + needs: [extractChangelog] + steps: + - name: Promote draft release to live release + run: | + echo "ID: ${{ steps.releasenotes.outputs.releaseID }}" + curl -L \ + -X PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/${{ github.repository }}/releases/${{ steps.draftrelease.outputs.id }}" \ + -d '{"draft": false, "prerelease": false, "make_latest": true}' + + notifyMuc: + name: Notify support MUC about new stable release + runs-on: ubuntu-latest + needs: [extractChangelog] + steps: + - name: Notify support MUC + uses: monal-im/xmpp-notifier@master + with: # Set the secrets as inputs + jid: ${{ secrets.BOT_JID }} + password: ${{ secrets.BOT_PASSWORD }} + server_host: ${{ secrets.BOT_SERVER }} + recipient: monal@chat.yax.im + recipient_is_room: true + bot_alias: "Monal Release Bot" + message: | + ${{ needs.extractChangelog.outputs.release-name }} was released: + ${{ needs.extractChangelog.outputs.release-notes }} + + notifyMastodon: + name: Post release info on mastodon + runs-on: ubuntu-latest + needs: [extractChangelog] + steps: + - name: Patch changelog length + id: changelog + env: + NOTES: ${{ needs.extractChangelog.outputs.release-notes }} + run: | + if [ "${#NOTES}" -gt 400 ]; then + NOTES="To see the complete list of bugfixes and improvements, check our releases page: https://github.com/monal-im/Monal/releases/tag/${{ needs.extractChangelog.outputs.release-tag }}" + fi + echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + - name: Post release info on mastodon + id: toot + uses: cbrgm/mastodon-github-action@v2.1.3 + with: + access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} + url: ${{ secrets.MASTODON_URL }} + + message: "${{ needs.extractChangelog.outputs.release-name }} released.\n\n${{ steps.changelog.outputs.notes }}\n\n#Monal #ios #macos #xmpp #im #chat #messaging" + visibility: "public" + language: "en" + - name: Get toot information + run: | + echo "Toot ID: ${{ steps.toot.outputs.id }}" + echo "Toot URL: ${{ steps.toot.outputs.url }}" + echo "Scheduled at: ${{ steps.toot.outputs.scheduled_at }}" diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index b3fae8ea6f..c28ba1143c 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -58,8 +58,48 @@ jobs: run: | buildNumber=$(git tag --sort="v:refname" |grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') git push origin Build_iOS_$buildNumber + - name: Extract version number and changelog from newest merge commit + id: releasenotes + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + mkdir -p /Users/ci/releases + OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" + touch "$OUTPUT_FILE" + echo "OUTPUT_FILE=$OUTPUT_FILE" | tee /dev/stderr >> "$GITHUB_OUTPUT" + + echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "version=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + + echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + + echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + + echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + + cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" + - name: Create fastlane metadata directory + id: metadata + env: + CHANGELOG: ${{ steps.releasenotes.outputs.notes_ios }} + run: | + path="$(mktemp -d)" + echo -n "$CHANGELOG" > "$path/release_notes.txt" + echo "path=$path" | tee /dev/stderr >> "$GITHUB_OUTPUT" - name: Publish ios to appstore connect - run: xcrun altool --upload-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + #run: xcrun altool --upload-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + env: + DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path }} + run: | + fastlane run upload_to_app_store api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" ipa:"./Monal/build/ipa/Monal.ipa" app_version:"${{ steps.releasenotes.outputs.version }}" reject_if_possible:true submit_for_review:true automatic_release:true skip_metadata: true skip_screenshots: true - name: Notarize catalyst run: xcrun notarytool submit ./Monal/build/app/Monal.zip --wait --team-id S8D843U34Y --key "/Users/ci/appstoreconnect/apiKey.p8" --key-id "$(cat /Users/ci/appstoreconnect/apiKeyId.txt)" --issuer "$(cat /Users/ci/appstoreconnect/apiIssuerId.txt)" - name: staple @@ -69,12 +109,19 @@ jobs: stapler validate "$APP_DIR" /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "../$APP_NAME.zip" cd ../../../.. - - name: upload new catalyst stable to monal-im.org + - name: Upload new catalyst stable to monal-im.org run: ./scripts/uploadNonAlpha.sh stable - name: Publish catalyst to appstore connect - run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id maccatalyst.G7YU7X7KRJ.SworIM + #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id maccatalyst.G7YU7X7KRJ.SworIM + env: + DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path }} + run: | + fastlane run upload_to_app_store api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" pkg:"./Monal/build/app/Monal.pkg" app_version:"${{ steps.releasenotes.outputs.version }}" reject_if_possible:true submit_for_review:true automatic_release:true skip_metadata: true skip_screenshots: true # - name: Update xmpp.org client list with new timestamp # run: ./scripts/push_xmpp.org.sh + - name: Remove fastlane metadata directory + run: | + rm -rf "${{ steps.metadata.outputs.path }}" - uses: actions/upload-artifact@v4 with: name: monal-catalyst-zip @@ -100,16 +147,8 @@ jobs: # name: monal-ios-dsym # path: Monal/build/ios_Monal.xcarchive/dSYMs # if-no-files-found: error - - name: Extract version number and changelog from newest merge commit - id: releasenotes - run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') - echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "$(git log -n 1 --merges --pretty=format:%b)" | tee /dev/stderr >> "$GITHUB_OUTPUT" - echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" - - name: Release + - name: Create Draft Release + id: draftrelease uses: softprops/action-gh-release@v2 with: name: "${{ steps.releasenotes.outputs.name }}" @@ -123,5 +162,8 @@ jobs: ./Monal/build/app/Monal.zip fail_on_unmatched_files: true token: ${{ secrets.GITHUB_TOKEN }} - draft: false prerelease: false + draft: true + - name: Write draft release id to build env + run: | + echo "releaseID=${{ steps.draftrelease.outputs.id }}" | tee /dev/stderr >> "${{ steps.releasenotes.outputs.OUTPUT_FILE }}" diff --git a/scripts/mail2webhook.py b/scripts/mail2webhook.py new file mode 100644 index 0000000000..69c984a589 --- /dev/null +++ b/scripts/mail2webhook.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +import sys +import argparse +import email +import email.parser +import re +import requests + +# see https://stackoverflow.com/a/60978847/3528174 +def to_camel_case(text): + s = text.replace("-", " ").replace("_", " ") + s = s.split() + if len(text) == 0: + return text + return s[0] + ''.join(i.capitalize() for i in s[1:]) + +# parse commandline +parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter, description="Simple python script to trigger a github ") +parser.add_argument("--token", metavar='TOKEN', required=True, help="Github token to use to authenticate the workflow trigger workflow") +parser.add_argument("--repo", metavar='REPO', required=True, help="Github user/organisation and repository name to trigger the workflow in (Example: 'monal-im/Monal')") +parser.add_argument("--type", metavar='TYPE', required=True, help="Event type to trigger the github workflow with") +parser.add_argument("--filter", metavar='FILTER', default=[], action='append', required=False, help="'key=value-regex' pairs that should be used to filter the app properties given in the mail body") +args = parser.parse_args() + + +parser = email.parser.BytesParser() +message = parser.parse(sys.stdin.buffer) + +subject = message["subject"] +date = message["date"] + +# python > 3.9 variant +#body = message.get_body(preferencelist=("plain",)) + +# python <= 3.9 variant +# see https://stackoverflow.com/a/32840516/3528174 +body = "" +if message.is_multipart(): + for part in message.walk(): + ctype = part.get_content_type() + cdispo = str(part.get('Content-Disposition')) + if ctype == 'text/plain' and 'attachment' not in cdispo: + body = part.get_payload(decode=True) # decode + break +else: + body = message.get_payload(decode=True) + +# transform body in an array of stripped strings +body = [s.strip() for s in str(body, 'UTF-8').split("\n")] + +# parse app properties +properties = {to_camel_case(k.strip()): v.strip() for k, v in [line.split(": ", 1) for line in body if len(line.split(": ", 1)) > 1]} + +# sanity checks +if "The following app has been approved for distribution:" not in body: + print("Wrong state mentioned in mail", file=sys.stderr) + sys.exit(0) +for entry in args.filter: + k, v = entry.split("=", 1) + if k not in properties: + print(f"Unknown filter key: '{k}'", file=sys.stderr) + sys.exit(0) + if re.search(v, properties[k]) == None: + print(f"Wrong {k}: '{properties[k]}'", file=sys.stderr) + sys.exit(0) + +# trigger workflow +with requests.post(f"https://api.github.com/repos/{args.repo}/dispatches", json={ + "event_type": args.type, + "client_payload": properties, +}, headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Authorization": f"Bearer {args.token}" +}) as r: + r.raise_for_status() + +sys.exit(0) From b979485caa881af4131abe0d5db2df28b1ad2683 Mon Sep 17 00:00:00 2001 From: Friedrich Altheide <11352905+FriedrichAltheide@users.noreply.github.com> Date: Sat, 6 Jul 2024 06:25:58 +0200 Subject: [PATCH 062/131] rust: fix build --- rust/sdp-to-jingle/src/xep_0167.rs | 11 ++++++----- rust/sdp-to-jingle/src/xep_0176.rs | 6 ------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/rust/sdp-to-jingle/src/xep_0167.rs b/rust/sdp-to-jingle/src/xep_0167.rs index 7ee95a8550..9454833749 100644 --- a/rust/sdp-to-jingle/src/xep_0167.rs +++ b/rust/sdp-to-jingle/src/xep_0167.rs @@ -285,10 +285,10 @@ impl JingleRtpSessionsPayloadType { //TODO: implement this for quickxml deserialization, too! if any::type_name::() == any::type_name::() { match param.value.to_lowercase().as_str() { - "false" => value = "false".to_string().clone(), - "0" => value = "false".to_string().clone(), - "true" => value = "true".to_string().clone(), - "1" => value = "true".to_string().clone(), + "false" => value.clone_from(&"false".to_string()), + "0" => value.clone_from(&"false".to_string()), + "true" => value.clone_from(&"true".to_string()), + "1" => value.clone_from(&"true".to_string()), _ => { panic!("unallowed truth value: {}", value) } @@ -663,7 +663,7 @@ impl JingleRtpSessions { SdpAttribute::MaxMessageSize(_) => {} SdpAttribute::MaxPtime(_) => {} SdpAttribute::Mid(name) => { - content.name = name.clone(); + content.name.clone_from(name); } SdpAttribute::Msid(_) => {} SdpAttribute::MsidSemantic(_) => {} @@ -736,6 +736,7 @@ impl JingleRtpSessions { SdpAttribute::SsrcGroup(semantics, ssrcs) => { jingle.add_ssrc_group(JingleSsrcGroup::new_from_sdp(semantics, ssrcs)); } + SdpAttribute::FrameRate(_) => {} } } if fingerprint.is_set() { diff --git a/rust/sdp-to-jingle/src/xep_0176.rs b/rust/sdp-to-jingle/src/xep_0176.rs index d868aa3d9f..8c0699cac5 100644 --- a/rust/sdp-to-jingle/src/xep_0176.rs +++ b/rust/sdp-to-jingle/src/xep_0176.rs @@ -174,12 +174,6 @@ impl JingleTransportCandidate { protocol: match candidate.transport { SdpAttributeCandidateTransport::Udp => "udp".to_string(), SdpAttributeCandidateTransport::Tcp => "tcp".to_string(), //not specced in xep-0176 - _ => { - return Err(SdpParserInternalError::Generic( - "Encountered some candidate transport (like tcp) not specced in XEP-0176!" - .to_string(), - )); - } }, raddr: candidate.raddr.as_ref().map(|addr| format!("{}", addr)), rport: candidate.rport, From f47132e650cf49e28ae8b26a43e0b04fdc54b672 Mon Sep 17 00:00:00 2001 From: Friedrich Altheide <11352905+FriedrichAltheide@users.noreply.github.com> Date: Sat, 6 Jul 2024 06:27:56 +0200 Subject: [PATCH 063/131] bump crates --- rust/Cargo.lock | 125 ++++++++++++++++++---------------- rust/sdp-to-jingle/Cargo.toml | 2 +- 2 files changed, 67 insertions(+), 60 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 07067bcb68..84a8ba227c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "bitflags" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "cfg-if" @@ -16,9 +16,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "errno" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys", @@ -26,9 +26,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.0.2" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" [[package]] name = "form_urlencoded" @@ -51,27 +51,27 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "linux-raw-sys" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "log" -version = "0.4.21" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "memchr" -version = "2.7.2" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "monal-panic-handler" @@ -95,18 +95,18 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.31.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +checksum = "86e446ed58cef1bbfe847bc2fda0e2e4ea9f0e57b90c507d4781292590d72a4e" dependencies = [ "memchr", "serde", @@ -114,18 +114,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.35" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] [[package]] name = "rustix" -version = "0.38.32" +version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ "bitflags", "errno", @@ -146,29 +146,29 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.197" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.55", + "syn 2.0.68", ] [[package]] name = "swift-bridge" -version = "0.1.53" +version = "0.1.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72088d7882bd9c900d194cbc6008222c876450f68ce97212ac764775307bfd74" +checksum = "6180c668892926e0bc19d75a81b0ee2fdce3ab15ff062a61b3ce9b4d562eac1b" dependencies = [ "swift-bridge-build", "swift-bridge-macro", @@ -176,9 +176,9 @@ dependencies = [ [[package]] name = "swift-bridge-build" -version = "0.1.53" +version = "0.1.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0216c84c63a11fb704946f9c4843c9fad28aaf2431cbbd674a37d86d71f2100" +checksum = "7b8256d2d8c35795afeab117528f5e42b2706ca29b20f768929d458c7f245fdd" dependencies = [ "proc-macro2", "swift-bridge-ir", @@ -188,9 +188,9 @@ dependencies = [ [[package]] name = "swift-bridge-ir" -version = "0.1.53" +version = "0.1.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183036306714fcb1a53192dd80b89694eef24389b034f3392109b3447006550f" +checksum = "a28407ee88b57fac3e8c9314a0eefb1f63a3743cb0beef4b8d93189d5d8ce0f1" dependencies = [ "proc-macro2", "quote", @@ -199,9 +199,9 @@ dependencies = [ [[package]] name = "swift-bridge-macro" -version = "0.1.53" +version = "0.1.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89560c6f6a3b65ec983c6fca5eb9d5e4c839ff41d8162c24339e258a20bf04a6" +checksum = "e69ec9898b591cfcf473a584e98b54517400dcc67b0d3f8fdf2a099ce7971e3a" dependencies = [ "proc-macro2", "quote", @@ -222,9 +222,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.55" +version = "2.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "002a1b3dbf967edfafc32655d0f377ab0bb7b994aa1d32c8cc7e9b8bf3ebb8f0" +checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" dependencies = [ "proc-macro2", "quote", @@ -245,9 +245,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "ce6b6a2fb3a985e99cebfaefa9faa3024743da73304ca1c683a36429613d3d22" dependencies = [ "tinyvec_macros", ] @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "url" -version = "2.5.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", @@ -292,9 +292,9 @@ dependencies = [ [[package]] name = "webrtc-sdp" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7351fba122c7f6566779efdef49d2213e842f69fa1c654eef1fd9301f425064" +checksum = "9f4994ae6a67e7ed5bfeebc87b10a0bd67da5e5dbfb68db8cafbc9c9ab784dcd" dependencies = [ "log", "serde", @@ -313,13 +313,14 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", + "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -328,42 +329,48 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.52.4" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/rust/sdp-to-jingle/Cargo.toml b/rust/sdp-to-jingle/Cargo.toml index 8f304300a0..8ad2bef786 100644 --- a/rust/sdp-to-jingle/Cargo.toml +++ b/rust/sdp-to-jingle/Cargo.toml @@ -11,6 +11,6 @@ crate-type = ["staticlib", "lib"] [dependencies] serde = {version = "1.0"} serde_derive = {version = "1.0"} -quick-xml = { version = "0.31.0", features = ["serialize", "overlapped-lists"] } +quick-xml = { version = "0.35.0", features = ["serialize", "overlapped-lists"] } webrtc-sdp = {version = "0.3.10", features = ["serialize"] } From c8a7e7ecb9abb5f26c5511cc310c1aa308f06790 Mon Sep 17 00:00:00 2001 From: thevaidik Date: Fri, 5 Jul 2024 18:48:35 +0530 Subject: [PATCH 064/131] Onboarding implementation --- Monal/Classes/ActiveChatsViewController.h | 1 + Monal/Classes/ActiveChatsViewController.m | 17 ++ Monal/Classes/BoardingCards.swift | 185 ++++++++++++++++++++++ Monal/Classes/GeneralSettings.swift | 32 ++-- Monal/Classes/SwiftuiHelpers.swift | 3 + Monal/Monal.xcodeproj/project.pbxproj | 4 + 6 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 Monal/Classes/BoardingCards.swift diff --git a/Monal/Classes/ActiveChatsViewController.h b/Monal/Classes/ActiveChatsViewController.h index ef345377a9..ebe5e61965 100644 --- a/Monal/Classes/ActiveChatsViewController.h +++ b/Monal/Classes/ActiveChatsViewController.h @@ -39,6 +39,7 @@ NS_ASSUME_NONNULL_BEGIN -(void) deleteConversation; -(void) showSettings; -(void) showPrivacySettings; +-(void) showOnboarding; -(void) showNotificationSettings; -(void) showDetails; -(void) showRegisterWithUsername:(NSString*) username onHost:(NSString*) host withToken:(NSString* _Nullable) token usingCompletion:(monal_id_block_t _Nullable) callback; diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index c09d3861bb..e966c1911c 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -426,6 +426,13 @@ -(void) segueToIntroScreensIfNeeded [self presentViewController:passwordMigration animated:YES completion:^{}]; return; } + + if(![[HelperTools defaultsDB] boolForKey:@"hasCompletedOnboarding"]) + { + [self showOnboarding]; + return; + } + // display quick start if the user never seen it or if there are 0 enabled accounts if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0) { @@ -433,6 +440,7 @@ -(void) segueToIntroScreensIfNeeded [self presentViewController:loginViewController animated:YES completion:^{}]; return; } + if(![[HelperTools defaultsDB] boolForKey:@"HasSeenPrivacySettings"]) { [self showPrivacySettings]; @@ -520,6 +528,15 @@ -(void) showPrivacySettings [self presentViewController:view animated:YES completion:^{}]; } +-(void) showOnboarding +{ + [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ + UIViewController* callViewController = [[SwiftuiInterface new] makeViewWithName:@"OnboardingView"]; + callViewController.modalPresentationStyle = UIModalPresentationFullScreen; + [self presentViewController:callViewController animated:NO completion:^{}]; + }]; +} + -(void) showSettings { [self performSegueWithIdentifier:@"showSettings" sender:self]; diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift new file mode 100644 index 0000000000..a5d1199790 --- /dev/null +++ b/Monal/Classes/BoardingCards.swift @@ -0,0 +1,185 @@ +// +// BoardingCards.swift +// Monal +// +// Created by Vaidik Dubey on 05/06/24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +import SwiftUI + +class OnboardingState: ObservableObject { + @defaultsDB("hasCompletedOnboarding") + var hasCompletedOnboarding: Bool +} + +struct OnboardingCard: Identifiable { + let id = UUID() + let title: Text? + let description: Text? + let imageName: String? + let articleText: Text? + let customView: AnyView? +} + +struct OnboardingView: View { + @ObservedObject var onboardingState = OnboardingState() + var delegate: SheetDismisserProtocol + let cards: [OnboardingCard] + + @State private var currentIndex = 0 + + var body: some View { + VStack { + TabView(selection: $currentIndex) { + ForEach(cards.indices, id: \.self) { index in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + HStack { + if currentIndex > 0 { + Button(action: { + currentIndex -= 1 + }) { + Image(systemName: "chevron.left") + .foregroundColor(.blue) + } + } + } + + if let imageName = cards[index].imageName { + HStack { + Image(systemName: imageName) + .font(.custom("MarkerFelt-Wide", size: 80)) + .foregroundColor(.blue) + } + } + + VStack { + if let title = cards[index].title { + title + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 4) + } + + if let description = cards[index].description { + description + .font(.custom("HelveticaNeue-Medium", size: 20)) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + Divider() + } + } + + if let articleText = cards[index].articleText { + articleText + .font(.custom("HelveticaNeue-Medium", size: 20)) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + } + + if let view = cards[index].customView { + view + .frame(minWidth: 350, maxWidth: .infinity, minHeight: 600, maxHeight: .infinity) + } + + HStack { + Spacer() + if index < cards.count - 1 { + Button(action: { + currentIndex += 1 + }) { + HStack { + Text("Next") + .fontWeight(.bold) + Image(systemName: "chevron.right") + } + .foregroundColor(.blue) + } + } + Spacer() + } + + HStack { + Spacer() + if index == cards.count - 1 { + Button(action: { + delegate.dismiss() + onboardingState.hasCompletedOnboarding = true + }) { + Text("Close") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + } + Spacer() + } + } + .padding() + } + .background(Color(UIColor.systemBackground)) + .cornerRadius(10) + .shadow(color: Color.gray.opacity(0.3), radius: 10) + .padding(.horizontal, 3) + .padding(.vertical, 5) + } + } + .tabViewStyle(PageTabViewStyle()) + .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) + .frame(width: 350, height: 770) + .padding() + } + .background(Color.clear) + } +} + +@ViewBuilder +func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { + let cards = [ + OnboardingCard( + title: Text("Welcome to Monal !"), + description: Text("Privacy like its 1999 🔒"), + imageName: "hand.wave", + articleText: Text(""" + Modern iOS and MacOS XMPP chat client. + """), + customView: nil + ), + OnboardingCard( + title: Text("Features"), + description: Text("Here's a quick look at what you can expect:"), + imageName: "sparkles", + articleText: Text(""" + • 🔐 OMEMO Encryption : Secure multi-end messaging using the OMEMO protocol.. + + • 🛜 Decentralized Network : Leverages the decentralized nature of XMPP, avoiding central servers. + + • 🌐 Data privacy : We do not sell or track information for external parties (nor for anyone else). + + • 👨‍💻 Open Source : The app's source code is publicly available for audit and contribution. + """), + customView: nil + ), + OnboardingCard( + title: Text("Settings"), + description: Text("These are important privacy settings you may want to review !"), + imageName: nil, + articleText: nil, + customView: AnyView(PrivacySettingsOnboarding(onboardingActive: true)) + ) + ] + OnboardingView(delegate: delegate, cards: cards) +} + +struct OnboardingView_Previews: PreviewProvider { + static var delegate = SheetDismisserProtocol() + static var previews: some View { + createOnboardingView(delegate: delegate) + .environmentObject(OnboardingState()) + } +} diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index 387081c488..a252bb410f 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -354,6 +354,17 @@ struct PrivacySettings: View { var body: some View { Form { + PrivacySettingsOnboarding(onboardingActive: false) + } + .navigationBarTitle(Text("Privacy"), displayMode: .inline) + } +} +struct PrivacySettingsOnboarding: View { + @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() + var onboardingActive: Bool + + var body: some View { + VStack { Section(header: Text("Activity indications")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) { Text("Send message received") @@ -372,7 +383,6 @@ struct PrivacySettings: View { Text("Let your contacts know when you last opened the app.") } } - Section(header: Text("Interactions")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.allowNonRosterContacts) { Text("Accept incoming messages from strangers") @@ -386,19 +396,19 @@ struct PrivacySettings: View { Text("Allow contacts not in your contact list to call you.") }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts) } - - Section(header: Text("Misc")) { - SettingsToggle(isOn: $generalSettingsDefaultsDB.allowVersionIQ) { - Text("Publish version") - Text("Allow contacts in your contact list to query your Monal and iOS versions.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcUseFallbackTurn) { - Text("Calls: Allow TURN fallback to Monal-Servers") - Text("This will make calls possible even if your XMPP server does not provide a TURN server.") + if !onboardingActive { + Section(header: Text("Misc")) { + SettingsToggle(isOn: $generalSettingsDefaultsDB.allowVersionIQ) { + Text("Publish version") + Text("Allow contacts in your contact list to query your Monal and iOS versions.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcUseFallbackTurn) { + Text("Calls: Allow TURN fallback to Monal-Servers") + Text("This will make calls possible even if your XMPP server does not provide a TURN server.") + } } } } - .navigationBarTitle(Text("Privacy"), displayMode: .inline) } } diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 40b26399cc..2386bfdc53 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -684,6 +684,9 @@ class SwiftuiInterface : NSObject { host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: PrivacySettings())) case "ActiveChatsNotificatioSettings": host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: NotificationSettings())) + case "OnboardingView": + host.rootView = AnyView(createOnboardingView(delegate:delegate)) + default: unreachable() } diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index 5dc9d911d2..ffb0970952 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 08CAF17FA202CF3CB760D93C /* Pods_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2B7A5555D807EE78C95217FD /* Pods_NotificationService.framework */; }; 1D3623260D0F684500981E51 /* MonalAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D3623250D0F684500981E51 /* MonalAppDelegate.m */; }; 1D60589B0D05DD56006BFB54 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 29B97316FDCFA39411CA2CEA /* main.m */; }; + 20D3611C2C10E12500E46587 /* BoardingCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D3611B2C10E12500E46587 /* BoardingCards.swift */; }; 20ED55852BADDA5C0005783E /* GeneralSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20ED55842BADDA5C0005783E /* GeneralSettings.swift */; }; 2601D9CB0FBF25EF004DB939 /* sworim.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 2601D9CA0FBF25EF004DB939 /* sworim.sqlite */; }; 260773C4232FC4E800BFD50F /* NotificationService.m in Sources */ = {isa = PBXBuildFile; fileRef = 260773C3232FC4E800BFD50F /* NotificationService.m */; }; @@ -307,6 +308,7 @@ 1D3623240D0F684500981E51 /* MonalAppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MonalAppDelegate.h; path = Classes/MonalAppDelegate.h; sourceTree = ""; }; 1D3623250D0F684500981E51 /* MonalAppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MonalAppDelegate.m; path = Classes/MonalAppDelegate.m; sourceTree = ""; }; 1D46F251C198E3D8FA55692F /* Pods-Monal.appstore.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.appstore.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.appstore.xcconfig"; sourceTree = ""; }; + 20D3611B2C10E12500E46587 /* BoardingCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoardingCards.swift; sourceTree = ""; }; 20ED55842BADDA5C0005783E /* GeneralSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettings.swift; sourceTree = ""; }; 213F5BFD4599EC9317B99E97 /* Pods-Monal.appstore-quicksy.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Monal.appstore-quicksy.xcconfig"; path = "Target Support Files/Pods-Monal/Pods-Monal.appstore-quicksy.xcconfig"; sourceTree = ""; }; 21E99538324C14220843F325 /* Pods-shareSheet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-shareSheet.debug.xcconfig"; path = "Target Support Files/Pods-shareSheet/Pods-shareSheet.debug.xcconfig"; sourceTree = ""; }; @@ -1226,6 +1228,7 @@ 54F0B81828231690003664BD /* WelcomeLogIn.swift */, C12436122434AB5D00B8F074 /* MLAttributedLabel.h */, C12436132434AB5D00B8F074 /* MLAttributedLabel.m */, + 20D3611B2C10E12500E46587 /* BoardingCards.swift */, ); name = OnBoard; sourceTree = ""; @@ -2076,6 +2079,7 @@ 54F0B81928231691003664BD /* WelcomeLogIn.swift in Sources */, E8CF9CC726249640001A1952 /* MLSettingsAboutViewController.m in Sources */, C10490492612ED2F0054AC9E /* MLEmoji.swift in Sources */, + 20D3611C2C10E12500E46587 /* BoardingCards.swift in Sources */, 3D06A515281FFCC000DDAE90 /* NotificationDebugging.swift in Sources */, 845D636B2AD4AEDA0066EFFB /* ImageViewer.swift in Sources */, 2636C43F177BD58C001CA71F /* XMPPEdit.m in Sources */, From bb4dc9afa21f1ba63f6f6b25278d6ec1c6d437f7 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 8 Jul 2024 08:24:51 +0200 Subject: [PATCH 065/131] Add historyID to mlfiletransfer info dictionary --- Monal/Classes/MLFiletransfer.m | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Monal/Classes/MLFiletransfer.m b/Monal/Classes/MLFiletransfer.m index 804d565f94..8a99d8f486 100644 --- a/Monal/Classes/MLFiletransfer.m +++ b/Monal/Classes/MLFiletransfer.m @@ -521,6 +521,7 @@ +(NSDictionary*) getFileInfoForMessage:(MLMessage*) msg @"mimeType": msg.filetransferMimeType, @"size": msg.filetransferSize, @"fileExtension": [filename pathExtension], + @"historyID": msg.messageDBId, }; else return @{ @@ -528,6 +529,7 @@ +(NSDictionary*) getFileInfoForMessage:(MLMessage*) msg @"filename": filename, @"needsDownloading": @YES, @"fileExtension": [filename pathExtension], + @"historyID": msg.messageDBId, }; } return @{ @@ -539,6 +541,7 @@ +(NSDictionary*) getFileInfoForMessage:(MLMessage*) msg @"cacheId": [cacheFile lastPathComponent], @"cacheFile": cacheFile, @"fileExtension": [filename pathExtension], + @"historyID": msg.messageDBId, }; } From 840ad88ad498d2a47801c5d165aeae29a82712d9 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 8 Jul 2024 10:29:50 +0200 Subject: [PATCH 066/131] Try to fix "Can't add self as subview" in active chats --- Monal/Classes/ActiveChatsViewController.m | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index e966c1911c..7eebc45f89 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -380,9 +380,6 @@ -(void) viewWillAppear:(BOOL) animated { DDLogDebug(@"active chats view will appear"); [super viewWillAppear:animated]; - if(self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) - [self refreshDisplay]; // load contacts - [self segueToIntroScreensIfNeeded]; } -(void) viewWillDisappear:(BOOL) animated @@ -395,6 +392,10 @@ -(void) viewDidAppear:(BOOL) animated { DDLogDebug(@"active chats view did appear"); [super viewDidAppear:animated]; + + if(self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) + [self refreshDisplay]; // load contacts + [self segueToIntroScreensIfNeeded]; } -(void) didReceiveMemoryWarning From 2748be4bfac875a430933d9176d59eeb63e5f8f2 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 8 Jul 2024 10:35:25 +0200 Subject: [PATCH 067/131] Fix crash on isEncrypted change when updating MLContact --- Monal/Classes/chatViewController.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/chatViewController.m b/Monal/Classes/chatViewController.m index 72bfaf8740..e61a94ac3b 100644 --- a/Monal/Classes/chatViewController.m +++ b/Monal/Classes/chatViewController.m @@ -594,7 +594,9 @@ -(IBAction) toggleEncryption:(id) sender -(void) observeValueForKeyPath:(NSString*) keyPath ofObject:(id) object change:(NSDictionary *) change context:(void*) context { if([keyPath isEqualToString:@"isEncrypted"] && object == self.contact) - [self displayEncryptionStateInUI]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self displayEncryptionStateInUI]; + }); } -(void) displayEncryptionStateInUI From d0c00e47e52baaaab7922937601f864a24d4454e Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 8 Jul 2024 12:44:59 +0200 Subject: [PATCH 068/131] Fix typo --- Monal/Classes/ActiveChatsViewController.m | 2 +- Monal/Classes/SwiftuiHelpers.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 7eebc45f89..a3caafa838 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -519,7 +519,7 @@ -(void) openConversationPlaceholder:(MLContact*) contact -(void) showNotificationSettings { - UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsNotificatioSettings"]; + UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsNotificationSettings"]; [self presentViewController:view animated:YES completion:^{}]; } diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 2386bfdc53..0a556821f4 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -682,7 +682,7 @@ class SwiftuiInterface : NSObject { host.rootView = AnyView(UIKitWorkaround(GeneralSettings())) case "ActiveChatsPrivacySettings": host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: PrivacySettings())) - case "ActiveChatsNotificatioSettings": + case "ActiveChatsNotificationSettings": host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: NotificationSettings())) case "OnboardingView": host.rootView = AnyView(createOnboardingView(delegate:delegate)) From 273b8f7829c28fdb9ef4c7d6fd67a0dd50e50814 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 12 Jul 2024 14:36:41 +0200 Subject: [PATCH 069/131] Properly display geo: urls having a query string The query string (usually containing a zoom factor) will be ignored, though. --- Monal/Classes/MLConstants.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/MLConstants.h b/Monal/Classes/MLConstants.h index 722c0f6aa4..81148aed4f 100644 --- a/Monal/Classes/MLConstants.h +++ b/Monal/Classes/MLConstants.h @@ -217,4 +217,4 @@ static inline NSString* _Nonnull LocalizationNotNeeded(NSString* _Nonnull s) //build MLXMLNode query statistics (will only optimize MLXMLNode queries if *not* defined) //#define QueryStatistics 1 -#define geoPattern @"^geo:(-?(?:90|[1-8][0-9]|[0-9])(?:\\.[0-9]{1,32})?),(-?(?:180|1[0-7][0-9]|[0-9]{1,2})(?:\\.[0-9]{1,32})?)(;.*)?$" +#define geoPattern @"^geo:(-?(?:90|[1-8][0-9]|[0-9])(?:\\.[0-9]{1,32})?),(-?(?:180|1[0-7][0-9]|[0-9]{1,2})(?:\\.[0-9]{1,32})?)(;.*)?([?].*)?$" From 31a4bfdd386d8d2184118934954ad8684e046a60 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 13 Jul 2024 12:24:52 +0200 Subject: [PATCH 070/131] Change empty active chats wording --- Monal/Classes/ActiveChatsViewController.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index a3caafa838..0899997a98 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -965,7 +965,7 @@ -(CGFloat) spaceHeightForEmptyDataSet:(UIScrollView*) scrollView -(NSAttributedString*) titleForEmptyDataSet:(UIScrollView*) scrollView { - NSString* text = NSLocalizedString(@"No one is here", @""); + NSString* text = NSLocalizedString(@"No active conversations", @""); NSDictionary* attributes = @{NSFontAttributeName: [UIFont boldSystemFontOfSize:18.0f], NSForegroundColorAttributeName: (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark ? [UIColor whiteColor] : [UIColor blackColor])}; @@ -975,7 +975,7 @@ -(NSAttributedString*) titleForEmptyDataSet:(UIScrollView*) scrollView - (NSAttributedString*)descriptionForEmptyDataSet:(UIScrollView*) scrollView { - NSString* text = NSLocalizedString(@"When you start talking to someone,\n they will show up here.", @""); + NSString* text = NSLocalizedString(@"When you start a conversation\nwith someone, they will\nshow up here.", @""); NSMutableParagraphStyle* paragraph = [NSMutableParagraphStyle new]; paragraph.lineBreakMode = NSLineBreakByWordWrapping; From 277a68031385610e2f142864f87261a31960732b Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 13 Jul 2024 12:12:39 +0200 Subject: [PATCH 071/131] Fix dark mode handling of chat placeholder image --- Monal/Classes/ActiveChatsViewController.m | 2 + Monal/Classes/MLPlaceholderViewController.h | 19 -------- Monal/Classes/MLPlaceholderViewController.m | 46 ------------------- Monal/Monal.xcodeproj/project.pbxproj | 6 --- Monal/localization/Base.lproj/Main.storyboard | 35 -------------- 5 files changed, 2 insertions(+), 106 deletions(-) delete mode 100644 Monal/Classes/MLPlaceholderViewController.h delete mode 100644 Monal/Classes/MLPlaceholderViewController.m diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 0899997a98..1230287e9b 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -380,6 +380,8 @@ -(void) viewWillAppear:(BOOL) animated { DDLogDebug(@"active chats view will appear"); [super viewWillAppear:animated]; + + [self openConversationPlaceholder:nil]; } -(void) viewWillDisappear:(BOOL) animated diff --git a/Monal/Classes/MLPlaceholderViewController.h b/Monal/Classes/MLPlaceholderViewController.h deleted file mode 100644 index e999d5b54f..0000000000 --- a/Monal/Classes/MLPlaceholderViewController.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// MLPlaceholderViewController.h -// Monal -// -// Created by Anurodh Pokharel on 1/5/20. -// Copyright © 2020 Monal.im. All rights reserved. -// - -#import -#import "HelperTools.h" -#import "MLImageManager.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface MLPlaceholderViewController : UIViewController - -@end - -NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/MLPlaceholderViewController.m b/Monal/Classes/MLPlaceholderViewController.m deleted file mode 100644 index 4913c80825..0000000000 --- a/Monal/Classes/MLPlaceholderViewController.m +++ /dev/null @@ -1,46 +0,0 @@ -// -// MLPlaceholderViewController.m -// Monal -// -// Created by Anurodh Pokharel on 1/5/20. -// Copyright © 2020 Monal.im. All rights reserved. -// - -#import "MLPlaceholderViewController.h" - -@interface MLPlaceholderViewController () - -@property (weak, nonatomic) IBOutlet UIImageView *backgroundImageView; - -@end - -@implementation MLPlaceholderViewController - -- (void) viewDidLoad -{ - [super viewDidLoad]; - // Do any additional setup after loading the view. -} - - --(void) viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; - - self.backgroundImageView.image = [UIImage imageNamed:@"park_colors"]; -} - --(void) viewWillDisappear:(BOOL)animated -{ - [super viewWillDisappear:animated]; - - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - --(void) dealloc -{ - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -@end diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index ffb0970952..29424f0190 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -40,7 +40,6 @@ 2664D28523F2312400CD4085 /* MLAccountPickerViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2664D28423F2312400CD4085 /* MLAccountPickerViewController.m */; }; 268DD58617C4541000C673A9 /* MLChatCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 268DD58517C4541000C673A9 /* MLChatCell.m */; }; 2696EED21791245A00BC54B8 /* chatViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2696EED11791245A00BC54B8 /* chatViewController.m */; }; - 26A78ED823C2B59400C7CF40 /* MLPlaceholderViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 26A78ED723C2B59400C7CF40 /* MLPlaceholderViewController.m */; }; 26AA70152146BBB900598605 /* ShareViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 26AA70142146BBB900598605 /* ShareViewController.m */; }; 26AA70182146BBB900598605 /* iosShare.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 26AA70162146BBB900598605 /* iosShare.storyboard */; }; 26AA701C2146BBB900598605 /* shareSheet.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 26AA70112146BBB800598605 /* shareSheet.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -399,8 +398,6 @@ 268DD58517C4541000C673A9 /* MLChatCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLChatCell.m; sourceTree = ""; }; 2696EED01791245A00BC54B8 /* chatViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = chatViewController.h; sourceTree = ""; }; 2696EED11791245A00BC54B8 /* chatViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = chatViewController.m; sourceTree = ""; }; - 26A78ED623C2B59400C7CF40 /* MLPlaceholderViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLPlaceholderViewController.h; sourceTree = ""; }; - 26A78ED723C2B59400C7CF40 /* MLPlaceholderViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLPlaceholderViewController.m; sourceTree = ""; }; 26AA70112146BBB800598605 /* shareSheet.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = shareSheet.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 26AA70132146BBB900598605 /* ShareViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ShareViewController.h; sourceTree = ""; }; 26AA70142146BBB900598605 /* ShareViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ShareViewController.m; sourceTree = ""; }; @@ -1064,8 +1061,6 @@ 26DB52121777EA5100B50353 /* Tab views */, 26158AF01FFA6E4500E53BDC /* MLWebViewController.h */, 26158AF11FFA6E4500E53BDC /* MLWebViewController.m */, - 26A78ED623C2B59400C7CF40 /* MLPlaceholderViewController.h */, - 26A78ED723C2B59400C7CF40 /* MLPlaceholderViewController.m */, 84FC375828981A5600634E3E /* PasswordMigration.swift */, 3D631822294BAB1D00026BE7 /* ContactPicker.swift */, 3D27D955290B0BB60014748B /* AddContactMenu.swift */, @@ -2089,7 +2084,6 @@ 3DC5035C2822F5220064C8A7 /* OmemoKeys.swift in Sources */, 262797AF178A577300B85D94 /* MLContactCell.m in Sources */, 2696EED21791245A00BC54B8 /* chatViewController.m in Sources */, - 26A78ED823C2B59400C7CF40 /* MLPlaceholderViewController.m in Sources */, C12436142434AB5D00B8F074 /* MLAttributedLabel.m in Sources */, 3D85E587282AE523006F5B3A /* OmemoQrCodeView.swift in Sources */, 849A53E4287135B2007E941A /* MLVoIPProcessor.m in Sources */, diff --git a/Monal/localization/Base.lproj/Main.storyboard b/Monal/localization/Base.lproj/Main.storyboard index dd2ae0e236..e0b4db6b7a 100644 --- a/Monal/localization/Base.lproj/Main.storyboard +++ b/Monal/localization/Base.lproj/Main.storyboard @@ -76,36 +76,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -169,7 +139,6 @@ - @@ -200,7 +169,6 @@ - @@ -2529,9 +2497,6 @@ - - - From 22eab94a80dbc19b81654a9cf30e8243c494f589 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 13 Jul 2024 16:29:30 +0200 Subject: [PATCH 072/131] Fix onboarding --- Monal/Classes/ActiveChatsViewController.h | 5 +- Monal/Classes/ActiveChatsViewController.m | 69 +++++++++++-- Monal/Classes/BoardingCards.swift | 117 ++++++++++++++-------- Monal/Classes/GeneralSettings.swift | 72 +++++++------ Monal/Classes/MLXMPPManager.m | 4 + Monal/Classes/RegisterAccount.swift | 7 -- Monal/Classes/SwiftuiHelpers.swift | 4 +- Monal/Classes/WelcomeLogIn.swift | 14 +-- 8 files changed, 181 insertions(+), 111 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.h b/Monal/Classes/ActiveChatsViewController.h index ebe5e61965..497d630066 100644 --- a/Monal/Classes/ActiveChatsViewController.h +++ b/Monal/Classes/ActiveChatsViewController.h @@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN @class chatViewController; @class MLCall; -@interface ActiveChatsViewController : UITableViewController +@interface ActiveChatsViewController : UITableViewController @property (nonatomic, strong) UITableView* chatListTable; @property (nonatomic, weak) IBOutlet UIBarButtonItem* settingsButton; @@ -25,6 +25,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, weak) IBOutlet UIBarButtonItem* composeButton; @property (nonatomic, strong) chatViewController* currentChatViewController; @property (nonatomic, strong) UIActivityIndicatorView* spinner; +@property (nonatomic) BOOL enqueueGeneralSettings; -(void) showCallContactNotFoundAlert:(NSString*) jid; -(void) callContact:(MLContact*) contact withUIKitSender:(_Nullable id) sender; @@ -38,7 +39,7 @@ NS_ASSUME_NONNULL_BEGIN -(void) showContacts; -(void) deleteConversation; -(void) showSettings; --(void) showPrivacySettings; +-(void) showGeneralSettings; -(void) showOnboarding; -(void) showNotificationSettings; -(void) showDetails; diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 1230287e9b..555f216fc5 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -395,6 +395,28 @@ -(void) viewDidAppear:(BOOL) animated DDLogDebug(@"active chats view did appear"); [super viewDidAppear:animated]; + [self refresh]; +} + +-(void) presentationControllerDidDismiss:(UIPresentationController*) presentationController +{ + DDLogError(@"DID DISMISS!"); + dispatch_async(dispatch_get_main_queue(), ^{ + [self refresh]; + }); +} + +-(void) presentationControllerWillDismiss:(UIPresentationController*) presentationController +{ + DDLogError(@"WILL DISMISS!"); + dispatch_async(dispatch_get_main_queue(), ^{ + [self refresh]; + }); +} + +-(void) refresh +{ + DDLogError(@"REFRESH!"); if(self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) [self refreshDisplay]; // load contacts [self segueToIntroScreensIfNeeded]; @@ -414,6 +436,7 @@ -(void) showAddContactWithJid:(NSString*) jid preauthToken:(NSString* _Nullable) [self presentChatWithContact:newContact]; }); }]; + addContactMenuView.presentationController.delegate = self; [self presentViewController:addContactMenuView animated:NO completion:^{}]; }]; }); @@ -426,6 +449,7 @@ -(void) segueToIntroScreensIfNeeded if(needingMigration.count > 0) { UIViewController* passwordMigration = [[SwiftuiInterface new] makePasswordMigration:needingMigration]; + passwordMigration.presentationController.delegate = self; [self presentViewController:passwordMigration animated:YES completion:^{}]; return; } @@ -436,17 +460,19 @@ -(void) segueToIntroScreensIfNeeded return; } - // display quick start if the user never seen it or if there are 0 enabled accounts - if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0) + if(self.enqueueGeneralSettings) { - UIViewController* loginViewController = [[SwiftuiInterface new] makeViewWithName:@"WelcomeLogIn"]; - [self presentViewController:loginViewController animated:YES completion:^{}]; + self.enqueueGeneralSettings = NO; + [self showGeneralSettings]; return; } - if(![[HelperTools defaultsDB] boolForKey:@"HasSeenPrivacySettings"]) + // display quick start if the user never seen it or if there are 0 enabled accounts + if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0) { - [self showPrivacySettings]; + UIViewController* loginViewController = [[SwiftuiInterface new] makeViewWithName:@"WelcomeLogIn"]; + loginViewController.presentationController.delegate = self; + [self presentViewController:loginViewController animated:YES completion:^{}]; return; } @@ -471,6 +497,7 @@ -(void) showWarningsIfNeeded [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { [_mamWarningDisplayed addObject:accountNo]; }]]; + messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } else @@ -485,6 +512,7 @@ -(void) showWarningsIfNeeded [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { [_smacksWarningDisplayed addObject:accountNo]; }]]; + messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } else @@ -499,6 +527,7 @@ -(void) showWarningsIfNeeded [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { [_pushWarningDisplayed addObject:accountNo]; }]]; + messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } else @@ -521,14 +550,20 @@ -(void) openConversationPlaceholder:(MLContact*) contact -(void) showNotificationSettings { - UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsNotificationSettings"]; - [self presentViewController:view animated:YES completion:^{}]; + [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ + UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsNotificationSettings"]; + view.presentationController.delegate = self; + [self presentViewController:view animated:YES completion:^{}]; + }]; } --(void) showPrivacySettings +-(void) showGeneralSettings { - UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsPrivacySettings"]; - [self presentViewController:view animated:YES completion:^{}]; + [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ + UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsGeneralSettings"]; + view.presentationController.delegate = self; + [self presentViewController:view animated:YES completion:^{}]; + }]; } -(void) showOnboarding @@ -536,6 +571,8 @@ -(void) showOnboarding [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* callViewController = [[SwiftuiInterface new] makeViewWithName:@"OnboardingView"]; callViewController.modalPresentationStyle = UIModalPresentationFullScreen; + //viewDidAppear (which in turn will call segueToIntroScreensIfNeeded) already called because of fullscreen presentation style above + //view.presentationController.delegate = self; [self presentViewController:callViewController animated:NO completion:^{}]; }]; } @@ -549,6 +586,7 @@ -(void) showCallContactNotFoundAlert:(NSString*) jid { UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Contact not found", @"") message:[NSString stringWithFormat:NSLocalizedString(@"You tried to call contact '%@' but this contact could not be found in your contact list.", @""), jid] preferredStyle:UIAlertControllerStyleAlert]; [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {}]]; + messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } @@ -592,6 +630,7 @@ -(void) callContact:(MLContact*) contact withUIKitSender:(_Nullable id) sender } else popPresenter.sourceView = self.view; + alert.presentationController.delegate = self; [self presentViewController:alert animated:YES completion:nil]; } } @@ -600,6 +639,7 @@ -(void) presentAccountPickerForContacts:(NSArray*) contacts andCallT { [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* accountPickerController = [[SwiftuiInterface new] makeAccountPickerForContacts:contacts andCallType:callType]; + accountPickerController.presentationController.delegate = self; [self presentViewController:accountPickerController animated:YES completion:^{}]; }]; } @@ -609,6 +649,8 @@ -(void) presentCall:(MLCall*) call [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* callViewController = [[SwiftuiInterface new] makeCallScreenForCall:call]; callViewController.modalPresentationStyle = UIModalPresentationFullScreen; + //viewDidAppear (which in turn will call segueToIntroScreensIfNeeded) already called because of fullscreen presentation style above + //callViewController.presentationController.delegate = self; [self presentViewController:callViewController animated:NO completion:^{}]; }]; } @@ -682,6 +724,7 @@ -(BOOL) showAccountNumberWarningIfNeeded [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { [alert dismissViewControllerAnimated:YES completion:nil]; }]]; + alert.presentationController.delegate = self; [self presentViewController:alert animated:YES completion:nil]; return YES; } @@ -714,6 +757,7 @@ -(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender else if([segue.identifier isEqualToString:@"showDetails"]) { UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:sender]; + detailsViewController.presentationController.delegate = self; [self presentViewController:detailsViewController animated:YES completion:^{}]; } else if([segue.identifier isEqualToString:@"showContacts"]) @@ -858,6 +902,7 @@ -(void) tableView:(UITableView*) tableView accessoryButtonTappedForRowWithIndexP selected = self.unpinnedContacts[indexPath.row]; } UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:selected]; + detailsViewController.presentationController.delegate = self; [self presentViewController:detailsViewController animated:YES completion:^{}]; } @@ -1034,6 +1079,7 @@ -(void) showRegisterWithUsername:(NSString*) username onHost:(NSString*) host wi DDLogWarn(@"Dummy reg completion called for accountNo: %@", accountNo); })), }]; + registerViewController.presentationController.delegate = self; [self presentViewController:registerViewController animated:YES completion:^{}]; }]; } @@ -1043,6 +1089,7 @@ -(void) showDetails if([MLNotificationManager sharedInstance].currentContact != nil) { UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:[MLNotificationManager sharedInstance].currentContact]; + detailsViewController.presentationController.delegate = self; [self presentViewController:detailsViewController animated:YES completion:^{}]; } } diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index a5d1199790..3743239893 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -6,7 +6,7 @@ // Copyright © 2024 monal-im.org. All rights reserved. // -import SwiftUI +import FrameUp class OnboardingState: ObservableObject { @defaultsDB("hasCompletedOnboarding") @@ -23,17 +23,20 @@ struct OnboardingCard: Identifiable { } struct OnboardingView: View { - @ObservedObject var onboardingState = OnboardingState() var delegate: SheetDismisserProtocol let cards: [OnboardingCard] - + @ObservedObject var onboardingState = OnboardingState() @State private var currentIndex = 0 var body: some View { - VStack { + ZStack { + Color.background + .edgesIgnoringSafeArea(.all) TabView(selection: $currentIndex) { ForEach(cards.indices, id: \.self) { index in + //SmartScrollView(.vertical, showsIndicators: true, optionalScrolling: true, shrinkToFit: false) { ScrollView { + Group { VStack(alignment: .leading, spacing: 16) { HStack { @@ -47,15 +50,13 @@ struct OnboardingView: View { } } - if let imageName = cards[index].imageName { - HStack { + HStack { + if let imageName = cards[index].imageName { Image(systemName: imageName) .font(.custom("MarkerFelt-Wide", size: 80)) .foregroundColor(.blue) + } - } - - VStack { if let title = cards[index].title { title .font(.title) @@ -63,14 +64,14 @@ struct OnboardingView: View { .foregroundColor(.primary) .padding(.bottom, 4) } - - if let description = cards[index].description { - description - .font(.custom("HelveticaNeue-Medium", size: 20)) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - Divider() - } + } + + if let description = cards[index].description { + description + .font(.custom("HelveticaNeue-Medium", size: 20)) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + Divider() } if let articleText = cards[index].articleText { @@ -82,9 +83,10 @@ struct OnboardingView: View { if let view = cards[index].customView { view - .frame(minWidth: 350, maxWidth: .infinity, minHeight: 600, maxHeight: .infinity) } + Spacer() + HStack { Spacer() if index < cards.count - 1 { @@ -98,16 +100,10 @@ struct OnboardingView: View { } .foregroundColor(.blue) } - } - Spacer() - } - - HStack { - Spacer() - if index == cards.count - 1 { + } else { Button(action: { - delegate.dismiss() onboardingState.hasCompletedOnboarding = true + delegate.dismiss() }) { Text("Close") .fontWeight(.bold) @@ -117,24 +113,24 @@ struct OnboardingView: View { .cornerRadius(10) } } - Spacer() } + + Spacer().frame(height: 16) } .padding() + .frame(maxHeight: .infinity) + .background(Color.green) + //.edgesIgnoringSafeArea([.bottom, .leading, .trailing]) } - .background(Color(UIColor.systemBackground)) - .cornerRadius(10) - .shadow(color: Color.gray.opacity(0.3), radius: 10) - .padding(.horizontal, 3) - .padding(.vertical, 5) + .background(Color.red) + .edgesIgnoringSafeArea([.bottom, .leading, .trailing]) + } + //.background(Color(UIColor.systemBackground)) + .background(Color.yellow) + .edgesIgnoringSafeArea([.bottom, .leading, .trailing]) } } - .tabViewStyle(PageTabViewStyle()) - .indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always)) - .frame(width: 350, height: 770) - .padding() } - .background(Color.clear) } } @@ -167,15 +163,56 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { ), OnboardingCard( title: Text("Settings"), - description: Text("These are important privacy settings you may want to review !"), + description: Text("These are important privacy settings you may want to review!"), + imageName: nil, + articleText: nil, + customView: AnyView(PrivacySettingsSubview(onboardingPart:0)) + ), + OnboardingCard( + title: Text("Settings"), + description: Text("These are important privacy settings you may want to review!"), imageName: nil, articleText: nil, - customView: AnyView(PrivacySettingsOnboarding(onboardingActive: true)) - ) + customView: AnyView(PrivacySettingsSubview(onboardingPart:1)) + ), + OnboardingCard( + title: Text("Even more to customize!"), + description: Text("You can customize even more, just use the button below to open the settings."), + imageName: "hand.wave", + articleText: nil, + customView: AnyView(TakeMeToSettingsView(delegate:delegate)) + ), ] OnboardingView(delegate: delegate, cards: cards) } +struct TakeMeToSettingsView: View { + @ObservedObject var onboardingState = OnboardingState() + var delegate: SheetDismisserProtocol + + var body: some View { + HStack { + Spacer() + Button(action: { + let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate + if let activeChats = appDelegate.activeChats { + activeChats.enqueueGeneralSettings = true + } + onboardingState.hasCompletedOnboarding = true + delegate.dismiss() + }) { + Text("Take me to settings") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + Spacer() + } + } +} + struct OnboardingView_Previews: PreviewProvider { static var delegate = SheetDismisserProtocol() static var previews: some View { diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index a252bb410f..c71a0339f5 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -98,9 +98,6 @@ class GeneralSettingsDefaultsDB: ObservableObject { @defaultsDB("allowCallsFromNonRosterContacts") var allowCallsFromNonRosterContacts: Bool - @defaultsDB("HasSeenPrivacySettings") - var hasSeenPrivacySettings: Bool - @defaultsDB("AutodownloadFiletransfers") var autodownloadFiletransfers : Bool @@ -192,9 +189,6 @@ struct GeneralSettings: View { } } .navigationBarTitle(Text("General Settings")) - .onAppear { - generalSettingsDefaultsDB.hasSeenPrivacySettings = true - } } } @@ -354,49 +348,53 @@ struct PrivacySettings: View { var body: some View { Form { - PrivacySettingsOnboarding(onboardingActive: false) + PrivacySettingsSubview(onboardingPart:-1) } .navigationBarTitle(Text("Privacy"), displayMode: .inline) } } -struct PrivacySettingsOnboarding: View { +struct PrivacySettingsSubview: View { @ObservedObject var generalSettingsDefaultsDB = GeneralSettingsDefaultsDB() - var onboardingActive: Bool + var onboardingPart: Int var body: some View { VStack { - Section(header: Text("Activity indications")) { - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) { - Text("Send message received") - Text("Let your contacts know if you received a message.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendDisplayedMarkers) { - Text("Send message displayed state") - Text("Let your contacts know if you read a message.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastChatState) { - Text("Send typing notifications") - Text("Let your contacts know if you are typing a message.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastUserInteraction) { - Text("Send last interaction time") - Text("Let your contacts know when you last opened the app.") + if onboardingPart == -1 || onboardingPart == 0 { + Section(header: Text("Activity indications")) { + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) { + Text("Send message received") + Text("Let your contacts know if you received a message.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendDisplayedMarkers) { + Text("Send message displayed state") + Text("Let your contacts know if you read a message.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastChatState) { + Text("Send typing notifications") + Text("Let your contacts know if you are typing a message.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastUserInteraction) { + Text("Send last interaction time") + Text("Let your contacts know when you last opened the app.") + } } } - Section(header: Text("Interactions")) { - SettingsToggle(isOn: $generalSettingsDefaultsDB.allowNonRosterContacts) { - Text("Accept incoming messages from strangers") - Text("Allow contacts not in your contact list to contact you.") + if onboardingPart == -1 || onboardingPart == 1 { + Section(header: Text("Interactions")) { + SettingsToggle(isOn: $generalSettingsDefaultsDB.allowNonRosterContacts) { + Text("Accept incoming messages from strangers") + Text("Allow contacts not in your contact list to contact you.") + } + SettingsToggle(isOn: Binding( + get: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts && generalSettingsDefaultsDB.allowNonRosterContacts }, + set: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts = $0 } + )) { + Text("Accept incoming calls from strangers") + Text("Allow contacts not in your contact list to call you.") + }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts) } - SettingsToggle(isOn: Binding( - get: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts && generalSettingsDefaultsDB.allowNonRosterContacts }, - set: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts = $0 } - )) { - Text("Accept incoming calls from strangers") - Text("Allow contacts not in your contact list to call you.") - }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts) } - if !onboardingActive { + if onboardingPart == -1 || onboardingPart == 2 { Section(header: Text("Misc")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.allowVersionIQ) { Text("Publish version") diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index 1f5a508c71..6b0aa1295d 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -144,6 +144,10 @@ -(void) defaultSettings [self upgradeBoolUserSettingsIfUnset:@"useInlineSafari" toDefault:NO]; else [self upgradeBoolUserSettingsIfUnset:@"useInlineSafari" toDefault:YES]; + + [self upgradeBoolUserSettingsIfUnset:@"hasCompletedOnboarding" toDefault:NO]; + + [[HelperTools defaultsDB] setBool:NO forKey:@"hasCompletedOnboarding"]; } -(void) upgradeFloatUserSettingsToInteger:(NSString*) settingsName diff --git a/Monal/Classes/RegisterAccount.swift b/Monal/Classes/RegisterAccount.swift index 90a7b7c9a8..6a29d81238 100644 --- a/Monal/Classes/RegisterAccount.swift +++ b/Monal/Classes/RegisterAccount.swift @@ -388,13 +388,6 @@ struct RegisterAccount: View { if(self.registerComplete == true) { self.delegate.dismiss() - if !HelperTools.defaultsDB().bool(forKey:"HasSeenPrivacySettings") { - let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate - if let activeChats = appDelegate.activeChats { - activeChats.showPrivacySettings() - } - } - if let completion = self.completionHandler { DDLogVerbose("Calling reg completion handler...") completion(self.registeredAccountNo as NSNumber) diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 0a556821f4..00b8e3b5ff 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -680,8 +680,8 @@ class SwiftuiInterface : NSObject { host.rootView = AnyView(ChatPlaceholder()) case "GeneralSettings" : host.rootView = AnyView(UIKitWorkaround(GeneralSettings())) - case "ActiveChatsPrivacySettings": - host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: PrivacySettings())) + case "ActiveChatsGeneralSettings": + host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: GeneralSettings())) case "ActiveChatsNotificationSettings": host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: NotificationSettings())) case "OnboardingView": diff --git a/Monal/Classes/WelcomeLogIn.swift b/Monal/Classes/WelcomeLogIn.swift index d1c277a944..0379ee5d73 100644 --- a/Monal/Classes/WelcomeLogIn.swift +++ b/Monal/Classes/WelcomeLogIn.swift @@ -110,16 +110,6 @@ struct WelcomeLogIn: View { } } } - - private func dismissAndShowPrivacySettings() { - self.delegate.dismiss() - if !HelperTools.defaultsDB().bool(forKey:"HasSeenPrivacySettings") { - let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate - if let activeChats = appDelegate.activeChats { - activeChats.showPrivacySettings() - } - } - } var body: some View { ScrollView { @@ -186,7 +176,7 @@ struct WelcomeLogIn: View { .alert(isPresented: $showAlert) { Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton: .default(alertPrompt.dismissLabel, action: { if(self.loginComplete == true) { - dismissAndShowPrivacySettings() + self.delegate.dismiss() } })) } @@ -225,7 +215,7 @@ struct WelcomeLogIn: View { if(DataLayer.sharedInstance().enabledAccountCnts() == 0) { Button(action: { - dismissAndShowPrivacySettings() + self.delegate.dismiss() }){ Text("Set up account later") .frame(maxWidth: .infinity) From ac7ba70365ea3c23123877c861a003a06b128749 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 14 Jul 2024 11:04:35 +0200 Subject: [PATCH 073/131] Fix delegate crash --- Monal/Classes/ActiveChatsViewController.m | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 555f216fc5..9d4a38280d 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -497,7 +497,6 @@ -(void) showWarningsIfNeeded [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { [_mamWarningDisplayed addObject:accountNo]; }]]; - messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } else @@ -512,7 +511,6 @@ -(void) showWarningsIfNeeded [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { [_smacksWarningDisplayed addObject:accountNo]; }]]; - messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } else @@ -527,7 +525,6 @@ -(void) showWarningsIfNeeded [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) { [_pushWarningDisplayed addObject:accountNo]; }]]; - messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } else @@ -586,7 +583,6 @@ -(void) showCallContactNotFoundAlert:(NSString*) jid { UIAlertController* messageAlert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Contact not found", @"") message:[NSString stringWithFormat:NSLocalizedString(@"You tried to call contact '%@' but this contact could not be found in your contact list.", @""), jid] preferredStyle:UIAlertControllerStyleAlert]; [messageAlert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleCancel handler:^(UIAlertAction* action __unused) {}]]; - messageAlert.presentationController.delegate = self; [self presentViewController:messageAlert animated:YES completion:nil]; } @@ -630,7 +626,6 @@ -(void) callContact:(MLContact*) contact withUIKitSender:(_Nullable id) sender } else popPresenter.sourceView = self.view; - alert.presentationController.delegate = self; [self presentViewController:alert animated:YES completion:nil]; } } @@ -724,7 +719,6 @@ -(BOOL) showAccountNumberWarningIfNeeded [alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"Close", @"") style:UIAlertActionStyleDefault handler:^(UIAlertAction* action __unused) { [alert dismissViewControllerAnimated:YES completion:nil]; }]]; - alert.presentationController.delegate = self; [self presentViewController:alert animated:YES completion:nil]; return YES; } From 43a41746d7497b8000ab47875f527bed7132afd8 Mon Sep 17 00:00:00 2001 From: Ryan Lintott Date: Sat, 13 Jul 2024 15:18:04 -0400 Subject: [PATCH 074/131] Updated BoardingCards view to take the full available space and scroll if necessary. --- Monal/Classes/BoardingCards.swift | 144 ++++++++++++++---------------- 1 file changed, 67 insertions(+), 77 deletions(-) diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index 3743239893..81edfec469 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -30,104 +30,94 @@ struct OnboardingView: View { var body: some View { ZStack { - Color.background - .edgesIgnoringSafeArea(.all) - TabView(selection: $currentIndex) { - ForEach(cards.indices, id: \.self) { index in - //SmartScrollView(.vertical, showsIndicators: true, optionalScrolling: true, shrinkToFit: false) { - ScrollView { - Group { - VStack(alignment: .leading, spacing: 16) { - - HStack { + /// Ensure the ZStack takes the entire area + Color.clear + + ForEach(Array(zip(cards, cards.indices)), id: \.1) { card, index in + /// Only show card that's visible + if index == currentIndex { + GeometryReader { proxy in + SmartScrollView(.vertical, showsIndicators: true, optionalScrolling: true, shrinkToFit: false) { + VStack(alignment: .leading, spacing: 16) { + if currentIndex > 0 { - Button(action: { + Button { currentIndex -= 1 - }) { - Image(systemName: "chevron.left") + } label: { + Label("Back", systemImage: "chevron.left") + .labelStyle(.iconOnly) .foregroundColor(.blue) } } - } - - HStack { - if let imageName = cards[index].imageName { - Image(systemName: imageName) - .font(.custom("MarkerFelt-Wide", size: 80)) - .foregroundColor(.blue) + + HStack { + if let imageName = card.imageName { + Image(systemName: imageName) + .font(.custom("MarkerFelt-Wide", size: 80)) + .foregroundColor(.blue) + + } - } - if let title = cards[index].title { - title + card.title? .font(.title) .fontWeight(.bold) .foregroundColor(.primary) .padding(.bottom, 4) } - } - - if let description = cards[index].description { - description - .font(.custom("HelveticaNeue-Medium", size: 20)) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - Divider() - } - - if let articleText = cards[index].articleText { - articleText + + if let description = card.description { + description + .font(.custom("HelveticaNeue-Medium", size: 20)) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + /// This ensures text doesn't get truncated which sometimes happens in ScrollView + .fixedSize(horizontal: false, vertical: true) + + Divider() + } + + card.articleText? .font(.custom("HelveticaNeue-Medium", size: 20)) .foregroundColor(.primary) .multilineTextAlignment(.leading) - } - - if let view = cards[index].customView { - view - } - - Spacer() - - HStack { + + card.customView + Spacer() - if index < cards.count - 1 { - Button(action: { - currentIndex += 1 - }) { - HStack { - Text("Next") + + Group { + if index < cards.count - 1 { + Button { + currentIndex += 1 + } label: { + HStack { + Text("Next") + .fontWeight(.bold) + Image(systemName: "chevron.right") + } + } + } else { + Button { + onboardingState.hasCompletedOnboarding = true + delegate.dismiss() + } label: { + Text("Close") .fontWeight(.bold) - Image(systemName: "chevron.right") + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) } - .foregroundColor(.blue) - } - } else { - Button(action: { - onboardingState.hasCompletedOnboarding = true - delegate.dismiss() - }) { - Text("Close") - .fontWeight(.bold) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(10) } } + .frame(maxWidth: .infinity, alignment: .trailing) } - - Spacer().frame(height: 16) + .padding(.bottom, 16) + .padding() + /// Sets the minimum frame height to the available height of the scrollview and the maxHeight to infinity + .frame(minHeight: proxy.size.height, maxHeight: .infinity) } - .padding() - .frame(maxHeight: .infinity) - .background(Color.green) - //.edgesIgnoringSafeArea([.bottom, .leading, .trailing]) - } - .background(Color.red) - .edgesIgnoringSafeArea([.bottom, .leading, .trailing]) } - //.background(Color(UIColor.systemBackground)) - .background(Color.yellow) - .edgesIgnoringSafeArea([.bottom, .leading, .trailing]) } } } @@ -151,7 +141,7 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { description: Text("Here's a quick look at what you can expect:"), imageName: "sparkles", articleText: Text(""" - • 🔐 OMEMO Encryption : Secure multi-end messaging using the OMEMO protocol.. + • 🔐 OMEMO Encryption : Secure multi-end messaging using the OMEMO protocol. • 🛜 Decentralized Network : Leverages the decentralized nature of XMPP, avoiding central servers. From 9001857efa0a246bd807829a5afef144f508cefe Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 14 Jul 2024 19:51:05 +0200 Subject: [PATCH 075/131] More onboarding fixes --- Monal/Classes/ActiveChatsViewController.h | 3 +- Monal/Classes/ActiveChatsViewController.m | 61 ++++++++++------- Monal/Classes/GeneralSettings.swift | 82 +++++++++++------------ Monal/Classes/SwiftuiHelpers.swift | 62 +++++++++++++---- 4 files changed, 126 insertions(+), 82 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.h b/Monal/Classes/ActiveChatsViewController.h index 497d630066..aa46b7f5ec 100644 --- a/Monal/Classes/ActiveChatsViewController.h +++ b/Monal/Classes/ActiveChatsViewController.h @@ -14,10 +14,11 @@ NS_ASSUME_NONNULL_BEGIN +@class UIHostingControllerWorkaround; @class chatViewController; @class MLCall; -@interface ActiveChatsViewController : UITableViewController +@interface ActiveChatsViewController : UITableViewController @property (nonatomic, strong) UITableView* chatListTable; @property (nonatomic, weak) IBOutlet UIBarButtonItem* settingsButton; diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 9d4a38280d..ba7e9dd168 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -39,6 +39,7 @@ @interface ActiveChatsViewController() { int _startedOrientation; double _portraitTop; double _landscapeTop; + BOOL _loginAutodisplayedAlready; } @property (atomic, strong) NSMutableArray* unpinnedContacts; @property (atomic, strong) NSMutableArray* pinnedContacts; @@ -67,6 +68,7 @@ +(void) initialize -(id) initWithNibName:(NSString*) nibNameOrNil bundle:(NSBundle*) nibBundleOrNil { self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; + _loginAutodisplayedAlready = NO; return self; } @@ -398,17 +400,8 @@ -(void) viewDidAppear:(BOOL) animated [self refresh]; } --(void) presentationControllerDidDismiss:(UIPresentationController*) presentationController +-(void) sheetDismissed { - DDLogError(@"DID DISMISS!"); - dispatch_async(dispatch_get_main_queue(), ^{ - [self refresh]; - }); -} - --(void) presentationControllerWillDismiss:(UIPresentationController*) presentationController -{ - DDLogError(@"WILL DISMISS!"); dispatch_async(dispatch_get_main_queue(), ^{ [self refresh]; }); @@ -416,7 +409,6 @@ -(void) presentationControllerWillDismiss:(UIPresentationController*) presentati -(void) refresh { - DDLogError(@"REFRESH!"); if(self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) [self refreshDisplay]; // load contacts [self segueToIntroScreensIfNeeded]; @@ -436,7 +428,9 @@ -(void) showAddContactWithJid:(NSString*) jid preauthToken:(NSString* _Nullable) [self presentChatWithContact:newContact]; }); }]; - addContactMenuView.presentationController.delegate = self; + addContactMenuView.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:addContactMenuView animated:NO completion:^{}]; }]; }); @@ -449,7 +443,9 @@ -(void) segueToIntroScreensIfNeeded if(needingMigration.count > 0) { UIViewController* passwordMigration = [[SwiftuiInterface new] makePasswordMigration:needingMigration]; - passwordMigration.presentationController.delegate = self; + passwordMigration.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:passwordMigration animated:YES completion:^{}]; return; } @@ -468,10 +464,13 @@ -(void) segueToIntroScreensIfNeeded } // display quick start if the user never seen it or if there are 0 enabled accounts - if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0) + if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0 && !_loginAutodisplayedAlready) { UIViewController* loginViewController = [[SwiftuiInterface new] makeViewWithName:@"WelcomeLogIn"]; - loginViewController.presentationController.delegate = self; + loginViewController.ml_disposeCallback = ^{ + self->_loginAutodisplayedAlready = YES; + [self sheetDismissed]; + }; [self presentViewController:loginViewController animated:YES completion:^{}]; return; } @@ -549,7 +548,9 @@ -(void) showNotificationSettings { [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsNotificationSettings"]; - view.presentationController.delegate = self; + view.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:view animated:YES completion:^{}]; }]; } @@ -558,7 +559,9 @@ -(void) showGeneralSettings { [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"ActiveChatsGeneralSettings"]; - view.presentationController.delegate = self; + view.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:view animated:YES completion:^{}]; }]; } @@ -568,8 +571,6 @@ -(void) showOnboarding [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* callViewController = [[SwiftuiInterface new] makeViewWithName:@"OnboardingView"]; callViewController.modalPresentationStyle = UIModalPresentationFullScreen; - //viewDidAppear (which in turn will call segueToIntroScreensIfNeeded) already called because of fullscreen presentation style above - //view.presentationController.delegate = self; [self presentViewController:callViewController animated:NO completion:^{}]; }]; } @@ -634,7 +635,9 @@ -(void) presentAccountPickerForContacts:(NSArray*) contacts andCallT { [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* accountPickerController = [[SwiftuiInterface new] makeAccountPickerForContacts:contacts andCallType:callType]; - accountPickerController.presentationController.delegate = self; + accountPickerController.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:accountPickerController animated:YES completion:^{}]; }]; } @@ -644,8 +647,6 @@ -(void) presentCall:(MLCall*) call [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ UIViewController* callViewController = [[SwiftuiInterface new] makeCallScreenForCall:call]; callViewController.modalPresentationStyle = UIModalPresentationFullScreen; - //viewDidAppear (which in turn will call segueToIntroScreensIfNeeded) already called because of fullscreen presentation style above - //callViewController.presentationController.delegate = self; [self presentViewController:callViewController animated:NO completion:^{}]; }]; } @@ -751,7 +752,9 @@ -(void) prepareForSegue:(UIStoryboardSegue*) segue sender:(id) sender else if([segue.identifier isEqualToString:@"showDetails"]) { UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:sender]; - detailsViewController.presentationController.delegate = self; + detailsViewController.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:detailsViewController animated:YES completion:^{}]; } else if([segue.identifier isEqualToString:@"showContacts"]) @@ -896,7 +899,9 @@ -(void) tableView:(UITableView*) tableView accessoryButtonTappedForRowWithIndexP selected = self.unpinnedContacts[indexPath.row]; } UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:selected]; - detailsViewController.presentationController.delegate = self; + detailsViewController.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:detailsViewController animated:YES completion:^{}]; } @@ -1073,7 +1078,9 @@ -(void) showRegisterWithUsername:(NSString*) username onHost:(NSString*) host wi DDLogWarn(@"Dummy reg completion called for accountNo: %@", accountNo); })), }]; - registerViewController.presentationController.delegate = self; + registerViewController.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:registerViewController animated:YES completion:^{}]; }]; } @@ -1083,7 +1090,9 @@ -(void) showDetails if([MLNotificationManager sharedInstance].currentContact != nil) { UIViewController* detailsViewController = [[SwiftuiInterface new] makeContactDetails:[MLNotificationManager sharedInstance].currentContact]; - detailsViewController.presentationController.delegate = self; + detailsViewController.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; [self presentViewController:detailsViewController animated:YES completion:^{}]; } } diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index c71a0339f5..b853145224 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -358,52 +358,50 @@ struct PrivacySettingsSubview: View { var onboardingPart: Int var body: some View { - VStack { - if onboardingPart == -1 || onboardingPart == 0 { - Section(header: Text("Activity indications")) { - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) { - Text("Send message received") - Text("Let your contacts know if you received a message.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendDisplayedMarkers) { - Text("Send message displayed state") - Text("Let your contacts know if you read a message.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastChatState) { - Text("Send typing notifications") - Text("Let your contacts know if you are typing a message.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastUserInteraction) { - Text("Send last interaction time") - Text("Let your contacts know when you last opened the app.") - } + if onboardingPart == -1 || onboardingPart == 0 { + Section(header: Text("Activity indications")) { + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) { + Text("Send message received") + Text("Let your contacts know if you received a message.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendDisplayedMarkers) { + Text("Send message displayed state") + Text("Let your contacts know if you read a message.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastChatState) { + Text("Send typing notifications") + Text("Let your contacts know if you are typing a message.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastUserInteraction) { + Text("Send last interaction time") + Text("Let your contacts know when you last opened the app.") } } - if onboardingPart == -1 || onboardingPart == 1 { - Section(header: Text("Interactions")) { - SettingsToggle(isOn: $generalSettingsDefaultsDB.allowNonRosterContacts) { - Text("Accept incoming messages from strangers") - Text("Allow contacts not in your contact list to contact you.") - } - SettingsToggle(isOn: Binding( - get: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts && generalSettingsDefaultsDB.allowNonRosterContacts }, - set: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts = $0 } - )) { - Text("Accept incoming calls from strangers") - Text("Allow contacts not in your contact list to call you.") - }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts) + } + if onboardingPart == -1 || onboardingPart == 1 { + Section(header: Text("Interactions")) { + SettingsToggle(isOn: $generalSettingsDefaultsDB.allowNonRosterContacts) { + Text("Accept incoming messages from strangers") + Text("Allow contacts not in your contact list to contact you.") } + SettingsToggle(isOn: Binding( + get: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts && generalSettingsDefaultsDB.allowNonRosterContacts }, + set: { generalSettingsDefaultsDB.allowCallsFromNonRosterContacts = $0 } + )) { + Text("Accept incoming calls from strangers") + Text("Allow contacts not in your contact list to call you.") + }.disabled(!generalSettingsDefaultsDB.allowNonRosterContacts) } - if onboardingPart == -1 || onboardingPart == 2 { - Section(header: Text("Misc")) { - SettingsToggle(isOn: $generalSettingsDefaultsDB.allowVersionIQ) { - Text("Publish version") - Text("Allow contacts in your contact list to query your Monal and iOS versions.") - } - SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcUseFallbackTurn) { - Text("Calls: Allow TURN fallback to Monal-Servers") - Text("This will make calls possible even if your XMPP server does not provide a TURN server.") - } + } + if onboardingPart == -1 || onboardingPart == 2 { + Section(header: Text("Misc")) { + SettingsToggle(isOn: $generalSettingsDefaultsDB.allowVersionIQ) { + Text("Publish version") + Text("Allow contacts in your contact list to query your Monal and iOS versions.") + } + SettingsToggle(isOn: $generalSettingsDefaultsDB.webrtcUseFallbackTurn) { + Text("Calls: Allow TURN fallback to Monal-Servers") + Text("This will make calls possible even if your XMPP server does not provide a TURN server.") } } } diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 00b8e3b5ff..20b5c762e8 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -574,6 +574,41 @@ extension View { } } +public extension UIViewController { + private struct AssociatedKeys { + static var DisposeCallbackKey = "ml_disposeCallbackKey" + } + + private class DisposeCallback : NSObject { + let callback: monal_void_block_t + + init(withCallback callback: @escaping monal_void_block_t) { + self.callback = callback + } + + deinit { + self.callback() + } + } + + @objc + var ml_disposeCallback: monal_void_block_t { + get { + return withUnsafePointer(to: &AssociatedKeys.DisposeCallbackKey) { pointer in + if let callback = (objc_getAssociatedObject(self, pointer) as? DisposeCallback)?.callback { + return callback + } + unreachable("You can't get what you did not set!") + } + } + set { + withUnsafePointer(to: &AssociatedKeys.DisposeCallbackKey) { pointer in + objc_setAssociatedObject(self, pointer, DisposeCallback(withCallback: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + } +} + // Interfaces between ObjectiveC/Storyboards and SwiftUI @objc class SwiftuiInterface : NSObject { @@ -663,33 +698,34 @@ class SwiftuiInterface : NSObject { @objc func makeView(name: String) -> UIViewController { let delegate = SheetDismisserProtocol() - let host = UIHostingController(rootView:AnyView(EmptyView())) - delegate.host = host + var host: UIHostingController? = nil + //let host = UIHostingController(rootView:AnyView(EmptyView())) switch(name) { // TODO names are currently taken from the segue identifier, an enum would be nice once everything is ported to SwiftUI case "DebugView": - host.rootView = AnyView(UIKitWorkaround(DebugView())) + host = UIHostingController(rootView:AnyView(UIKitWorkaround(DebugView()))) case "WelcomeLogIn": - host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:WelcomeLogIn(delegate:delegate))) + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate:delegate, to:WelcomeLogIn(delegate:delegate)))) case "LogIn": - host.rootView = AnyView(UIKitWorkaround(WelcomeLogIn(delegate:delegate))) + host = UIHostingController(rootView:AnyView(UIKitWorkaround(WelcomeLogIn(delegate:delegate)))) case "ContactRequests": - host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactRequestsMenu())) + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactRequestsMenu()))) case "CreateGroup": - host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: CreateGroupMenu(delegate: delegate))) + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: CreateGroupMenu(delegate: delegate)))) case "ChatPlaceholder": - host.rootView = AnyView(ChatPlaceholder()) + host = UIHostingController(rootView:AnyView(ChatPlaceholder())) case "GeneralSettings" : - host.rootView = AnyView(UIKitWorkaround(GeneralSettings())) + host = UIHostingController(rootView:AnyView(UIKitWorkaround(GeneralSettings()))) case "ActiveChatsGeneralSettings": - host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: GeneralSettings())) + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: GeneralSettings()))) case "ActiveChatsNotificationSettings": - host.rootView = AnyView(AddTopLevelNavigation(withDelegate: delegate, to: NotificationSettings())) + host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: NotificationSettings()))) case "OnboardingView": - host.rootView = AnyView(createOnboardingView(delegate:delegate)) + host = UIHostingController(rootView:AnyView(createOnboardingView(delegate:delegate))) default: unreachable() } - return host + delegate.host = host! + return host! } } From 1bf35c0829569f741f5d33e40a5478218814c197 Mon Sep 17 00:00:00 2001 From: Ryan Lintott Date: Sun, 14 Jul 2024 13:38:16 -0400 Subject: [PATCH 076/131] BoardingCards updated to improve accessibility Fixed tap area on back button as it was tiny Hid the title image from accessibility and merged it with the title into one accessibility element making it a header Made each card a modal for accessibility so VoiceOver moves back to the top when tapping next. --- Monal/Classes/BoardingCards.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index 81edfec469..e0331a6f12 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -47,6 +47,7 @@ struct OnboardingView: View { Label("Back", systemImage: "chevron.left") .labelStyle(.iconOnly) .foregroundColor(.blue) + .padding() } } @@ -55,6 +56,7 @@ struct OnboardingView: View { Image(systemName: imageName) .font(.custom("MarkerFelt-Wide", size: 80)) .foregroundColor(.blue) + .accessibilityHidden(true) } @@ -64,6 +66,8 @@ struct OnboardingView: View { .foregroundColor(.primary) .padding(.bottom, 4) } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) if let description = card.description { description @@ -118,6 +122,7 @@ struct OnboardingView: View { .frame(minHeight: proxy.size.height, maxHeight: .infinity) } } + .accessibilityAddTraits(.isModal) } } } From a4d26a4dcf057afc664b010001580b111baeeb2d Mon Sep 17 00:00:00 2001 From: Ryan Lintott Date: Sun, 14 Jul 2024 13:58:25 -0400 Subject: [PATCH 077/131] Fixed accessibility labels on Contacts toolbar buttons --- Monal/Classes/ContactsViewController.m | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/ContactsViewController.m b/Monal/Classes/ContactsViewController.m index 7eb6ca5f28..3adace979f 100644 --- a/Monal/Classes/ContactsViewController.m +++ b/Monal/Classes/ContactsViewController.m @@ -68,7 +68,8 @@ -(void) configureContactRequestsImage hasNotification:[[DataLayer sharedInstance] allContactRequests].count > 0 withTapHandler:requestsTapRecoginzer]; [self.navigationItem.rightBarButtonItems[1] setIsAccessibilityElement:YES]; - [self.navigationItem.rightBarButtonItems[1] setAccessibilityLabel:NSLocalizedString(@"Open list of pending contact requests", @"")]; + [self.navigationItem.rightBarButtonItems[1] setAccessibilityLabel:NSLocalizedString(@"Pending contact requests", @"")]; + [self.navigationItem.rightBarButtonItems[1] setAccessibilityTraits:UIAccessibilityTraitButton]; } @@ -106,11 +107,13 @@ -(void) viewDidLoad UIBarButtonItem* addContact = [UIBarButtonItem new]; addContact.image = [UIImage systemImageNamed:@"person.fill.badge.plus"]; + addContact.accessibilityLabel = @"Add contact"; [addContact setAction:@selector(openAddContacts:)]; [addContact setTarget:self]; UIBarButtonItem* createGroup = [[UIBarButtonItem alloc] init]; createGroup.image = [UIImage systemImageNamed:@"person.3.fill"]; + createGroup.accessibilityLabel = @"Create contact group"; [createGroup setAction:@selector(openCreateGroup:)]; [createGroup setTarget:self]; self.navigationItem.rightBarButtonItems = @[addContact, [[UIBarButtonItem alloc] init], createGroup]; From 7c52fab6f74ced93c0c77089daf368b934ea2670 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 15 Jul 2024 01:58:13 +0200 Subject: [PATCH 078/131] Improve onboarding cards display on macOS and iPad --- Monal/Classes/ActiveChatsViewController.m | 11 ++++++++--- Monal/Classes/BoardingCards.swift | 9 +++++++-- Monal/Classes/MLXMPPManager.m | 3 +++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index ba7e9dd168..3d9f8a201c 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -569,9 +569,14 @@ -(void) showGeneralSettings -(void) showOnboarding { [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ - UIViewController* callViewController = [[SwiftuiInterface new] makeViewWithName:@"OnboardingView"]; - callViewController.modalPresentationStyle = UIModalPresentationFullScreen; - [self presentViewController:callViewController animated:NO completion:^{}]; + UIViewController* view = [[SwiftuiInterface new] makeViewWithName:@"OnboardingView"]; + if(UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPad) + view.modalPresentationStyle = UIModalPresentationFullScreen; + else + view.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; + [self presentViewController:view animated:NO completion:^{}]; }]; } diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index e0331a6f12..4cdfe62003 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -126,6 +126,11 @@ struct OnboardingView: View { } } } + .onAppear { + //force portrait mode and lock ui there + UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") + (UIApplication.shared.delegate as! MonalAppDelegate).orientationLock = .portrait + } } } @@ -134,10 +139,10 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { let cards = [ OnboardingCard( title: Text("Welcome to Monal !"), - description: Text("Privacy like its 1999 🔒"), + description: Text("Become part of a worldwide decentralized chat network!"), imageName: "hand.wave", articleText: Text(""" - Modern iOS and MacOS XMPP chat client. + Modern iOS and macOS XMPP chat client.\n\nXMPP is a federated network: Just like email, you can register your account on many servers and still talk to anyone, even if they signed up on a different server. """), customView: nil ), diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index 6b0aa1295d..b2d2ad1297 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -147,7 +147,10 @@ -(void) defaultSettings [self upgradeBoolUserSettingsIfUnset:@"hasCompletedOnboarding" toDefault:NO]; +//always show onboarding on simulator for now +#if TARGET_OS_SIMULATOR [[HelperTools defaultsDB] setBool:NO forKey:@"hasCompletedOnboarding"]; +#endif } -(void) upgradeFloatUserSettingsToInteger:(NSString*) settingsName From 21856fe062deb8223bfd8363dfcd55b232a3ab29 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 16 Jul 2024 22:56:49 +0200 Subject: [PATCH 079/131] Promisify loading overlay --- Monal/Classes/AddContactMenu.swift | 22 ++++---- Monal/Classes/ContactDetails.swift | 20 +++++--- Monal/Classes/DebugView.swift | 16 ++---- Monal/Classes/LoadingOverlay.swift | 71 +++++++++++++++++++++----- Monal/Classes/MemberList.swift | 82 +++++++++++++++--------------- Monal/Classes/SwiftHelpers.swift | 36 +++++++++++++ Monal/Classes/SwiftuiHelpers.swift | 12 +++-- Monal/Classes/WelcomeLogIn.swift | 12 ++--- Monal/Classes/xmpp.h | 3 +- Monal/Classes/xmpp.m | 46 +++++++++-------- 10 files changed, 202 insertions(+), 118 deletions(-) diff --git a/Monal/Classes/AddContactMenu.swift b/Monal/Classes/AddContactMenu.swift index 94011b2dc9..dbe2d5d898 100644 --- a/Monal/Classes/AddContactMenu.swift +++ b/Monal/Classes/AddContactMenu.swift @@ -134,10 +134,11 @@ struct AddContactMenu: View { } return } - showLoadingOverlay(overlay, headline: NSLocalizedString("Adding...", comment: "")) - account.checkJidType(jid, withCompletion: { type, errorMsg in + showPromisingLoadingOverlay(overlay, headline:NSLocalizedString("Adding...", comment: ""), description:"") { + account.checkJidType(jid) + }.done { type in + let type = type as! String if type == "account" { - hideLoadingOverlay(overlay) let contact = MLContact.createContact(fromJid: jid, andAccountNo: account.accountNo) self.newContact = contact MLXMPPManager.sharedInstance().add(contact, withPreauthToken:preauthToken) @@ -145,21 +146,20 @@ struct AddContactMenu: View { trustFingerprints(self.importScannedFingerprints ? self.scannedFingerprints : [:], for:jid, on:account) successAlert(title: Text("Permission Requested"), message: Text("The new contact will be added to your contacts list when the person you've added has approved your request.")) } else if type == "muc" { - performMucAction(account:account, mucJid:jid, overlay:overlay, headlineView:Text("Adding Group/Channel..."), descriptionView:Text("")) { - account.joinMuc(jid) + showPromisingLoadingOverlay(overlay, headlineView:Text("Adding Group/Channel..."), descriptionView:Text("")) { + promisifyMucAction(account:account, mucJid:jid) { + account.joinMuc(jid) + } }.done { _ in self.newContact = MLContact.createContact(fromJid: jid, andAccountNo: account.accountNo) successAlert(title: Text("Success!"), message: Text("Successfully joined group/channel \(jid)!")) }.catch { error in errorAlert(title: Text("Error entering group/channel!"), message: Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } - } else { - hideLoadingOverlay(overlay) - errorAlert(title: Text("Error"), message: Text(errorMsg ?? "Undefined error")) } - }) + }.catch { error in + errorAlert(title: Text("Error"), message: Text(error.localizedDescription)) + } } var body: some View { diff --git a/Monal/Classes/ContactDetails.swift b/Monal/Classes/ContactDetails.swift index ab67fae0d9..87ce059d64 100644 --- a/Monal/Classes/ContactDetails.swift +++ b/Monal/Classes/ContactDetails.swift @@ -149,8 +149,10 @@ struct ContactDetails: View { .destructive( Text("Yes"), action: { - performMucAction(account:account, mucJid:contact.contactJid, overlay:overlay, headlineView:Text("Removing avatar..."), descriptionView:Text("")) { - self.account.mucProcessor.publishAvatar(nil, forMuc: contact.contactJid) + showPromisingLoadingOverlay(overlay, headlineView:Text("Removing avatar..."), descriptionView:Text("")) { + promisifyMucAction(account:account, mucJid:contact.contactJid) { + self.account.mucProcessor.publishAvatar(nil, forMuc: contact.contactJid) + } }.catch { error in errorAlert(title: Text("Error removing avatar!"), message: Text("\(String(describing:error))")) hideLoadingOverlay(overlay) @@ -542,8 +544,10 @@ struct ContactDetails: View { .destructive( Text("Yes"), action: { - performMucAction(account:account, mucJid:contact.contactJid, overlay:overlay, headlineView:contact.mucType == "group" ? Text("Destroying group...") : Text("Destroying channel..."), descriptionView:Text("")) { - self.account.mucProcessor.destroyRoom(contact.contactJid as String) + showPromisingLoadingOverlay(overlay, headlineView:contact.mucType == "group" ? Text("Destroying group...") : Text("Destroying channel..."), descriptionView:Text("")) { + promisifyMucAction(account:account, mucJid:contact.contactJid) { + self.account.mucProcessor.destroyRoom(contact.contactJid as String) + } }.done { callback in if let callback = callback { self.successCallback = callback @@ -551,8 +555,6 @@ struct ContactDetails: View { successAlert(title: Text("Success"), message: contact.mucType == "group" ? Text("Successfully destroyed group.") : Text("Successfully destroyed channel.")) }.catch { error in errorAlert(title: Text("Error destroying group!"), message: Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } } ) @@ -655,8 +657,10 @@ struct ContactDetails: View { }, onCanceled: { inputImage = nil }) { (image, cropRect, angle) in - performMucAction(account:account, mucJid:contact.contactJid, overlay:overlay, headlineView:Text("Uploading avatar..."), descriptionView:Text("")) { - self.account.mucProcessor.publishAvatar(image, forMuc: contact.contactJid) + showPromisingLoadingOverlay(overlay, headlineView:Text("Uploading avatar..."), descriptionView:Text("")) { + promisifyMucAction(account:account, mucJid:contact.contactJid) { + self.account.mucProcessor.publishAvatar(image, forMuc: contact.contactJid) + } }.catch { error in errorAlert(title: Text("Error changing avatar!"), message: Text("\(String(describing:error))")) hideLoadingOverlay(overlay) diff --git a/Monal/Classes/DebugView.swift b/Monal/Classes/DebugView.swift index 7fe70c6121..33f851f3b1 100644 --- a/Monal/Classes/DebugView.swift +++ b/Monal/Classes/DebugView.swift @@ -177,8 +177,8 @@ struct CrashTestingView: View { } struct DebugView: View { - @State private var isReconnecting: Bool = false @StateObject private var overlay = LoadingOverlayState() + var body: some View { TabView { LogFilesView() @@ -200,18 +200,10 @@ struct DebugView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .addLoadingOverlay(overlay) - .onChange(of: isReconnecting) { _ in - if isReconnecting{ - showLoadingOverlay(overlay, headline: "Reconnecting", description: "Will log out and reconnect all (connected) accounts.") - } else { - hideLoadingOverlay(overlay) - } - } .navigationBarItems(trailing:Button("Reconnect All") { - isReconnecting = true - MLXMPPManager.sharedInstance().reconnectAll() - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - isReconnecting = false + showLoadingOverlay(overlay, headline: "Reconnecting", description: "Will log out and reconnect all (connected) accounts.") { + MLXMPPManager.sharedInstance().reconnectAll() + return after(seconds:3.0) } }) } diff --git a/Monal/Classes/LoadingOverlay.swift b/Monal/Classes/LoadingOverlay.swift index 89fbbbe801..e40a5e0e6c 100644 --- a/Monal/Classes/LoadingOverlay.swift +++ b/Monal/Classes/LoadingOverlay.swift @@ -52,8 +52,8 @@ extension View { } } -func showLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2) { - DispatchQueue.main.async { +func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2) -> Guarantee { + return HelperTools.wait(atLeastSeconds:1.0, for:AnyPromise(DispatchQueue.main.async(.promise) { overlay.headline = AnyView(headline) overlay.description = AnyView(description) overlay.enabled = true @@ -63,23 +63,70 @@ func showLoadingOverlay(_ overlay: LoadingOverlayState, headli DispatchQueue.main.asyncAfter(deadline: .now() + 0.250) { overlay.objectWillChange.send() } + })).toGuarantee() +} + +func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "") -> Guarantee { + return showPromisingLoadingOverlay(overlay, headlineView:Text(headline), descriptionView:Text(description)) +} + +func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) -> Promise { + return Promise { seal in + showPromisingLoadingOverlay(overlay, headlineView: headline, descriptionView: description).done { + let _ = firstlyClosure().done { value in + hideLoadingOverlay(overlay) + seal.fulfill(value) + }.catch { error in + hideLoadingOverlay(overlay) + seal.reject(error) + } + } } } -func showLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "") { - DispatchQueue.main.async { - overlay.headline = AnyView(Text(headline)) - overlay.description = AnyView(Text(description)) - overlay.enabled = true - //only rerender ui once (not sure if this optimization is really needed, if this is missing, use @Published for member vars of state class) - overlay.objectWillChange.send() - //make sure to really draw the overlay on race conditions - DispatchQueue.main.asyncAfter(deadline: .now() + 0.250) { - overlay.objectWillChange.send() +func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) -> Guarantee { + return Guarantee { seal in + showPromisingLoadingOverlay(overlay, headlineView: headline, descriptionView: description).done { + let _ = firstlyClosure().finally { + hideLoadingOverlay(overlay) + seal(()) + } } } } +func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) -> Promise { + return showPromisingLoadingOverlay(overlay, headlineView:Text(headline), descriptionView:Text(description), firstlyClosure:firstlyClosure) +} + +func showPromisingLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) -> Guarantee { + return showPromisingLoadingOverlay(overlay, headlineView:Text(headline), descriptionView:Text(description), firstlyClosure:firstlyClosure) +} + +func showLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2) { + let _ = showPromisingLoadingOverlay(overlay, headlineView:headline, descriptionView:description) +} + +func showLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "") { + let _ = showPromisingLoadingOverlay(overlay, headline:headline, description:description) +} + +func showLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) { + let _ = showPromisingLoadingOverlay(overlay, headlineView:headline, descriptionView:description, firstlyClosure:firstlyClosure) +} + +func showLoadingOverlay(_ overlay: LoadingOverlayState, headlineView headline: T1, descriptionView description: T2, firstlyClosure: @escaping () -> U) { + let _ = showPromisingLoadingOverlay(overlay, headlineView:headline, descriptionView:description, firstlyClosure:firstlyClosure) +} + +func showLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) { + let _ = showPromisingLoadingOverlay(overlay, headline:headline, description:description, firstlyClosure:firstlyClosure) +} + +func showLoadingOverlay(_ overlay: LoadingOverlayState, headline: T, description: T = "", firstlyClosure: @escaping () -> U) { + let _ = showPromisingLoadingOverlay(overlay, headline:headline, description:description, firstlyClosure:firstlyClosure) +} + func hideLoadingOverlay(_ overlay: LoadingOverlayState) { DispatchQueue.main.async { overlay.headline = AnyView(Text("")) diff --git a/Monal/Classes/MemberList.swift b/Monal/Classes/MemberList.swift index 8d917f56c6..091e98e3df 100644 --- a/Monal/Classes/MemberList.swift +++ b/Monal/Classes/MemberList.swift @@ -83,8 +83,8 @@ struct MemberList: View { } } - func performAction(headlineView: some View, descriptionView: some View, action: @escaping ()->Void) -> Promise { - return performMucAction(account:self.account, mucJid:self.muc.contactJid, overlay:self.overlay, headlineView:headlineView, descriptionView:descriptionView, action:action) + func promisifyAction(action: @escaping ()->Void) -> Promise { + return promisifyMucAction(account:self.account, mucJid:self.muc.contactJid, action:action) } func showAlert(title: Text, description: Text) { @@ -172,46 +172,45 @@ struct MemberList: View { navigationActive = contact } else if newAffiliation == "reinvite" { //first remove potential ban, then reinvite - (affiliations[contact] == "outcast" ? - performAction(headlineView: Text("Unblocking user"), descriptionView: Text("Unblocking user for this group/channel: \(contact.contactJid as String)")) { - account.mucProcessor.setAffiliation(self.muc.mucType == "group" ? "member" : "none", ofUser:contact.contactJid, inMuc:self.muc.contactJid) - } : - Promise.value(nil) - ).then { _ in - return performAction(headlineView: Text("Inviting user"), descriptionView: Text("Inviting user to this group/channel: \(contact.contactJid as String)")) { - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 1.0) { - account.mucProcessor.inviteUser(contact.contactJid, inMuc: self.muc.contactJid) + var outcastResolution: Promise = Promise.value(nil) + if affiliations[contact] == "outcast" { + outcastResolution = showPromisingLoadingOverlay(self.overlay, headlineView: Text("Unblocking user"), descriptionView: Text("Unblocking user for this group/channel: \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.setAffiliation(self.muc.mucType == "group" ? "member" : "none", ofUser:contact.contactJid, inMuc:self.muc.contactJid) } } - .recover { error in + } + outcastResolution.then { _ in + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting user"), descriptionView: Text("Inviting user to this group/channel: \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.inviteUser(contact.contactJid, inMuc: self.muc.contactJid) + } + }.catch { error in showAlert(title:Text("Error inviting user!"), description:Text("\(String(describing:error))")) - return Guarantee.value(nil as monal_void_block_t?) } + return Guarantee.value(()) }.catch { error in showAlert(title:Text("Error unblocking user!"), description:Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } } else if newAffiliation == "outcast" { showActionSheet(title: Text("Block user?"), description: Text("Do you want to block this user from entering this group/channel?")) { DDLogVerbose("Changing affiliation of \(String(describing:contact)) to: \(String(describing:newAffiliation))...") - performAction(headlineView: Text("Blocking member"), descriptionView: Text("Blocking \(contact.contactJid as String)")) { - account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Blocking member"), descriptionView: Text("Blocking \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) + } }.catch { error in showAlert(title:Text("Error blocking user!"), description:Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } } } else { DDLogVerbose("Changing affiliation of \(String(describing:contact)) to: \(String(describing:newAffiliation))...") - performAction(headlineView: Text("Changing affiliation"), descriptionView: - Text("Changing affiliation to \(mucAffiliationToString(affiliations[contact])): \(contact.contactJid as String)")) { - account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Changing affiliation"), descriptionView: Text("Changing affiliation to \(mucAffiliationToString(affiliations[contact])): \(contact.contactJid as String)")) { + promisifyAction { + account.mucProcessor.setAffiliation(newAffiliation, ofUser:contact.contactJid, inMuc:self.muc.contactJid) + } }.catch { error in showAlert(title:Text("Error changing affiliation!"), description:Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } } } @@ -230,27 +229,28 @@ struct MemberList: View { for member in newMemberList { if !memberList.contains(member) { if self.muc.mucType == "group" { - performAction(headlineView: Text("Adding new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { - account.mucProcessor.setAffiliation("member", ofUser:member.contactJid, inMuc:self.muc.contactJid) - }.then { _ in - return performAction(headlineView: Text("Inviting new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { - account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) - }.recover { error in + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Adding new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.setAffiliation("member", ofUser:member.contactJid, inMuc:self.muc.contactJid) + } + }.done { _ in + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting new member"), descriptionView: Text("Adding \(member.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) + } + }.catch { error in showAlert(title:Text("Error inviting new member!"), description:Text("\(String(describing:error))")) - return Guarantee.value(nil as monal_void_block_t?) } }.catch { error in showAlert(title:Text("Error adding new member!"), description:Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } } else { - performAction(headlineView: Text("Inviting new participant"), descriptionView: Text("Adding \(member.contactJid as String)...")) { - account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Inviting new participant"), descriptionView: Text("Adding \(member.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.inviteUser(member.contactJid, inMuc: self.muc.contactJid) + } }.catch { error in showAlert(title:Text("Error inviting new participant!"), description:Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } } } @@ -301,12 +301,12 @@ struct MemberList: View { .onDelete(perform: { memberIdx in let member = memberList[memberIdx.first!] showActionSheet(title: Text("Remove \(mucAffiliationToString(affiliations[member]))?"), description: self.muc.mucType == "group" ? Text("Do you want to remove that user from this group? That user won't be able to enter it again until added back to the group.") : Text("Do you want to remove that user from this channel? That user will be able to enter it again if you don't block them.")) { - performAction(headlineView: Text("Removing \(mucAffiliationToString(affiliations[member]))"), descriptionView: Text("Removing \(member.contactJid as String)...")) { - account.mucProcessor.setAffiliation("none", ofUser: member.contactJid, inMuc: self.muc.contactJid) + showPromisingLoadingOverlay(self.overlay, headlineView: Text("Removing \(mucAffiliationToString(affiliations[member]))"), descriptionView: Text("Removing \(member.contactJid as String)...")) { + promisifyAction { + account.mucProcessor.setAffiliation("none", ofUser: member.contactJid, inMuc: self.muc.contactJid) + } }.catch { error in showAlert(title:Text("Error removing user!"), description:Text("\(String(describing:error))")) - }.finally { - hideLoadingOverlay(overlay) } } }) diff --git a/Monal/Classes/SwiftHelpers.swift b/Monal/Classes/SwiftHelpers.swift index 0756b7dc18..2be6fb5ddc 100644 --- a/Monal/Classes/SwiftHelpers.swift +++ b/Monal/Classes/SwiftHelpers.swift @@ -221,6 +221,42 @@ struct RuntimeError: LocalizedError { } } +extension AnyPromise { + public func toGuarantee() -> Guarantee { + return Guarantee { seal in + self.done { value in + if let value = value as? T { + seal(value) + } else { + HelperTools.throwException(withName:"AnyPromiseConversionError", reason:"Could not cast value to type \(String(describing: T.self))", userInfo:[ + "type": "\(String(describing: T.self))", + "promise": "\(String(describing: self))", + ]) + } + }.catch { error in + HelperTools.throwException(withName:"AnyPromiseConversionError", reason:"Uncatched promise error: \(error)", userInfo:[ + "error": "\(String(describing:error))", + "promise": "\(String(describing: self))", + ]) + } + } + } + + public func toPromise() -> Promise { + return Promise { seal in + self.done { value in + if let value = value as? T { + seal.fulfill(value) + } else { + seal.reject(PMKError.invalidCallingConvention) + } + }.catch { error in + seal.reject(error) + } + } + } +} + //see https://www.avanderlee.com/swift/property-wrappers/ //and https://fatbobman.com/en/posts/adding-published-ability-to-custom-property-wrapper-types/ @propertyWrapper diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 20b5c762e8..df3f4e82c4 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -88,10 +88,9 @@ func getContactList(viewContact: (ObservableKVOWrapper?)) -> OrderedS } } -func performMucAction(account: xmpp, mucJid: String, overlay: LoadingOverlayState, headlineView: Optional, descriptionView: Optional, action: @escaping ()->Void) -> Promise { - showLoadingOverlay(overlay, headlineView:headlineView, descriptionView:descriptionView) +func promisifyMucAction(account: xmpp, mucJid: String, action: @escaping () throws -> Void) -> Promise { return Promise { seal in - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 1.0) { + DispatchQueue.global(qos: .background).async { account.mucProcessor.addUIHandler({_data in let data = _data as! NSDictionary let success : Bool = data["success"] as! Bool; if !success { @@ -104,7 +103,12 @@ func performMucAction(account: xmpp, mucJid: String, overlay: LoadingOverlayStat } } }, forMuc:mucJid) - action() + do { + try action() + } catch { + seal.reject(error) + } + } } } diff --git a/Monal/Classes/WelcomeLogIn.swift b/Monal/Classes/WelcomeLogIn.swift index 0379ee5d73..4aaaed40db 100644 --- a/Monal/Classes/WelcomeLogIn.swift +++ b/Monal/Classes/WelcomeLogIn.swift @@ -264,13 +264,11 @@ struct WelcomeLogIn: View { if let notificationAccountNo = notification.userInfo?["accountNo"] as? NSNumber, let completed = notification.userInfo?["completed"] as? NSNumber, let all = notification.userInfo?["all"] as? NSNumber, let newAccountNo : NSNumber = self.newAccountNo { if(notificationAccountNo.intValue == newAccountNo.intValue) { isLoadingOmemoBundles = true - DispatchQueue.main.async { - showLoadingOverlay( - overlay, - headline:NSLocalizedString("Loading omemo bundles", comment: ""), - description:String(format: NSLocalizedString("Loading omemo bundles: %@ / %@", comment: ""), completed, all) - ) - } + showLoadingOverlay( + overlay, + headline:NSLocalizedString("Loading omemo bundles", comment: ""), + description:String(format: NSLocalizedString("Loading omemo bundles: %@ / %@", comment: ""), completed, all) + ) } } } diff --git a/Monal/Classes/xmpp.h b/Monal/Classes/xmpp.h index 5aa9e8bbdc..ff6ec897e3 100644 --- a/Monal/Classes/xmpp.h +++ b/Monal/Classes/xmpp.h @@ -46,6 +46,7 @@ FOUNDATION_EXPORT NSString* const kFileName; FOUNDATION_EXPORT NSString* const kContentType; FOUNDATION_EXPORT NSString* const kData; +@class AnyPromise; @class MLPubSub; @class MLXMLNode; @class XMPPDataForm; @@ -159,7 +160,7 @@ typedef void (^monal_iq_handler_t)(XMPPIQ* _Nullable); -(void) updateRosterItem:(MLContact*) contact withName:(NSString*) name; --(void) checkJidType:(NSString*) jid withCompletion:(void (^)(NSString* type, NSString* _Nullable errorMessage)) completion; +-(AnyPromise*) checkJidType:(NSString*) jid; /** join a room on the conference server diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m index 2072513e2e..bcbd5f24ff 100644 --- a/Monal/Classes/xmpp.m +++ b/Monal/Classes/xmpp.m @@ -4441,28 +4441,30 @@ -(void) leaveMuc:(NSString* _Nonnull) room [self.mucProcessor leave:room withBookmarksUpdate:YES keepBuddylistEntry:NO]; } --(void) checkJidType:(NSString*) jid withCompletion:(void (^)(NSString* type, NSString* _Nullable errorMessage)) completion -{ - XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType]; - [discoInfo setiqTo:jid]; - [discoInfo setDiscoInfoNode]; - [self sendIq:discoInfo withResponseHandler:^(XMPPIQ* response) { - NSSet* features = [NSSet setWithArray:[response find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; - //check if this is a muc or account - if([features containsObject:@"http://jabber.org/protocol/muc"]) - return completion(@"muc", nil); - else - return completion(@"account", nil); - } andErrorHandler:^(XMPPIQ* error) { - //this means the jid is an account which can not be queried if not subscribed - if([error check:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}service-unavailable"]) - return completion(@"account", nil); - else if([error check:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}subscription-required"]) - return completion(@"account", nil); - //any other error probably means the remote server is not reachable or (even more likely) the jid is incorrect - NSString* errorDescription = [HelperTools extractXMPPError:error withDescription:NSLocalizedString(@"Unexpected error while checking type of jid:", @"")]; - DDLogError(@"checkJidType got an error, informing user: %@", errorDescription); - return completion(@"error", error == nil ? NSLocalizedString(@"Unexpected error while checking type of jid, please try again", @"") : errorDescription); +-(AnyPromise*) checkJidType:(NSString*) jid +{ + return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) { + XMPPIQ* discoInfo = [[XMPPIQ alloc] initWithType:kiqGetType]; + [discoInfo setiqTo:jid]; + [discoInfo setDiscoInfoNode]; + [self sendIq:discoInfo withResponseHandler:^(XMPPIQ* response) { + NSSet* features = [NSSet setWithArray:[response find:@"{http://jabber.org/protocol/disco#info}query/feature@var"]]; + //check if this is a muc or account + if([features containsObject:@"http://jabber.org/protocol/muc"]) + return resolve(@"muc"); + else + return resolve(@"account"); + } andErrorHandler:^(XMPPIQ* error) { + //this means the jid is an account which can not be queried if not subscribed + if([error check:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}service-unavailable"]) + return resolve(@"account"); + else if([error check:@"//error/{urn:ietf:params:xml:ns:xmpp-stanzas}subscription-required"]) + return resolve(@"account"); + //any other error probably means the remote server is not reachable or (even more likely) the jid is incorrect + NSString* errorDescription = [HelperTools extractXMPPError:error withDescription:NSLocalizedString(@"Unexpected error while checking type of jid:", @"")]; + DDLogError(@"checkJidType got an error, informing user: %@", errorDescription); + resolve([NSError errorWithDomain:@"Monal" code:0 userInfo:@{NSLocalizedDescriptionKey: error == nil ? NSLocalizedString(@"Unexpected error while checking type of jid, please try again", @"") : errorDescription}]); + }]; }]; } From d179ea46b718eed90f9cdb3bd434252f8adc75a6 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Wed, 17 Jul 2024 22:25:15 +0200 Subject: [PATCH 080/131] Add new waitAtLeastSeconds:forPromise: helper tools method --- Monal/Classes/HelperTools.h | 3 +++ Monal/Classes/HelperTools.m | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/Monal/Classes/HelperTools.h b/Monal/Classes/HelperTools.h index 85e25e5b7d..cabe9eca55 100644 --- a/Monal/Classes/HelperTools.h +++ b/Monal/Classes/HelperTools.h @@ -28,6 +28,7 @@ NS_ASSUME_NONNULL_BEGIN +@class AnyPromise; @class MLXMLNode; @class xmpp; @class XMPPStanza; @@ -180,6 +181,8 @@ void swizzle(Class c, SEL orig, SEL new); +(UIView*) MLCustomViewHeaderWithTitle:(NSString*) title; +(CIImage*) createQRCodeFromString:(NSString*) input; ++(AnyPromise*) waitAtLeastSeconds:(NSTimeInterval) seconds forPromise:(AnyPromise*) promise; + //don't use these four directly, but via createTimer() makro +(MLDelayableTimer*) startDelayableQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue; +(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_block_t) handler andCancelHandler:(monal_void_block_t _Nullable) cancelHandler andFile:(char*) file andLine:(int) line andFunc:(char*) func onQueue:(dispatch_queue_t _Nullable) queue; diff --git a/Monal/Classes/HelperTools.m b/Monal/Classes/HelperTools.m index 1b56af12b2..9d690661fb 100644 --- a/Monal/Classes/HelperTools.m +++ b/Monal/Classes/HelperTools.m @@ -2309,6 +2309,13 @@ +(monal_void_block_t) startQueuedTimer:(double) timeout withHandler:(monal_void_ }; } ++(AnyPromise*) waitAtLeastSeconds:(NSTimeInterval) seconds forPromise:(AnyPromise*) promise +{ + return PMKWhen(@[promise, PMKAfter(seconds)]).then(^{ + return promise; + }); +} + +(NSString*) encodeRandomResource { u_int32_t i=arc4random(); From 3fb81845769c8880fa542d279c132fc5f8ef56c9 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Wed, 17 Jul 2024 22:23:55 +0200 Subject: [PATCH 081/131] Improve text on boarding cards and general settings --- Monal/Classes/BoardingCards.swift | 20 ++++++++++++++------ Monal/Classes/GeneralSettings.swift | 6 +++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index 4cdfe62003..20524d1a35 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -76,8 +76,12 @@ struct OnboardingView: View { .multilineTextAlignment(.leading) /// This ensures text doesn't get truncated which sometimes happens in ScrollView .fixedSize(horizontal: false, vertical: true) - + } + + if card.imageName != nil || card.description != nil || card.imageName != nil { + Spacer().frame(height: 1) Divider() + Spacer().frame(height: 1) } card.articleText? @@ -148,16 +152,20 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { ), OnboardingCard( title: Text("Features"), - description: Text("Here's a quick look at what you can expect:"), + description: nil, imageName: "sparkles", articleText: Text(""" - • 🔐 OMEMO Encryption : Secure multi-end messaging using the OMEMO protocol. + 🛜 Decentralized Network : + Leverages the decentralized nature of XMPP, avoiding central servers. - • 🛜 Decentralized Network : Leverages the decentralized nature of XMPP, avoiding central servers. + 🌐 Data privacy : + We do not sell or track information for external parties (nor for anyone else). - • 🌐 Data privacy : We do not sell or track information for external parties (nor for anyone else). + 🔐 End-to-end encryption : + Secure multi-end messaging using the OMEMO protocol. - • 👨‍💻 Open Source : The app's source code is publicly available for audit and contribution. + 👨‍💻 Open Source : + The app's source code is publicly available for audit and contribution. """), customView: nil ), diff --git a/Monal/Classes/GeneralSettings.swift b/Monal/Classes/GeneralSettings.swift index b853145224..20f06a990e 100644 --- a/Monal/Classes/GeneralSettings.swift +++ b/Monal/Classes/GeneralSettings.swift @@ -361,11 +361,11 @@ struct PrivacySettingsSubview: View { if onboardingPart == -1 || onboardingPart == 0 { Section(header: Text("Activity indications")) { SettingsToggle(isOn: $generalSettingsDefaultsDB.sendReceivedMarkers) { - Text("Send message received") + Text("Send message receipts") Text("Let your contacts know if you received a message.") } SettingsToggle(isOn: $generalSettingsDefaultsDB.sendDisplayedMarkers) { - Text("Send message displayed state") + Text("Send read receipts") Text("Let your contacts know if you read a message.") } SettingsToggle(isOn: $generalSettingsDefaultsDB.sendLastChatState) { @@ -515,6 +515,6 @@ struct AttachmentSettings: View { struct ContentView_Previews: PreviewProvider { static var previews: some View { - PrivacySettings() + GeneralSettings() } } From d2b1eb7f5dae77af49af0be56a21b2733afaf6b8 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 02:53:23 +0200 Subject: [PATCH 082/131] Improve onboarding flow further --- Monal/Classes/ActiveChatsViewController.m | 28 +++++++++++------------ Monal/Classes/BoardingCards.swift | 8 ++++--- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 3d9f8a201c..30f676cb0b 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -39,7 +39,7 @@ @interface ActiveChatsViewController() { int _startedOrientation; double _portraitTop; double _landscapeTop; - BOOL _loginAutodisplayedAlready; + BOOL _loginAlreadyAutodisplayed; } @property (atomic, strong) NSMutableArray* unpinnedContacts; @property (atomic, strong) NSMutableArray* pinnedContacts; @@ -59,18 +59,13 @@ @implementation ActiveChatsViewController +(void) initialize { + DDLogDebug(@"initializing active chats class"); _mamWarningDisplayed = [NSMutableSet new]; _smacksWarningDisplayed = [NSMutableSet new]; _pushWarningDisplayed = [NSMutableSet new]; } #pragma mark view lifecycle --(id) initWithNibName:(NSString*) nibNameOrNil bundle:(NSBundle*) nibBundleOrNil -{ - self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]; - _loginAutodisplayedAlready = NO; - return self; -} -(void) configureComposeButton { @@ -89,8 +84,10 @@ -(void) configureComposeButton -(void) viewDidLoad { + DDLogDebug(@"active chats view did load"); [super viewDidLoad]; + _loginAlreadyAutodisplayed = NO; _startedOrientation = 0; self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; @@ -136,6 +133,7 @@ -(void) viewDidLoad -(void) dealloc { + DDLogDebug(@"active chats dealloc"); [[NSNotificationCenter defaultCenter] removeObserver:self]; } @@ -402,16 +400,16 @@ -(void) viewDidAppear:(BOOL) animated -(void) sheetDismissed { - dispatch_async(dispatch_get_main_queue(), ^{ - [self refresh]; - }); + [self refresh]; } -(void) refresh { - if(self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) - [self refreshDisplay]; // load contacts - [self segueToIntroScreensIfNeeded]; + dispatch_async(dispatch_get_main_queue(), ^{ + if(self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) + [self refreshDisplay]; // load contacts + [self segueToIntroScreensIfNeeded]; + }); } -(void) didReceiveMemoryWarning @@ -464,11 +462,11 @@ -(void) segueToIntroScreensIfNeeded } // display quick start if the user never seen it or if there are 0 enabled accounts - if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0 && !_loginAutodisplayedAlready) + if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0 && !_loginAlreadyAutodisplayed) { UIViewController* loginViewController = [[SwiftuiInterface new] makeViewWithName:@"WelcomeLogIn"]; loginViewController.ml_disposeCallback = ^{ - self->_loginAutodisplayedAlready = YES; + self->_loginAlreadyAutodisplayed = YES; [self sheetDismissed]; }; [self presentViewController:loginViewController animated:YES completion:^{}]; diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index 20524d1a35..c2d9153b4d 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -131,9 +131,11 @@ struct OnboardingView: View { } } .onAppear { - //force portrait mode and lock ui there - UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") - (UIApplication.shared.delegate as! MonalAppDelegate).orientationLock = .portrait + if UIDevice.current.userInterfaceIdiom != .pad { + //force portrait mode and lock ui there + UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation") + (UIApplication.shared.delegate as! MonalAppDelegate).orientationLock = .portrait + } } } } From 2fd2543f71a7294e91dbd11d1769ec50305ea9c8 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Sat, 13 Jul 2024 00:06:43 +0100 Subject: [PATCH 083/131] Sort OmemoKeys known devices by device ID This is needed so that live updates when devices are added or removed happen with a predictable ordering. Without this, each time there is an update, the items in the list could shuffle around, making it difficult to see which is the new device that has been added. --- Monal/Classes/OmemoKeys.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/OmemoKeys.swift b/Monal/Classes/OmemoKeys.swift index 09eea4dd70..14877e315b 100644 --- a/Monal/Classes/OmemoKeys.swift +++ b/Monal/Classes/OmemoKeys.swift @@ -211,10 +211,14 @@ struct OmemoKeysForContact: View { self.contactJid = contact.obj.contactJid self.account = account self.deviceId = account.omemo.getDeviceId() - self.deviceIds = OrderedSet(self.account.omemo.knownDevices(forAddressName: self.contactJid)) + self.deviceIds = OmemoKeysForContact.knownDevices(account: self.account, jid: self.contactJid) self.selectedDeviceForDeletion = -1 } + private static func knownDevices(account: xmpp, jid: String) -> OrderedSet { + return OrderedSet(account.omemo.knownDevices(forAddressName: jid).sorted { return $0.intValue < $1.intValue }) + } + func deleteButton(deviceId: NSNumber) -> some View { Button(action: { selectedDeviceForDeletion = deviceId // SwiftUI does not like to have deviceID nested in multiple functions, so safe this in the struct... From b39f4af446744199a3ab12fca3bf7a551003d7ad Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Mon, 15 Jul 2024 23:27:56 +0100 Subject: [PATCH 084/131] Refresh known devices on updates from the server We add notifications to refresh the device list after any function call in MLOMEMO that could cause an update to contactDeviceId in MLSignalStore. We only pass the account in the notification, and rely on the view to retrieve the full list of devices for that account, so as to keep the view in sync with the database. This allows the view to self-correct if an update is missed; if we have missed a call to refresh and the view does not show a particular device, it will get picked up the next time that refresh is called with some other device update. --- Monal/Classes/MLConstants.h | 1 + Monal/Classes/MLOMEMO.m | 17 +++++++++++++++++ Monal/Classes/OmemoKeys.swift | 12 +++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/MLConstants.h b/Monal/Classes/MLConstants.h index 81148aed4f..f512d23fb7 100644 --- a/Monal/Classes/MLConstants.h +++ b/Monal/Classes/MLConstants.h @@ -164,6 +164,7 @@ static inline NSString* _Nonnull LocalizationNotNeeded(NSString* _Nonnull s) #define kMLResourceBoundNotice @"kMLResourceBoundNotice" #define kMonalFinishedCatchup @"kMonalFinishedCatchup" #define kMonalFinishedOmemoBundleFetch @"kMonalFinishedOmemoBundleFetch" +#define kMonalOmemoStateUpdated @"kMonalOmemoStateUpdated" #define kMonalUpdateBundleFetchStatus @"kMonalUpdateBundleFetchStatus" #define kMonalIdle @"kMonalIdle" #define kMonalFiletransfersIdle @"kMonalFiletransfersIdle" diff --git a/Monal/Classes/MLOMEMO.m b/Monal/Classes/MLOMEMO.m index 752696b827..8c60dc3a0d 100644 --- a/Monal/Classes/MLOMEMO.m +++ b/Monal/Classes/MLOMEMO.m @@ -101,6 +101,13 @@ -(OmemoState*) state return [NSSet setWithArray:[self.monalSignalStore knownDevicesForAddressName:addressName]]; } +-(void) notifyKnownDevicesUpdated:(NSString*) jid +{ + [[MLNotificationQueue currentQueue] postNotificationName:kMonalOmemoStateUpdated object:self.account userInfo:@{ + @"jid": jid + }]; +} + -(BOOL) createLocalIdentiyKeyPairIfNeeded { if(self.monalSignalStore.deviceid == 0) @@ -120,6 +127,7 @@ -(BOOL) createLocalIdentiyKeyPairIfNeeded //do everything done in MLSignalStore init not already mimicked above [self.monalSignalStore cleanupKeys]; [self.monalSignalStore reloadCachedPrekeys]; + [self notifyKnownDevicesUpdated:address.name]; //we generated a new identity DDLogWarn(@"Created new omemo identity with deviceid: %@", @(self.monalSignalStore.deviceid)); //don't alert on new deviceids we could never see before because this is our first connection (otherwise, we'd already have our own deviceid) @@ -470,6 +478,8 @@ -(void) processOMEMODevices:(NSSet*) receivedDevices from:(NSString*) //handle our own devicelist if([self.account.connectionProperties.identity.jid isEqualToString:source]) [self handleOwnDevicelistUpdate:receivedDevices]; + else + [self notifyKnownDevicesUpdated:source]; } -(void) handleOwnDevicelistUpdate:(NSSet*) receivedDevices @@ -519,6 +529,8 @@ -(void) handleOwnDevicelistUpdate:(NSSet*) receivedDevices //publish own devicelist directly after publishing our bundle [self publishOwnDeviceList]; } + + [self notifyKnownDevicesUpdated:self.account.connectionProperties.identity.jid]; } -(void) publishOwnDeviceList @@ -759,6 +771,8 @@ -(void) processOMEMOKeys:(MLXMLNode*) item forJid:(NSString*) jid andRid:(NSNumb //found and imported a working key --> try to (re)build a new session proactively (or repair a broken one) [self sendKeyTransportElement:jid forRids:[NSSet setWithArray:@[rid]]]; //this will remove the queuedSessionRepairs entry, if any + [self notifyKnownDevicesUpdated:jid]; + return; } while(++processedKeys < preKeyIds.count); DDLogError(@"Could not import a single prekey from bundle for rid %@ (tried %lu keys)", rid, processedKeys); @@ -1307,6 +1321,7 @@ -(void) checkIfSessionIsStillNeeded:(NSString*) buddyJid isMuc:(BOOL) isMuc else if([self.monalSignalStore checkIfSessionIsStillNeeded:buddyJid] == NO) [danglingJids addObject:buddyJid]; + [self notifyKnownDevicesUpdated:buddyJid]; DDLogVerbose(@"Unsubscribing from dangling jids: %@", danglingJids); for(NSString* jid in danglingJids) [self.account.pubsub unsubscribeFromNode:@"eu.siacs.conversations.axolotl.devicelist" forJid:jid withHandler:$newHandler(self, handleDevicelistUnsubscribe)]; @@ -1328,6 +1343,7 @@ -(NSNumber*) getTrustLevel:(SignalAddress*) address identityKey:(NSData*) identi -(void) addIdentityManually:(SignalAddress*) address identityKey:(NSData* _Nonnull) identityKey { [self.monalSignalStore saveIdentity:address identityKey:identityKey]; + [self notifyKnownDevicesUpdated:address.name]; } -(void) updateTrust:(BOOL) trust forAddress:(SignalAddress*)address @@ -1370,6 +1386,7 @@ -(void) deleteDeviceForSource:(NSString*) source andRid:(NSNumber*) rid SignalAddress* address = [[SignalAddress alloc] initWithName:source deviceId:rid.unsignedIntValue]; [self.monalSignalStore deleteDeviceforAddress:address]; [self.monalSignalStore deleteSessionRecordForAddress:address]; + [self notifyKnownDevicesUpdated:address.name]; } //debug button in contactdetails ui diff --git a/Monal/Classes/OmemoKeys.swift b/Monal/Classes/OmemoKeys.swift index 14877e315b..61aabde4c0 100644 --- a/Monal/Classes/OmemoKeys.swift +++ b/Monal/Classes/OmemoKeys.swift @@ -219,6 +219,10 @@ struct OmemoKeysForContact: View { return OrderedSet(account.omemo.knownDevices(forAddressName: jid).sorted { return $0.intValue < $1.intValue }) } + private func refreshKnownDevices() -> Void { + self.deviceIds = OmemoKeysForContact.knownDevices(account: self.account, jid: self.contactJid) + } + func deleteButton(deviceId: NSNumber) -> some View { Button(action: { selectedDeviceForDeletion = deviceId // SwiftUI does not like to have deviceID nested in multiple functions, so safe this in the struct... @@ -237,7 +241,6 @@ struct OmemoKeysForContact: View { return // should be unreachable } account.omemo.deleteDevice(forSource: self.contactJid, andRid: self.selectedDeviceForDeletion) - self.deviceIds.remove(self.selectedDeviceForDeletion) }, secondaryButton: .cancel(Text("Abort")) ) @@ -257,6 +260,13 @@ struct OmemoKeysForContact: View { } } } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalOmemoStateUpdated")).receive(on: RunLoop.main)) { notification in + if notification.userInfo?["jid"] as? String == self.contactJid { + withAnimation() { + refreshKnownDevices() + } + } + } } } From 599c666dc779bfc90202b147c1257a77669253f6 Mon Sep 17 00:00:00 2001 From: Matthew Fennell Date: Mon, 15 Jul 2024 23:28:47 +0100 Subject: [PATCH 085/131] Use existing method for OmemoKeysEntry trust level --- Monal/Classes/OmemoKeys.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Monal/Classes/OmemoKeys.swift b/Monal/Classes/OmemoKeys.swift index 61aabde4c0..aadcefbaf6 100644 --- a/Monal/Classes/OmemoKeys.swift +++ b/Monal/Classes/OmemoKeys.swift @@ -134,8 +134,7 @@ struct OmemoKeysEntry: View { let trustLevelBinding = Binding.init(get: { return (self.trustLevel.int32Value != MLOmemoNotTrusted) }, set: { keyEnabled in - self.account.omemo.updateTrust(keyEnabled, for: self.address) - self.trustLevel = self.account.omemo.getTrustLevel(self.address, identityKey: self.fingerprint) + setTrustLevel(keyEnabled) }) let fingerprintString = HelperTools.signalHexKeyWithSpaces(with: fingerprint) From 8f83a1ead1147c1c5e8f76c457690ec70309db7a Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 03:44:17 +0200 Subject: [PATCH 086/131] Update develop-push workflow to post alpha releases to dedicated channel --- .github/workflows/develop-push.yml | 32 ++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/develop-push.yml b/.github/workflows/develop-push.yml index aff248d8f8..fa9220bf1b 100644 --- a/.github/workflows/develop-push.yml +++ b/.github/workflows/develop-push.yml @@ -15,6 +15,10 @@ jobs: buildAndPublishAlpha: # The type of runner that the job will run on runs-on: ['ARM64', 'self-hosted'] + outputs: + id: ${{ steps.changelog.outputs.id }} + timestamp: ${{ steps.changelog.outputs.timestamp }} + message: ${{ steps.changelog.outputs.message }} env: APP_NAME: "Monal.alpha" APP_DIR: "Monal.alpha.app" @@ -52,6 +56,7 @@ jobs: tar -cf "../$APP_NAME.tar" "$APP_DIR" cd ../../../.. - name: save changelog + id: changelog env: ID: ${{github.event.head_commit.id}} TIMESTAMP: ${{github.event.head_commit.timestamp}} @@ -60,6 +65,12 @@ jobs: echo "ID: $ID" > changes.txt echo "Timestamp: $TIMESTAMP" >> changes.txt echo "$MESSAGE" >> changes.txt + + echo "id=$ID" >> "$GITHUB_OUTPUT" + echo "timestamp=$TIMESTAMP" >> "$GITHUB_OUTPUT" + echo "message<<__EOF__" >> "$GITHUB_OUTPUT" + echo "$MESSAGE" >> "$GITHUB_OUTPUT" + echo "__EOF__" >> "$GITHUB_OUTPUT" - name: Uploading to alpha site run: ./scripts/uploadAlpha.sh - name: Notarize catalyst @@ -91,3 +102,24 @@ jobs: # chmod +x ./scripts/updateLocalization.sh # chmod +x ./scripts/xliff_extractor.py # ./scripts/updateLocalization.sh NOCOMMIT + notifyMuc: + name: Notify support MUC about new Alpharelease + runs-on: ubuntu-latest + needs: [buildAndPublishAlpha] + steps: + - name: Notify + uses: monal-im/xmpp-notifier@master + with: # Set the secrets as inputs + jid: ${{ secrets.BOT_JID }} + password: ${{ secrets.BOT_PASSWORD }} + server_host: ${{ secrets.BOT_SERVER }} + recipient: monal-alpha@chat.yax.im + recipient_is_room: true + bot_alias: "Monal Release Bot" + message: | + New alpha build based on the following commit: + ${{ needs.buildAndPublishAlpha.outputs.id }} + ${{ needs.buildAndPublishAlpha.outputs.timestamp }} + ${{ needs.buildAndPublishAlpha.outputs.message }} + + Download page: https://downloads.monal-im.org/monal-im/alpha/ From 1583bb1d78640aca53ca68a0f434e40cafed221b Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 06:44:05 +0200 Subject: [PATCH 087/131] Allow optionals in defaultsDB (read AND write) --- Monal/Classes/SwiftHelpers.swift | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/SwiftHelpers.swift b/Monal/Classes/SwiftHelpers.swift index 2be6fb5ddc..bd1e974f73 100644 --- a/Monal/Classes/SwiftHelpers.swift +++ b/Monal/Classes/SwiftHelpers.swift @@ -280,7 +280,15 @@ public struct defaultsDB { } } set { - container.set(newValue, forKey: key) + if let optional = newValue as? OptionalProtocol { + if optional.isSome() { + container.set(newValue, forKey: key) + } else { + container.removeObject(forKey:key) + } + } else { + container.set(newValue, forKey: key) + } container.synchronize() } } @@ -302,6 +310,27 @@ public struct defaultsDB { } } +//see https://stackoverflow.com/a/32780793 +protocol OptionalProtocol { + func isSome() -> Bool + func unwrap() -> Any +} +extension Optional : OptionalProtocol { + func isSome() -> Bool { + switch self { + case .none: return false + case .some: return true + } + } + + func unwrap() -> Any { + switch self { + // If a nil is unwrapped it will crash! + case .none: preconditionFailure("nil unwrap!") + case .some(let unwrapped): return unwrapped + } + } +} @objcMembers public class SwiftHelpers: NSObject { From f7f1324c19d625178977927bd7c9428319ca95ce Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 06:43:28 +0200 Subject: [PATCH 088/131] Allow customized next button text for BoardingCards --- Monal/Classes/BoardingCards.swift | 22 ++++++++++++++-------- Monal/Classes/MLXMPPManager.m | 8 ++++---- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index c2d9153b4d..d48489fa34 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -20,6 +20,7 @@ struct OnboardingCard: Identifiable { let imageName: String? let articleText: Text? let customView: AnyView? + let nextText: String? } struct OnboardingView: View { @@ -99,7 +100,7 @@ struct OnboardingView: View { currentIndex += 1 } label: { HStack { - Text("Next") + Text(card.nextText ?? NSLocalizedString("Next", comment:"onboarding")) .fontWeight(.bold) Image(systemName: "chevron.right") } @@ -107,9 +108,9 @@ struct OnboardingView: View { } else { Button { onboardingState.hasCompletedOnboarding = true - delegate.dismiss() + delegate.dismissWithoutAnimation() } label: { - Text("Close") + Text(card.nextText ?? NSLocalizedString("Close", comment:"onboarding")) .fontWeight(.bold) .padding() .background(Color.blue) @@ -150,7 +151,8 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { articleText: Text(""" Modern iOS and macOS XMPP chat client.\n\nXMPP is a federated network: Just like email, you can register your account on many servers and still talk to anyone, even if they signed up on a different server. """), - customView: nil + customView: nil, + nextText: nil ), OnboardingCard( title: Text("Features"), @@ -169,28 +171,32 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { 👨‍💻 Open Source : The app's source code is publicly available for audit and contribution. """), - customView: nil + customView: nil, + nextText: nil ), OnboardingCard( title: Text("Settings"), description: Text("These are important privacy settings you may want to review!"), imageName: nil, articleText: nil, - customView: AnyView(PrivacySettingsSubview(onboardingPart:0)) + customView: AnyView(PrivacySettingsSubview(onboardingPart:0)), + nextText: nil ), OnboardingCard( title: Text("Settings"), description: Text("These are important privacy settings you may want to review!"), imageName: nil, articleText: nil, - customView: AnyView(PrivacySettingsSubview(onboardingPart:1)) + customView: AnyView(PrivacySettingsSubview(onboardingPart:1)), + nextText: nil ), OnboardingCard( title: Text("Even more to customize!"), description: Text("You can customize even more, just use the button below to open the settings."), imageName: "hand.wave", articleText: nil, - customView: AnyView(TakeMeToSettingsView(delegate:delegate)) + customView: AnyView(TakeMeToSettingsView(delegate:delegate)), + nextText: nil ), ] OnboardingView(delegate: delegate, cards: cards) diff --git a/Monal/Classes/MLXMPPManager.m b/Monal/Classes/MLXMPPManager.m index b2d2ad1297..1fb8697992 100644 --- a/Monal/Classes/MLXMPPManager.m +++ b/Monal/Classes/MLXMPPManager.m @@ -147,10 +147,10 @@ -(void) defaultSettings [self upgradeBoolUserSettingsIfUnset:@"hasCompletedOnboarding" toDefault:NO]; -//always show onboarding on simulator for now -#if TARGET_OS_SIMULATOR - [[HelperTools defaultsDB] setBool:NO forKey:@"hasCompletedOnboarding"]; -#endif +// //always show onboarding on simulator for now +// #if TARGET_OS_SIMULATOR +// [[HelperTools defaultsDB] setBool:NO forKey:@"hasCompletedOnboarding"]; +// #endif } -(void) upgradeFloatUserSettingsToInteger:(NSString*) settingsName From efa3fd0fbd0eb70f5eeed1faff7fe155450443b0 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 10:00:30 +0200 Subject: [PATCH 089/131] Fix portrait mode on iPad When starting the app while in portrait mode, the screen would go grey. After rotating the device the UI would appear again (even if rotated back into portrait mode). This apparently was related to the old MLPlaceholderViewController setting self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; in its viewWillAppear: handler. I could not yet implement that using our swiftui chat placeholder view. But: the swiftui placeholder view will still be used, the MLPlaceholderViewController is only used to set the correct preferredDisplayMode. --- Monal/Classes/MLPlaceholderViewController.m | 22 +++++++++++++++++++ Monal/Monal.xcodeproj/project.pbxproj | 4 ++++ Monal/localization/Base.lproj/Main.storyboard | 13 +++++++++++ 3 files changed, 39 insertions(+) create mode 100644 Monal/Classes/MLPlaceholderViewController.m diff --git a/Monal/Classes/MLPlaceholderViewController.m b/Monal/Classes/MLPlaceholderViewController.m new file mode 100644 index 0000000000..450d5f133c --- /dev/null +++ b/Monal/Classes/MLPlaceholderViewController.m @@ -0,0 +1,22 @@ +// +// MLPlaceholderViewController.m +// Monal +// +// Created by Anurodh Pokharel on 1/5/20. +// Copyright © 2020 Monal.im. All rights reserved. +// + +#import + +@interface MLPlaceholderViewController : UIViewController +@end + +@implementation MLPlaceholderViewController + +-(void) viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + self.splitViewController.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; +} + +@end diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index 29424f0190..dff94f2b8b 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -148,6 +148,7 @@ 845EFFBD2918721800C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; 845EFFBE2918723D00C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; 846DF27C2937FAA600AAB9C0 /* ChatPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */; }; + 848227912C4A6194003CCA33 /* MLPlaceholderViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */; }; 848717F3295ED64600B8D288 /* MLCall.m in Sources */ = {isa = PBXBuildFile; fileRef = 848717F1295ED64500B8D288 /* MLCall.m */; }; 848904A9289C82C30097E19C /* SCRAM.m in Sources */ = {isa = PBXBuildFile; fileRef = 848904A8289C82C30097E19C /* SCRAM.m */; }; 848C73E02BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 848C73DF2BDF2014007035C9 /* PrivacyInfo.xcprivacy */; }; @@ -581,6 +582,7 @@ 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLDelayableTimer.h; sourceTree = ""; }; 845D636A2AD4AEDA0066EFFB /* ImageViewer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = ""; }; 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPlaceholder.swift; sourceTree = ""; }; + 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLPlaceholderViewController.m; sourceTree = ""; }; 848717F1295ED64500B8D288 /* MLCall.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MLCall.m; path = Classes/MLCall.m; sourceTree = SOURCE_ROOT; }; 848717F2295ED64500B8D288 /* MLCall.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MLCall.h; path = Classes/MLCall.h; sourceTree = SOURCE_ROOT; }; 848904A8289C82C30097E19C /* SCRAM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCRAM.m; sourceTree = ""; }; @@ -1051,6 +1053,7 @@ 26715E5E17650AF900684F3D /* View Controllers */ = { isa = PBXGroup; children = ( + 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */, 849248482AD4CEC400986C1A /* ZoomableContainer.swift */, 845D636A2AD4AEDA0066EFFB /* ImageViewer.swift */, 841B6F16297AFB340074F9B7 /* Calls */, @@ -2104,6 +2107,7 @@ 8441EFF92921B53500E851E9 /* BackgroundSettings.swift in Sources */, 841898AC2957DBAD00FEC77D /* RichAlert.swift in Sources */, 840E23CA28ADA56900A7FAC9 /* MLUploadQueueCell.m in Sources */, + 848227912C4A6194003CCA33 /* MLPlaceholderViewController.m in Sources */, 262E51921AD8CAC600788351 /* MLButtonCell.m in Sources */, 841EE4302A426F2300D3AF14 /* MLCrashReporter.m in Sources */, E8DED06225388BE8003167FF /* MLSearchViewController.m in Sources */, diff --git a/Monal/localization/Base.lproj/Main.storyboard b/Monal/localization/Base.lproj/Main.storyboard index e0b4db6b7a..c12ba625db 100644 --- a/Monal/localization/Base.lproj/Main.storyboard +++ b/Monal/localization/Base.lproj/Main.storyboard @@ -76,6 +76,14 @@ + + + + + + + + @@ -139,6 +147,7 @@ + @@ -169,6 +178,7 @@ + @@ -2497,6 +2507,9 @@ + + + From 9b6226dfd9da6b4f9c5cef7985433eed54f560f1 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 11:15:11 +0200 Subject: [PATCH 090/131] --- 918 --- 6.4.1rc1 From a8476bffdfbeb7404c2f0fc15d769484748c0461 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 11:58:43 +0200 Subject: [PATCH 091/131] Fix sed expressions when extracting changelog from merge commit --- .github/workflows/beta.build-push.yml | 6 +++--- .github/workflows/stable.build-push.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index da2feffead..cfe75aba88 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -77,15 +77,15 @@ jobs: echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index c28ba1143c..e372161755 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -74,15 +74,15 @@ jobs: echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" From 4661f70147c353fa604a407045ff8e97a6dad2ce Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 14:13:05 +0200 Subject: [PATCH 092/131] Rework add contact and contact requests menu --- Monal/Classes/ActiveChatsViewController.m | 19 ++++--- Monal/Classes/AddContactMenu.swift | 67 +++++++++++++++-------- Monal/Classes/ContactRequestsMenu.swift | 30 +++++----- Monal/Classes/ContactsViewController.m | 41 ++++++-------- Monal/Classes/SwiftuiHelpers.swift | 2 - 5 files changed, 82 insertions(+), 77 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 30f676cb0b..839a4af5b1 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -69,17 +69,18 @@ +(void) initialize -(void) configureComposeButton { - UIImage* composeImage = [[UIImage systemImageNamed:@"person.2.fill"] imageWithTintColor:UIColor.monalGreen]; + UIImage* image = [[UIImage systemImageNamed:@"person.2.fill"] imageWithTintColor:UIColor.monalGreen]; + UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(showContacts:)]; + self.composeButton.customView = [HelperTools + buttonWithNotificationBadgeForImage:image + hasNotification:[[DataLayer sharedInstance] allContactRequests].count > 0 + withTapHandler:tapRecognizer]; + [self.composeButton setIsAccessibilityElement:YES]; if([[DataLayer sharedInstance] allContactRequests].count > 0) - { - self.composeButton.image = [HelperTools imageWithNotificationBadgeForImage:composeImage]; - } + [self.composeButton setAccessibilityLabel:NSLocalizedString(@"Open contact list (contact requests pending)", @"")]; else - { - self.composeButton.image = composeImage; - } - [self.composeButton setAccessibilityLabel:@"Open contacts list"]; - [self.composeButton setAccessibilityHint:NSLocalizedString(@"Open contact list", @"")]; + [self.composeButton setAccessibilityLabel:NSLocalizedString(@"Open contact list", @"")]; + [self.composeButton setAccessibilityTraits:UIAccessibilityTraitButton]; } -(void) viewDidLoad diff --git a/Monal/Classes/AddContactMenu.swift b/Monal/Classes/AddContactMenu.swift index dbe2d5d898..2c7960cc11 100644 --- a/Monal/Classes/AddContactMenu.swift +++ b/Monal/Classes/AddContactMenu.swift @@ -172,6 +172,10 @@ struct AddContactMenu: View { } else { + if DataLayer.sharedInstance().allContactRequests().count > 0 { + ContactRequestsMenu() + } + Section(header:Text("Contact and Group/Channel Jids are usually in the format: name@domain.tld")) { if connectedAccounts.count > 1 { Picker("Use account", selection: $selectedAccount) { @@ -190,18 +194,16 @@ struct AddContactMenu: View { .addClearButton(isEditing: isEditingJid, text:$toAdd) .disabled(scannedFingerprints != nil) .foregroundColor(scannedFingerprints != nil ? .secondary : .primary) - .onChange(of: toAdd) { _ in - toAdd = toAdd.replacingOccurrences(of: " ", with: "") - } - } - if scannedFingerprints != nil && scannedFingerprints!.count > 0 { - Section(header: Text("A contact was scanned through the QR code scanner")) { - Toggle(isOn: $importScannedFingerprints) { - Text("Import and trust OMEMO fingerprints from QR code") + .onChange(of: toAdd) { _ in toAdd = toAdd.replacingOccurrences(of: " ", with: "") } + + if scannedFingerprints != nil && scannedFingerprints!.count > 0 { + Section(header: Text("A contact was scanned through the QR code scanner")) { + Toggle(isOn: $importScannedFingerprints) { + Text("Import and trust OMEMO fingerprints from QR code") + } } } - } - Section { + if scannedFingerprints != nil { Button(action: { toAdd = "" @@ -212,26 +214,43 @@ struct AddContactMenu: View { .foregroundColor(.red) }) } - Button(action: { - showAlert = toAddEmptyAlert || toAddInvalidAlert + + HStack { + Spacer() + + Button(action: { + showAlert = toAddEmptyAlert || toAddInvalidAlert - if !showAlert { - let jidComponents = HelperTools.splitJid(toAdd) - if jidComponents["host"] == nil || jidComponents["host"]!.isEmpty { - errorAlert(title: Text("Error"), message: Text("Something went wrong while parsing your input...")) - showAlert = true - return + if !showAlert { + let jidComponents = HelperTools.splitJid(toAdd) + if jidComponents["host"] == nil || jidComponents["host"]!.isEmpty { + errorAlert(title: Text("Error"), message: Text("Something went wrong while parsing your input...")) + showAlert = true + return + } + // use the canonized jid from now on (lowercased, resource removed etc.) + addJid(jid: jidComponents["user"]!) } - // use the canonized jid from now on (lowercased, resource removed etc.) - addJid(jid: jidComponents["user"]!) + }) { + scannedFingerprints == nil ? Text("Add") : Text("Add scanned contact") } - }, label: { - scannedFingerprints == nil ? Text("Add Group/Channel or Contact") : Text("Add scanned Group/Channel or Contact") - }) - .disabled(toAddEmpty || toAddInvalid) + //.fontWeight(.bold) + .padding() + .background(toAddEmpty || toAddInvalid ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + .disabled(toAddEmpty || toAddInvalid) + } + } + + if DataLayer.sharedInstance().allContactRequests().count == 0 { + Section { + ContactRequestsMenu() + } } } } + .padding() .alert(isPresented: $showAlert) { Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton:.default(Text("Close"), action: { showAlert = false diff --git a/Monal/Classes/ContactRequestsMenu.swift b/Monal/Classes/ContactRequestsMenu.swift index 023caeda9f..bbb8caf0df 100644 --- a/Monal/Classes/ContactRequestsMenu.swift +++ b/Monal/Classes/ContactRequestsMenu.swift @@ -66,26 +66,22 @@ struct ContactRequestsMenu: View { @State private var pendingRequests: [MLContact] var body: some View { - Form { - List { - Section(header: Text("Allowing someone to add you as a contact lets them see your profile picture and when you are online.")) { - if(pendingRequests.isEmpty) { - Text("No pending requests") - .foregroundColor(.secondary) - } - ForEach(pendingRequests.indices, id: \.self) { idx in - ContactRequestsMenuEntry( - contact: pendingRequests[idx], - doDelete: { - self.pendingRequests.remove(at: idx) - } - ) - } + List { + Section(header: Text("Allowing someone to add you as a contact lets them see your profile picture and when you are online.")) { + if(pendingRequests.isEmpty) { + Text("No pending constact requests") + .foregroundColor(.secondary) + } + ForEach(pendingRequests.indices, id: \.self) { idx in + ContactRequestsMenuEntry( + contact: pendingRequests[idx], + doDelete: { + self.pendingRequests.remove(at: idx) + } + ) } } } - .navigationBarTitle(Text("Contact Requests"), displayMode: .inline) - .navigationViewStyle(.stack) .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRefresh")).receive(on: RunLoop.main)) { notification in self.pendingRequests = DataLayer.sharedInstance().allContactRequests() as! [MLContact] } diff --git a/Monal/Classes/ContactsViewController.m b/Monal/Classes/ContactsViewController.m index 3adace979f..ed27f5d6a0 100644 --- a/Monal/Classes/ContactsViewController.m +++ b/Monal/Classes/ContactsViewController.m @@ -53,24 +53,20 @@ -(void) openCreateGroup:(id) sender [self presentViewController:createGroupView animated:YES completion:^{}]; } --(void) openContactRequests:(id) sender +-(void) configureAddContactImage { - UIViewController* contactRequestsView = [[SwiftuiInterface new] makeViewWithName:@"ContactRequests"]; - [self presentViewController:contactRequestsView animated:YES completion:^{}]; -} - --(void) configureContactRequestsImage -{ - UIImage* requestsImage = [[UIImage systemImageNamed:@"person.crop.circle.fill.badge.questionmark"] imageWithTintColor:UIColor.monalGreen]; - UITapGestureRecognizer* requestsTapRecoginzer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(openContactRequests:)]; - self.navigationItem.rightBarButtonItems[1].customView = [HelperTools - buttonWithNotificationBadgeForImage:requestsImage + UIImage* image = [[UIImage systemImageNamed:@"person.fill.badge.plus"] imageWithTintColor:UIColor.monalGreen]; + UITapGestureRecognizer* tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(openAddContacts:)]; + self.navigationItem.rightBarButtonItems[0].customView = [HelperTools + buttonWithNotificationBadgeForImage:image hasNotification:[[DataLayer sharedInstance] allContactRequests].count > 0 - withTapHandler:requestsTapRecoginzer]; - [self.navigationItem.rightBarButtonItems[1] setIsAccessibilityElement:YES]; - [self.navigationItem.rightBarButtonItems[1] setAccessibilityLabel:NSLocalizedString(@"Pending contact requests", @"")]; - [self.navigationItem.rightBarButtonItems[1] setAccessibilityTraits:UIAccessibilityTraitButton]; - + withTapHandler:tapRecognizer]; + [self.navigationItem.rightBarButtonItems[0] setIsAccessibilityElement:YES]; + if([[DataLayer sharedInstance] allContactRequests].count > 0) + [self.navigationItem.rightBarButtonItems[0] setAccessibilityLabel:NSLocalizedString(@"Add contact (contact requests pending)", @"")]; + else + [self.navigationItem.rightBarButtonItems[0] setAccessibilityLabel:NSLocalizedString(@"Add contact", @"")]; + [self.navigationItem.rightBarButtonItems[0] setAccessibilityTraits:UIAccessibilityTraitButton]; } #pragma mark view life cycle @@ -105,20 +101,15 @@ -(void) viewDidLoad self.tableView.emptyDataSetSource = self; self.tableView.emptyDataSetDelegate = self; - UIBarButtonItem* addContact = [UIBarButtonItem new]; - addContact.image = [UIImage systemImageNamed:@"person.fill.badge.plus"]; - addContact.accessibilityLabel = @"Add contact"; - [addContact setAction:@selector(openAddContacts:)]; - [addContact setTarget:self]; - UIBarButtonItem* createGroup = [[UIBarButtonItem alloc] init]; createGroup.image = [UIImage systemImageNamed:@"person.3.fill"]; createGroup.accessibilityLabel = @"Create contact group"; [createGroup setAction:@selector(openCreateGroup:)]; [createGroup setTarget:self]; - self.navigationItem.rightBarButtonItems = @[addContact, [[UIBarButtonItem alloc] init], createGroup]; + + self.navigationItem.rightBarButtonItems = @[[[UIBarButtonItem alloc] init], createGroup]; - [self configureContactRequestsImage]; + [self configureAddContactImage]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleContactUpdate) name:kMonalContactRemoved object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleContactUpdate) name:kMonalContactRefresh object:nil]; @@ -194,7 +185,7 @@ -(BOOL) canBecomeFirstResponder -(void) reloadTable { - [self configureContactRequestsImage]; + [self configureAddContactImage]; if(self.contactsTable.hasUncommittedUpdates) return; [self.contactsTable reloadData]; diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index df3f4e82c4..8827721083 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -711,8 +711,6 @@ class SwiftuiInterface : NSObject { host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate:delegate, to:WelcomeLogIn(delegate:delegate)))) case "LogIn": host = UIHostingController(rootView:AnyView(UIKitWorkaround(WelcomeLogIn(delegate:delegate)))) - case "ContactRequests": - host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: ContactRequestsMenu()))) case "CreateGroup": host = UIHostingController(rootView:AnyView(AddTopLevelNavigation(withDelegate: delegate, to: CreateGroupMenu(delegate: delegate)))) case "ChatPlaceholder": From 918b22583c8fc8d470242eedd1cb7d287813bb1b Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 16:29:48 +0200 Subject: [PATCH 093/131] don't show account picker with only one entry when creating group --- Monal/Classes/CreateGroupMenu.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Monal/Classes/CreateGroupMenu.swift b/Monal/Classes/CreateGroupMenu.swift index 8cb54c522f..f3692a7740 100644 --- a/Monal/Classes/CreateGroupMenu.swift +++ b/Monal/Classes/CreateGroupMenu.swift @@ -45,12 +45,14 @@ struct CreateGroupMenu: View { else { Section() { - Picker(selection: $selectedAccount, label: Text("Use account")) { - ForEach(Array(self.connectedAccounts.enumerated()), id: \.element) { idx, account in - Text(account.connectionProperties.identity.jid).tag(account as xmpp?) + if connectedAccounts.count > 1 { + Picker(selection: $selectedAccount, label: Text("Use account")) { + ForEach(Array(self.connectedAccounts.enumerated()), id: \.element) { idx, account in + Text(account.connectionProperties.identity.jid).tag(account as xmpp?) + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) TextField(NSLocalizedString("Group Name (optional)", comment: "placeholder when creating new group"), text: $groupName, onEditingChanged: { isEditingGroupName = $0 }) .autocorrectionDisabled() From df276c7f4439bc8d67d13b736286658070c9b384 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 17:04:52 +0200 Subject: [PATCH 094/131] Show intro screen after deleting all accounts --- Monal/Classes/ActiveChatsViewController.h | 1 + Monal/Classes/MLSettingsTableViewController.m | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/Monal/Classes/ActiveChatsViewController.h b/Monal/Classes/ActiveChatsViewController.h index aa46b7f5ec..7efd519214 100644 --- a/Monal/Classes/ActiveChatsViewController.h +++ b/Monal/Classes/ActiveChatsViewController.h @@ -46,6 +46,7 @@ NS_ASSUME_NONNULL_BEGIN -(void) showDetails; -(void) showRegisterWithUsername:(NSString*) username onHost:(NSString*) host withToken:(NSString* _Nullable) token usingCompletion:(monal_id_block_t _Nullable) callback; -(void) showAddContactWithJid:(NSString*) jid preauthToken:(NSString* _Nullable) preauthToken prefillAccount:(xmpp* _Nullable) account andOmemoFingerprints:(NSDictionary* _Nullable) fingerprints; +-(void) sheetDismissed; @end diff --git a/Monal/Classes/MLSettingsTableViewController.m b/Monal/Classes/MLSettingsTableViewController.m index a73297329e..ae684d6da6 100644 --- a/Monal/Classes/MLSettingsTableViewController.m +++ b/Monal/Classes/MLSettingsTableViewController.m @@ -13,6 +13,8 @@ #import "DataLayer.h" #import "MLXMPPManager.h" #import "XMPPEdit.h" +#import "MonalAppDelegate.h" +#import "ActiveChatsViewController.h" #import @import SafariServices; @@ -106,6 +108,12 @@ -(void) viewWillAppear:(BOOL)animated self.selected = nil; } +-(void) viewWillDisappear:(BOOL) animated +{ + [super viewWillDisappear:animated]; + [((MonalAppDelegate*)UIApplication.sharedApplication.delegate).activeChats sheetDismissed]; +} + #pragma mark - key commands -(BOOL) canBecomeFirstResponder From 16ec8a8d2f92df5605520293395d7c10180a9286 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 17:22:40 +0200 Subject: [PATCH 095/131] Add new timestamp formatter --- Monal/Classes/HelperTools.h | 1 + Monal/Classes/HelperTools.m | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/Monal/Classes/HelperTools.h b/Monal/Classes/HelperTools.h index cabe9eca55..ea4fd932b2 100644 --- a/Monal/Classes/HelperTools.h +++ b/Monal/Classes/HelperTools.h @@ -147,6 +147,7 @@ void swizzle(Class c, SEL orig, SEL new); +(NSSet*) getOwnFeatureSet; +(NSString*) getEntityCapsHashForIdentities:(NSArray*) identities andFeatures:(NSSet*) features andForms:(NSArray*) forms; +(NSString* _Nullable) formatLastInteraction:(NSDate*) lastInteraction; ++(NSString*) stringFromTimeInterval:(NSUInteger) interval; +(NSDate*) parseDateTimeString:(NSString*) datetime; +(NSString*) generateDateTimeString:(NSDate*) datetime; +(NSString*) encodeRandomResource; diff --git a/Monal/Classes/HelperTools.m b/Monal/Classes/HelperTools.m index 9d690661fb..300bb79fd3 100644 --- a/Monal/Classes/HelperTools.m +++ b/Monal/Classes/HelperTools.m @@ -2219,6 +2219,15 @@ +(NSString* _Nullable) formatLastInteraction:(NSDate*) lastInteraction } } ++(NSString*) stringFromTimeInterval:(NSUInteger) interval +{ + NSUInteger hours = interval / 3600; + NSUInteger minutes = (interval % 3600) / 60; + NSUInteger seconds = interval % 60; + + return [NSString stringWithFormat:@"%luh %lumin and %lusec", hours, minutes, seconds]; +} + +(NSDate*) parseDateTimeString:(NSString*) datetime { static NSDateFormatter* rfc3339DateFormatter; From c7d98f031ac6ea8327371ce49a88f90921b3bbf8 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Fri, 19 Jul 2024 17:50:40 +0200 Subject: [PATCH 096/131] --- 919 --- 6.4.1rc2 From 83647de81f0c02b574bb896cb319a29f7ff9a81a Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 13 Jul 2024 20:16:33 +0200 Subject: [PATCH 097/131] Implement Quicksy onboarding flow --- Monal/Classes/ActiveChatsViewController.m | 15 + Monal/Classes/BoardingCards.swift | 19 + Monal/Classes/HelperTools.h | 1 + Monal/Classes/HelperTools.m | 6 + Monal/Classes/MLSettingsTableViewController.m | 15 +- Monal/Classes/Quicksy_RegisterAccount.swift | 501 ++++++++++++++++++ Monal/Classes/RichAlert.swift | 5 + Monal/Classes/SwiftuiHelpers.swift | 4 + .../Quicksy Launch Screen.storyboard | 56 ++ Monal/Monal.xcodeproj/project.pbxproj | 8 + Monal/Quicksy-Info.plist | 2 +- 11 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 Monal/Classes/Quicksy_RegisterAccount.swift create mode 100644 Monal/Monal-iOS/Quicksy Launch Screen.storyboard diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 839a4af5b1..95238176b3 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -462,6 +462,20 @@ -(void) segueToIntroScreensIfNeeded return; } +#ifdef IS_QUICKSY + if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0) + { + UIViewController* view = [[SwiftuiInterface new] makeAccountRegistration:@{}]; + if(UIDevice.currentDevice.userInterfaceIdiom != UIUserInterfaceIdiomPad) + view.modalPresentationStyle = UIModalPresentationFullScreen; + else + view.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; + [self presentViewController:view animated:NO completion:^{}]; + return; + } +#else // display quick start if the user never seen it or if there are 0 enabled accounts if([[DataLayer sharedInstance] enabledAccountCnts].intValue == 0 && !_loginAlreadyAutodisplayed) { @@ -473,6 +487,7 @@ -(void) segueToIntroScreensIfNeeded [self presentViewController:loginViewController animated:YES completion:^{}]; return; } +#endif [self showWarningsIfNeeded]; } diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index d48489fa34..77f7f48e6f 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -143,6 +143,24 @@ struct OnboardingView: View { @ViewBuilder func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { +#if IS_QUICKSY + let cards = [ + OnboardingCard( + title: Text("Welcome to Quicksy !"), + description: nil, + imageName: "hand.wave", + articleText: Text(""" + Quicksy syncs your contact list in regular intervals to make suggestions about possible contacts who are already on Quicksy. + + Quicksy shares and stores images, audio recordings, videos and other media to deliver them to the intended recipients. Files will be stored for up to 30 days. + + Find more Information in our [Privacy Policy](https://quicksy.im/privacy.htm). + """), + customView: nil, + nextText: "Accept and continue" + ), + ] +#else let cards = [ OnboardingCard( title: Text("Welcome to Monal !"), @@ -199,6 +217,7 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { nextText: nil ), ] +#endif OnboardingView(delegate: delegate, cards: cards) } diff --git a/Monal/Classes/HelperTools.h b/Monal/Classes/HelperTools.h index ea4fd932b2..c927a03f1a 100644 --- a/Monal/Classes/HelperTools.h +++ b/Monal/Classes/HelperTools.h @@ -150,6 +150,7 @@ void swizzle(Class c, SEL orig, SEL new); +(NSString*) stringFromTimeInterval:(NSUInteger) interval; +(NSDate*) parseDateTimeString:(NSString*) datetime; +(NSString*) generateDateTimeString:(NSDate*) datetime; ++(NSString*) generateRandomPassword; +(NSString*) encodeRandomResource; +(NSData* _Nullable) sha1:(NSData* _Nullable) data; diff --git a/Monal/Classes/HelperTools.m b/Monal/Classes/HelperTools.m index 300bb79fd3..5d1b97b80d 100644 --- a/Monal/Classes/HelperTools.m +++ b/Monal/Classes/HelperTools.m @@ -2325,6 +2325,12 @@ +(AnyPromise*) waitAtLeastSeconds:(NSTimeInterval) seconds forPromise:(AnyPromis }); } ++(NSString*) generateRandomPassword +{ + u_int32_t i=arc4random(); + return [self hexadecimalString:[NSData dataWithBytes: &i length: sizeof(i)]]; +} + +(NSString*) encodeRandomResource { u_int32_t i=arc4random(); diff --git a/Monal/Classes/MLSettingsTableViewController.m b/Monal/Classes/MLSettingsTableViewController.m index ae684d6da6..6fc4cb29ad 100644 --- a/Monal/Classes/MLSettingsTableViewController.m +++ b/Monal/Classes/MLSettingsTableViewController.m @@ -28,8 +28,10 @@ }; enum SettingsAccountRows { +#ifndef IS_QUICKSY QuickSettingsRow, AdvancedSettingsRow, +#endif SettingsAccountRowsCnt }; @@ -60,6 +62,10 @@ //this will hold all disabled rows of all enums (this is needed because the code below still references these rows) enum DummySettingsRows { +#ifdef IS_QUICKSY + QuickSettingsRow, + AdvancedSettingsRow, +#endif DummySettingsRowsBegin = 100, }; @@ -137,7 +143,11 @@ -(NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger) { switch(section) { +#ifdef IS_QUICKSY case kSettingSectionAccounts: return [self getAccountNum] + SettingsAccountRowsCnt; +#else + case kSettingSectionAccounts: return [self getAccountNum] + SettingsAccountRowsCnt; +#endif case kSettingSectionApp: return SettingsAppRowsCnt; case kSettingSectionSupport: return SettingsSupportRowCnt; #ifndef DEBUG @@ -214,7 +224,9 @@ -(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NS } else { - MLAssert(indexPath.row - [self getAccountNum] < SettingsAccountRowsCnt, @"Tried to tap onto a row ment to be for a concrete account, not for quick or advanced settings"); +#ifndef IS_QUICKSY + MLAssert(indexPath.row - [self getAccountNum] < SettingsAccountRowsCnt, @"Tried to tap onto a row meant to be for a concrete account, not for quick or advanced settings"); + // User selected one of the 'add account' promts switch(indexPath.row - [self getAccountNum]) { case QuickSettingsRow: @@ -226,6 +238,7 @@ -(UITableViewCell*) tableView:(UITableView*) tableView cellForRowAtIndexPath:(NS default: unreachable(); } +#endif } break; } diff --git a/Monal/Classes/Quicksy_RegisterAccount.swift b/Monal/Classes/Quicksy_RegisterAccount.swift new file mode 100644 index 0000000000..16ce9355f8 --- /dev/null +++ b/Monal/Classes/Quicksy_RegisterAccount.swift @@ -0,0 +1,501 @@ +// +// Quicksy_RegisterAccount.swift +// Monal +// +// Created by Thilo Molitor on 13.07.24. +// Copyright © 2024 monal-im.org. All rights reserved. +// + +let QUICKSY_BASE_URL = "https://api.quicksy.im"; + +func sendSMSRequest(to number:String) -> Promise<(data: Data, response: URLResponse)> { + var rq = URLRequest(url: URL(string: "\(QUICKSY_BASE_URL)/authentication/\(number)")!) + rq.httpMethod = "GET" + rq.addValue(Locale.current.languageCode ?? "en", forHTTPHeaderField: "Accept-Language") + rq.addValue(UIDevice.current.identifierForVendor?.uuidString.lowercased() ?? UUID().uuidString.lowercased(), forHTTPHeaderField: "Installation-Id") + rq.addValue("Quicksy/2.10.0", forHTTPHeaderField: "User-Agent") + DDLogDebug("Request: \(String(describing:rq))") + if let headers = rq.allHTTPHeaderFields { + for (key, value) in headers { + DDLogDebug("Header: \(key): \(value)") + } + } + return firstly { + URLSession.shared.dataTask(.promise, with: rq).validate() + } +} + +func sendRegisterRequest(number:String, pin:String, password:String) -> Promise<(data: Data, response: URLResponse)> { + var rq = URLRequest(url: URL(string: "\(QUICKSY_BASE_URL)/password")!) + rq.httpMethod = "POST" + rq.addValue(HelperTools.encodeBase64(with:"\(number)\0\(pin)"), forHTTPHeaderField: "Authorization") + rq.addValue("Quicksy/2.10.0", forHTTPHeaderField: "User-Agent") + rq.httpBody = password.data(using:.utf8) + DDLogDebug("Request: \(String(describing:rq))") + if let headers = rq.allHTTPHeaderFields { + for (key, value) in headers { + DDLogDebug("Header: \(key): \(value)") + } + } + return firstly { + URLSession.shared.dataTask(.promise, with: rq).validate() + } +} + +class Quicksy_State: ObservableObject { + @defaultsDB("Quicksy_phoneNumber") + var phoneNumber: String? +} + +struct Quicksy_Country: Identifiable, Hashable { + let id = UUID() + let name: String + let code: String + let mobilePattern: String +} + +struct Quicksy_RegisterAccount: View { + var delegate: SheetDismisserProtocol + let countries: [Quicksy_Country] = [ + Quicksy_Country(name: NSLocalizedString("Germany", comment:"quicksy country"), code: "+49", mobilePattern: "^1\\d{10}$") , + Quicksy_Country(name: NSLocalizedString("United States", comment:"quicksy country"), code: "+1", mobilePattern: "^\\d{10}$"), + Quicksy_Country(name: NSLocalizedString("Canada", comment:"quicksy country"), code: "+1", mobilePattern: "^\\d{10}$"), + Quicksy_Country(name: NSLocalizedString("United Kingdom", comment:"quicksy country"), code: "+44", mobilePattern: "^7\\d{9}$"), + Quicksy_Country(name: NSLocalizedString("Australia", comment:"quicksy country"), code: "+61", mobilePattern: "^4\\d{8}$"), + Quicksy_Country(name: NSLocalizedString("India", comment:"quicksy country"), code: "+91", mobilePattern: "^[789]\\d{9}$"), + ] + @StateObject private var overlay = LoadingOverlayState() + @ObservedObject var state = Quicksy_State() + @State private var currentIndex = 0 + @State var selectedCountry: Quicksy_Country? + @State var phoneNumber: String = "" + //ios>=15 + //@FocusState var phoneNumberFocused: Bool = false + @State var showPhoneNumberCheckAlert: String? + @State var pin: String = "" + //ios>=15 + //@FocusState var pinFocused: Bool = false + @State var showErrorAlert: PMKHTTPError? + @State var showBackAlert: Bool? + + //login state + @State private var alertPrompt = AlertPrompt(dismissLabel: Text("Close")) + @State private var showAlert = false + @State var currentTimeout : DispatchTime? = nil + @State var errorObserverEnabled = false + @State var newAccountNo: NSNumber? = nil + @State var loginComplete = false + @State var isLoadingOmemoBundles = false + + init(delegate: SheetDismisserProtocol) { + self.delegate = delegate + self.state.phoneNumber = nil + } + + private func requestSMS(for number:String) { + showPhoneNumberCheckAlert = nil + showPromisingLoadingOverlay(overlay, headline:NSLocalizedString("Requesting validation SMS...", comment: ""), description: "") { + sendSMSRequest(to:number) + }.done { data, response in + DDLogDebug("Got sendSMSRequest success: \(String(describing:response))\n\(String(describing:data))") + state.phoneNumber = number + }.catch { error in + DDLogError("Catched sendSMSRequest error: \(String(describing:error))") + if let response = error as? PMKHTTPError { + showErrorAlert = response + } + } + } + + private func createAccount() { + let password = HelperTools.generateRandomPassword() + if let number = state.phoneNumber { + showPromisingLoadingOverlay(overlay, headline:NSLocalizedString("Registering account...", comment: ""), description: "") { + sendRegisterRequest(number:number, pin:pin, password:password) + }.done { result in + DDLogDebug("Got sendRegisterRequest success: \(String(describing:result))") + startLoginTimeout() + showLoadingOverlay(overlay, headline:NSLocalizedString("Logging in", comment: "")) + self.errorObserverEnabled = true + self.newAccountNo = MLXMPPManager.sharedInstance().login("\(number)@quicksy.im", password: password) + if(self.newAccountNo == nil) { + currentTimeout = nil // <- disable timeout on error + errorObserverEnabled = false + showLoginErrorAlert(errorMessage:NSLocalizedString("Account already configured!", comment: "")) + self.newAccountNo = nil + } + + }.catch { error in + DDLogError("Catched sendRegisterRequest error: \(String(describing:error))") + if let response = error as? PMKHTTPError { + showErrorAlert = response + } + } + } + } + + private var isValidNumber: Bool { + guard let selectedCountry = selectedCountry else { + return false + } + let phonePredicate = NSPredicate(format: "SELF MATCHES %@", selectedCountry.mobilePattern) + return phoneNumber.allSatisfy { $0.isNumber } && phoneNumber.count > 0 && phonePredicate.evaluate(with: phoneNumber) + } + + private func showTimeoutAlert() { + DDLogVerbose("Showing timeout alert...") + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Timeout Error") + alertPrompt.message = Text("We were not able to connect your account. Please check your username and password and make sure you are connected to the internet.") + showAlert = true + } + + private func showSuccessAlert() { + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Success!") + alertPrompt.message = Text("You are set up and connected.") + showAlert = true + } + + private func showLoginErrorAlert(errorMessage: String) { + hideLoadingOverlay(overlay) + alertPrompt.title = Text("Error") + alertPrompt.message = Text(String(format: NSLocalizedString("We were not able to connect your account. Please check your username and password and make sure you are connected to the internet.\n\nTechnical error message: %@", comment: ""), errorMessage)) + showAlert = true + } + + private func startLoginTimeout() { + let newTimeout = DispatchTime.now() + 30.0; + self.currentTimeout = newTimeout + DispatchQueue.main.asyncAfter(deadline: newTimeout) { + if(newTimeout == self.currentTimeout) { + DDLogWarn("First login timeout triggered...") + if(self.newAccountNo != nil) { + DDLogVerbose("Removing account...") + MLXMPPManager.sharedInstance().removeAccount(forAccountNo: self.newAccountNo!) + self.newAccountNo = nil + } + self.currentTimeout = nil + showTimeoutAlert() + } + } + } + + var body: some View { + ZStack { + /// Ensure the ZStack takes the entire area + Color.clear + + if state.phoneNumber == nil { + VStack(alignment: .leading) { + Text("Verify your phone number") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + + Text("Quicksy will send an SMS message (carrier charges may apply) to verify your phone number. Enter your country code and phone number:") + + HStack { + Text("Country:") + Picker(selection: $selectedCountry, label: EmptyView()) { + ForEach(countries) { country in + Text("\(country.name) (\(country.code))").tag(country as Quicksy_Country?) + } + } + .pickerStyle(MenuPickerStyle()) + } + + HStack { + if let selectedCountry = selectedCountry { + Text(selectedCountry.code) + } + TextField("Phone Number", text: $phoneNumber) + //ios>=15 + //.focused($phoneNumberFocused) + .keyboardType(.numberPad) + .onChange(of: phoneNumber) { newValue in + let filtered = newValue.filter { "0123456789".contains($0) } + if filtered != newValue { + phoneNumber = filtered + } + } + } + .padding() + .border(phoneNumber.count==0 ? Color.gray : (isValidNumber ? Color.green : Color.red), width: phoneNumber.count==0 ? 1 : 2) + + Spacer() + + if let selectedCountry = selectedCountry { + HStack { + Spacer() + + Button(action: { + showPhoneNumberCheckAlert = selectedCountry.code + phoneNumber + }) { + Text("Next") + .fontWeight(.bold) + .padding() + .background(!isValidNumber ? Color(UIColor.lightGray) : Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + .disabled(!isValidNumber) + } + } + } + .richAlert(isPresented:$showPhoneNumberCheckAlert, title:Text("Check this number?"), body:{ number in + VStack(alignment: .leading) { + Text("We will check the number **\(number)**. Is this okay or do you want to change the number?") + } + }, buttons: { number in + HStack { + Button(action: { + showPhoneNumberCheckAlert = nil + }) { + Text("Change it") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + + Spacer() + + Button(action: { + requestSMS(for:number) + }) { + Text("OK") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + } + }) + .onAppear { + selectedCountry = countries[0] + //ios>=15 + //phoneNumberFocused = true + } + } else if let number = state.phoneNumber { + VStack(alignment: .leading) { + Text("Verify your phone number") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + + Text("We sent you an SMS to \(number)") + Text("Please enter the six-digit pin below") + HStack { + TextField("Pin", text: $pin) + //ios>=15 + //.focused($phoneNumberFocused) + .keyboardType(.numberPad) + .onChange(of: pin) { newValue in + let filtered = newValue.filter { "0123456789".contains($0) } + if filtered != newValue { + pin = filtered + } + } + } + .padding() + .border(pin.count==0 ? Color.gray : (pin.count==6 ? Color.green : Color.red), width: pin.count==0 ? 1 : 2) + + Spacer().frame(height:16) + + Button(action: { + requestSMS(for:number) + }) { + Text("Send SMS again") + } + .frame(maxWidth: .infinity, alignment: .center).padding() + + Spacer() + + HStack { + Button(action: { + showBackAlert = true + }) { + Text("Previous") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + + Spacer() + + Button(action: { + createAccount() + }) { + Text("Next") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + } + } + .richAlert(isPresented:$showBackAlert, title:Text("Cancel?")) { error in + VStack(alignment: .leading) { + Text("Are you sure to cancel the registration process?") + } + } buttons: { error in + HStack { + Button(action: { + showBackAlert = nil + }) { + Text("No") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + + Spacer() + + Button(action: { + showBackAlert = nil + state.phoneNumber = nil + }) { + Text("Yes") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + } + } + .onAppear { + //ios>=15 + //pinFocused = true + } + } + } + .alert(isPresented: $showAlert) { + Alert(title: alertPrompt.title, message: alertPrompt.message, dismissButton: .default(alertPrompt.dismissLabel, action: { + if(self.loginComplete == true) { + self.delegate.dismissWithoutAnimation() + } + })) + } + .richAlert(isPresented:$showErrorAlert, title:Text("Error requesting SMS!"), body:{ error in + VStack(alignment: .leading) { + Text("An error happened when trying to request the SMS:") + .bold() + Spacer().frame(height:16) + switch error { + case .badStatusCode(let code, _, let response): + switch code { + case 400: + Text("Invalid user input.") + case 401: + Text("The pin you have entered is incorrect.") + case 403: + Text("You are using an out of date version of this app.") + case 404: + Text("The pin we have sent you has expired.") + case 409: + Text("This phone number is currently logged in with another device.") + case 429: + Text("Too many attempts, please try again in \(HelperTools.string(fromTimeInterval:UInt(response.value(forHTTPHeaderField:"Retry-After") ?? "0") ?? 0)).") + case 500: + Text("Something went wrong processing your request.") + case 501: + Text("Temporarily unavailable. Try again later.") + case 502: + Text("Temporarily unavailable. Try again later.") + case 503: + Text("Temporarily unavailable. Try again later.") + default: + Text("Unexpected error processing your request.") + } + } + } + }, buttons: { error in + HStack { + Spacer() + + Button(action: { + showErrorAlert = nil + }) { + Text("OK") + .fontWeight(.bold) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + } + }) + .padding() + .addLoadingOverlay(overlay) + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kXMPPError")).receive(on: RunLoop.main)) { notification in + if(self.errorObserverEnabled == false) { + return + } + if let xmppAccount = notification.object as? xmpp, let newAccountNo : NSNumber = self.newAccountNo, let errorMessage = notification.userInfo?["message"] as? String { + if(xmppAccount.accountNo.intValue == newAccountNo.intValue) { + DispatchQueue.main.async { + currentTimeout = nil // <- disable timeout on error + errorObserverEnabled = false + showLoginErrorAlert(errorMessage: errorMessage) + MLXMPPManager.sharedInstance().removeAccount(forAccountNo: newAccountNo) + self.newAccountNo = nil + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMLResourceBoundNotice")).receive(on: RunLoop.main)) { notification in + if let xmppAccount = notification.object as? xmpp, let newAccountNo : NSNumber = self.newAccountNo { + if(xmppAccount.accountNo.intValue == newAccountNo.intValue) { + DispatchQueue.main.async { + currentTimeout = nil // <- disable timeout on successful connection + self.errorObserverEnabled = false + showLoadingOverlay(overlay, headline:NSLocalizedString("Loading contact list", comment: "")) + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalUpdateBundleFetchStatus")).receive(on: RunLoop.main)) { notification in + if let notificationAccountNo = notification.userInfo?["accountNo"] as? NSNumber, let completed = notification.userInfo?["completed"] as? NSNumber, let all = notification.userInfo?["all"] as? NSNumber, let newAccountNo : NSNumber = self.newAccountNo { + if(notificationAccountNo.intValue == newAccountNo.intValue) { + isLoadingOmemoBundles = true + showLoadingOverlay( + overlay, + headline:NSLocalizedString("Loading omemo bundles", comment: ""), + description:String(format: NSLocalizedString("Loading omemo bundles: %@ / %@", comment: ""), completed, all) + ) + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalFinishedOmemoBundleFetch")).receive(on: RunLoop.main)) { notification in + if let notificationAccountNo = notification.userInfo?["accountNo"] as? NSNumber, let newAccountNo : NSNumber = self.newAccountNo { + if(notificationAccountNo.intValue == newAccountNo.intValue && isLoadingOmemoBundles) { + DispatchQueue.main.async { + self.loginComplete = true + showSuccessAlert() + } + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalFinishedCatchup")).receive(on: RunLoop.main)) { notification in + if let xmppAccount = notification.object as? xmpp, let newAccountNo : NSNumber = self.newAccountNo { + if(xmppAccount.accountNo.intValue == newAccountNo.intValue && !isLoadingOmemoBundles) { + DispatchQueue.main.async { + self.loginComplete = true + showSuccessAlert() + } + } + } + } + } +} diff --git a/Monal/Classes/RichAlert.swift b/Monal/Classes/RichAlert.swift index d03e90d9f4..bd1f7afa29 100644 --- a/Monal/Classes/RichAlert.swift +++ b/Monal/Classes/RichAlert.swift @@ -103,6 +103,11 @@ extension View { func richAlert(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:buttons)) } + //apparently this is sometimes somehow needed to not confuse the compiler into using some of the other functions instead of this + //(it tries to use the title(), body(), buttons(X) variant in Quicksy_RegisterAccount) + func richAlertX(isPresented: Binding, title: @autoclosure @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { + modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:buttons)) + } //title(), body(X), buttons(X) func richAlert(isPresented: Binding, @ViewBuilder title: @escaping () -> some View, @ViewBuilder body: @escaping (_ data: T) -> some View, @ViewBuilder buttons: @escaping (_ data: T) -> some View) -> some View { modifier(RichAlertView(isPresented:isPresented, alertTitle:{ _ in title() }, alertBody:body, alertButtons:buttons)) diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index 8827721083..c05d6368c0 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -668,7 +668,11 @@ class SwiftuiInterface : NSObject { let delegate = SheetDismisserProtocol() let host = UIHostingController(rootView:AnyView(EmptyView())) delegate.host = host +#if IS_QUICKSY + host.rootView = AnyView(Quicksy_RegisterAccount(delegate:delegate)) +#else host.rootView = AnyView(AddTopLevelNavigation(withDelegate:delegate, to:RegisterAccount(delegate:delegate, registerData:registerData))) +#endif return host } diff --git a/Monal/Monal-iOS/Quicksy Launch Screen.storyboard b/Monal/Monal-iOS/Quicksy Launch Screen.storyboard new file mode 100644 index 0000000000..3172981417 --- /dev/null +++ b/Monal/Monal-iOS/Quicksy Launch Screen.storyboard @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index dff94f2b8b..04bf0e6cb0 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -144,6 +144,7 @@ 844921EA2C29F9A000B99A9C /* MLDelayableTimer.m in Sources */ = {isa = PBXBuildFile; fileRef = 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */; }; 844921EC2C29F9BE00B99A9C /* MLDelayableTimer.h in Headers */ = {isa = PBXBuildFile; fileRef = 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */; }; 844EEC6D28E718DB00CB5EF9 /* UIColor+Theme.m in Sources */ = {isa = PBXBuildFile; fileRef = 26D59D9220714F32006F1DEE /* UIColor+Theme.m */; }; + 845836BA2C49F36300B11EC5 /* Quicksy Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 845836B92C49F36300B11EC5 /* Quicksy Launch Screen.storyboard */; }; 845D636B2AD4AEDA0066EFFB /* ImageViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845D636A2AD4AEDA0066EFFB /* ImageViewer.swift */; }; 845EFFBD2918721800C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; 845EFFBE2918723D00C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; @@ -159,6 +160,7 @@ 849ADF3F2BACF0360009BCD7 /* CocoaLumberjack in Frameworks */ = {isa = PBXBuildFile; productRef = 849ADF3E2BACF0360009BCD7 /* CocoaLumberjack */; }; 849ADF412BACF0360009BCD7 /* CocoaLumberjackSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 849ADF402BACF0360009BCD7 /* CocoaLumberjackSwift */; }; 849ADF432BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend in Frameworks */ = {isa = PBXBuildFile; productRef = 849ADF422BACF0360009BCD7 /* CocoaLumberjackSwiftLogBackend */; }; + 84BBAECA2C42D272009492E2 /* Quicksy_RegisterAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BBAEC92C42D272009492E2 /* Quicksy_RegisterAccount.swift */; }; 84C1CD502A8C764D007076ED /* SwiftHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C1CD4F2A8C764D007076ED /* SwiftHelpers.swift */; }; 84C1CD522A8F617F007076ED /* MLStreamRedirect.m in Sources */ = {isa = PBXBuildFile; fileRef = 84C1CD512A8F617F007076ED /* MLStreamRedirect.m */; }; 84C1CD542A8F6196007076ED /* MLStreamRedirect.h in Headers */ = {isa = PBXBuildFile; fileRef = 84C1CD532A8F6196007076ED /* MLStreamRedirect.h */; }; @@ -580,6 +582,7 @@ 8441EFF82921B53500E851E9 /* BackgroundSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundSettings.swift; sourceTree = ""; }; 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLDelayableTimer.m; sourceTree = ""; }; 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLDelayableTimer.h; sourceTree = ""; }; + 845836B92C49F36300B11EC5 /* Quicksy Launch Screen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = "Quicksy Launch Screen.storyboard"; path = "Monal-iOS/Quicksy Launch Screen.storyboard"; sourceTree = ""; }; 845D636A2AD4AEDA0066EFFB /* ImageViewer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = ""; }; 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPlaceholder.swift; sourceTree = ""; }; 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLPlaceholderViewController.m; sourceTree = ""; }; @@ -591,6 +594,7 @@ 849248482AD4CEC400986C1A /* ZoomableContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZoomableContainer.swift; sourceTree = ""; }; 849A53E3287135B2007E941A /* MLVoIPProcessor.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = MLVoIPProcessor.m; path = Classes/MLVoIPProcessor.m; sourceTree = SOURCE_ROOT; }; 849A53E5287135D7007E941A /* MLVoIPProcessor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = MLVoIPProcessor.h; path = Classes/MLVoIPProcessor.h; sourceTree = SOURCE_ROOT; }; + 84BBAEC92C42D272009492E2 /* Quicksy_RegisterAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quicksy_RegisterAccount.swift; sourceTree = ""; }; 84C1CD4F2A8C764D007076ED /* SwiftHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftHelpers.swift; sourceTree = ""; }; 84C1CD512A8F617F007076ED /* MLStreamRedirect.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLStreamRedirect.m; sourceTree = ""; }; 84C1CD532A8F6196007076ED /* MLStreamRedirect.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLStreamRedirect.h; sourceTree = ""; }; @@ -1128,6 +1132,7 @@ 26B2A4B41B73040000272E63 /* Monal-iOS */ = { isa = PBXGroup; children = ( + 845836B92C49F36300B11EC5 /* Quicksy Launch Screen.storyboard */, 841EE42E2A426F0900D3AF14 /* tools */, 84D31CDA28653AA9006D7926 /* WebRTC */, C1E4654424EE515200CA5AAF /* localization */, @@ -1227,6 +1232,7 @@ C12436122434AB5D00B8F074 /* MLAttributedLabel.h */, C12436132434AB5D00B8F074 /* MLAttributedLabel.m */, 20D3611B2C10E12500E46587 /* BoardingCards.swift */, + 84BBAEC92C42D272009492E2 /* Quicksy_RegisterAccount.swift */, ); name = OnBoard; sourceTree = ""; @@ -1778,6 +1784,7 @@ 848C73E02BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */, 26B0CA8B21AE410E0080B133 /* AlertSounds in Resources */, 842790852A32D16D005C18CC /* CallSounds in Resources */, + 845836BA2C49F36300B11EC5 /* Quicksy Launch Screen.storyboard in Resources */, 26470F521835C4080069E3E0 /* Media.xcassets in Resources */, 26B2A4BB1B73061400272E63 /* Images.xcassets in Resources */, 26E8462824EABAED00ECE419 /* Main.storyboard in Resources */, @@ -2064,6 +2071,7 @@ 3D65B78D27234B74005A30F4 /* ContactDetails.swift in Sources */, E89DD32525C6626400925F62 /* MLFileTransferDataCell.m in Sources */, E89DD32825C6626400925F62 /* MLFileTransferTextCell.m in Sources */, + 84BBAECA2C42D272009492E2 /* Quicksy_RegisterAccount.swift in Sources */, 261A6281176C055400059090 /* ContactsViewController.m in Sources */, 26B0CA8921AE2E3C0080B133 /* MLSoundsTableViewController.m in Sources */, 84D31CE628653B83006D7926 /* WebRTCClient.swift in Sources */, diff --git a/Monal/Quicksy-Info.plist b/Monal/Quicksy-Info.plist index 5704bed50e..9f695c4aeb 100644 --- a/Monal/Quicksy-Info.plist +++ b/Monal/Quicksy-Info.plist @@ -108,7 +108,7 @@ UIFileSharingEnabled UILaunchStoryboardName - Launch Screen + Quicksy Launch Screen UIMainStoryboardFile Main UIPrerenderedIcon From 02f8668e6ec9b0d7deaf4b58123d7a6ef04b8fb1 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 20 Jul 2024 02:05:16 +0200 Subject: [PATCH 098/131] Implement contact list sync for quicksy --- Monal/Classes/ActiveChatsViewController.m | 61 +++++++++++++++++++++ Monal/Classes/ContactsViewController.m | 4 ++ Monal/Classes/MLIQProcessor.m | 26 +++++++++ Monal/Classes/Quicksy_RegisterAccount.swift | 7 ++- Monal/Classes/XMPPIQ.h | 4 ++ Monal/Classes/XMPPIQ.m | 14 +++++ Monal/Quicksy-Info.plist | 2 + 7 files changed, 116 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 95238176b3..ae513de561 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -6,6 +6,7 @@ // // +#import #import "ActiveChatsViewController.h" #import "DataLayer.h" #import "xmpp.h" @@ -19,6 +20,8 @@ #import "MLSettingsAboutViewController.h" #import "MLVoIPProcessor.h" #import "MLCall.h" //for MLCallType +#import "XMPPIQ.h" +#import "MLIQProcessor.h" #import "UIColor+Theme.h" #import @@ -490,8 +493,66 @@ -(void) segueToIntroScreensIfNeeded #endif [self showWarningsIfNeeded]; + +#ifdef IS_QUICKSY + [self syncContacts]; +#endif } +#ifdef IS_QUICKSY +-(void) syncContacts +{ + CNContactStore* store = [[CNContactStore alloc] init]; + [store requestAccessForEntityType:CNEntityTypeContacts completionHandler:^(BOOL granted, NSError* _Nullable error) { + if(granted) + { + NSString* countryCode = @"+49"; //[[HelperTools defaultsDB] objectForKey:@"Quicksy_countryCode"]; + NSCharacterSet* allowedCharacters = [[NSCharacterSet characterSetWithCharactersInString:@"+0123456789"] invertedSet]; + NSMutableDictionary* numbers = [NSMutableDictionary new]; + + CNContactFetchRequest* request = [[CNContactFetchRequest alloc] initWithKeysToFetch:@[CNContactPhoneNumbersKey, CNContactNicknameKey, CNContactGivenNameKey, CNContactFamilyNameKey]]; + NSError* error; + [store enumerateContactsWithFetchRequest:request error:&error usingBlock:^(CNContact* _Nonnull contact, BOOL* _Nonnull stop) { + if(!error) + { + NSString* name = [[NSString stringWithFormat:@"%@ %@", contact.givenName, contact.familyName] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + for(CNLabeledValue* phone in contact.phoneNumbers) + { + //add country code if missing + NSString* number = [[phone.value.stringValue componentsSeparatedByCharactersInSet:allowedCharacters] componentsJoinedByString:@""]; + if(countryCode != nil && ![number hasPrefix:@"+"] && ![number hasPrefix:@"00"]) + { + DDLogVerbose(@"Adding country code '%@' to number: %@", countryCode, number); + number = [NSString stringWithFormat:@"%@%@", countryCode, number]; + } + numbers[number] = name; + } + } + else + DDLogWarn(@"Error fetching contacts: %@", error); + }]; + + DDLogDebug(@"Got list of contact phone numbers: %@", numbers); + + NSArray* connectedAccounts = [MLXMPPManager sharedInstance].connectedXMPP; + if(connectedAccounts.count == 0) + { + DDLogError(@"No connected account while trying to send quicksy phonebook!"); + return; + } + else if(connectedAccounts.count > 1) + DDLogWarn(@"More than 1 connected account while trying to send quicksy phonebook, using first one!"); + + XMPPIQ* iqNode = [[XMPPIQ alloc] initWithType:kiqGetType to:@"api.quicksy.im"]; + [iqNode setQuicksyPhoneBook:numbers.allKeys]; + [connectedAccounts[0] sendIq:iqNode withHandler:$newHandler(MLIQProcessor, handleQuicksyPhoneBook, $ID(numbers))]; + } + else + DDLogError(@"Access to contacts not granted!"); + }]; +} +#endif + -(void) showWarningsIfNeeded { dispatch_async(dispatch_get_main_queue(), ^{ diff --git a/Monal/Classes/ContactsViewController.m b/Monal/Classes/ContactsViewController.m index ed27f5d6a0..34ed6305be 100644 --- a/Monal/Classes/ContactsViewController.m +++ b/Monal/Classes/ContactsViewController.m @@ -235,9 +235,13 @@ -(void) loadContactsWithFilter:(NSString*) filter { if(!contact.isSelfChat) onlySelfChats = NO; +#ifdef IS_QUICKSY + [contactsToDisplay addObject:contact]; +#else //ignore all contacts not at least in subscribedTo or asking state if(contact.isInRoster) [contactsToDisplay addObject:contact]; +#endif } if(!onlySelfChats) self.contacts = contactsToDisplay; diff --git a/Monal/Classes/MLIQProcessor.m b/Monal/Classes/MLIQProcessor.m index 4b5e53fc61..45bb7f99df 100644 --- a/Monal/Classes/MLIQProcessor.m +++ b/Monal/Classes/MLIQProcessor.m @@ -762,6 +762,32 @@ +(BOOL) processRosterWithAccount:(xmpp*) account andIqNode:(XMPPIQ*) iqNode }]; $$ +#ifdef IS_QUICKSY +$$class_handler(handleQuicksyPhoneBook, $$ID(xmpp*, account), $$ID(XMPPIQ*, iqNode), $$ID(NSDictionary*, numbers)) + if([iqNode check:@"/"]) + { + DDLogError(@"Quicksy phonebook synchronize returned an error: %@", [iqNode findFirst:@"error"]); + [HelperTools postError:NSLocalizedString(@"Failed to synchronize phonebook", @"") withNode:iqNode andAccount:account andIsSevere:NO]; + return; + } + + for(MLXMLNode* entry in [iqNode find:@"{im.quicksy.synchronization:0}phone-book/entry"]) + { + NSString* nick = numbers[[entry findFirst:@"/@number"]]; + for(NSString* jid in [entry find:@"jid#"]) + { + DDLogDebug(@"Adding '%@' with nick '%@' to local roster...", jid, nick); + [[DataLayer sharedInstance] addContact:jid forAccount:account.accountNo nickname:nick]; +#ifndef DISABLE_OMEMO + // Request omemo devicelist + [account.omemo subscribeAndFetchDevicelistIfNoSessionExistsForJid:jid]; +#endif// DISABLE_OMEMO + + } + } +$$ +#endif + +(void) respondWithErrorTo:(XMPPIQ*) iqNode onAccount:(xmpp*) account { XMPPIQ* errorIq = [[XMPPIQ alloc] initAsErrorTo:iqNode]; diff --git a/Monal/Classes/Quicksy_RegisterAccount.swift b/Monal/Classes/Quicksy_RegisterAccount.swift index 16ce9355f8..2202a23345 100644 --- a/Monal/Classes/Quicksy_RegisterAccount.swift +++ b/Monal/Classes/Quicksy_RegisterAccount.swift @@ -45,6 +45,9 @@ func sendRegisterRequest(number:String, pin:String, password:String) -> Promise< class Quicksy_State: ObservableObject { @defaultsDB("Quicksy_phoneNumber") var phoneNumber: String? + + @defaultsDB("Quicksy_countryCode") + var countryCode: String? } struct Quicksy_Country: Identifiable, Hashable { @@ -108,6 +111,7 @@ struct Quicksy_RegisterAccount: View { } private func createAccount() { + state.countryCode = selectedCountry!.code //used to add a country code to phonebook entries not having any let password = HelperTools.generateRandomPassword() if let number = state.phoneNumber { showPromisingLoadingOverlay(overlay, headline:NSLocalizedString("Registering account...", comment: ""), description: "") { @@ -124,7 +128,6 @@ struct Quicksy_RegisterAccount: View { showLoginErrorAlert(errorMessage:NSLocalizedString("Account already configured!", comment: "")) self.newAccountNo = nil } - }.catch { error in DDLogError("Catched sendRegisterRequest error: \(String(describing:error))") if let response = error as? PMKHTTPError { @@ -153,7 +156,7 @@ struct Quicksy_RegisterAccount: View { private func showSuccessAlert() { hideLoadingOverlay(overlay) alertPrompt.title = Text("Success!") - alertPrompt.message = Text("You are set up and connected.") + alertPrompt.message = Text("Quicksy is now set up and connected.") showAlert = true } diff --git a/Monal/Classes/XMPPIQ.h b/Monal/Classes/XMPPIQ.h index 3b7141b0c0..569ff59912 100644 --- a/Monal/Classes/XMPPIQ.h +++ b/Monal/Classes/XMPPIQ.h @@ -136,6 +136,10 @@ removes a contact from the roster -(void) setGetRoomConfig; -(void) setRoomConfig:(XMPPDataForm*) configForm; +#ifdef IS_QUICKSY +-(void) setQuicksyPhoneBook:(NSArray*) numbers; +#endif + @end NS_ASSUME_NONNULL_END diff --git a/Monal/Classes/XMPPIQ.m b/Monal/Classes/XMPPIQ.m index 9643747a7a..09a1845448 100644 --- a/Monal/Classes/XMPPIQ.m +++ b/Monal/Classes/XMPPIQ.m @@ -449,4 +449,18 @@ -(void) setMucAdminQueryWithAffiliation:(NSString*) affiliation forJid:(NSString ] andData:nil]]; } +#ifdef IS_QUICKSY +-(void) setQuicksyPhoneBook:(NSArray*) numbers +{ + MLXMLNode* envelope = [[MLXMLNode alloc] initWithElement:@"phone-book" andNamespace:@"im.quicksy.synchronization:0"]; + for(NSString* number in numbers) + { + [envelope addChildNode:[[MLXMLNode alloc] initWithElement:@"entry" withAttributes:@{ + @"number": number, + } andChildren:@[] andData:nil]]; + } + [self addChildNode:envelope]; +} +#endif + @end diff --git a/Monal/Quicksy-Info.plist b/Monal/Quicksy-Info.plist index 9f695c4aeb..531c210809 100644 --- a/Monal/Quicksy-Info.plist +++ b/Monal/Quicksy-Info.plist @@ -88,6 +88,8 @@ Quicksy allows users to save photos received in conversations. NSPhotoLibraryUsageDescription Quicksy allows users to upload photos to recipients in a conversation + NSContactsUsageDescription + Quicksy syncs your contact list in regular intervals to make suggestions about possible contacts, who are already using the app. NSUserActivityTypes INSendMessageIntent From 8a5fd84e1f36a7496fbdfff5b9a978067165c963 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 20 Jul 2024 23:34:50 +0200 Subject: [PATCH 099/131] Add more logging to share sheet --- Monal/shareSheet-iOS/ShareViewController.m | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Monal/shareSheet-iOS/ShareViewController.m b/Monal/shareSheet-iOS/ShareViewController.m index 241b60ecf0..e39c379ac0 100644 --- a/Monal/shareSheet-iOS/ShareViewController.m +++ b/Monal/shareSheet-iOS/ShareViewController.m @@ -55,10 +55,14 @@ -(void) viewDidLoad [self.navigationController.navigationBar setBackgroundColor:[UIColor monaldarkGreen]]; self.navigationController.navigationItem.title = NSLocalizedString(@"Monal", @""); + DDLogInfo(@"Extension context: %@", self.extensionContext); + DDLogDebug(@"Raw extension context intent: %@", self.extensionContext.intent); if(self.extensionContext.intent != nil && [self.extensionContext.intent isKindOfClass:[INSendMessageIntent class]]) { INSendMessageIntent* intent = (INSendMessageIntent*)self.extensionContext.intent; + DDLogDebug(@"Got usable intent: %@", intent); self.intentContact = [HelperTools unserializeData:[intent.conversationIdentifier dataUsingEncoding:NSISOLatin1StringEncoding]]; + DDLogInfo(@"Extracted intent contact: %@", self.intentContact); [self.intentContact refresh]; //make sure we are up to date } } @@ -73,6 +77,7 @@ - (void) presentationAnimationDidFinish if(self.intentContact != nil) { + DDLogInfo(@"Intent contact given: %@", self.intentContact); //check if intentContact is in enabled account list for(NSDictionary* accountToCheck in self.accounts) { @@ -89,6 +94,7 @@ - (void) presentationAnimationDidFinish //no intent given or intent contact not found --> select initial recipient (contact with most recent interaction) if(!self.account || !self.recipient) { + DDLogInfo(@"No recipient given, selecting the one with the most recent interaction..."); BOOL recipientFound = NO; for(MLContact* recipient in self.recipients) { From a4fc7d4f0bed7fa9d698e4a9018d38baf1efc98e Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sat, 20 Jul 2024 23:13:46 +0200 Subject: [PATCH 100/131] Update occupant_id on muc reflection, fixes #956 --- Monal/Classes/DataLayer.h | 2 +- Monal/Classes/DataLayer.m | 12 +++++++++--- Monal/Classes/MLMessageProcessor.m | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Monal/Classes/DataLayer.h b/Monal/Classes/DataLayer.h index eb53f0e8b6..ef9b77818c 100644 --- a/Monal/Classes/DataLayer.h +++ b/Monal/Classes/DataLayer.h @@ -182,7 +182,7 @@ extern NSString* const kMessageTypeFiletransfer; -(NSNumber*) getSmallestHistoryId; -(NSNumber*) getBiggestHistoryId; --(NSNumber* _Nullable) hasMessageForStanzaId:(NSString*) stanzaId orMessageID:(NSString*) messageId withInboundDir:(BOOL) inbound andJid:(NSString*) jid onAccount:(NSNumber*) accountNo; +-(NSNumber* _Nullable) hasMessageForStanzaId:(NSString*) stanzaId orMessageID:(NSString*) messageId withInboundDir:(BOOL) inbound occupantId:(NSString* _Nullable) occupantId andJid:(NSString*) jid onAccount:(NSNumber*) accountNo; /* adds a specified message to the database diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index 5fa05a657a..d3fd229709 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -1260,7 +1260,7 @@ -(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) i return nil; return [self.db idWriteTransaction:^{ - if(!checkForDuplicates || [self hasMessageForStanzaId:stanzaid orMessageID:messageid withInboundDir:inbound andJid:buddyName onAccount:accountNo] == nil) + if(!checkForDuplicates || [self hasMessageForStanzaId:stanzaid orMessageID:messageid withInboundDir:inbound occupantId:occupantId andJid:buddyName onAccount:accountNo] == nil) { //this is always from a contact NSDateFormatter* formatter = [NSDateFormatter new]; @@ -1317,7 +1317,7 @@ -(NSNumber*) addMessageToChatBuddy:(NSString*) buddyName withInboundDir:(BOOL) i }]; } --(NSNumber* _Nullable) hasMessageForStanzaId:(NSString*) stanzaId orMessageID:(NSString*) messageId withInboundDir:(BOOL) inbound andJid:(NSString*) jid onAccount:(NSNumber*) accountNo +-(NSNumber* _Nullable) hasMessageForStanzaId:(NSString*) stanzaId orMessageID:(NSString*) messageId withInboundDir:(BOOL) inbound occupantId:(NSString* _Nullable) occupantId andJid:(NSString*) jid onAccount:(NSNumber*) accountNo { if(accountNo == nil) return (NSNumber*)nil; @@ -1346,12 +1346,18 @@ -(NSNumber* _Nullable) hasMessageForStanzaId:(NSString*) stanzaId orMessageID:(N if(historyId != nil) { DDLogVerbose(@"found by origin-id or messageid"); - if(stanzaId) + if(stanzaId!=nil) { DDLogDebug(@"Updating stanzaid of message_history_id %@ to %@ for (account=%@, messageid=%@, inbound=%d)...", historyId, stanzaId, accountNo, messageId, inbound); //this entry needs an update of its stanzaid [self.db executeNonQuery:@"UPDATE message_history SET stanzaid=? WHERE message_history_id=?" andArguments:@[stanzaId, historyId]]; } + if(occupantId!=nil) + { + DDLogDebug(@"Updating occupant_id of message_history_id %@ to %@ for (account=%@, messageid=%@, inbound=%d)...", historyId, occupantId, accountNo, messageId, inbound); + //only update occupant id if not set yet + [self.db executeNonQuery:@"UPDATE message_history SET occupant_id=? WHERE occupant_id IS NULL AND message_history_id=?" andArguments:@[nilWrapper(occupantId), historyId]]; + } return historyId; } } diff --git a/Monal/Classes/MLMessageProcessor.m b/Monal/Classes/MLMessageProcessor.m index 0c0427e3f3..b7059261ed 100644 --- a/Monal/Classes/MLMessageProcessor.m +++ b/Monal/Classes/MLMessageProcessor.m @@ -692,7 +692,7 @@ +(MLMessage* _Nullable) processMessage:(XMPPMessage*) messageNode andOuterMessag { //just try to use the probably reflected message to update the stanzaid of our message in the db //messageId is always a proper origin-id in this case, because inbound == NO and Monal uses origin-ids - NSNumber* historyId = [[DataLayer sharedInstance] hasMessageForStanzaId:stanzaid orMessageID:messageId withInboundDir:inbound andJid:buddyName onAccount:account.accountNo]; + NSNumber* historyId = [[DataLayer sharedInstance] hasMessageForStanzaId:stanzaid orMessageID:messageId withInboundDir:inbound occupantId:occupantId andJid:buddyName onAccount:account.accountNo]; if(historyId != nil) { message = [[DataLayer sharedInstance] messageForHistoryID:historyId]; From 4ac649597f31187cd2fb663d43cdd9d9dc84dd29 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 01:00:35 +0200 Subject: [PATCH 101/131] Don't set a default type for mucs when creating them, fixes #1115 --- Monal/Classes/DataLayer.m | 1 + Monal/Classes/DataLayerMigrations.m | 14 ++++++++++++++ Monal/Classes/MLMucProcessor.m | 7 ++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Monal/Classes/DataLayer.m b/Monal/Classes/DataLayer.m index d3fd229709..c8d8d80f4d 100644 --- a/Monal/Classes/DataLayer.m +++ b/Monal/Classes/DataLayer.m @@ -947,6 +947,7 @@ -(BOOL) initMuc:(NSString*) room forAccountId:(NSNumber*) accountNo andMucNick:( BOOL encrypt = NO; #ifndef DISABLE_OMEMO // omemo for non group MUCs is disabled once the type of the muc is set + // (for channel type mucs this will be disabled while creating the muc shortly after this function is called) encrypt = [[HelperTools defaultsDB] boolForKey:@"OMEMODefaultOn"]; #endif// DISABLE_OMEMO diff --git a/Monal/Classes/DataLayerMigrations.m b/Monal/Classes/DataLayerMigrations.m index f9b0734628..12e924d8f0 100644 --- a/Monal/Classes/DataLayerMigrations.m +++ b/Monal/Classes/DataLayerMigrations.m @@ -1066,6 +1066,20 @@ FOREIGN KEY('account_id') REFERENCES 'account'('account_id') ON DELETE CASCADE \ [db executeNonQuery:@"ALTER TABLE muc_members DROP COLUMN occupant_id;"]; }]; + //make new mucs not have a default type instead of 'channel' + //(that means that the default encryption gets turned off when entering a channel and kept on when entering a group) + [self updateDB:db withDataLayer:dataLayer toVersion:6.403 withBlock:^{ + [db executeNonQuery:@"ALTER TABLE buddylist RENAME COLUMN 'muc_type' to 'muc_type_old';"]; + [db executeNonQuery:@"ALTER TABLE buddylist ADD COLUMN 'muc_type' VARCHAR(10) DEFAULT NULL;"]; + [db executeNonQuery:@"UPDATE buddylist SET muc_type=muc_type_old;"]; + [db executeNonQuery:@"ALTER TABLE buddylist DROP COLUMN 'muc_type_old';"]; + }]; + + //make sure all existing mucs get their type and encryption state correctly updated, too + [self updateDB:db withDataLayer:dataLayer toVersion:6.404 withBlock:^{ + [db executeNonQuery:@"UPDATE buddylist SET muc_type=NULL;"]; + }]; + //check if device id changed and invalidate state, if so //but do so only for non-sandbox (e.g. non-development) installs diff --git a/Monal/Classes/MLMucProcessor.m b/Monal/Classes/MLMucProcessor.m index 240d4cf42b..0235708d84 100644 --- a/Monal/Classes/MLMucProcessor.m +++ b/Monal/Classes/MLMucProcessor.m @@ -1451,14 +1451,19 @@ -(void) publishAvatar:(UIImage* _Nullable) image forMuc:(NSString*) room } //make public channels "mention only" on first join if([@"channel" isEqualToString:mucType]) + { + DDLogDebug(@"Configuring new muc %@ to be mention-only...", iqNode.fromUser); [[DataLayer sharedInstance] setMucAlertOnMentionOnly:iqNode.fromUser onAccount:_account.accountNo]; + } } if(![mucType isEqualToString:[[DataLayer sharedInstance] getMucTypeOfRoom:iqNode.fromUser andAccount:_account.accountNo]]) { - DDLogInfo(@"Configuring muc %@ to type '%@'...", iqNode.fromUser, mucType); + DDLogInfo(@"Configuring muc %@ to be of type '%@'...", iqNode.fromUser, mucType); [[DataLayer sharedInstance] updateMucTypeTo:mucType forRoom:iqNode.fromUser andAccount:_account.accountNo]; } + else + DDLogDebug(@"Muc %@ is already configured to be of type '%@' ('%@')...", iqNode.fromUser, mucType, [[DataLayer sharedInstance] getMucTypeOfRoom:iqNode.fromUser andAccount:_account.accountNo]); if(!mucName || ![mucName length]) mucName = @""; From dc85178639a0c2efdc1a1c2b79783d6e8a069abe Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 01:27:21 +0200 Subject: [PATCH 102/131] Show add contact menu when tapping a contact request notification Previously this opened a new chat without accepting or denying the contact request, fixes #1114 --- Monal/Classes/ActiveChatsViewController.h | 1 + Monal/Classes/ActiveChatsViewController.m | 17 +++++++++++++++++ Monal/Classes/ContactRequestsMenu.swift | 2 +- Monal/Classes/MonalAppDelegate.m | 8 +++++++- Monal/Classes/SwiftuiHelpers.swift | 2 +- 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.h b/Monal/Classes/ActiveChatsViewController.h index 7efd519214..4a4aef98a3 100644 --- a/Monal/Classes/ActiveChatsViewController.h +++ b/Monal/Classes/ActiveChatsViewController.h @@ -46,6 +46,7 @@ NS_ASSUME_NONNULL_BEGIN -(void) showDetails; -(void) showRegisterWithUsername:(NSString*) username onHost:(NSString*) host withToken:(NSString* _Nullable) token usingCompletion:(monal_id_block_t _Nullable) callback; -(void) showAddContactWithJid:(NSString*) jid preauthToken:(NSString* _Nullable) preauthToken prefillAccount:(xmpp* _Nullable) account andOmemoFingerprints:(NSDictionary* _Nullable) fingerprints; +-(void) showAddContact; -(void) sheetDismissed; @end diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index ae513de561..c7402871cb 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -438,6 +438,23 @@ -(void) showAddContactWithJid:(NSString*) jid preauthToken:(NSString* _Nullable) }); } +-(void) showAddContact +{ + dispatch_async(dispatch_get_main_queue(), ^{ + [self dismissCompleteViewChainWithAnimation:NO andCompletion:^{ + UIViewController* addContactMenuView = [[SwiftuiInterface new] makeAddContactViewWithDismisser:^(MLContact* _Nonnull newContact) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self presentChatWithContact:newContact]; + }); + }]; + addContactMenuView.ml_disposeCallback = ^{ + [self sheetDismissed]; + }; + [self presentViewController:addContactMenuView animated:NO completion:^{}]; + }]; + }); +} + -(void) segueToIntroScreensIfNeeded { //open password migration if needed diff --git a/Monal/Classes/ContactRequestsMenu.swift b/Monal/Classes/ContactRequestsMenu.swift index bbb8caf0df..da74f14c22 100644 --- a/Monal/Classes/ContactRequestsMenu.swift +++ b/Monal/Classes/ContactRequestsMenu.swift @@ -28,7 +28,7 @@ struct ContactRequestsMenuEntry: View { appDelegate.openChat(of:contact) } label: { Image(systemName: "text.bubble") - .accentColor(.black) + .accentColor(.primary) } //see https://www.hackingwithswift.com/forums/swiftui/tap-button-in-hstack-activates-all-button-actions-ios-14-swiftui-2/2952 .buttonStyle(BorderlessButtonStyle()) diff --git a/Monal/Classes/MonalAppDelegate.m b/Monal/Classes/MonalAppDelegate.m index cc6f2ed251..dc15a8d2ac 100644 --- a/Monal/Classes/MonalAppDelegate.m +++ b/Monal/Classes/MonalAppDelegate.m @@ -1000,7 +1000,13 @@ -(void) userNotificationCenter:(UNUserNotificationCenter*) center didReceiveNoti [[MLXMPPManager sharedInstance] removeContact:fromContact]; } else if([response.actionIdentifier isEqualToString:@"com.apple.UNNotificationDefaultActionIdentifier"]) //open chat of this contact - [self openChatOfContact:fromContact]; + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{ + while(self.activeChats == nil) + usleep(100000); + dispatch_async(dispatch_get_main_queue(), ^{ + [(ActiveChatsViewController*)self.activeChats showAddContact]; + }); + }); } else { diff --git a/Monal/Classes/SwiftuiHelpers.swift b/Monal/Classes/SwiftuiHelpers.swift index c05d6368c0..8d2f659f07 100644 --- a/Monal/Classes/SwiftuiHelpers.swift +++ b/Monal/Classes/SwiftuiHelpers.swift @@ -685,7 +685,7 @@ class SwiftuiInterface : NSObject { return host } - @objc + @objc(makeAddContactViewWithDismisser:) func makeAddContactView(dismisser: @escaping (MLContact) -> ()) -> UIViewController { let delegate = SheetDismisserProtocol() let host = UIHostingController(rootView:AnyView(EmptyView())) From 38b876d606e94f64f787b5ac8c2f5193cb2c1239 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 01:50:21 +0200 Subject: [PATCH 103/131] Allow to trigger onboarding flow using debug settings --- Monal/Classes/DebugView.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/DebugView.swift b/Monal/Classes/DebugView.swift index 33f851f3b1..0e3949ad55 100644 --- a/Monal/Classes/DebugView.swift +++ b/Monal/Classes/DebugView.swift @@ -8,7 +8,7 @@ class DebugDefaultDB: ObservableObject { @defaultsDB("udpLoggerEnabled") - var udpLoggerEnabled:Bool + var udpLoggerEnabled: Bool @defaultsDB("udpLoggerPort") var udpLoggerPort: String @@ -18,6 +18,9 @@ class DebugDefaultDB: ObservableObject { @defaultsDB("udpLoggerKey") var udpLoggerKey: String + + @defaultsDB("hasCompletedOnboarding") + var hasCompletedOnboarding: Bool } struct LogFilesView: View { @@ -133,9 +136,17 @@ struct UDPConfigView: View { } struct CrashTestingView: View { + @ObservedObject var defaultDB = DebugDefaultDB() + var body: some View { VStack(alignment:.leading, spacing: 25) { - Text("This allows you to forcefully crash the app using several different methods to test the crash handling.") + Section(header: Text("Some debug settings.")) { + Toggle(isOn: $defaultDB.hasCompletedOnboarding) { + Text("Don't show onboarding") + } + } + + Text("The following buttons allow you to forcefully crash the app using several different methods to test the crash handling.") Group { Button("Try to call unknown handler method") { From 230eb714e7f2d63d02e886c6f002d9aa3fba6425 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 01:51:05 +0200 Subject: [PATCH 104/131] Add gear icon to privacy settings list in onboarding --- Monal/Classes/BoardingCards.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index 77f7f48e6f..0de18be6fe 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -195,7 +195,7 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { OnboardingCard( title: Text("Settings"), description: Text("These are important privacy settings you may want to review!"), - imageName: nil, + imageName: "gear", articleText: nil, customView: AnyView(PrivacySettingsSubview(onboardingPart:0)), nextText: nil @@ -203,7 +203,7 @@ func createOnboardingView(delegate: SheetDismisserProtocol) -> some View { OnboardingCard( title: Text("Settings"), description: Text("These are important privacy settings you may want to review!"), - imageName: nil, + imageName: "gear", articleText: nil, customView: AnyView(PrivacySettingsSubview(onboardingPart:1)), nextText: nil From d502cf935d7fb7e73fcb59a0c5d7817191b689e7 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 02:02:29 +0200 Subject: [PATCH 105/131] Fix line wrapping on ios <= 16 in onboarding title, fixes #1113 --- Monal/Classes/BoardingCards.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index 0de18be6fe..9e1aa3a02d 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -66,6 +66,8 @@ struct OnboardingView: View { .fontWeight(.bold) .foregroundColor(.primary) .padding(.bottom, 4) + //needed for ios < 16, see https://stackoverflow.com/a/59684944 + .fixedSize(horizontal: false, vertical: true) } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isHeader) From d895eecc62a68ae9b10f6f4e37fb23b5a4645a31 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 03:08:13 +0200 Subject: [PATCH 106/131] Make clear which account a contact request belongs to, fixes #1117 --- Monal/Classes/ContactRequestsMenu.swift | 71 ++++++++++++++----------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/Monal/Classes/ContactRequestsMenu.swift b/Monal/Classes/ContactRequestsMenu.swift index da74f14c22..d00bdd4f64 100644 --- a/Monal/Classes/ContactRequestsMenu.swift +++ b/Monal/Classes/ContactRequestsMenu.swift @@ -8,20 +8,15 @@ struct ContactRequestsMenuEntry: View { let contact : MLContact - let doDelete: () -> () @State private var isDeleted = false - private func delete() { - if(isDeleted == false) { - isDeleted = true - doDelete() - } - } - var body: some View { HStack { Text(contact.contactJid) + .foregroundColor(.secondary) + Spacer() + Group { Button { let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate @@ -35,7 +30,6 @@ struct ContactRequestsMenuEntry: View { Button { // deny request - self.delete() //update ui first because the array index can change afterwards MLXMPPManager.sharedInstance().remove(contact) } label: { Image(systemName: "trash.circle") @@ -46,7 +40,6 @@ struct ContactRequestsMenuEntry: View { Button { // accept request - self.delete() //update ui first because the array index can change afterwards MLXMPPManager.sharedInstance().add(contact) let appDelegate = UIApplication.shared.delegate as! MonalAppDelegate appDelegate.openChat(of:contact) @@ -63,35 +56,53 @@ struct ContactRequestsMenuEntry: View { } struct ContactRequestsMenu: View { - @State private var pendingRequests: [MLContact] - - var body: some View { - List { - Section(header: Text("Allowing someone to add you as a contact lets them see your profile picture and when you are online.")) { - if(pendingRequests.isEmpty) { - Text("No pending constact requests") - .foregroundColor(.secondary) + @State var pendingRequests: [xmpp:[MLContact]] = [:] + + func updateRequests() { + let requests = DataLayer.sharedInstance().allContactRequests() as! [MLContact] + var connectedAccounts: [Int:xmpp] = [:] + for account in MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp] { + connectedAccounts[account.accountNo.intValue] = account + } + self.pendingRequests.removeAll() + for contact in requests { + //add only requests having an enabled (dubbed connected) account + //(should be a noop because allContactRequests() returns only enabled accounts) + if let account = connectedAccounts[contact.accountId.intValue] { + if self.pendingRequests[account] == nil { + self.pendingRequests[account] = [] } - ForEach(pendingRequests.indices, id: \.self) { idx in - ContactRequestsMenuEntry( - contact: pendingRequests[idx], - doDelete: { - self.pendingRequests.remove(at: idx) + self.pendingRequests[account]!.append(contact) + } + } + } + + var body: some View { + Section(header: Text("Allowing someone to add you as a contact lets them see your profile picture and when you are online.")) { + if(pendingRequests.isEmpty) { + Text("No pending constact requests") + .foregroundColor(.secondary) + } else { + List { + ForEach(self.pendingRequests.sorted(by:{ $0.0.connectionProperties.identity.jid < $1.0.connectionProperties.identity.jid }), id: \.key) { account, requests in + Section(header: Text("Account: \(account.connectionProperties.identity.jid)")) { + ForEach(requests.indices, id: \.self) { idx in + ContactRequestsMenuEntry(contact: requests[idx]) + } } - ) + } } } } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRefresh")).receive(on: RunLoop.main)) { notification in - self.pendingRequests = DataLayer.sharedInstance().allContactRequests() as! [MLContact] + updateRequests() } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("kMonalContactRemoved")).receive(on: RunLoop.main)) { notification in - self.pendingRequests = DataLayer.sharedInstance().allContactRequests() as! [MLContact] + updateRequests() + } + .onAppear { + updateRequests() } - } - - init() { - self.pendingRequests = DataLayer.sharedInstance().allContactRequests() as! [MLContact] } } From b9fb0e7f3d29bf840d0a1ae17571b1f506769acf Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 03:49:00 +0200 Subject: [PATCH 107/131] Make blue buttons somewhat smaller (padding=10 instead of default) --- Monal/Classes/AddContactMenu.swift | 2 +- Monal/Classes/BoardingCards.swift | 6 +++--- Monal/Classes/Quicksy_RegisterAccount.swift | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Monal/Classes/AddContactMenu.swift b/Monal/Classes/AddContactMenu.swift index 2c7960cc11..c2ee2385a0 100644 --- a/Monal/Classes/AddContactMenu.swift +++ b/Monal/Classes/AddContactMenu.swift @@ -235,7 +235,7 @@ struct AddContactMenu: View { scannedFingerprints == nil ? Text("Add") : Text("Add scanned contact") } //.fontWeight(.bold) - .padding() + .padding(10) .background(toAddEmpty || toAddInvalid ? Color.gray : Color.blue) .foregroundColor(.white) .cornerRadius(10) diff --git a/Monal/Classes/BoardingCards.swift b/Monal/Classes/BoardingCards.swift index 9e1aa3a02d..b3186e2b98 100644 --- a/Monal/Classes/BoardingCards.swift +++ b/Monal/Classes/BoardingCards.swift @@ -48,7 +48,7 @@ struct OnboardingView: View { Label("Back", systemImage: "chevron.left") .labelStyle(.iconOnly) .foregroundColor(.blue) - .padding() + .padding(10) } } @@ -114,7 +114,7 @@ struct OnboardingView: View { } label: { Text(card.nextText ?? NSLocalizedString("Close", comment:"onboarding")) .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -240,7 +240,7 @@ struct TakeMeToSettingsView: View { }) { Text("Take me to settings") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) diff --git a/Monal/Classes/Quicksy_RegisterAccount.swift b/Monal/Classes/Quicksy_RegisterAccount.swift index 2202a23345..19b02bb33b 100644 --- a/Monal/Classes/Quicksy_RegisterAccount.swift +++ b/Monal/Classes/Quicksy_RegisterAccount.swift @@ -238,7 +238,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("Next") .fontWeight(.bold) - .padding() + .padding(10) .background(!isValidNumber ? Color(UIColor.lightGray) : Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -258,7 +258,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("Change it") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -271,7 +271,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("OK") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -325,7 +325,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("Previous") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -338,7 +338,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("Next") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -356,7 +356,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("No") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -370,7 +370,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("Yes") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) @@ -432,7 +432,7 @@ struct Quicksy_RegisterAccount: View { }) { Text("OK") .fontWeight(.bold) - .padding() + .padding(10) .background(Color.blue) .foregroundColor(.white) .cornerRadius(10) From 1dc54ecaad4f26799c4c3306da93000f37371898 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 04:38:40 +0200 Subject: [PATCH 108/131] Don't show account labels in contact requests when only one account --- Monal/Classes/ContactRequestsMenu.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/Monal/Classes/ContactRequestsMenu.swift b/Monal/Classes/ContactRequestsMenu.swift index d00bdd4f64..cb03d545b4 100644 --- a/Monal/Classes/ContactRequestsMenu.swift +++ b/Monal/Classes/ContactRequestsMenu.swift @@ -57,22 +57,23 @@ struct ContactRequestsMenuEntry: View { struct ContactRequestsMenu: View { @State var pendingRequests: [xmpp:[MLContact]] = [:] + @State var connectedAccounts: [Int:xmpp] = [:] func updateRequests() { let requests = DataLayer.sharedInstance().allContactRequests() as! [MLContact] - var connectedAccounts: [Int:xmpp] = [:] + connectedAccounts.removeAll() for account in MLXMPPManager.sharedInstance().connectedXMPP as! [xmpp] { connectedAccounts[account.accountNo.intValue] = account } - self.pendingRequests.removeAll() + pendingRequests.removeAll() for contact in requests { //add only requests having an enabled (dubbed connected) account //(should be a noop because allContactRequests() returns only enabled accounts) if let account = connectedAccounts[contact.accountId.intValue] { - if self.pendingRequests[account] == nil { - self.pendingRequests[account] = [] + if pendingRequests[account] == nil { + pendingRequests[account] = [] } - self.pendingRequests[account]!.append(contact) + pendingRequests[account]!.append(contact) } } } @@ -84,11 +85,17 @@ struct ContactRequestsMenu: View { .foregroundColor(.secondary) } else { List { - ForEach(self.pendingRequests.sorted(by:{ $0.0.connectionProperties.identity.jid < $1.0.connectionProperties.identity.jid }), id: \.key) { account, requests in - Section(header: Text("Account: \(account.connectionProperties.identity.jid)")) { + ForEach(pendingRequests.sorted(by:{ $0.0.connectionProperties.identity.jid < $1.0.connectionProperties.identity.jid }), id: \.key) { account, requests in + if connectedAccounts.count == 1 { ForEach(requests.indices, id: \.self) { idx in ContactRequestsMenuEntry(contact: requests[idx]) } + } else { + Section(header: Text("Account: \(account.connectionProperties.identity.jid)")) { + ForEach(requests.indices, id: \.self) { idx in + ContactRequestsMenuEntry(contact: requests[idx]) + } + } } } } From 3bad85583585a4b1177b165c0480caccde4ded08 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Sun, 21 Jul 2024 04:39:29 +0200 Subject: [PATCH 109/131] Make sure so remove line feeds not starting a new bullet point This makes sure the IOS_ONLY and MACOS_ONLY prefixes will correctly be detected and able to remove the whole bullet point instead of only one line while leaving the remaining ones intact. --> One bullet point should always be in one line without line feeds. --- .github/workflows/beta.build-push.yml | 6 +++--- .github/workflows/stable.build-push.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index cfe75aba88..0d8107f840 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -77,15 +77,15 @@ jobs: echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index e372161755..5dc77bf8fe 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -74,15 +74,15 @@ jobs: echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" From ee06718acefa8291e42b51a8c1814c40252a283f Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 01:28:24 +0200 Subject: [PATCH 110/131] Add quicksy build workflow --- .github/workflows/beta.build-push.yml | 1 + .github/workflows/develop-push.yml | 1 + .github/workflows/publish-quicksy-release.yml | 79 +++++++++++++ .github/workflows/quicksy.build-push.yml | 109 ++++++++++++++++++ .github/workflows/stable.build-push.yml | 1 + scripts/build.sh | 94 +++++++-------- 6 files changed, 239 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/publish-quicksy-release.yml create mode 100644 .github/workflows/quicksy.build-push.yml diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 0d8107f840..1b82b14cfb 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -21,6 +21,7 @@ jobs: release-notes: ${{ steps.releasenotes.outputs.notes }} env: APP_NAME: "Monal" + BUILD_SCHEME: "Monal" APP_DIR: "Monal.app" BUILD_TYPE: "Beta" EXPORT_OPTIONS_CATALYST_APPSTORE: "../scripts/exportOptions/Stable_Catalyst_ExportOptions.plist" diff --git a/.github/workflows/develop-push.yml b/.github/workflows/develop-push.yml index fa9220bf1b..ea6e9e6349 100644 --- a/.github/workflows/develop-push.yml +++ b/.github/workflows/develop-push.yml @@ -21,6 +21,7 @@ jobs: message: ${{ steps.changelog.outputs.message }} env: APP_NAME: "Monal.alpha" + BUILD_SCHEME: "Monal Alpha" APP_DIR: "Monal.alpha.app" BUILD_TYPE: "Alpha" ALPHA_UPLOAD_SECRET: ${{ secrets.ALPHA_UPLOAD_SECRET }} diff --git a/.github/workflows/publish-quicksy-release.yml b/.github/workflows/publish-quicksy-release.yml new file mode 100644 index 0000000000..590edbaad0 --- /dev/null +++ b/.github/workflows/publish-quicksy-release.yml @@ -0,0 +1,79 @@ +name: Publish Quicksy release +on: + repository_dispatch: + types: [distribution] +jobs: + extractChangelog: + runs-on: self-hosted + outputs: + release-buildNumber: ${{ steps.releasenotes.outputs.buildNumber }} + release-tag: ${{ steps.releasenotes.outputs.tag }} + release-version: ${{ steps.releasenotes.outputs.version }} + release-name: ${{ steps.releasenotes.outputs.name }} + release-notes: ${{ steps.releasenotes.outputs.notes }} + release-notes_ios: ${{ steps.releasenotes.outputs.notes_ios }} + # create release only if the ios app made it to the appstore and ignore the macos appstore state + if: github.event.client_payload.Platform == 'iOS' + steps: + # - run: | + # echo ${{ github.event.client_payload.AppName }} + # echo ${{ github.event.client_payload.Platform }} + # echo ${{ github.event.client_payload.AppVersionNumber }} + - name: Load release info + id: releasenotes + run: | + buildNumber="$(fastlane run app_store_build_number api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" app_identifier:"G7YU7X7KRJ.SworIM" live:false version:"${{ github.event.client_payload.AppVersionNumber }}" 2>&1 | tee /dev/stderr | grep Result | sed -E 's/^.*Result: ([0-9]+).*$/\1/g')" + mkdir -p /Users/ci/releases + OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" + touch "$OUTPUT_FILE" + cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" + + # notifyMuc: + # name: Notify support MUC about new stable release + # runs-on: ubuntu-latest + # needs: [extractChangelog] + # steps: + # - name: Notify support MUC + # uses: monal-im/xmpp-notifier@master + # with: # Set the secrets as inputs + # jid: ${{ secrets.BOT_JID }} + # password: ${{ secrets.BOT_PASSWORD }} + # server_host: ${{ secrets.BOT_SERVER }} + # recipient: monal@chat.yax.im + # recipient_is_room: true + # bot_alias: "Monal Release Bot" + # message: | + # ${{ needs.extractChangelog.outputs.release-name }} was released: + # ${{ needs.extractChangelog.outputs.release-notes }} + # + # notifyMastodon: + # name: Post release info on mastodon + # runs-on: ubuntu-latest + # needs: [extractChangelog] + # steps: + # - name: Patch changelog length + # id: changelog + # env: + # NOTES: ${{ needs.extractChangelog.outputs.release-notes }} + # run: | + # if [ "${#NOTES}" -gt 400 ]; then + # NOTES="To see the complete list of bugfixes and improvements, check our releases page: https://github.com/monal-im/Monal/releases/tag/${{ needs.extractChangelog.outputs.release-tag }}" + # fi + # echo "notes<<__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + # echo "$NOTES" >> "$GITHUB_OUTPUT" + # echo "__EOF__" | tee /dev/stderr >> "$GITHUB_OUTPUT" + # - name: Post release info on mastodon + # id: toot + # uses: cbrgm/mastodon-github-action@v2.1.3 + # with: + # access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} + # url: ${{ secrets.MASTODON_URL }} + # + # message: "${{ needs.extractChangelog.outputs.release-name }} released.\n\n${{ steps.changelog.outputs.notes }}\n\n#Monal #quicksy #ios #macos #xmpp #im #chat #messaging" + # visibility: "public" + # language: "en" + # - name: Get toot information + # run: | + # echo "Toot ID: ${{ steps.toot.outputs.id }}" + # echo "Toot URL: ${{ steps.toot.outputs.url }}" + # echo "Scheduled at: ${{ steps.toot.outputs.scheduled_at }}" diff --git a/.github/workflows/quicksy.build-push.yml b/.github/workflows/quicksy.build-push.yml new file mode 100644 index 0000000000..7ddda0f08a --- /dev/null +++ b/.github/workflows/quicksy.build-push.yml @@ -0,0 +1,109 @@ +# build a new stable release and push it to apple +name: quicksy.build-push + +# Controls when the action will run. +on: + # Triggers the workflow on push + push: + branches: [ quicksy ] + + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + buildAndPublishStable: + # The type of runner that the job will run on + runs-on: self-hosted + env: + APP_NAME: "Quicksy" + BUILD_SCHEME: "Quicksy" + APP_DIR: "Monal.app" + BUILD_TYPE: "AppStore" + EXPORT_OPTIONS_IOS: "../scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist" + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - uses: actions/checkout@v4 + with: + clean: true + submodules: true + fetch-depth: 100 + fetch-tags: true + show-progress: true + lfs: true + - name: Checkout submodules + run: git submodule update -f --init --remote + - name: Get last build tag and increment it + run: | + oldBuildNumber=$(git tag --sort="v:refname" |grep "Quicksy_Build_iOS" | tail -n1 | sed 's/Quicksy_Build_iOS_//g') + buildNumber=$(expr $oldBuildNumber + 1) + echo "New buildNumber is $buildNumber" + git tag Quicksy_Build_iOS_$buildNumber + - name: Insert buildNumber into plists + run: sh ./scripts/set_version_number.sh + - name: Import TURN secrets + run: | + if [[ -e "/Users/ci/secrets.quicksy_stable" ]]; then + echo "#import \"/Users/ci/secrets.quicksy_stable\"" > Monal/Classes/secrets.h + fi + - name: Make our build scripts executable + run: chmod +x ./scripts/build.sh + - name: Run build + run: ./scripts/build.sh + - name: validate ios app + run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + - name: push tag to stable repo + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Quicksy_Build_iOS" | tail -n1 | sed 's/Quicksy_Build_iOS_//g') + git push origin Build_iOS_$buildNumber + - name: Extract version number and changelog from newest merge commit + id: releasenotes + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Quicksy_Build_iOS" | tail -n1 | sed 's/Quicksy_Build_iOS_//g') + mkdir -p /Users/ci/releases + OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" + touch "$OUTPUT_FILE" + echo "OUTPUT_FILE=$OUTPUT_FILE" | tee /dev/stderr >> "$GITHUB_OUTPUT" + + echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "tag=Quicksy_Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "version=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + + echo "name=Quicksy $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + + echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + + echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + + cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" + - name: Create fastlane metadata directory + id: metadata + env: + CHANGELOG: ${{ steps.releasenotes.outputs.notes_ios }} + run: | + path="$(mktemp -d)" + echo -n "$CHANGELOG" > "$path/release_notes.txt" + echo "path=$path" | tee /dev/stderr >> "$GITHUB_OUTPUT" + - name: Publish ios to appstore connect + #run: xcrun altool --upload-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + env: + DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path }} + run: | + fastlane run upload_to_app_store api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" ipa:"./Monal/build/ipa/Monal.ipa" app_version:"${{ steps.releasenotes.outputs.version }}" reject_if_possible:true submit_for_review:true automatic_release:true skip_metadata: true skip_screenshots: true + - name: Remove fastlane metadata directory + run: | + rm -rf "${{ steps.metadata.outputs.path }}" + - uses: actions/upload-artifact@v4 + with: + name: monal-ios + path: Monal/build/ipa/Monal.ipa + if-no-files-found: error + # - uses: actions/upload-artifact@v4 + # with: + # name: monal-ios-dsym + # path: Monal/build/ios_Monal.xcarchive/dSYMs + # if-no-files-found: error diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index 5dc77bf8fe..9cf5cdd4d7 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -17,6 +17,7 @@ jobs: runs-on: self-hosted env: APP_NAME: "Monal" + BUILD_SCHEME: "Monal" APP_DIR: "Monal.app" BUILD_TYPE: "AppStore" EXPORT_OPTIONS_CATALYST_APPSTORE: "../scripts/exportOptions/Stable_Catalyst_ExportOptions.plist" diff --git a/scripts/build.sh b/scripts/build.sh index 9bcb101369..d0b31bdf3e 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -42,53 +42,55 @@ echo "* Installing macOS & iOS Pods *" echo "***************************************" pod install --repo-update -echo "" -echo "***************************" -echo "* Archiving macOS *" -echo "***************************" -xcrun xcodebuild \ - -workspace "Monal.xcworkspace" \ - -scheme "Monal" \ - -sdk macosx \ - -configuration $BUILD_TYPE \ - -destination 'generic/platform=macOS,variant=Mac Catalyst,name=Any Mac' \ - -archivePath "build/macos_$APP_NAME.xcarchive" \ - -allowProvisioningUpdates \ - archive \ - BUILD_LIBRARIES_FOR_DISTRIBUTION=YES \ - SUPPORTS_MACCATALYST=YES - -echo "" -echo "****************************" -echo "* Exporting macOS *" -echo "****************************" -# see: https://gist.github.com/cocoaNib/502900f24846eb17bb29 -# and: https://forums.developer.apple.com/thread/100065 -# and: for developer-id distribution (distribution *outside* of appstore) an developer-id certificate must be used for building -if [ ! -z ${EXPORT_OPTIONS_CATALYST_APPSTORE} ]; then - echo "***************************************" - echo "* Exporting AppStore macOS *" - echo "***************************************" - exportMacOS "$EXPORT_OPTIONS_CATALYST_APPSTORE" "$BUILD_TYPE" -fi - -if [ ! -z ${EXPORT_OPTIONS_CATALYST_APP_EXPORT} ]; then - echo "***********************************" - echo "* Exporting app macOS *" - echo "***********************************" - exportMacOS "$EXPORT_OPTIONS_CATALYST_APP_EXPORT" "$BUILD_TYPE" +if [ "$BUILD_SCHEME" != "Quicksy" ]; then + echo "" + echo "***************************" + echo "* Archiving macOS *" + echo "***************************" + xcrun xcodebuild \ + -workspace "Monal.xcworkspace" \ + -scheme "$BUILD_SCHEME" \ + -sdk macosx \ + -configuration $BUILD_TYPE \ + -destination 'generic/platform=macOS,variant=Mac Catalyst,name=Any Mac' \ + -archivePath "build/macos_$APP_NAME.xcarchive" \ + -allowProvisioningUpdates \ + archive \ + BUILD_LIBRARIES_FOR_DISTRIBUTION=YES \ + SUPPORTS_MACCATALYST=YES echo "" - echo "**************************" - echo "* Packing macOS zip *" - echo "**************************" - cd build/app - mkdir tar_release - mv "$APP_NAME.app" "tar_release/$APP_DIR" - cd tar_release - /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "../$APP_NAME".zip - cd ../../.. - ls -l build/app + echo "****************************" + echo "* Exporting macOS *" + echo "****************************" + # see: https://gist.github.com/cocoaNib/502900f24846eb17bb29 + # and: https://forums.developer.apple.com/thread/100065 + # and: for developer-id distribution (distribution *outside* of appstore) an developer-id certificate must be used for building + if [ ! -z ${EXPORT_OPTIONS_CATALYST_APPSTORE} ]; then + echo "***************************************" + echo "* Exporting AppStore macOS *" + echo "***************************************" + exportMacOS "$EXPORT_OPTIONS_CATALYST_APPSTORE" "$BUILD_TYPE" + fi + + if [ ! -z ${EXPORT_OPTIONS_CATALYST_APP_EXPORT} ]; then + echo "***********************************" + echo "* Exporting app macOS *" + echo "***********************************" + exportMacOS "$EXPORT_OPTIONS_CATALYST_APP_EXPORT" "$BUILD_TYPE" + + echo "" + echo "**************************" + echo "* Packing macOS zip *" + echo "**************************" + cd build/app + mkdir tar_release + mv "$APP_NAME.app" "tar_release/$APP_DIR" + cd tar_release + /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "../$APP_NAME".zip + cd ../../.. + ls -l build/app + fi fi echo "" @@ -97,7 +99,7 @@ echo "* Archiving iOS *" echo "*************************" xcrun xcodebuild \ -workspace "Monal.xcworkspace" \ - -scheme "Monal" \ + -scheme "$BUILD_SCHEME" \ -sdk iphoneos \ -configuration $BUILD_TYPE \ -archivePath "build/ios_$APP_NAME.xcarchive" \ From a053112c30934353c4ab2244bbe608cd02e58d9a Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 01:40:17 +0200 Subject: [PATCH 111/131] Update quicksy icon to fit iOS design (always background color) --- .../Quicksy-ios-1024.png | Bin 74960 -> 62062 bytes .../Quicksy-ios-1024.png | Bin 74960 -> 62062 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-ios-1024.png b/Monal/Images.xcassets/QuicksyAppIcon.appiconset/Quicksy-ios-1024.png index 7239ddd96887b23fd05c0a7b20fefe118ffa28fa..00cde6f443a64a2b795c7b33bca8dca5cfafbab7 100644 GIT binary patch literal 62062 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π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 z ztq*xxRI2TIg(N2qf2KSou5%pSZm;h6(ODIN(ye+w~kx zw7exg_^kc|7etf?tilQ?4+wM-B?1Dh0gn#n6t|)S8Ibb@0lrcj5Z|`&l)$CAG6nGAdN7{7yJS(u+uB;2G`a@Itb` zACd6e5q}zi!C!*QR2`5=8T*b(v_A6k9m1Tdb$8L;TEo{s2bAl;e=>g+r`H84w8D_N zBxf(%P$(`)3-L*0&owW|o{6JSk8N=Gimn})=5a8i!HNf8KXEow)7pWC#YV2ktot&* z5={0u;q|KMa%s!}>#;8$-Ma7k6Wa5(nJ*3VvdGv}bS&ORG$$zFanQZ?B-N6-=mGK7 z$wwmY2$_?%%!%JUQ%Gw0v(P^G;o!?>Z&v3;(Uk-{9CCA1gs>(yLDyBPgRy>r<^*&l z4MdK@A!ysO0)Vh1?$@U5jDg+qz9xw;(Gzw;y57Y2t-@jw0*0#v-S*o!Lru&e)1*1rOY&)^xs@M`CWG+` zB_4u(EA<(91T(v!(Oghfe1lhBDz@D z+N$4;$8PF|<-~>UVWxh2NK?Fd!;3SNhIF73Nk^E`}Mu>mI zVZb3hIHZ@9)9Q3FAMYvgJq$*9Kx+c}b#HjcK(J0ZaR8h_wW(AR)A(x^wJ~h$yj^g- zy=T-?xsrYS1ReYuv0DO^#_*Qc-1%@O7UFbX)QDc^5R0|4 zd%#j~F>46&)7NX^sV0lxJrB5USMSbwT^AmUwOB5kgZYFHne~1NetOpv#}SSTub zH;%wCVG$3>=?TmUh?ckUkS`ItW6js@>vlwDVUkFsDpRXS%*WI~ZgzWDD)ETiq|>BG z;!ndtBFMogl*=0^0;t8MEd z1Bvy@8A9ir%@!XeTBHn8U9kpQK*Q^_n&6eYY6vAg=2+}1nc=>mloYE3Elh?5OI3PH z>hSK*S2$8PlRq&O>hScIc z-a!;f6)Iy`tyhm!;6dHU)c7Qn6blauI`F9KWvhpm_TF465^U#DCClemGa`wh zt)vp56s)J@)k;0y&^!@`f#fxyN;RuFAmAq)I=cpiIw3)w@14yEf1bNfP(ir0y5Y}S zZ2?|C{O>6BMF@>8q8;D2e%n`t*(TyJ_R#~c_B^x~QZEKoS)NkJ5u`&%N;^bDIAAaU zNv8Nim!IBBr(?B~wYc9!O$2Kv1vO#VM;y>{GLjnAPa>Uy`=cuI)7p;*tPukHVSo3C ztE$JL^W%3z(ORC&@#=E7ZOLujKy|qp*&U8E-;KuTsc;2Q8UUrf=S>b`Tb>5#5isoi zB%pZZof>#0m<9aARlW}tMpN|jOcv8l$c_%{POayaGrpb{)+?S@0<`coHFR>tz|=MU zmiGvOWtt=FXn{Gm1An*I{`u*%K#3OVNWsAKz*mm2#c_J}U zYurD8n@_rF;z3&lE|iqa`k26qvM=u;C%cp!h`cIr(QhHae6k3wX)z40_O|x-)Tskc zLHzQQtXajv3|;>g5%B#w=<>%w>&t)Ok(f)lVgH_iFK^E)*uKxswY~xgTM>H4M>iky zwm;X)QO!4#c2=M+h}1gdFhPesq}7Ylnjpn;#}@~+h};nR^nqyte(ODGMYW*!w3oZX1)IB=g(1X)W z&$QN{q!Rdi-2&`{mRa}egvmmvOn$5PrwwnEKy83hGpBd1G)F85-Np^IO2N`jgqN@&GnqXXLA(=!0%fGYWq$LiKLsXk}_jM-2at9Medo_yOJxt$qwyZO`Ej-j5Zb?ATxDU62&>)o8v zPYIbe96FC{+V<^xGV{ck==P(JG_R;{-GDNoMny^_FAI*r>(ZiGYxr<}jWAkTp56-R zjc4$;Q4&D{m^m^B^JZ?@k#l!X*CM)Vz8iAJz6q9=d$$7WS6s~r0)z19DUc_Om@@?7 z+V^MKMRW}p`cuk^&p%(^cD~ZtaiLZ%xEj7bjkZEzA426?uYYagt&wrkNMr%U;{DzB zbEfa5xk+nLSS4uYhF1aS3t3IYDT}hG^VBdJk-#na^=9qzdo~Yp#R9*OAd%`h_h$Gp z&RZ$yVBULwK?=<_%=nrP$Z3tB`+44PUvAMrl-)NG1<&**=M|^ zaB5{E+fpUBt~S}*@peQ*zg3R2Efi}?PRDjr3!phpqD6!sG2IlUVcX%`T$eT^? zh@N?F_|;PuQ==S`!U0{_bUEEvcgmmHB^PeJZeg;% z2}!^3c{r=PBfVa+gaE{FauT#5*k8YNR1mc&G>JpPL`ve-jE?xH{njK=GtGg}`+nMnfH1z%QTyI4j zAdoG%`QRo?Lj!9gWi`Ey7uj$1mAS(F4ac*7brG-CRZl?yWgb`L4X709+E_g?;f}8? zp|gCjZ=POZE$g$}W_{Tf#1qY5W;14l)k7$ev1x#2-dv_=_^h|VyCnqr#S))b<+!k2 zssSe}#Ec0E!PKWn{2r-hByh&EBARVt)1LC1oYw#p98tfVtt8Y1tu~ z_95^H*^oul*sfnZzI4C}j6>xtuv^B4#^m*s{sTNgCp93-EI0$6$U)G`K;|1_ z7Yyn?%(t`Eas@k=O|{3PtbcR#y@b{v|#^kxCY zL=N>T9kUw`k;7!)q0OSN7oTs$ml!)aM7!Jf)*V^At&KPSf|m;$`7>n&0~W;3mj40 zL!2+%-hWI^pWpk{F2U3z<%A`Wd(+HQS zb5ygKv0l8G!6b8WGOx|QQa{igoBiwhg?sFS1clViG%VGw=J)f%7Zo9(LiX-}EDwcx z4&~W|VksfL){#KBDEEWYt~Lm`ivRKC!zZrHCdAN;((BLOyzQ*#6Dz{5R%=h(_|Jym z|Gfq4zwAqIm4k;e34(%R*SWqhz|p|}e$WqK7x-*|iy@HWW$@MPKVAoZB!Q6^gH@0r zOf35q2q;K<|Ld_Pg7g#`6g5)_;pFr>#)07IfBpPltvNv7PS;*@?O$Dx7v~2cxc(uu z{NHBLWYz?mRMM`adoX}30)`&Z5B^I=|MBF*PXh1_feK|n2nSm{LfRkv9}@!L6532E zU|J|8h2)mRCgz`?(V+D8e{Bfd66faHrGTM;|9mnya+Gj@$xv_zaWDU$L#X(Ch|ddk z(9hM^$5j106Q+4VjBoZI;&J*p5U=nV0ur};Q~uo{K`oMKsa`|^F(P(Das2UI(^NA1v zpN9P>F@UrIf$@rx0&?lz>jwDVtt79 zc^n}4D|IA5f_?RJ`F|CK`gx{C`zhtds<)0k zl~JGw>e3Go#0AB91+THb`^$j)TzK%)v~5-mGFu7q7%;-+E0Z1=Ch&XY;O_6KCi%>f zdhtxlRy_6!fm0pP^n#s}mt7!usTK>MDAP7m2TdOOZtBtZ6hXJ{fAe{2#T=tPvC%4h z0jbE#-5}}l4ULOH{tV;N-viBN<4$JVHtU-<{bvVb)fc;@XM6L+)eCF1`%_m>&li=E zNChmZL_wCe0CF&^;N6Qu+kM#maCKxv8VWCoo?-eiHKC*Qn*iSo%}Sw4{i&PZ09||+ znTF^<3BhE_!rpv}63voPfroJe9uIQ`{%1&PsyUW5XQQnv?4J6Oprkx^j2|8Kiu&9k zSGOtypbIMf!tTyak%>}09|r`R@Pp2s0{P$-3mD-G57o?h zU_d}S^n^TWO@t?wo9Ng#?(&c3zTPwvt5Jw{+y#3;xKVHq4)9v!z{T1B$V+MX@M zd27kGgfsDZc1;v~2$YAp$oW5~r*E&O8fH6$J8u-6?G~#AS*kMx)Y(e~d|Ht;&1!g; z=jIDy^Z(06t68u`)|N91FKEY3jiu_opT3^1TN!>wpFR9CeXh`zCcWl0UdCPudaISq z?q(lV?p<@JbyLkW%Z52g9(cNfN#-LDXMP;&divG*$#9bq6!K*p$KR@l34jM?NqG-ta$#b?4ZbLhWzHbV_9K=fyRQWutW_Gin&)0Mr2G_AM0 z_p14~vc~tRqSEwTg8#dyC_89!b;BD5$<+Z*W4mU8u;bP3f@Bjp#(JMNA}DS4Ad7j` z*5VtX+6n>DucUxi=eG;d@HIygEs&>4K;%r`*hlY3{;_$_U^PR{B}1|cnxLSs&^K#? z_-Ox!XuEXK?sXT*azRC43{$Md`}{M|XEE|jld(wB<%_)t5*`Lj3AtGPqHfgY3SeDl zb-Atz{o-w33!i@!7x7hyNsk87l6PICSX8Ba^)ZL1(uil{EX%u zh@63N$-z@J0h{#ofrvuF&L(qnN@uNW=bkD)#F2dHf&)~q-L;J zhaU%7#d@sTaTq%=A@ax&hCE~L`6&`xY zJkq*AfU$7%X6Ma!99RyWg(`+y*7W2siqG-ZjwHuNezW%U?4bXGus3X2O})|%<9zDc zh_zRJ9MZc**?c^vhQ^^C%DSN;b6}HTQs4OxoNFb|vN>DygiKFh{(+2_uBUene*O#! z4HAqVj0P3s?Cev3k8CAn0)~}vmV596&|kDM(S_QJ%f-l1%e+cs6CD{*plaqhOdPK2 zVJNB-Z~C=ZOpOmip+q2-%wxqb>}si<2%I{>OQGF0K((zvYO~0D%<8&Kv1W3vsi~Uh z{HvAdN7MPppknbg|2d~DIM)#c&K2K2g@Mi!?|IJTYqR}7JCmEo2cSHBH@|xwSfO09 zb_Y08KoLQke(pwT%H?UA5hp6fF7iCyxeVV<^&wbETISkDEUPMFYcKhPzr|MY0Dl9; zmTcuJcpF4%0f`^H&?cA7@_=i_y~@4ElKc}>po=v3uh%B*QA#2!wP@TNV zZ8y_DT1j**8TE5){iIdk;zGUULlh_DNOaNg)pT<;ryu85-=ucqWUJrPiJi&D;s|z% zL2#2tIRO07gz!i&z%;s-Jrq0*7trojHGeeh_0U7bVZwc3(*Tdb*Z_4Dp)uLTSqxWF84FA+z@r!Qietd%mO~ z`{UNpwnUF)(s*62utR(mj9X26^Hwn8-7m(+>S&wI$9|dfan=l=MTr{o?u704)CJ35 z3?fy!_j$BgL3immOwJ%y!+F?v@RMSs<9cpb^~!`wEzg&M4N{oht7CAvrrAB_Qey_K z&Mq>`SXo@|2>H;*dy2I1V@EVQhe7vmvLW+B1he&2q;TiNW!S`3M=?0GQjxCLQ3}5` z9>OT6m;)s zyFkb)5?e=V6q_a_KK=O99huasSj2;J%~#P0LTs-ym#oul3y5d6FnFaYBUndXTg za@vj;ScIoV5#0<8GCxCUKEH2g5OlUG7>%kM9$bX<#h*L>qQ1Vv(oB{HSVvN^jg?Zp z<@`I6x2VRS^bS80{}exnqI8nV!8lo(9t{%v4B1b~4Pxo)0q8!mK4((dbH-787&I;3 zi{cSoAt{WD@BMl+t!jrZ?F*&ECG#WxTHaBvS`TG8sF7Xir;;?Q;dwEWF3x@?Wvi`= z`0Xq|vX4G#-!irU59uk0b+DQ^xE?9{FP6$gSW0u%K#h+N>Tk4>+D2uQE|2)yNwu`7 zj@GSf5x~%@0T3^QNXhIQH@Qn95Y&jViCGXWfhO<2-c^UiAH)utohr$y-#nOd#37d7 z57r0Q?-!}Z3;tolT0C=C!bxD;Lo&D7pP$>u zCuSz8-bCKrqX{@*$JRBay%;n9ZCDrCqQkREbDgv5clJ@otp8|rHot-sEC;j*-&L;o zxZ_WtZT-x~UFOo;$*{Sy{)@FF4-~QYYP4}OOr+ub62nac294a)QTH|!dm;!=QJR;h zYE~ZqRHHSx%r=;BGk~rrco}N^x@Wy<_PsZSoX*$d0P+?LXS!{Z=8nXd#%}m-`Y%KX zXJx?0fe#;U&mPADbMc#P%=aSflZHS}pu^$e^-_LtpRR7`1hR84-E>PQkSs`DGe^UP zqE@WU!Swk^G-^}uQtPLlLW&X$L~%3?ugV;QIm9vFUR+vP&(M4}x@#fvHwOPXKBo)> zX~zsf>dbp>_EdFI)zQAocm8?WuKBpNe$i~vOe7D47sx}m7m&V2Z=7c7wLAWMCMTiu z&r(&rTM~_D>q9Iz&l%sFm1TXMMM=RUSh3gg&ljidI|zZEXwv!P3IE0iBC_y_hg;rl zWD5xNurM1i4S)B+ZhGiat|7OF@lhh4#R?MiDj+nWz3&MXfj#jLawgAOeF`> z-mhEkRk2TBsD@bXzeYFq`=X6!5^mjZiZdzB1&4N_m7_aCu$~w>i%S#+-(BXgD$Vuk ztHmz~AAJqKAK1!(Ku=dDIKIs^ip%%4u!63Nu4@WpTnhxyk4{v9LFU67H{=H=6zg%J zD`XWD?`!;LPxk3*M?~pWljacRJi0l?V?Ph-2rP3uSjWk(hr!Dn3&|YPMl(2WrYcj;8Lh+!iQPu=%|1JAQETuY6TY)W(&jUxPz znY6xns`;Sp(7D9qtsFB0#;pN4Z5r$iR>qDg3E*OHwv0HxSJ_x!?@juH; zb*rNVjQx7neY&}c-t%x`Yk}SeD(AO%r~`<>v=-hQXc+3C(xCHeN)S zUIHD9BX3vbI5DJZ9?LuYCBppEgzAl6v%VH}K}6nSD-CqHzt zVQlbCDwAFB27-wx8kieCSh>2U5C?c0b(nk2?k{YE zcVDEu*MAd6sH{j7nMQwl>r;u#tb9;jcp();uKYtqb*wSNgTiFH-Ol^arsZt+Et67H zS34T)-fINx2kx3b0e!dT-@rMZ?Fb(fN+0T9*{a_5Sidx_jN%aZbem|Hae1+ zzqpvdGC3B#Qumm=zqZv!G3_=5K+{n@Mrbb>UI$~?I;3(kX$+}j5o{2I>V7%|bV~gi z1kVL;Lfw$_Q!STOb3r%QulFi_?=Ip#Gx`Q5zhurf9oW6Nx(NDYPyKP4zvMN30|nb| zmt;_9QxMJ)<@@FHe*e&hA3G#YJ_m0xU@i4BC^l2K`WttQD~>M;w|0;z9J_wlK-M~u zvknGoz3#8WZEu-8>%8wX5Wo0#t><=2!BoRcS!3z$fCBV5Lo!LO@xmE|NeekfDNx7` zd{-cx_`v_GG=%zr1jKS-?{~)z%X99G>Ss49M1fjqd%k*hA5uWx8AXBGz0Y;dA0+%$ zKzKshcoG@Tn^8{cZ#AH*xE&MC#6*C3F$RLFQYt-Awd!I)NRfl_VXso2_pvx7Ox$N4 zi^Yewa$1!Tkf3~k4YizGe5L0d$WEB%0A3Q{n*lWx)F4^luuf(Vj!LLD#l}g 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π&=&Sedp>n(XJ;8x5g3KEnsW&SnhV2y0;oEgdt1m1LD%$UV?i+dH zLPeL@f6~(+Dqr9jS+emqct?BB2S1-9yb;6CxwNAFUYc$K>0jRr$7Y4I-=8e@b?n>Flp)^&fSUdp4k3w)m~2P2+=in-AfR#fws8CX5|@4g zK{2=p_!{`mG3Fb%3Kpi2aA4lU$+~HSjmN>GE$yoUm#%0Z&l$@e2Ev=`KR!&%Ffofq zif$34_Im)C)AqfHo!sffZmhuZ_=26k&CG4XW#`V`)WwvHmae=C!XG;Y5qEy=wSzh< zbrfJYNxxIgt6z`=P=DK(`$*n4pNU<CCGM&x7k7B2c3^Y-H855oxO$@D9!SNk!G-Q`3zUl$sv{~sjPu#pha|onuOpN74!@~kr*C9lvkq<=m)E2VtvpmzM zC3dJn&_6HViH3(cmhKw)y~4bff~+P=q5YJ%o%vtpzaw$-Z8HoE9V>6V6#3UqlciDI zfjhjO545?{WnJ$c8m4{tTwDLQ*fI}MZF=XY@obQ(-mn)cK{*!LhlK;VfNkl4w<2_a z5;#aBA@05JpYIF*&jNTf53tJ&li1un+;Y6B23;;P@Ci+9W{8&_^e{x7LP%7wDJ?=^ zCd8~@k61S3DQ-_e1rdeUe6>CiMgmnHSe35(qhrU!+g*W1F4Pcc zOc4*&tI(&C{Dak|c+ra=ZMs_?pCI?>dw@|5ioCz8sp4efd?RM0gVEIQm%IKOnLuPz zJ7h^A{d(fM2=WuJ+P{kP_>nyQ8ay)wi_*(7vQnaoxS64gKqz5YIIDPCGf_BvP9DZB zDDP8v=(wQ!TsSZ4W{kNDL0RXRxtS~y_Y~n5AeqAX&w5Z4iSAR$P4Mj?z7t(nY{z0J zSizMgZ-aLB*SE~W)dforTJqQ9IlW|Ujm9$zM;P2Cdg_~h^RxAU`Y zNc9AWG63z}K9hr1n#;b%iaH7TRa}WO3t{S|IA_KjPYT< zB~1TN!gq@9)b5)5ERPpJF7TSJ<#> za*(XK$j;90V5NIyw}}bZbdmj|+O+!3*;iX{T*paA7p_AGOtN?EtoDz|UmQtcMIik9NajxcLh{F(xCrMtlqZ*)^-q7C1E^7rY(Sq!`NzhFz) z?zw1)s6EyFWD!WhfI?>udGV=7wRz53+@9R{$!n01<P z+?*8+J~K3KodUys`wu;cK~kwbg^hHZ?)Fkw*=5mY-ER%NkrebeKJvm~Up1%UELwe! zWZ#-*hD9b*zEp>>OC!tr-AP=EQjfe?OF*9n1q6B|#>Iv#`O*%X(KTHICiv|vM9~gp zVc1=zOqD1su+}!qdTPj%uBA^gDid0f&~ReuF4+c2h_`7hHk2G46K<{33t#a(ew?+s zQi?}cn<($u!el%jx@A<2B$2T&-Flul*|4#;;~J9(C6XLKIB4Rp!{gh8?OmJZf~LW( z>t+VE8Lla%a4(xb=v+rcC%86bU1ph|4^JdAG zaekV&SWFVkWWaw}B0^VRhLl+K!JNw(7@l_z7g|$@EB+MBvLk49evKyjk|n4MIAKj_ zg&hee7p#ai#^_GITim5D0*nFLNeZ;qtn9#o1DW@ar`s&uH|aZRq?sce4I~!kj(n5lXyBIfkx94HBYZ2l*T1#J z(!XsbS!v~nKqM$EA5HR%CnyssY^*J>q2s#-aZeV7A#DGpdv8;fP^Fv@~dV18M z9f=2pPUk)b^-t{B7F_+rAMI4A7|}TMImv-r?CVyV1Ft-tBC_WBaP%O4j#K zfhUD}PY{8}*V;DJ9=9`+O_zxkR796LRyBQEAAdA=>x!%DZk0XK`uD|;T(an zj*)_Q?5|&4t-s|83v)a*+AA~wMJGeqd$JPGU-GgysWwVxCFpQ;R0VDZ6svxAtQ`o{y%%X1 zkSPYEpWK6A2|5(iA@R0l4rD(#AhU|fTdw3k~e@A|7_^j1?meK^0kJU$+1e?76K)7^;~kd%^y*^Hwb%dEpK=lCdHGE zM4e)!I2^_$bfM^nQbT}Y5lY~W?Sbg`u$1bQ!()2U$D+TR%Gc7zg*T#W zf_!G^g)M1SB2ZKg{w9`BGsq85*gu|lX8(AtYj|ep4e$C6RC4~xPQ&!XM`5LOJjZ<7 zi#K!j1{{@?H+<}~Jle=EyKr=hXb5RU1+E=oj~*3fW_yZ?;%U^k3A6bqeddtFyNB#< zHj8B+JAGgr=Tf9XD?=x|6zg~FY~qFsTkFoGNFjS$ds`jf$Mjq?m%e_@Ftp5+Ncoa*ti;Xj62}#;HdnU4{j;0DgO;& z8q9_TT`gQK{yXNDqIRXzXHML4_SMro@^OdQv!7CwjC%!SkK5dmd-L{ zU9Ip9`o5g*c|%jK{}s00?T3>rv6a7tzqmY|!W)-B=AqEnXJ>wNx=!Xd9U2L9NGdE! zmhbCFiHaj1(+?HJ+K)lGexfJeIBV=|GY!gG03)LC~o0fIPbdr{-Ewl;JEd?tlpcN})v0|tIN4E}>4 zcq;t&-yon=qQ9RB|2R+Izym>ONJB;OyfCFA@ee-o3M`Z}1LEJzt~UsaJT==02J;@W6sVq(=>DsR|OuHMz;Q+zuHdeq{H z=!ljgeZ1X;2uG>DzRAg)sh#kJ#>?pyQ$DXHWa4j>mm3EifRmyy(B3~d&2gOa$}}nv z41q-B7_pL#l3iyswUfkin$)f9&nP3+pNh0!q;ojF&8zBAh~3E!2-*3p-e&)@bIPqR z^*FO5=hMl{OGROMQ3BhUucqBrs#{}vz8dcL}mux(GU`~-%QW(B6 zVB%{&VDzfB+DSW)=lFKtYC+|S8%l_=M^l$OD`4e)({$LgOWT<~Imfrns)Mrm6GqMmT*(&4X1BrLk_#M_ zt@eB=i+8;*`>HN*+2GLqacVR{H0m)BG3NY1sYZ5!_$Uoh-Sv)jHZmw)I6YrQIQ*Cam;X)o#sFW8SpMS)FOfKn`%?Jqi&^xlY z@y1Qs?aQg8AM=$A=XbidBV_r8W)eR_$9}2D35|i{NL;+h2%150!LPRWcAl`lLd5*^ zPLj2I`6g3d%|(&oIlJw5u39v$A9!rti!SPw2Ys0{=J!4w=2O0u!9bood2p1u!aRAl zr9n)nB_@EI%VJ^oJZQ`3K%}=%yt}arh#k^Gjfkd3hB|b;ZHTCjRw};lzXV+O#t;)n z`mnC3inKVa!gzHvi{ZeIRh_Kuvo*CUy05%dqr;i>Lhp|mWm($4n7KvJ&2TJ43{}^M z+V6^ugr<#nDIw7|%jtCeB;Ajz9=9Zur#6`bbkZMj{A$$MgWTR34STsRmHDMawkE zl{g`{aOS|8Z|bW9o9T0w)$SKk0;0EAbl8ukVTy_?ULCg#V;~8sZZl!pv0A@BhA+MT z1qJJ)+yxCC?6uRaH|iX_I^HJdk4S%bx{t%r_Jz5EoWZN4K?ABKwbcp%UG@lR*k-<< zBZ+c zJo7=;_VI2Tqy(V@+g#hdG+-=-NF9k$fY;H1_+YeDDov38tTQWTTevs6g*YbOT?{tl= zs=jG)vD)nV>a7JTp+%Ja?n&~Ak3$c%>kkpor~qI0jOR3pf7~FDw3w z@9hAm5D9fw7~(xcZq~~fzvV+jLsHXYA7&RvLh#3=z29C+cuhWo56zi#cs1Rz zF*x4RP6||jLEFInFNK9A+#~3h9PuF{VT;o&ZwT@3aNeEiA3Njph4ndvuc^YN#VlV+ z&ZnARS^6$9JvJbe!dIS)qS`%64>(JyyizUjT$A)|)9WT~uJ-kaca!X9Y7=z$?j@^; zQ*hGK6R9b>8v*NigO}%vM-G==A6s8wG_>Xt1XG~C197(R96|*kkS?0TCzv0;itV!s z{OaM0cd9bzaVddw9tK~tw1%%_sOFU{GmI58err4?SuF92gW#)Fc+fDEw4<*Qu;LB+ z1~WDRQLTHV*{5{EnmYq`Hf~6bY7pclhJ9^98B*555L)swj;HzuzyBLmFoK3K`ak6} zN&u3#(23&XuC6wg&5ajE^^yiHrd2;7?hoICB0$2=Sf)SzK4Fwc`!+$bg+TI8meT@I9=aaNIqNa9%Oh-7FxhS)6(C+SP3rxd99PTjusO zgvDQC7>I6UIgLetBk(==v6!%a;o5pzx7Y|WO=ws8>nqlDmx-`$^}Mx&Il{;}fIRt7v% zQ`^sYcl0@C5^{EhV=iR*UE!sKRNvr8tYLGkMfDEXja{;5x(GrKoFu_MNSDYaPVD$i zpV+Yr$$K|h6F|fdL<7MO0g@L>Vx3=~rC*S6h>krH5~fF(^c$d{lTw@rVEj6yz~Pua zee|MdDJ2vCeN;sYfdvzygmW=PS+xl)p;dK`@$|yNw6B1y%K=c*{I(j8!VrU;ZT)P{ z6!|rq;l0tz{X1WredDWl<1TMhsu=uecOB6yBen&BYdjM*7;1I^ac%%o(JRsb;&#OS zX|E%qOH7{HZI%@e^<82Y2Ix*Tg$W?GG(*cQW@AP?gqmX%9#$v}5X?gZ;3J}tn(G(> z$yA8$=*l@tAU@LH@HKChITt#Y01ufY z>CK5_wD{?IQsG!nhX%XR^KyeJ8FrgI7usklc8|=di0coSeuA|ya||`h`Vcw9!^Y)G z2#F-Yp)IYID(^vXa|*hCTlYNfwN#x=|6?6nRY%Y4#t$#p+q`@*4K$NQq2;ZSwg!_y zrwL)18bL*($RMDMFXVI#jdU!CMU|N&a`UaX2c%yTEQOTwEIJgXJK+jT`aC_x9WNxO z(H!{*aM5Q17s>6|+(+jl#?`qM%3I_X>7rW>$6$yTLa8M`n#M-OY2zr|&sJZ3F~lafo0p_1;rb;mG#{M4 z`r`$G#m+^FX|Q9^PXIVLeZ4gfVQnZ1J=Gn#BUWP>Ryy#fxn)f9m3PAvP4wImz0TLp zb7szrmBJP`4jUd>3MH(10u4(&r?{d9VH=1KGd4WmxIKJ?V?|4D-Z?}TlHScP)L&tW zR{Nir;cj9luTua*mZ#8z=6#leS+Nhu#LWZ-x;hbz6K`(JbvkZ2>OYj9+||rUF3W&H zL(+?ELs62vEy!U4woeKH!dhozM;r!i3*%S{&bg9?#6uf3IhkO_1|CAWHVQfQtc`kp`F ztz1rr126=M!2J6cJ1HY!_|(Q;BoG!F#S8D2&nPLVnu*aQ>gMJWAcTVogvdukN98V> z9g)D$bV$NVu!9Kyl_nCY@l%4rDzGYRT*}f4_LxPP(zR|0c!;vo?ayEX*nBYdL0w%v zU?w`OX-1fYVq3bM>|o{reEj3W8u_0R0Tv-d;r--dVQlsz9xqN21y{f}C17N!#8H(% z^b^EwuSV`7?$X+ee(C1lEMlLJs*@0Q3t^GOg~<6h2Dp6oEzG8})5CPaUrLecLEc0M z0u&*vFk->1onP|g5MsrYT1+-f(&1;4f)ytPmSi+1?e~LXaV*23A@&F$b7LUQkG>r& z@J5D-sPf7NBi~~DTj{Rqh#8y4Lq8Xs0v0%2?BV#mF6%=yKFeIFFbw2O1fnaZ=CMG$ z;Eb4iK)Zy)lUp};8%*82OyC6IiDEENE|__?z@&WI?)%HUNh_e=FuR zwfcSZYvq-wFozx@eCA|hg4WN0!8&LP7++m~Q$;xc!?JvC;2lBA8b#n_2aYrHq$-?k z`4@{n^rBnWrsD&S{uC!l&jZ{3lBfvja&}d=tY|9fBMh+4Ie<#`J27d<=gDZtJB1{D zuVc1bFWcg$PIZX>1T+!Z#0GS~!Pn1xwQQjQJuoi|{=O{*z-8yWH#q;`376ZQbli>c zlETOAu+=3C1fwD#;4H;OzLJ%M5mli_0s$fA&KrO>6acNPow}wHj!uJ@37+B?PB(L& zEHO)C?O^^nh}7vK;3F&TZwp;@O{2>BSKhKLf&l+-5`!Z_7?;}&WQ&9XdCnhns@ME= zJY)%`sFk74Jl87c(-E)ZOCa5uCD(&oyqtn5v1^E&s7qz#!z1V!AJcEghJ@S1ey%r0 zKB0tFII+@SV3^ET&F^JV3}|Ze=plPC&1_>*SWQcC+4jU=rVikN=Dx-5+slv`6k?brtsR8G+Q z3HmlAoNE%bOJm5jGV}hliw=G>V6!^}1`MEVgs>q9S3~tsW(Lz2tMogm$;+)5I+^v)Lb3cA=Vd=9pE1_-H1Y)`i3LeB|@e)6c_#98De9ZF^pE5S9d_g1DXiQUplYULE8~!clBWmJ-rx( zdQ{t|v~z3~fW zuC0!(KWGWQ6rh?$E#?u@TNO3Ln=#EEV8|>&yc;d;62w9Bi)B772_svvf)U@ON?C>2 zF_;n{$VygexSFLUHoY!i`DNGSPW_D3{Ds|^Y*(0-XVE<*7ui((F0IP0zhSZX;ta?Z z0q8-4N!1lSY=Au#)%cy`+W{*<+sJCevum`!G!8PamBHouomL9Frj+-Cv8*8aHv+>x z96E?)(@RF@=zCJa7j4a&?N%37Y(RkiQu#d?LJsWM3X7RRi^Fy}Jul`IP2;j~=3v={yzmB=@>7{vRP&Bvn5T+{Q{CixUbV$V`A2 z^|DDy?0aHzv;2(hRwMk~n3B^JzYG_Hj)!;WP&*2ly;jO;?}MhBJ|(sQ-aQS@DKgR_ z0gQ0M2(Ki+79_Fa&%v$V{rZm;k_HJ_es4|dNq+By$-U%2g`EOOd!8&Hz=2FaJKPFL znhK!p*|>B1jPh@i2*H?L4PALM>(l>2?5x}9fv)d&y1_8QYGd|!0&oMm29L%|*f^o72lz;XncL}qMol0>7Rtu>LKr8$ z!!7RR=!4W1iv{#R$@zAXRE4XJd)e2o`j*vb;8_n1g+FSufp_oZ7|z_~`}Y17`*bu& z+EXpSTHE>aobZpNbka6ATjHE2igskwIykC+u@M9pA-z~}I=jkpUDoJ`@|-)6<12v5 z;g=Y+7JOaD=M|Y&MzBK|FQS}~4DZU3ogsI~tm6G^zplD`*^zsb!0DXFz#(TM1UmyE zyG|Ebs}ylnsx{lK;{e(Mn5)1Ho|t!5bsTyei;zY72_rD*_XttmrdW6CU<1?;m%es* znOery9IGSB9mMlL&s58q>LqifyC7oDDUR93FJUHdA+0w~>9Gmo91o5X(|Pa|**o?& zZbdepWBK*|N>n=qd9~<_-}1m%!@K*BO$N+e6wyug8T9LUSscb1VvNeaC4q zLPTon`}QGMUO_9x--k0rwrRpIFgXrY_~(3@Rg$g6preRO5vazen%D9u3i%%rO~c*p zAKjXxBOK!QPEQ_BjYWu0cs(nTY8G8j7FjW0kEdg$jE+F62SH*GjYt^hmsAX|`};ac z;n*pKD|G!wj-ZqX1qp&gR}o9D7&Y4@xyPHbKJ`@-!K5%e!7(e`wi%B5Sp^$EEjOR? z{i1ykA&=PA-AN%}+5s9LliRP~+rq6|k|ANOh?8QIeHiMT0eDNO&3&itPgw`>exfeG3+%mjz*pt>>#Waa3iRm_!cM^YY}vM7VcnIWF@MyM~St)h8U< zR?F&cgviK$=^Z(*g=Qo*Exr~m=hL;mAk?bFWBP}O9vH$68*}~5T&hG|qYVF}<^Q!! z)G!ufPBU*f+2sNI5Thnb8jGZMF~(>@kB%O_>1LMN#zuSiN{gItHy-M-=92%-wKz4{ zc5)X#wzA+-dS!3H`J;;>2dqxx=wX+kQc;@PFG=Vdkw@&{iw0F!TZHFs@>u>Es0?!) zdpJ8gQt?dNd_~w_=`_j{7(pHt4Cc)Pu#$uHXGfORR`ilF{W+E?T8M1TAg||W)q?4I z!M9ALJUkM3|KLZD4qg9==#QVW^*iA9V~V=MDEM2f-(7&MU*Gv z34`T9%=5a*R+C+iRowk41IXVe5rO>`EkJNGWSfbg)Kq-4{^84$<&fs+M?{(mIjIUe zz%xiKYP~>$aO5$*?Net7Y4}_tQsK84BRFAvQi5}W$&aVu**fQpM{lAf2w|ymjAS&Y zUIR{;|R!!F(Z$`1xBa|42G2HIw4r_&>6DeC6 zn-BU92WVNjvQz(-A1V*Nvi_v#-K#i0DlSP?8s$_aAU=9{zMm*DAnWJHxxU11Ec>`xI4G~B5@n{%|mM(I79&M~>2>L8){ zNyh4hnB>ZjZYRg*Z})NchR*|qs+^=>E&bh`fcpa zc>Y;1pV(azXZBfWfObqd|3qJW6e~QCF%=T_<}21J#x!~m#}c0w)bUKP*}W|zSZSIX zC>2$AYr+rpXu^|qbh~Xra5%sQB*kB13P}q!mF2TSx0&7ijn8rG{t<-)NV9lU{W=%i zM)NvHwVsetqR=o)h$&g;&M*G(nqb{oQCF+CArt|3%`)vQE~mQ>%J4`LC`l9Xb3Hw5 z07T($a8p5_r>&o>qSL1Ny6<>^(o_`Br9TT9z`3&~kM;;_IP9aq5o zy6gc9p{@fqywmflMD6uSi}H?GIpz!_3>=-Hrf|J1(cdr^$5e*8o*6Cj8*dmK3nF{l zY|dE54U}SIrDR(zKn)N?4hSm2%qpE!?JJAx!+gH;0qxVtV`{%o0%64|E^azGze*yQ zl5GNWgPa_Ya=Tb}W{bGjtwKcj_y9N#EK`+G;`|78LHjToC1($Z;C1kOw zq=_?AWR*qat9~NjE_5GA$Eg)$2uj&z)C%IO$D$QMSo_%Oqq0nd8NxFcS(VY>zU zS`p*d%q9ayZx?@+__IA2Vu#PNSuwnzxgq(BXQphX6y+!u{RVN72z80*j5PbY&M8Hz zE$6lq*I0k2b_B#d0HF|TQ_n!O&FiHHRYEeTVIV!|&Vf5^?-?5e9#T!v<+M%&RF*V- zv;J8pCoG6HL}V9H4a9!GM5y7S6+x!Y5085g$w~F`mWe!#`n=Jh9Urk>?eG3^{zR(S znN`W~aqOkgAk8y;bv6NZh?AOA&3Z_6VmS8@v*}g%sgj$Lz4X7$4y;i^esS~8V3BA2 z`^7OK=j)Mn$azhWC0Tq`Ep%pxxO%Xs9=52O{+`V^-lThAFvJk) zVuF!?J{>*wghf47`!-|kr^XsQ8ZAq>5c_#FWI85?Oqe}UVztP#Vdqk@rU|JEFJQ>y zMU#AV*m2KBJI7`--+2YD2P6)J^3iV~DN^)yRaLEg=WC6eJLJnKse+>hTtcYyiJZjN z6N{dq+Br5l9N=Q^^gE*hp-C|&lM3!;;+XO1uxC@@9dr?~XxA)eg_=|o)x&G;c%_BN zcfR`i#jEXyes2$`7v!m95LU$1eOt@t+VZ(>#POc{-@k$i12RQUE|!gu?|19=wb$Ug zZD;D!|H6>;yehgn_TkYxQXlg9d>?{ZIiVy;N-)KSRB^`F81qF1L6(V{Y7y(V&Pfcv zBSLV-=V)A<(ZPZlO=E<2+RY2S^jLMk|0-{^c$DewjrnCdNyhI0k@)V-9l%Dr=-TF9 z+!6m!=;-(G{rwkxR989l2noO$;=bxDguAUYyPdh7K7&Mqm$Zj>OW!|*iK2vAAG#8E zHO=nsbyV26JrVW_2n(OzK5_K|KY_<4+E1)HNO*LFusr>%02qQD+2)r$XQ9y4nPYA> z7n&+lj!ty~4#YM(Pq~QLSlYHEOk{PUCg5@Z^^fv<RrmGnv{O6cBNa3|n;3piomOp#QXIE7T!; zZ=4L`?-OuoTFWb6wK{qWItTS*4%yRTMS<2HVC&MqP>f33zC#JG<_}w}6&D8o0sn9? zCbG(d8-2kJ{%KBKB`ES7kRLCrH)FdXxCMz8S)H!Ia|_lgQ|?8TCfW`@Gax*`;P))oGk6xA-CZImSnjUvaU-x7UN+%22dvo(iQ4OrT} zS0V5&sL4zNSNDY8?|n$wf(HmHU)zu(8r1{&A2D1+X#-n+5Ezi5c6azUYXHzPv@JvM z?YS&p$C*BAFKsVgfT4(X?0x?4l9@u{33F35_-m2N8ftsC<;|WN`>xxbe%Pc;uSeeF?l*spS;#9-QjSbnY5x4I z!hW4mj=Dk+cNH=_#@0I0;b77;2-k?68#fQu%sKV?z?PBoLvCsa<)^l^J|`;*(-@H%Qvc^@^K2<2ILT18Q0+gAeZ^s}kZu(pgxQie)oe1EIkTbRUdlS^GR?$A=~F7)sC0TNiC(_3zna}i1UM)A=}P;OzxG_y|ygR zOcSgPoSD_r)yl7unXQ*wyiTpH`X3c>8=GvDJ}%N+S!B1o=?+S~nCQ17 z-g;MQMi<8~Z$te9Kh(*n{tN*1PEsa)#^80G*Jj*YJiMZJ2}R)t{bZAThNKLWHTXO- zjXUCh(Tkivjjp~^awgp99Eg<$5!$IA2xX~oO0AJ8mIlqG=W8V!8fY>Gg3c(;=UTSEa(lt z2gncYv~_K&~ zN(_x;S}X$59v9hqgz_=~%K6sr&4-JdB!<&>8ZQm|w{P-ZtY+>)W1e5YDf9c#AUTZUNEP#X*V&Ueccwy|lZQ`~j3C^&1TqH# zD+>cC+1RvD6+&%s_re+pm*qc2 z3t?@@CcM_~E*EHfF4Oo~@H6_S2nQx^IZcUC3}4ab7<~!>!hCP#vnlOY$~7a^GX}>S<`y_{PISeG-w4 zk)07hT$FbOd}Jm=@P^>qVWYOEPw#QsHqR9IP&IM?wn@li+jEJ6Alb7QqSJy$&)6KTHde>Y9p?0fEG zw)GR_9ri*{9%enoX?^XO=QF2=uSXk8InglGt+_y@*hlF^0iNXgJhp$-21A?^v^?F_ zl{nyj7T5nkIZgCB@a!xrpf$&=b;}|#zfgnmuaaR1bLuutSo+waA|q;&){+`W7w)s$ z5;bnVeM>0q_|bztq)06)mqR3b_NOCY~ndFkf}YTE*MKp z{`P8*^idH?SS`z(7LWJ$c(vq;c=aLm#x_RjF)O*Xl%yzkjo%gXUEoQcuslp;{hEs+ z&EaWcz9Kt-0;d&|)FYK9t5a$3LEEe*3SZj~aj&@vn3)sB;ZXyoU^DQwQ0 zg&U>Y;WMYW{IfOo(%xfIrbvzOf|-zHu7zH`6}w{r2={Cs&I_s9`RkQs7L418$-PH3 zcKiB%7hz5?_B^uR=L>Y*{N>G z#g!`oLe1Ma}t*>3%ct97GU~Rnk1d2$FZ1sAtCE|JG<~dO3+4JpSMYIslQL_P?*L2D~ z?nbJxZs-W?;O-lGH1 z%YX>WYzkh#ZOZkQCdTkg^kZmv9mfc|Fp^dR;=eh7>f0U^m7cI{KD1t*+SD~4>^Kk4 zhJhl_x8i0mje?xDfcweYb{;z4PT#@o**xf`5)m!Ck-oh>^=u(=)F$S&Rvc`2F)ndf zT5fHq{~KK}q37urlY$mgWSjNo)A!J!&`;!vHI`?`_YS zGh(OmO;CFAwmZ5(wvxs^8f5`O@;3pSzVuQxe~UVxqlO*S0H*zV2CB^CgcP(@lz7kF z)P!VNW=zdLd>tMtP~E^$5@TlH zM72q_? z#r4g}O6}WHlwz98j2T5KhCY95VFXpVnBd}Ar|kOhCRHjozIvukhTda_?uGlX9Ww=8 z9468X3}5!zAZX@gXtPep`C^venCfNu_Gc+Uwp0f|IlyryK5!u6<$n1{5xXnmsEfGQ zvJ*c(lR5!fOT(@wieDBTr!eO2kiq}>V^6!owiw!o5>MR{Jk>qNA4M-m45`(NrBDg> zSmUp2uHAI9{0b>28~w zIvG}H{u8EyMviVtX2zg1c37%@9Q#{GBd81I0LMOhQX;J&?%=QLFzb|-1jnV}YWaHP#NJ#3d|w4evQlZ9F~g)`=idGb zob!g!3%pH^=HTO=?PI3f$rCWbuK(B_nBNZ8IU&;KahR+`{=UOW5ZRKfJ=E0{)VF(` zF}Hb1#9cRbFV$es{BUE4sYAG9rwYK5{~>g4kXH<{@+PvmO>78K*mMzh?SDp8!t42& ziq%iov#3b>WkARurPC5Z9d*5{DxU;C@5dzQu;9mZm!l?J*yA1$E5cCkVG7DuK=9Dz zXi+-Ob;fIBPhAFrWgwPql7*YvtM9-F{r5$jv8u8B!5X zR($Eu6NtYZT>g}0uN=`hADH#bErC#5$8SX1>KM?T!PKzD9{-ra>Krhuf`eZoWifJ-O(7Ho*D^mS>DW!%-396`jxkfPoZWb#iA;BQ4FKKo~Y z1YFTlu*FNm?F!Ak<7desHl-5x21)jn8DwG_@BX=O!7uAu2U2AS|9FwE1w*L}YSr_% zO`a%Xlhn}Pvti(7rHSEwIyw6F>0;|sHrg;0XHWZ8{L(OfEh6D5=w|(EiD=XqJYAmj zrf$X&w$3G9D5#D(ZY4M23G2QuL9i;E@r(wzc%|ZhLk&uoH=pWgo;u68lSreaFTNjT zsQD1gQM-749us9hg}653Gmgq;=VBQbWs-KP4UEYeSoSf&^A{L_qb1-v+Pq8tT-!-8 z=){rHN0B}tEDUa#-FGJ3ZA6$6%1*uIw&a&05Nb=lCNrVrYy-irP5yYIw7|0)saL&0 zfAQYKrJjRDJNI^auJT8!@m=L@*%Tv6z4`Ig%Y*%Z#-82e-g|XG7-=aCulLU$>xg1$ zPIv041}NGwliYC9yyIjYE@%CeB_Fi0?R7QiI=JtGMC?^c!R*qV(C8xH=Qj(lmIVb%T&>ehE)4*&aCCx|1RsWY$N#fAf|N@bo-3 zT7UP3iVS}>a!A>m2!Sm#HQaf3jns37(7gS3E9co$d8~&_$~h~dB-_h{4&>S1^9S90 zG$Lvb%UBZkq;v#J2}0?W)5#=~$x|!oXFA@5qvMsY<&W#z&b}GnGbLeiHV_=~jDfa# zWTKX;J{|O)X&kWL6FdB>kTs*^-V6;HqhOKY#V^j#+&dLJR+_C zf;tX4S-Kc!G&M?Z2W*i@e&O0DDnc8uI|J?=E9u)wBtVR~tnZ2fZQOmzCDGP@)LP1s zN{rzJ>^P~TGOMZtv&4vi4l$moBK*L?c~RYezs-%zWx(CO9Rx=QNMEHvSjqPfY0Cb& zQ~r7W;dGFU_3{mI-6>@@+d6v|ny{mEJtep4+`O_~S-YQeK&C_iW<FTL#ZtLazHc-f#YP9PFaRdf%$ z;TuKNhIRFMMkrrmL6JIoD=`^mp!I zM+jVWgD6bEHQGj}eesjtjZ6WE&eb(aa7)2e`DEpvcD0{-h&Y19#koaAYp=(r6+@{9 z@Qd%iS^YC1xBw69;{d^SWq9=)oW2oc|2=BE5E3p^lJWdUI`i5n(sMEts2E}>X{Dww zVCaU%-F@XMB&jOyXyWvPyvO2WMjV-Z5a^bAS@-^Iayadm{on!gYFTR&M)raHG4EW0 zx)}_mPx+y{>zICQiPxMKe9tu`5X=iM%Nf716U-N0j+ zJHc@XdN-*(^v|S%q)Y`>f+Q!KZs|PbzZY^?dH`qmDiL9xldwK8KGfURlT_4-~TkVXlx=7%3XM)Oq6>>u(u$(6YTsf@m+z4zz<0X?>RpdWH+-)SV!rZDnVljJ7-YKONj+PfvJ1jzcZ2sAjya^=kBZ!T z`3(+%c85!6eX_YZTsYwymY$(P4^15&OOn=$x1X5!C&B{#VD+yx8HuzyMcm#*UD^n+ z3RqX)SA#_c>*1KhB!^BMQ0=wtsyG9p?vjfu3?c&tW?_f^(L_5Nl5?72@h&eEXO&OF z&04Mfw{vP&{iS@CVRGq3%Eq6=b1NI7&O)VUZcpMK>6uSC?az`>7!d4gRpp3!wcjqD zpDa?LgIqma3kBPIM`>mA{y}r8P^u&B(bSCxfuX|Gz+FT{ABYt+`6q}%346n;IzE|$ zOmVtq?#t}MK}INn;XP@dYj8!a%0JvXT!1bH1JhTxC9*;08@-i?;q zlrz>GLZ+1vV?asbIZHUJ1Vam>f|**927xK4*z3>&&x6NGHT#m z@C&c=&kp$uKup$~In~c;C|dr}0BZg;Ea{ecgZ2aEp|xy?$VlE=nm*w`$txMeJ z{us_KQDQks5sEHPUio+%K@nOmk6sPe{-gZiR~T~=mmpwN_nFs>AfzS?_s)w&WWUUk zDJGnj#!byHuf^T=BE~$_=?kC4%fIdlX8uFR1i!0(8zXY#!w&|3yAR4q%Q9Jb!Bez ze~uPJ=IzalF(iiGF5!c=>m&AFmV*w7OiaW`MK@k$LJyU z<@#7$6)%La0^P<7IqW+7Hj5&LOKV=&daSuJ&t)J)4uOR70exQ!fhKI3P)cLJdz$m@0@P1Cc0L;zes@6i^ov=dD{x8l$0yEF}GZQrL2w51EY zJ}zm|uqm$QD(l>ZSw^To%{=S~oe*u+e)WP^Pt};0)IyhGCqYm*Hj1Y1=v5GF{TFyh zP^^W6bi5vk;GTzzhME3)T=r*GoC3@&cNXnH`%TwWMu7+yBG+8~g~w{2C?M<-Y1{_z z{JqQmNrKQQL*obO7_02*;M#vIFya~(n<|b+s_k{qZZPqSlZxdD#`n7(s1(_*0t- zr6mmil58ob<>#c9SsNI-bw?j^nLMcV^D*^bTMDFUYl4ceU5ouMu>a&p_$NLHL^gT( z&b5c!|B*EYeH?!yKxwlD9{Ovg-cm@y_q+mX`a*=|0PQx=SO%HQg{UZd>FJxFc zq~D)57}Jo%3tfD>jL)g$CHzW`y#Plcz?HbMNfGIA!`r)XbVo)S>NNK$D@ZQ6zyPJwY!5kkM%?VD!9rZe6sFRMXmZmCFlAc#XvDny# zezCTVu8)j*C8Vx{cWjnCb2VKDpA}<1z~|`KGZo-CT2~~bzB)D5{&`VoeN!_aW-JSD zkd#WOaq;D|N4j@bnu#dK>&}hOG)5 z_hS!%mpi_2=IF??Z%ZPyS)`^nuSGaBvAxZIo3`Ym$I=d@$VY)Y27Wnma-7+yNRDt=1&%IIRg;B4ok<4{~`p(r~oG;%|%EkG3EE*Y+M zIIm!)vN1q!-awX zS#JD{k36%z4HgY2K9fRByDVn;O&h;xBz#tZvDksRXVo^~c?z-6D?g*;8$Wax0{Cw5 z>26K;EQ~JTV^ZW+nGV3o{P7Xz77)d)x)01G`?RY$X3~bhF9b^U@V5{Ve(Dx|XJNu< zh@&-oBXsMri2~J9LF!Wc{NlHxb9{#=pUJH#qWa+JBGpenzcd;BSh^Ut{aj{5vcEKL1z2;0XmKI0lr`EFH8&_i`q;v=U!EC zD@mqryDtIysF8{2Nen(UPt0~mnX%7k+loo7g6SEXzn`1Z;*V&TM^zo~1#M|Oir(0+5hsgPF>qV#Rd_BAV zL4WAM+*AO5ZM7ipoeD&nrfB(o+z)(tyy45h(E+Irp4ms5Da6m1mAqt^1-9oB9~}(y zLr*9ez@w!;D_65LM{*R)>C{U))BDYK8?SdM1PFRo?<&c zbzLu(<4>Q(4_w_M#^eky;2P@i;lam1=@pF?(KdN6!<{V1qkFQ-`S2WurG86k^4Qj$ z9nb1dYqgA{9~5qwbWarpg5Sx6lEt=5Y-MQqdkxvuOr(o^kvQV2`K42o?71InOwcuZujPbz`)7yg|z~Ohb&7KHLS*z#|gjI1}jcBQGJ)M z;#+^^xYO5WnH4ztanq-#vCt7Z9{6>R;d0#4$IYe#+^uto;|iUQUSHq;S0>1`)HP(v z(}SP0yC{arQA{1GCDmdnat7OBGhvk+m5>{GbKH!5Y09p~O$g7CBRs)~ttFk2cGW%Q zxZoq|m-zHqVQGf${q~zTpG6~jwqI!Oth9^C-_rS?t+x(~D*E=u&x`}ifWXit-AGI4 zAl)IMbf|Qwlu|R4D3a10QX&G9lB0x3r+{<`NF$xU<9*)u-rxQDKF{Hgnc3{KW9_v* zEB6PuAzOvIDDVzI#7RCV0Jflf-t2KB;9Ve^#PM{Qfdz4o?bC9CR%us z2+T1S7=^l@&JaF5)a74g^qa$bd%{X3;aS>tE=Rx-GcCK{(zadUSr=y(6l6DNuiyv~ z0;y0h3ma%hltcio#HjvA&(zSDvT>^Mp1Ny~NZyO@oGUT6ujj2pLqkzr&|2TK{m=V} zv1WXwpYIa@FD|d6!N2mJ*C|mXY8WiUjyO)U(2~P_%CoNhk=faP(9&D0*sz$OcpAtn zItx;KGwV4%qWG9iug~BL#8jk5H#d7VZ!8ybwoV*(WYa(@iSZ!5OnRm}`3KJ{#)|`F zB5IqEev!@leRFecCNi@e1?)XVEoD{Zq`cT$Slv8U6ocDER0q#br@iKb~p9&A&H{v z*YrzE)AM-Gi_MXzeF8rpuwv8oSqHgrG@Jvgx0+GWG}lL5jZp>VV;d$t_Cb|;QbDyS zOUvu0yh4NAf8)=?fS?irh|b5yjC9V@vrv=?m?O478-??I1I}iVBUU`N-pm+R<JC zOz}u{>#%ODMP0U+yc7KL%wtG#`BV1K%ekj#4=Q-Zh}g2-FzR8MkM_P;#b=7YI#jaR z4AC=XoL3~y;F~E;{>DH60n0j|+})qFCa!jc*wmP~Nh^Go4z0a}a`Rn?3soOEz`+zg zjl|2y!s_qGF#$|tc-I9sBX@I}o(*4j5$j%rZMIAr4yeR?kQ8g>c-qGh9G#?0drd2r z$e#$PwTnBW1TNSt$(=wM8=?b`6bBlL?c;N#&@{GOAu>WXhciY%<%+tAS;JdnX2FHeH zF7{hiKz*9UkQf;8ldwG(&=cp=J>?^jI1oG@rKV|7no-}@_|746ziW>1#mw!C2-H)Y zr-#o!KRMdk-_D#BNM))mU+aeum%GN3)ygZ=I?*oYw$7G|>%|;W{_a0T#wJ4r4te9u z7GKNM>LxE#EwjmP`RJLy8WDg#L2SytkpFyH{76`?RneSTV#a;lWeq%=@#?JJaeh}# zp~_z}82Exqm8^gN8d*8YPyT-NoTKe<_5K;}gni_V1KzcdpEd=2zMD2Da)I;bsfcVD zXbm`!eK9q+JaftUM(B1QIqwXP&Fk5-ii4S)mpITi>w>5h*a7VYB_B}zZM3=F2Z2tx z50+WQ(P>?$l<->gTdB?8PSU0o*A{PQ`Xuj?p1*?kERfesB#OTMhf6Tp3d*T1QMS`_ z)n$`@O5uBRC}}%%H$*a~Yof5sb}f^iq__o3GR45@^P3%Imsa;7&_3(9Vuup1Tb5IX ze`;m8+4%Kpo7#!3%1U{X&=ZAr6JYSJrMddw)c{w2X6nhjS3KdTC!RZxvJ39y%S&95 zJg2i)YPuyKylpk#c8`C{Y*WD1=VHyu&SStWc`COYUa`?7i&uBUjCuYQ7LPNV(<<$G z??^YLCvsu_HWl}g)y`8SJn{#n2|o(Sx1E8{c9jtB|HfMb@k4e_xiz{W(a=mg%PA>dWBKj^odSEMril zFgdZUk*DjkCP#ds>7o94y}8voU3{vu>{YdL!rgA`1eigXme7Q3b!WE=7pRNy&%|x} zr6owJxonrC91E^Dh(4FN8#xD6Mab_j)U>V8sZMj_MP}-oD>p`ekMI}A)Hv<697Qwfm5Q9(0qL+(WcbQv#>~9o36CISo^KNshnR$vy{FZlP z)}`InprDK+t0|@fFQCMoV#C2t`>}gL$*@)GA)lo6Ci8c#*bk%v9vv;2`&pTCu_rS5 zY89RjRz&Z(#j<{*XH@n%z@U7gVl8BgmE?fd8;Y1+m8c}-L~y{)SX63N3$JTrI#eq9e6wXMg8PdcZ}1-hiPm;ntfKQ z3}PKUxs%*deP`aJKkQ7Z%HK%HLKf?xc3gy5=Ge^)CwgPOV+sH)JD^B=+k0Paevvyn z_8v#exBQ4pL``L(ei0C4(pl%WqdTaSW{UaCX)MdKW$ z4Kde~r!GHk;2Ry(bZSJt4|M!dCj5Lv*3vIN8|h)r^wDmyivZBhjt|y}ir%Wli*6-` z3qYVCfU)?s^F$pg{6swL$61E|QkV22r8bUjT=j<~R1!Al_m&m!$z{EG=>!!Suj%4z zkn1XpVlAB{1oM|FQI3@jJ;$%MYUe1v?I_+T$g>8E^3>ftw5g7$WV5z4r4rbOqCc*5 z99}oAlHIOYeLQkze5&ITOvGOM=+BS+vB|n8X38%+L-j1lCR~@vAH4Iqp4Sh+@TGMg zSrF-wjT*Mzd4aNqL#3BFzt=P{c{mE`F^o}g&G0S%YTJqJbg}c2&hvuZMGn4Qth_~K ze>bA}1IKUs?@wOzwY~Y|ZEFXnxN~J<8Z6xP$MWpo`6{vMoh{CpCcpK&`XrJ@w2p&O zfG0dI^jDQ9(70m+{w(NpLTOOU$lP=F)I@}Sm;fxb=U7>wo%lH|OAi$H>B59KGu1qH>cu{?q-gnHAmJ*Ic^% z*Ix0%EK~zHALW-HrwTi{4^-_6TnryquE_SRPz_wSx6Z$k5QUy)=GJ&yP1XFed_ezb zIBq~$m~>8-I6t(Y+!N6|wZ)Pu_F~SHeM_&_gwZ4F4MYDh3lX8ZlOl-hPG6LE-?*0^ zH2uoz^R<2bmH4(o&h<5ZhSXm3(}~(|7sopn$TDn!pA~mOj{wM2s!=zSzp0u54sTT~ zwmhVZ@G$d8u6y&Y>DwGs+K#uM35XHj%PII+vm=MKsZ}?FC*0PP+6dYk)lF)on|jHm zm*y7G$1J`kbJN1~6&_Rw-(^Hthu?0fm0A_`PR>jqdz^;)12O4_;NYnWOjzvfaF3-K z#uSvW=_dTePka&E1NUW(CHy3Hczyn9vZAl~m1GGf+Huz4>TqA)t6@&}Z5qpSN#0l? zn$t6qxwrals?xU}N!`6&M)=9rtM20B^S6;W!3R>@yNQ!?@vdgyq-o63f`2NF+yCY} z>W;a-LZuCJn8d)UtuF_%*pH656r;UyQP`SmeIotJIZkw}}gvc8~Bz^WDn zn2IrWLMC!5Cb$eS#7(X(n@#Gxk*DElss^-;4}U*0;DPO;n20dLqu0%p%n(8)i7U=g;wpnp)YA7By76Mq6rS z|F>XT9wAtKN{>g%qGI(MZnG?%v6XfG?6Z^o_33BLXTt}(k1^%}mU41iuf^s2=IqFe z`$oGH&jUC*;cK)Q49=Ueo~2c_cUAlOd_Y8|gvVLaUyM zT0Mr=%kLqxS%mo%)+DtK%_Fl@C(}ev&!X)MGrvq;$O~QaaF@-r-++w6oLJ>lqCWs& z6}~c-#|h5!?IBn_*!^`sE~lZbs-D8eZcyW+vv_%sV2aVae>k^ZDNr`_wD+V=nx6Fw zyK0W7YK|6iU2ArEUOD!AQlFWoV*oA#T!IU^uD;)e4=7+KgYf2DLPcPhVc8bVd*6NQ z`8?uu+Ln#2-D2W)h7$QjC*6C$v=qgioCr++St3p8YT0I+eG5CMN->;x5Iuc#a%Hvr zz#jH%C$Mr9x2C^ckqK1dT-rC5m5&ATNGO42*(^Bbw9zU)LMA?|ohVHGuQv5Ii{rF| z%Yz{sN9Z?k%f{a4ElEK-87ea({haFIrXgpG=kjq-Pr{&|~`PwV!tz zP(!YdhLg8EQZhFx1E&KV(|Y8Dxyr%kb@c{H$DF2Tx*x0~|bopjmD?)~`w*8BeU zcC3QeIGrM{+^Q~q8P~7gv4!06k$g!L%uVBLH9Y!r;YfcL!Sof1s!k-d!p)GlB;}gf zNPj60^>b-S{tI)1pd1G7Fu5*Bfw-L!zUO$HUl*vF`%&Eaa>nZMi~mpk(OmXI{FBdH z%Y$Xa$6U5|wOi07?pgd(N{RNKH~>E+!bxkrNCSF$7JN%GdgJ_kyIIX+-MPI0Bj@z+ z5$-{QaURvEC4Q!ss4GW*$`i4NmhEQQCCOx*O+AtK5&NPLIbzGIc}{WIL5|F)$^AIc zy#z)C^LL8=B(%&!Svx@8=N2*z(rp$}a@RZR#^aWLsXcSm^oLvo9rfSPdChXZR4xuQ z@=C7W42CHyE0B|uBbAj8V1KxO)949pJP&GnzWQ7NXMk7bCYyqO9vrUL|Kz*oeQ%pT z{B-tbR-(TV_0rP0rHx<9FE(G!r2aN(Jq~cy9oR52qJAI^9rtQ@^}fSsq+ei$z9H{Y z)UeTser6^#h@Whp7}wVTsm%was=KAs7jIh3PU}>|f!tVZdul=o7_)f&kh1mYheO3Z zJM)BiaHW{}G2uQ8+^34s4ycX+*tD6zKqd?ra)k52i?!yf8;Z!Rj2N`Fp(eTJW_?)o;)Z~&=+T-$ zVnm{X0T);u?n04kW)Kf{mt~NvQs~gp+eR&5NL>zE&(K9hRz)%SF~G{39Wq~M`!k(h z&$nC(Qz~U%+CZSPu`2FBNJdA~2lPuT&c7!*fv(?FcmHtUH2pHo@Y@?D_q7f2-xMD% zvgOrP?!847HCRMk=U~t`VJ?y8=PjGvMm?Wqo~eZccS8bU;kC`C@y(b^u;EVmyQ);wiWjIF|G_4NA zZkejI5js$}@j@>g^;ch5^gT6IbQf?FRZ}dVk57mBQXsmoXV~~3e}I(XMhe|5RUs*6 zu%b89bVBZ7>KPo6$^quZq^eufyPb(JwDz{d60YDI8wL!fEIehkI-J33!XN19Xm?zr zF@G9~?cl>B3krTyD28e2_TYUu#Z3&7(JmIvlG{vVC?u_b)(jt-6OCn@MHALFYU-;{Q}&~p@0#4YfSTWMLpyMhSn^&xu_USqQy2?VCh3P^W%rTWuJb&hPLZvh}|MQBib1w zQO|JSEq*k?$)s%QOBb~P9!Sh<$c{Pxd1hB8s_y@&m`QSVP;(%(J2BtKc0(CJt39GXD2v5EJif|YR#30Xg@bNT!wlYgoktwU>8CVt~c>R$9t_ zh61^e+mG#>|KvF;qusY6aG?vN^8}sjot)u*)MuJHz%gz-UKiW*B)*IDstFi642D); zEp==8p_l!k$5a&x}*?t^vET@cM_40c7geg%nCdf2nZN z@H{r_sm0rC9US0>EzrE-qFM4l7RQ2gd0Q#}fG(nRc;Szd3@MOhO(o}$>Esm_gMl2d zqBk&sLMseoC9gLyS#d^#x2YI%BDI0`hyiOZYGXCyP}E(QU=|JLJGY>SClSsPO@qnB zwEZ-Ex;NU5dMfaJ-qUUqhTb`{1_4o~xjE8anyk-}-aEnk_ zB6x;I4!+q5fwnw!uhQt?89oDo9myE-Oohuj=FOiD`XLmPIn-#EhLm5J{fzaaM(B6y zIMMg7GF1dbAlST6ZlE*c{h1^!BUyR&z1@iS^F6ovg9Q=uoq}4{uRT?5RpU_V9AxSb zze5*uZcQIc!pRErSdap~^gty7p!y}Kq)deufx^4YQLf=FN8H`(2;ePj2c<22wbF zJIrAb_MM@FdTYily)Ffi8K02|oLU7mSr<s4O0&z3Gw$ ztq$T-uvZzfq@^L%YVi3^K2j$M+|{VSRTO?p`#P0jvdq`dOQa4|FDH{j-bF{&3mL*; z{yZE&*Ig)6=tN%RGR*s-TcSDha+s#IDy1zg|D6NQpK+gYs6|*SWN!$K@xJ@`xr+5! zy0e`PlMkG2X%h@!JQc zZyIrqaZTIzOgOzCao&q7UmKU-6M(BJpq7Kr?vetcOC67R-$lTcs1>cHZ=ZMko;wTm z8;i?6c!HZ%uVHQf!@HZi-`h*;CPy^z5od8C&02EnghVPfQaRY`)nOWIkJl5NKnEpk zt!=2$2!T4#-jR1ekhg9D84xX zjO@qU#p}_6Gik?&m-P(Pqz;GEhR*otE=hvl9Yn8>Lk}%0PS5!K-6p0-p4KrBWTUySxmXrYmMpv^b3XP)rpMSSQvV zdJ*DY2*&`~=hBSgZ@TdU`)5 z0-GS!_zm`@$H5N95WSew@B2W%I5PrxTpj(#z=j0Y#3~rD%C>|1txKP5BD*p9gd70n zc@p-(;%&YtW=WRKgKGz%e0C7q>ZYhW_i6*vE!4-~ofn4pt7H>e?W~3U^dUt`Kwq1L zA734);4G|BjtJ96?!E@D(O61r%b}P+b2g7WdpLnS*?s?czi!@MfJhn9i-M{Sb$#dd zvpC~feAYEI%C~$};R>paH$OVQaciW#AN2 z+>Oxsx#zf&Id}th7c}Jth`6q`f z>o6RPR0r;>0V&TOr!B2>n$6w$_8>~eBJqrDt84mwoT^8MGnCQ&OAU5|T1oh-p?a*E z-Oi5f zc>MQvq4!n5oAxMgp~IiKWi;syeQ^|)J=hZ)(i7jFMi4)5GwDfULY{M13*!9gy3NUM z5~Ew!B#_Kh48iQfi$!p-pY$PmmN)m(&S0~%yJ=@S)XQInmexhQzS-kGziBc97I6KD z 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%0A3Q{n*lWx)F4^luuf(Vj!LLD#l}g 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 w7arvpTjxBQ_2YByo9FFSt> Date: Mon, 22 Jul 2024 02:07:37 +0200 Subject: [PATCH 112/131] Make sure to automatically set marketing version number The number will be extracted from the pull request in our build workflows, just like the changelog. --- .github/workflows/beta.build-push.yml | 43 +++++++++-------- .github/workflows/develop-push.yml | 2 +- .github/workflows/quicksy.build-push.yml | 47 +++++++++++-------- .github/workflows/stable.build-push.yml | 47 ++++++++++--------- ...onal-Info.plist => Monal.Alpha-Info.plist} | 0 ...itlements => Monal.Alpha.ios.entitlements} | 0 ...lements => Monal.Alpha.macos.entitlements} | 0 Monal/Monal.xcodeproj/project.pbxproj | 24 +++++----- scripts/set_version_number.sh | 10 ++-- 9 files changed, 93 insertions(+), 80 deletions(-) rename Monal/{Alpha.Monal-Info.plist => Monal.Alpha-Info.plist} (100%) rename Monal/{Alpha.Monal.ios.entitlements => Monal.Alpha.ios.entitlements} (100%) rename Monal/{Alpha.Monal.macos.entitlements => Monal.Alpha.macos.entitlements} (100%) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 1b82b14cfb..285ae11014 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -45,27 +45,11 @@ jobs: buildNumber=$(expr $oldBuildNumber + 1) echo "New buildNumber is $buildNumber" git tag Build_iOS_$buildNumber - - name: Insert buildNumber into plists - run: sh ./scripts/set_version_number.sh - - name: Import TURN secrets - run: | - if [[ -e "/Users/ci/secrets.monal_beta" ]]; then - echo "#import \"/Users/ci/secrets.monal_beta\"" > Monal/Classes/secrets.h - fi - - name: Make our build scripts executable - run: chmod +x ./scripts/build.sh - - name: Run build - run: ./scripts/build.sh - - name: validate ios app - run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - - name: Push beta tag to repo - run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') - git push origin Build_iOS_$buildNumber - name: Extract version number and changelog from newest merge commit id: releasenotes run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + buildNumber="$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g')" + version="$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" mkdir -p /Users/ci/releases OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" touch "$OUTPUT_FILE" @@ -73,7 +57,8 @@ jobs: echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "version=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "version=$version" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "buildVersion="$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" @@ -90,6 +75,26 @@ jobs: echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" + - name: Insert buildNumber and version into plists + env: + buildNumber: ${{ steps.releasenotes.outputs.buildNumber }} + buildVersion: ${{ steps.releasenotes.outputs.buildVersion }} + run: sh ./scripts/set_version_number.sh + - name: Import TURN secrets + run: | + if [[ -e "/Users/ci/secrets.monal_beta" ]]; then + echo "#import \"/Users/ci/secrets.monal_beta\"" > Monal/Classes/secrets.h + fi + - name: Make our build scripts executable + run: chmod +x ./scripts/build.sh + - name: Run build + run: ./scripts/build.sh + - name: validate ios app + run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + - name: Push beta tag to repo + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + git push origin Build_iOS_$buildNumber - name: Publish ios to appstore connect #run: xcrun altool --upload-app -f ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" env: diff --git a/.github/workflows/develop-push.yml b/.github/workflows/develop-push.yml index ea6e9e6349..ae1b7d989d 100644 --- a/.github/workflows/develop-push.yml +++ b/.github/workflows/develop-push.yml @@ -20,7 +20,7 @@ jobs: timestamp: ${{ steps.changelog.outputs.timestamp }} message: ${{ steps.changelog.outputs.message }} env: - APP_NAME: "Monal.alpha" + APP_NAME: "Monal.Alpha" BUILD_SCHEME: "Monal Alpha" APP_DIR: "Monal.alpha.app" BUILD_TYPE: "Alpha" diff --git a/.github/workflows/quicksy.build-push.yml b/.github/workflows/quicksy.build-push.yml index 7ddda0f08a..e2c3848d9d 100644 --- a/.github/workflows/quicksy.build-push.yml +++ b/.github/workflows/quicksy.build-push.yml @@ -39,37 +39,20 @@ jobs: buildNumber=$(expr $oldBuildNumber + 1) echo "New buildNumber is $buildNumber" git tag Quicksy_Build_iOS_$buildNumber - - name: Insert buildNumber into plists - run: sh ./scripts/set_version_number.sh - - name: Import TURN secrets - run: | - if [[ -e "/Users/ci/secrets.quicksy_stable" ]]; then - echo "#import \"/Users/ci/secrets.quicksy_stable\"" > Monal/Classes/secrets.h - fi - - name: Make our build scripts executable - run: chmod +x ./scripts/build.sh - - name: Run build - run: ./scripts/build.sh - - name: validate ios app - run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - - name: push tag to stable repo - run: | - buildNumber=$(git tag --sort="v:refname" | grep "Quicksy_Build_iOS" | tail -n1 | sed 's/Quicksy_Build_iOS_//g') - git push origin Build_iOS_$buildNumber - name: Extract version number and changelog from newest merge commit id: releasenotes run: | - buildNumber=$(git tag --sort="v:refname" | grep "Quicksy_Build_iOS" | tail -n1 | sed 's/Quicksy_Build_iOS_//g') + buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') mkdir -p /Users/ci/releases OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" touch "$OUTPUT_FILE" echo "OUTPUT_FILE=$OUTPUT_FILE" | tee /dev/stderr >> "$GITHUB_OUTPUT" echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "tag=Quicksy_Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "version=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "name=Quicksy $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" @@ -79,7 +62,31 @@ jobs: echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" + cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" + - name: Insert buildNumber into plists + env: + buildNumber: ${{ steps.releasenotes.outputs.buildNumber }} + buildVersion: ${{ steps.releasenotes.outputs.buildVersion }} + run: sh ./scripts/set_version_number.sh + - name: Import TURN secrets + run: | + if [[ -e "/Users/ci/secrets.quicksy_stable" ]]; then + echo "#import \"/Users/ci/secrets.quicksy_stable\"" > Monal/Classes/secrets.h + fi + - name: Make our build scripts executable + run: chmod +x ./scripts/build.sh + - name: Run build + run: ./scripts/build.sh + - name: validate ios app + run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + - name: push tag to stable repo + run: | + buildNumber=$(git tag --sort="v:refname" | grep "Quicksy_Build_iOS" | tail -n1 | sed 's/Quicksy_Build_iOS_//g') + git push origin Build_iOS_$buildNumber - name: Create fastlane metadata directory id: metadata env: diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index 9cf5cdd4d7..f0d580d32a 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -41,28 +41,11 @@ jobs: buildNumber=$(expr $oldBuildNumber + 1) echo "New buildNumber is $buildNumber" git tag Build_iOS_$buildNumber - - name: Insert buildNumber into plists - run: sh ./scripts/set_version_number.sh - - name: Import TURN secrets - run: | - if [[ -e "/Users/ci/secrets.monal_stable" ]]; then - echo "#import \"/Users/ci/secrets.monal_stable\"" > Monal/Classes/secrets.h - fi - - name: Make our build scripts executable - run: chmod +x ./scripts/build.sh - - run: chmod +x ./scripts/push_xmpp.org.sh - - name: Run build - run: ./scripts/build.sh - - name: validate ios app - run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - - name: push tag to stable repo - run: | - buildNumber=$(git tag --sort="v:refname" |grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') - git push origin Build_iOS_$buildNumber - name: Extract version number and changelog from newest merge commit id: releasenotes run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + buildNumber="$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g')" + version="$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" mkdir -p /Users/ci/releases OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" touch "$OUTPUT_FILE" @@ -70,9 +53,10 @@ jobs: echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "version=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "version=$version" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "buildVersion="$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" @@ -87,6 +71,27 @@ jobs: echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" + - name: Insert buildNumber and version into plists + env: + buildNumber: ${{ steps.releasenotes.outputs.buildNumber }} + buildVersion: ${{ steps.releasenotes.outputs.buildVersion }} + run: sh ./scripts/set_version_number.sh + - name: Import TURN secrets + run: | + if [[ -e "/Users/ci/secrets.monal_stable" ]]; then + echo "#import \"/Users/ci/secrets.monal_stable\"" > Monal/Classes/secrets.h + fi + - name: Make our build scripts executable + run: chmod +x ./scripts/build.sh + - run: chmod +x ./scripts/push_xmpp.org.sh + - name: Run build + run: ./scripts/build.sh + - name: validate ios app + run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" + - name: push tag to stable repo + run: | + buildNumber=$(git tag --sort="v:refname" |grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + git push origin Build_iOS_$buildNumber - name: Create fastlane metadata directory id: metadata env: diff --git a/Monal/Alpha.Monal-Info.plist b/Monal/Monal.Alpha-Info.plist similarity index 100% rename from Monal/Alpha.Monal-Info.plist rename to Monal/Monal.Alpha-Info.plist diff --git a/Monal/Alpha.Monal.ios.entitlements b/Monal/Monal.Alpha.ios.entitlements similarity index 100% rename from Monal/Alpha.Monal.ios.entitlements rename to Monal/Monal.Alpha.ios.entitlements diff --git a/Monal/Alpha.Monal.macos.entitlements b/Monal/Monal.Alpha.macos.entitlements similarity index 100% rename from Monal/Alpha.Monal.macos.entitlements rename to Monal/Monal.Alpha.macos.entitlements diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index 04bf0e6cb0..2fc1ceb74d 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -750,7 +750,7 @@ C1E856A828DECF5F00B104E9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = external/eu.lproj/iosShare.strings; sourceTree = ""; }; C1E856A928DECF5F00B104E9 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = external/eu.lproj/Localizable.strings; sourceTree = ""; }; C1E8A7F62B8E47C300760220 /* EditGroupSubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditGroupSubject.swift; sourceTree = ""; }; - C1F0AD15288BCE6F00BB0182 /* Alpha.Monal.ios.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Alpha.Monal.ios.entitlements; sourceTree = ""; }; + C1F0AD15288BCE6F00BB0182 /* Monal.Alpha.ios.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Monal.Alpha.ios.entitlements; sourceTree = ""; }; C1F5C7A72775DA000001F295 /* MLContactSoftwareVersionInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MLContactSoftwareVersionInfo.h; sourceTree = ""; }; C1F5C7A82775DA000001F295 /* MLContactSoftwareVersionInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MLContactSoftwareVersionInfo.m; sourceTree = ""; }; C1F5C7AB2777621B0001F295 /* ContactResources.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactResources.swift; sourceTree = ""; }; @@ -1277,7 +1277,7 @@ isa = PBXGroup; children = ( 8414ADF92A7ABAC900EFFCCC /* Packages */, - C1F0AD15288BCE6F00BB0182 /* Alpha.Monal.ios.entitlements */, + C1F0AD15288BCE6F00BB0182 /* Monal.Alpha.ios.entitlements */, C1567E3628255C64006E9637 /* Monal.ios.entitlements */, C1567E3528255C64006E9637 /* Monal.macos.entitlements */, 26AA70222146E2B900598605 /* shareSheet.entitlements */, @@ -2680,7 +2680,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.1; + MARKETING_VERSION = 0.0.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -3047,7 +3047,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.1; + MARKETING_VERSION = 0.0.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -3209,7 +3209,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.1; + MARKETING_VERSION = 0.0.1; ONLY_ACTIVE_ARCH = YES; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; RUN_CLANG_STATIC_ANALYZER = YES; @@ -3485,7 +3485,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.1; + MARKETING_VERSION = 0.0.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; RUN_CLANG_STATIC_ANALYZER = YES; SDKROOT = iphoneos; @@ -3838,7 +3838,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.1; + MARKETING_VERSION = 0.0.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -4252,7 +4252,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = NO; MACOSX_DEPLOYMENT_TARGET = 11.0; - MARKETING_VERSION = 6.4.1; + MARKETING_VERSION = 0.0.1; PRESERVE_DEAD_CODE_INITS_AND_TERMS = NO; RUN_CLANG_STATIC_ANALYZER = YES; SDKROOT = iphoneos; @@ -4288,9 +4288,9 @@ CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; CLANG_WARN_VEXING_PARSE = YES_ERROR; CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; - CODE_SIGN_ENTITLEMENTS = Alpha.Monal.ios.entitlements; - "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Alpha.Monal.ios.entitlements; - "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Alpha.Monal.macos.entitlements; + CODE_SIGN_ENTITLEMENTS = Monal.Alpha.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]" = Monal.Alpha.ios.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = Monal.Alpha.macos.entitlements; COMPILER_INDEX_STORE_ENABLE = YES; CURRENT_PROJECT_VERSION = 0; DEAD_CODE_STRIPPING = NO; @@ -4314,7 +4314,7 @@ GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; GCC_WARN_PEDANTIC = NO; GCC_WARN_STRICT_SELECTOR_MATCH = NO; - INFOPLIST_FILE = "Alpha.Monal-Info.plist"; + INFOPLIST_FILE = "Monal.Alpha-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Monal.alpha; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, diff --git a/scripts/set_version_number.sh b/scripts/set_version_number.sh index 03d0a4b3c7..7be1713259 100755 --- a/scripts/set_version_number.sh +++ b/scripts/set_version_number.sh @@ -5,12 +5,6 @@ set -e cd Monal -echo "" -echo "*******************************************" -echo "* Reading buildNumber *" -echo "*******************************************" -buildNumber=$(git tag --sort="v:refname" |grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') - echo "" echo "*******************************************" echo "* Setting buildNumber to $buildNumber *" @@ -18,4 +12,6 @@ echo "*******************************************" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "NotificationService/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "shareSheet-iOS/Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "Monal-Info.plist" \ No newline at end of file +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "$APP_NAME-Info.plist" + +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "$APP_NAME-Info.plist" \ No newline at end of file From fe965c471e704aaa2669b2be256db0268b02bb9e Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 02:51:45 +0200 Subject: [PATCH 113/131] Make sure the UI correctly reflects contact status Either we already allowed each other or we allow this contact and asked them to allow us. Everything else is displayed as "add contact" rather than "remove contact". --- Monal/Classes/ContactsViewController.m | 5 +++-- Monal/Classes/MLContact.m | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Monal/Classes/ContactsViewController.m b/Monal/Classes/ContactsViewController.m index 34ed6305be..674d60df5b 100644 --- a/Monal/Classes/ContactsViewController.m +++ b/Monal/Classes/ContactsViewController.m @@ -238,8 +238,9 @@ -(void) loadContactsWithFilter:(NSString*) filter #ifdef IS_QUICKSY [contactsToDisplay addObject:contact]; #else - //ignore all contacts not at least in subscribedTo or asking state - if(contact.isInRoster) + //ignore all contacts not at least in any roster state: e.g. subscribedTo or asking state + //OR is subscribedFrom (e.g. we approved them already, bit they don't approve us) + if((contact.isSubscribedTo || contact.hasOutgoingContactRequest) || contact.isSubscribedFrom) [contactsToDisplay addObject:contact]; #endif } diff --git a/Monal/Classes/MLContact.m b/Monal/Classes/MLContact.m index 3529ba613a..5532729a75 100644 --- a/Monal/Classes/MLContact.m +++ b/Monal/Classes/MLContact.m @@ -508,10 +508,10 @@ +(NSSet*) keyPathsForValuesAffectingIsSelfChat -(BOOL) isInRoster { - // mucs have a subscription of both (ensured by the datalayer) - return [self.subscription isEqualToString:kSubBoth] - || [self.subscription isEqualToString:kSubTo] - || [self.ask isEqualToString:kAskSubscribe]; + //either we already allowed each other or we allow this contact and asked them to allow us + //--> if isInRoster is true this is displayed as "remove contact" in contact details, otherwise it will be displayed as "add contact" + //(mucs have a subscription of 'both', ensured by the datalayer) + return [self.subscription isEqualToString:kSubBoth] || ([self.subscription isEqualToString:kSubFrom] && [self.ask isEqualToString:kAskSubscribe]); } +(NSSet*) keyPathsForValuesAffectingIsInRoster From f379135cd114b011e79a8ec725ea043b44023635 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 03:32:24 +0200 Subject: [PATCH 114/131] --- 920 --- 6.4.1-rc3 From 9dd7215fbd185e770c539a98832af6dc0b3b7bda Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 03:32:49 +0200 Subject: [PATCH 115/131] Fix syntax error in github workflows --- .github/workflows/beta.build-push.yml | 2 +- .github/workflows/quicksy.build-push.yml | 8 +++++--- .github/workflows/stable.build-push.yml | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 285ae11014..d7012c33a0 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -58,7 +58,7 @@ jobs: echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "version=$version" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "buildVersion="$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "buildVersion=$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" diff --git a/.github/workflows/quicksy.build-push.yml b/.github/workflows/quicksy.build-push.yml index e2c3848d9d..2d11247ed3 100644 --- a/.github/workflows/quicksy.build-push.yml +++ b/.github/workflows/quicksy.build-push.yml @@ -42,7 +42,8 @@ jobs: - name: Extract version number and changelog from newest merge commit id: releasenotes run: | - buildNumber=$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g') + buildNumber="$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g')" + version="$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" mkdir -p /Users/ci/releases OUTPUT_FILE="/Users/ci/releases/$buildNumber.output" touch "$OUTPUT_FILE" @@ -50,9 +51,10 @@ jobs: echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "version=$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "version=$version" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "buildVersion=$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "name=Quicksy $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index f0d580d32a..2dc97e7cfa 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -54,7 +54,7 @@ jobs: echo "buildNumber=$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "tag=Build_iOS_$buildNumber" | tee /dev/stderr >> "$OUTPUT_FILE" echo "version=$version" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "buildVersion="$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "buildVersion=$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" From b91244187b8c0eea405846d0a905fcfa8c0b29c1 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 04:12:48 +0200 Subject: [PATCH 116/131] --- 921 --- 6.4.1-rc3 From 8036aeb990782a62c4da4fe71cc3528eed4f4ad8 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 04:11:25 +0200 Subject: [PATCH 117/131] Repair newline fixes for releasenotes --- .github/workflows/beta.build-push.yml | 22 +++++++++++++++++++--- .github/workflows/quicksy.build-push.yml | 22 +++++++++++++++++++--- .github/workflows/stable.build-push.yml | 24 ++++++++++++++++++++---- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index d7012c33a0..119ed72a16 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -48,6 +48,22 @@ jobs: - name: Extract version number and changelog from newest merge commit id: releasenotes run: | + function repairNotes { + awk '{ + if (NR == 1) { + printf("%s", $0) + } else { + if ($0 ~ /^[\t ]*(-|IOS_ONLY[\t ]*-|MACOS_ONLY[\t ]*-).*$/) { + printf("\n%s", $0) + } else { + printf(" %s", $0) + } + } + } + END { + printf("\n") + }' + } buildNumber="$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g')" version="$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" mkdir -p /Users/ci/releases @@ -63,15 +79,15 @@ jobs: echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]?(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/quicksy.build-push.yml b/.github/workflows/quicksy.build-push.yml index 2d11247ed3..c89540d653 100644 --- a/.github/workflows/quicksy.build-push.yml +++ b/.github/workflows/quicksy.build-push.yml @@ -42,6 +42,22 @@ jobs: - name: Extract version number and changelog from newest merge commit id: releasenotes run: | + function repairNotes { + awk '{ + if (NR == 1) { + printf("%s", $0) + } else { + if ($0 ~ /^[\t ]*(-|IOS_ONLY[\t ]*-|MACOS_ONLY[\t ]*-).*$/) { + printf("\n%s", $0) + } else { + printf(" %s", $0) + } + } + } + END { + printf("\n") + }' + } buildNumber="$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g')" version="$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" mkdir -p /Users/ci/releases @@ -57,15 +73,15 @@ jobs: echo "name=Quicksy $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]?(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index 2dc97e7cfa..760ba9351b 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -44,6 +44,22 @@ jobs: - name: Extract version number and changelog from newest merge commit id: releasenotes run: | + function repairNotes { + awk '{ + if (NR == 1) { + printf("%s", $0) + } else { + if ($0 ~ /^[\t ]*(-|IOS_ONLY[\t ]*-|MACOS_ONLY[\t ]*-).*$/) { + printf("\n%s", $0) + } else { + printf(" %s", $0) + } + } + } + END { + printf("\n") + }' + } buildNumber="$(git tag --sort="v:refname" | grep "Build_iOS" | tail -n1 | sed 's/Build_iOS_//g')" version="$(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1/g')" mkdir -p /Users/ci/releases @@ -56,18 +72,18 @@ jobs: echo "version=$version" | tee /dev/stderr >> "$OUTPUT_FILE" echo "buildVersion=$(echo "$version" | grep -oE '^[0-9]+(\.[0-9]+){0,2}')" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "name=Monal Beta $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" + echo "name=Monal $(git log -n 1 --merges --pretty=format:%s | sed -E 's/^[\t\n ]*([^\n\t ]+)[\t\n ]+\(([^\n\t ]+)\)[\t\n ]*$/\1 (Build '$buildNumber', PR \2)/g')" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]?(.*)$/\1/g' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_ios<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | grep -v '^[\t\n ]*MACOS_ONLY.*$' | sed -E 's/^[\t\n ]*IOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" echo "notes_macos<<__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" - echo "$(git log -n 1 --merges --pretty=format:%b)" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\n\([^-]\)/ \1/g' | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]*(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" + echo "$(git log -n 1 --merges --pretty=format:%b)" | repairNotes | grep -v '^[\t\n ]*IOS_ONLY.*$' | sed -E 's/^[\t\n ]*MACOS_ONLY[\t\n ]?(.*)$/\1/g' | tee /dev/stderr >> "$OUTPUT_FILE" echo "__EOF__" | tee /dev/stderr >> "$OUTPUT_FILE" cat "$OUTPUT_FILE" >> "$GITHUB_OUTPUT" From 03b88b9df15c3c622067943ec3964edbe2dbab35 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 04:22:17 +0200 Subject: [PATCH 118/131] Fix again (remove carriage returns before merging linewraps) --- .github/workflows/beta.build-push.yml | 2 +- .github/workflows/quicksy.build-push.yml | 2 +- .github/workflows/stable.build-push.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 119ed72a16..0cc75e1f08 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -49,7 +49,7 @@ jobs: id: releasenotes run: | function repairNotes { - awk '{ + sed 's/\r//g' | awk '{ if (NR == 1) { printf("%s", $0) } else { diff --git a/.github/workflows/quicksy.build-push.yml b/.github/workflows/quicksy.build-push.yml index c89540d653..8a6ea1275f 100644 --- a/.github/workflows/quicksy.build-push.yml +++ b/.github/workflows/quicksy.build-push.yml @@ -43,7 +43,7 @@ jobs: id: releasenotes run: | function repairNotes { - awk '{ + sed 's/\r//g' | awk '{ if (NR == 1) { printf("%s", $0) } else { diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index 760ba9351b..e4e4e7fb59 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -45,7 +45,7 @@ jobs: id: releasenotes run: | function repairNotes { - awk '{ + sed 's/\r//g' | awk '{ if (NR == 1) { printf("%s", $0) } else { From d0c68f926d696518b0803384217e8bf2482e165f Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 15:07:30 +0200 Subject: [PATCH 119/131] Improve version update script --- .github/workflows/beta.build-push.yml | 46 ++++++++++----------- .github/workflows/quicksy.build-push.yml | 24 +++++------ .github/workflows/stable.build-push.yml | 51 ++++++++++++------------ scripts/set_version_number.sh | 11 +++-- 4 files changed, 67 insertions(+), 65 deletions(-) diff --git a/.github/workflows/beta.build-push.yml b/.github/workflows/beta.build-push.yml index 0cc75e1f08..49a3e73f71 100644 --- a/.github/workflows/beta.build-push.yml +++ b/.github/workflows/beta.build-push.yml @@ -105,6 +105,21 @@ jobs: run: chmod +x ./scripts/build.sh - name: Run build run: ./scripts/build.sh + - uses: actions/upload-artifact@v4 + with: + name: monal-ios + path: Monal/build/ipa/Monal.ipa + if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: monal-catalyst-dsym + path: Monal/build/macos_Monal.xcarchive/dSYMs + if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: monal-ios-dsym + path: Monal/build/ios_Monal.xcarchive/dSYMs + if-no-files-found: error - name: validate ios app run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - name: Push beta tag to repo @@ -126,14 +141,6 @@ jobs: stapler validate "$APP_DIR" /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "../$APP_NAME.zip" cd ../../../.. - - name: Upload new catalyst beta to monal-im.org - run: ./scripts/uploadNonAlpha.sh beta - - name: Publish catalyst to appstore connect - #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal - env: - PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes_macos }} - run: | - fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - uses: actions/upload-artifact@v4 with: name: monal-catalyst-zip @@ -144,21 +151,14 @@ jobs: name: monal-catalyst-pkg path: Monal/build/app/Monal.pkg if-no-files-found: error - - uses: actions/upload-artifact@v4 - with: - name: monal-ios - path: Monal/build/ipa/Monal.ipa - if-no-files-found: error - - uses: actions/upload-artifact@v4 - with: - name: monal-catalyst-dsym - path: Monal/build/macos_Monal.xcarchive/dSYMs - if-no-files-found: error - - uses: actions/upload-artifact@v4 - with: - name: monal-ios-dsym - path: Monal/build/ios_Monal.xcarchive/dSYMs - if-no-files-found: error + - name: Upload new catalyst beta to monal-im.org + run: ./scripts/uploadNonAlpha.sh beta + - name: Publish catalyst to appstore connect + #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id org.monal-im.prod.catalyst.monal + env: + PILOT_CHANGELOG: ${{ steps.releasenotes.outputs.notes_macos }} + run: | + fastlane run upload_to_testflight api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" pkg:"./Monal/build/app/Monal.pkg" distribute_external:true groups:"Internal Pre-Beta Testers","Public Beta" reject_build_waiting_for_review:true submit_beta_review:true - name: Release uses: softprops/action-gh-release@v2 with: diff --git a/.github/workflows/quicksy.build-push.yml b/.github/workflows/quicksy.build-push.yml index 8a6ea1275f..d8562007b0 100644 --- a/.github/workflows/quicksy.build-push.yml +++ b/.github/workflows/quicksy.build-push.yml @@ -5,7 +5,7 @@ name: quicksy.build-push on: # Triggers the workflow on push push: - branches: [ quicksy ] + branches: [ stable ] workflow_dispatch: @@ -99,6 +99,16 @@ jobs: run: chmod +x ./scripts/build.sh - name: Run build run: ./scripts/build.sh + - uses: actions/upload-artifact@v4 + with: + name: monal-ios + path: Monal/build/ipa/Monal.ipa + if-no-files-found: error + # - uses: actions/upload-artifact@v4 + # with: + # name: monal-ios-dsym + # path: Monal/build/ios_Monal.xcarchive/dSYMs + # if-no-files-found: error - name: validate ios app run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - name: push tag to stable repo @@ -118,17 +128,7 @@ jobs: env: DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path }} run: | - fastlane run upload_to_app_store api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" ipa:"./Monal/build/ipa/Monal.ipa" app_version:"${{ steps.releasenotes.outputs.version }}" reject_if_possible:true submit_for_review:true automatic_release:true skip_metadata: true skip_screenshots: true + fastlane run upload_to_app_store api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" ipa:"./Monal/build/ipa/Monal.ipa" app_version:"${{ steps.releasenotes.outputs.version }}" reject_if_possible:true submit_for_review:false automatic_release:false skip_metadata: true skip_screenshots: true - name: Remove fastlane metadata directory run: | rm -rf "${{ steps.metadata.outputs.path }}" - - uses: actions/upload-artifact@v4 - with: - name: monal-ios - path: Monal/build/ipa/Monal.ipa - if-no-files-found: error - # - uses: actions/upload-artifact@v4 - # with: - # name: monal-ios-dsym - # path: Monal/build/ios_Monal.xcarchive/dSYMs - # if-no-files-found: error diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index e4e4e7fb59..7270e066d7 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -99,9 +99,23 @@ jobs: fi - name: Make our build scripts executable run: chmod +x ./scripts/build.sh - - run: chmod +x ./scripts/push_xmpp.org.sh - name: Run build run: ./scripts/build.sh + - uses: actions/upload-artifact@v4 + with: + name: monal-ios + path: Monal/build/ipa/Monal.ipa + if-no-files-found: error + # - uses: actions/upload-artifact@v4 + # with: + # name: monal-catalyst-dsym + # path: Monal/build/macos_Monal.xcarchive/dSYMs + # if-no-files-found: error + # - uses: actions/upload-artifact@v4 + # with: + # name: monal-ios-dsym + # path: Monal/build/ios_Monal.xcarchive/dSYMs + # if-no-files-found: error - name: validate ios app run: xcrun altool --validate-app --file ./Monal/build/ipa/Monal.ipa --type ios -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" - name: push tag to stable repo @@ -131,6 +145,16 @@ jobs: stapler validate "$APP_DIR" /usr/bin/ditto -c -k --sequesterRsrc --keepParent "$APP_DIR" "../$APP_NAME.zip" cd ../../../.. + - uses: actions/upload-artifact@v4 + with: + name: monal-catalyst-zip + path: Monal/build/app/Monal.zip + if-no-files-found: error + - uses: actions/upload-artifact@v4 + with: + name: monal-catalyst-pkg + path: Monal/build/app/Monal.pkg + if-no-files-found: error - name: Upload new catalyst stable to monal-im.org run: ./scripts/uploadNonAlpha.sh stable - name: Publish catalyst to appstore connect @@ -144,31 +168,6 @@ jobs: - name: Remove fastlane metadata directory run: | rm -rf "${{ steps.metadata.outputs.path }}" - - uses: actions/upload-artifact@v4 - with: - name: monal-catalyst-zip - path: Monal/build/app/Monal.zip - if-no-files-found: error - - uses: actions/upload-artifact@v4 - with: - name: monal-catalyst-pkg - path: Monal/build/app/Monal.pkg - if-no-files-found: error - - uses: actions/upload-artifact@v4 - with: - name: monal-ios - path: Monal/build/ipa/Monal.ipa - if-no-files-found: error - # - uses: actions/upload-artifact@v4 - # with: - # name: monal-catalyst-dsym - # path: Monal/build/macos_Monal.xcarchive/dSYMs - # if-no-files-found: error - # - uses: actions/upload-artifact@v4 - # with: - # name: monal-ios-dsym - # path: Monal/build/ios_Monal.xcarchive/dSYMs - # if-no-files-found: error - name: Create Draft Release id: draftrelease uses: softprops/action-gh-release@v2 diff --git a/scripts/set_version_number.sh b/scripts/set_version_number.sh index 7be1713259..4d71931c7e 100755 --- a/scripts/set_version_number.sh +++ b/scripts/set_version_number.sh @@ -2,16 +2,19 @@ # Abort on Error set -e +set -x cd Monal echo "" -echo "*******************************************" -echo "* Setting buildNumber to $buildNumber *" -echo "*******************************************" +echo "***************************************************" +echo "* Setting buildNumber to $buildNumber and version to $version *" +echo "***************************************************" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "NotificationService/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "shareSheet-iOS/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "$APP_NAME-Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "$APP_NAME-Info.plist" \ No newline at end of file +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "NotificationService/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "shareSheet-iOS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "$APP_NAME-Info.plist" From 7d661c6450a871dbba97cb72771b0218a267fb7b Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 15:38:41 +0200 Subject: [PATCH 120/131] Fix typo in version update script --- scripts/set_version_number.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/set_version_number.sh b/scripts/set_version_number.sh index 4d71931c7e..e9868a6483 100755 --- a/scripts/set_version_number.sh +++ b/scripts/set_version_number.sh @@ -2,19 +2,20 @@ # Abort on Error set -e -set -x cd Monal echo "" echo "***************************************************" -echo "* Setting buildNumber to $buildNumber and version to $version *" +echo "* Setting buildNumber to $buildNumber and version to $buildVersion *" echo "***************************************************" +set -x + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "NotificationService/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "shareSheet-iOS/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "$APP_NAME-Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "NotificationService/Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "shareSheet-iOS/Info.plist" -/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $version" "$APP_NAME-Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $buildVersion" "NotificationService/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $buildVersion" "shareSheet-iOS/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $buildVersion" "$APP_NAME-Info.plist" From b5cafcce940ac1b5748527c27d7405d17fd6ba27 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 15:41:16 +0200 Subject: [PATCH 121/131] --- 924 --- 6.4.1-rc3 From b8ab28103289b353253e0cf646b12d040b05b816 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 17:33:05 +0200 Subject: [PATCH 122/131] Make sure quicksy contact sync happens on every app foreground --- Monal/Classes/ActiveChatsViewController.m | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index c7402871cb..5fa9624dc8 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -267,10 +267,8 @@ -(void) handleRefreshDisplayNotification:(NSNotification*) notification { // filter notifcations from within this class if([notification.object isKindOfClass:[ActiveChatsViewController class]]) - { return; - } - [self refreshDisplay]; + [self refresh]; } -(void) handleContactRemoved:(NSNotification*) notification @@ -410,8 +408,7 @@ -(void) sheetDismissed -(void) refresh { dispatch_async(dispatch_get_main_queue(), ^{ - if(self.unpinnedContacts.count == 0 && self.pinnedContacts.count == 0) - [self refreshDisplay]; // load contacts + [self refreshDisplay]; // load contacts [self segueToIntroScreensIfNeeded]; }); } From 4451af3ac2e04fbaf379c2dc859ac733ba6230a8 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 17:41:26 +0200 Subject: [PATCH 123/131] Fix adding of country code to quicksy phonebook numbers --- Monal/Classes/ActiveChatsViewController.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/ActiveChatsViewController.m b/Monal/Classes/ActiveChatsViewController.m index 5fa9624dc8..24771259ab 100755 --- a/Monal/Classes/ActiveChatsViewController.m +++ b/Monal/Classes/ActiveChatsViewController.m @@ -520,7 +520,7 @@ -(void) syncContacts [store requestAccessForEntityType:CNEntityTypeContacts completionHandler:^(BOOL granted, NSError* _Nullable error) { if(granted) { - NSString* countryCode = @"+49"; //[[HelperTools defaultsDB] objectForKey:@"Quicksy_countryCode"]; + NSString* countryCode = [[HelperTools defaultsDB] objectForKey:@"Quicksy_countryCode"]; NSCharacterSet* allowedCharacters = [[NSCharacterSet characterSetWithCharactersInString:@"+0123456789"] invertedSet]; NSMutableDictionary* numbers = [NSMutableDictionary new]; @@ -537,7 +537,7 @@ -(void) syncContacts if(countryCode != nil && ![number hasPrefix:@"+"] && ![number hasPrefix:@"00"]) { DDLogVerbose(@"Adding country code '%@' to number: %@", countryCode, number); - number = [NSString stringWithFormat:@"%@%@", countryCode, number]; + number = [NSString stringWithFormat:@"%@%@", countryCode, [number hasPrefix:@"0"] ? [number substringFromIndex:1] : number]; } numbers[number] = name; } From 5fb649c880be6f191aa1c938657026876887430d Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 19:50:52 +0200 Subject: [PATCH 124/131] Fix race condition when sending outgoing IQ while connecting --- Monal/Classes/xmpp.m | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Monal/Classes/xmpp.m b/Monal/Classes/xmpp.m index bcbd5f24ff..fb245b58fc 100644 --- a/Monal/Classes/xmpp.m +++ b/Monal/Classes/xmpp.m @@ -3241,14 +3241,19 @@ -(void) sendIq:(XMPPIQ*) iq withResponseHandler:(monal_iq_handler_t) resultHandl -(void) sendIq:(XMPPIQ*) iq withHandler:(MLHandler*) handler { - if(handler) - { - DDLogVerbose(@"Adding %@ to iqHandlers...", handler); - @synchronized(_iqHandlers) { - _iqHandlers[iq.id] = [@{@"iq":iq, @"timeout":@(IQ_TIMEOUT), @"handler":handler} mutableCopy]; + //serialize this state update with other receive queue updates + //not doing this will make it race with a readState call in the receive queue before the write of this update can happen, + //which will remove this entry from state and the iq answer received later on be discarded + [self dispatchAsyncOnReceiveQueue:^{ + if(handler) + { + DDLogVerbose(@"Adding %@ to iqHandlers...", handler); + @synchronized(self->_iqHandlers) { + self->_iqHandlers[iq.id] = [@{@"iq":iq, @"timeout":@(IQ_TIMEOUT), @"handler":handler} mutableCopy]; + } } - } - [self send:iq]; //this will also call persistState --> we don't need to do this here explicitly (to make sure our iq delegate is stored to db) + [self send:iq]; //this will also call persistState --> we don't need to do this here explicitly (to make sure our iq delegate is stored to db) + }]; } -(void) send:(MLXMLNode*) stanza From 5cdd773b98187c66e7c4ff1b9ca0eb478757e120 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Mon, 22 Jul 2024 20:52:48 +0200 Subject: [PATCH 125/131] Add quicksy export options plist file --- .../Quicksy_Stable_iOS_ExportOptions.plist | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist diff --git a/scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist b/scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist new file mode 100644 index 0000000000..a2dab0a13a --- /dev/null +++ b/scripts/exportOptions/Quicksy_Stable_iOS_ExportOptions.plist @@ -0,0 +1,16 @@ + + + + + method + app-store + compileBitcode + + uploadBitcode + + signingStyle + automatic + teamID + S8D843U34Y + + \ No newline at end of file From 85f22dcdc783539f1ec9939fdfd11b591e2ad495 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 23 Jul 2024 02:11:58 +0200 Subject: [PATCH 126/131] Update quicksy country codes from ITU pdf The PDF located at http://www.itu.int/dms_pub/itu-t/opb/sp/T-SP-E.164C-2011-PDF-E.pdf can be downloaded, parsed and used to generate swift code by calling the itu_pdf_to_swift.py python script. Example: ../scripts/itu_pdf_to_swift.py > Classes/CountryCodes.swift --- Monal/Classes/CountryCodes.swift | 243 ++++++++++++++++++++ Monal/Classes/Quicksy_RegisterAccount.swift | 18 +- Monal/Monal.xcodeproj/project.pbxproj | 4 + scripts/itu_pdf_to_swift.py | 135 +++++++++++ scripts/requirements.txt | 1 + 5 files changed, 385 insertions(+), 16 deletions(-) create mode 100644 Monal/Classes/CountryCodes.swift create mode 100755 scripts/itu_pdf_to_swift.py create mode 100644 scripts/requirements.txt diff --git a/Monal/Classes/CountryCodes.swift b/Monal/Classes/CountryCodes.swift new file mode 100644 index 0000000000..8d306d0e82 --- /dev/null +++ b/Monal/Classes/CountryCodes.swift @@ -0,0 +1,243 @@ +// This file was automatically generated by scripts/itu_pdf_to_swift.py +// Please run this python script again to update this file +// Example ../scripts/itu_pdf_to_swift.py > Classes/CountryCodes.swift + +public struct Quicksy_Country: Identifiable, Hashable { + public let id = UUID() + public let name: String + public let code: String + public let pattern: String +} + +public let COUNTRY_CODES: [Quicksy_Country] = [ + Quicksy_Country(name: NSLocalizedString("Afghanistan", comment:"quicksy country"), code: "+93", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Albania", comment:"quicksy country"), code: "+355", pattern: "^([0-9]{3,9})$") , + Quicksy_Country(name: NSLocalizedString("Algeria", comment:"quicksy country"), code: "+213", pattern: "^([0-9]{8}|[0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("American Samoa", comment:"quicksy country"), code: "+1", pattern: "^(684)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Andorra", comment:"quicksy country"), code: "+376", pattern: "^([0-9]{6}|[0-9]{8}|[0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Angola", comment:"quicksy country"), code: "+244", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Anguilla", comment:"quicksy country"), code: "+1", pattern: "^(264)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Antigua and Barbuda", comment:"quicksy country"), code: "+1", pattern: "^(268)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Argentina", comment:"quicksy country"), code: "+54", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Armenia", comment:"quicksy country"), code: "+374", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Aruba", comment:"quicksy country"), code: "+297", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Australia", comment:"quicksy country"), code: "+61", pattern: "^([0-9]{5,15})$") , + Quicksy_Country(name: NSLocalizedString("Australian External Territories", comment:"quicksy country"), code: "+672", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Austria", comment:"quicksy country"), code: "+43", pattern: "^([0-9]{4,13})$") , + Quicksy_Country(name: NSLocalizedString("Azerbaijan", comment:"quicksy country"), code: "+994", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Bahamas", comment:"quicksy country"), code: "+1", pattern: "^(242)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Bahrain", comment:"quicksy country"), code: "+973", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Bangladesh", comment:"quicksy country"), code: "+880", pattern: "^([0-9]{6,10})$") , + Quicksy_Country(name: NSLocalizedString("Barbados", comment:"quicksy country"), code: "+1", pattern: "^(246)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Belarus", comment:"quicksy country"), code: "+375", pattern: "^([0-9]{9,10})$") , + Quicksy_Country(name: NSLocalizedString("Belgium", comment:"quicksy country"), code: "+32", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Belize", comment:"quicksy country"), code: "+501", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Benin", comment:"quicksy country"), code: "+229", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Bermuda", comment:"quicksy country"), code: "+1", pattern: "^(441)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Bhutan", comment:"quicksy country"), code: "+975", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Bolivia (Plurinational State of)", comment:"quicksy country"), code: "+591", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Bonaire, Sint Eustatius and Saba", comment:"quicksy country"), code: "+599", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Bosnia and Herzegovina", comment:"quicksy country"), code: "+387", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Botswana", comment:"quicksy country"), code: "+267", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Brazil", comment:"quicksy country"), code: "+55", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("British Virgin Islands", comment:"quicksy country"), code: "+1", pattern: "^(284)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Brunei Darussalam", comment:"quicksy country"), code: "+673", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Bulgaria", comment:"quicksy country"), code: "+359", pattern: "^([0-9]{7,9})$") , + Quicksy_Country(name: NSLocalizedString("Burkina Faso", comment:"quicksy country"), code: "+226", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Burundi", comment:"quicksy country"), code: "+257", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Cambodia", comment:"quicksy country"), code: "+855", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Cameroon", comment:"quicksy country"), code: "+237", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Canada", comment:"quicksy country"), code: "+1", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Cape Verde", comment:"quicksy country"), code: "+238", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Cayman Islands", comment:"quicksy country"), code: "+1", pattern: "^(345)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Central African Rep.", comment:"quicksy country"), code: "+236", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Chad", comment:"quicksy country"), code: "+235", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Chile", comment:"quicksy country"), code: "+56", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("China", comment:"quicksy country"), code: "+86", pattern: "^([0-9]{5,12})$") , + Quicksy_Country(name: NSLocalizedString("Colombia", comment:"quicksy country"), code: "+57", pattern: "^([0-9]{8}|[0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Comoros", comment:"quicksy country"), code: "+269", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Congo", comment:"quicksy country"), code: "+242", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Cook Islands", comment:"quicksy country"), code: "+682", pattern: "^([0-9]{5})$") , + Quicksy_Country(name: NSLocalizedString("Costa Rica", comment:"quicksy country"), code: "+506", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Côte d'Ivoire", comment:"quicksy country"), code: "+225", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Croatia", comment:"quicksy country"), code: "+385", pattern: "^([0-9]{8,12})$") , + Quicksy_Country(name: NSLocalizedString("Cuba", comment:"quicksy country"), code: "+53", pattern: "^([0-9]{6,8})$") , + Quicksy_Country(name: NSLocalizedString("Curaçao", comment:"quicksy country"), code: "+599", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Cyprus", comment:"quicksy country"), code: "+357", pattern: "^([0-9]{8}|[0-9]{11})$") , + Quicksy_Country(name: NSLocalizedString("Czech Rep.", comment:"quicksy country"), code: "+420", pattern: "^([0-9]{4,12})$") , + Quicksy_Country(name: NSLocalizedString("Dem. People's Rep. of Korea", comment:"quicksy country"), code: "+850", pattern: "^([0-9]{6,17})$") , + Quicksy_Country(name: NSLocalizedString("Dem. Rep. of the Congo", comment:"quicksy country"), code: "+243", pattern: "^([0-9]{5,9})$") , + Quicksy_Country(name: NSLocalizedString("Denmark", comment:"quicksy country"), code: "+45", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Diego Garcia", comment:"quicksy country"), code: "+246", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Djibouti", comment:"quicksy country"), code: "+253", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Dominica", comment:"quicksy country"), code: "+1", pattern: "^(767)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Dominican Rep.", comment:"quicksy country"), code: "+1", pattern: "^(809|829)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Ecuador", comment:"quicksy country"), code: "+593", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Egypt", comment:"quicksy country"), code: "+20", pattern: "^([0-9]{7,9})$") , + Quicksy_Country(name: NSLocalizedString("El Salvador", comment:"quicksy country"), code: "+503", pattern: "^([0-9]{7}|[0-9]{8}|[0-9]{11})$") , + Quicksy_Country(name: NSLocalizedString("Equatorial Guinea", comment:"quicksy country"), code: "+240", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Eritrea", comment:"quicksy country"), code: "+291", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Estonia", comment:"quicksy country"), code: "+372", pattern: "^([0-9]{7,10})$") , + Quicksy_Country(name: NSLocalizedString("Ethiopia", comment:"quicksy country"), code: "+251", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Falkland Islands (Malvinas)", comment:"quicksy country"), code: "+500", pattern: "^([0-9]{5})$") , + Quicksy_Country(name: NSLocalizedString("Faroe Islands", comment:"quicksy country"), code: "+298", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Fiji", comment:"quicksy country"), code: "+679", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Finland", comment:"quicksy country"), code: "+358", pattern: "^([0-9]{5,12})$") , + Quicksy_Country(name: NSLocalizedString("France", comment:"quicksy country"), code: "+33", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("French Departments and Territories in the Indian Ocean", comment:"quicksy country"), code: "+262", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("French Guiana", comment:"quicksy country"), code: "+594", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("French Polynesia", comment:"quicksy country"), code: "+689", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Gabon", comment:"quicksy country"), code: "+241", pattern: "^([0-9]{6}|[0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Gambia", comment:"quicksy country"), code: "+220", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Georgia", comment:"quicksy country"), code: "+995", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Germany", comment:"quicksy country"), code: "+49", pattern: "^([0-9]{6,13})$") , + Quicksy_Country(name: NSLocalizedString("Ghana", comment:"quicksy country"), code: "+233", pattern: "^([0-9]{5,9})$") , + Quicksy_Country(name: NSLocalizedString("Gibraltar", comment:"quicksy country"), code: "+350", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Greece", comment:"quicksy country"), code: "+30", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Greenland", comment:"quicksy country"), code: "+299", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Grenada", comment:"quicksy country"), code: "+1", pattern: "^(473)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Guadeloupe", comment:"quicksy country"), code: "+590", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Guam", comment:"quicksy country"), code: "+1", pattern: "^(671)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Guatemala", comment:"quicksy country"), code: "+502", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Guinea", comment:"quicksy country"), code: "+224", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Guinea-Bissau", comment:"quicksy country"), code: "+245", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Guyana", comment:"quicksy country"), code: "+592", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Haiti", comment:"quicksy country"), code: "+509", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Honduras", comment:"quicksy country"), code: "+504", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Hong Kong, China", comment:"quicksy country"), code: "+852", pattern: "^([0-9]{4}|[0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Hungary", comment:"quicksy country"), code: "+36", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Iceland", comment:"quicksy country"), code: "+354", pattern: "^([0-9]{7}|[0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("India", comment:"quicksy country"), code: "+91", pattern: "^([0-9]{7,10})$") , + Quicksy_Country(name: NSLocalizedString("Indonesia", comment:"quicksy country"), code: "+62", pattern: "^([0-9]{5,10})$") , + Quicksy_Country(name: NSLocalizedString("Iran (Islamic Republic of)", comment:"quicksy country"), code: "+98", pattern: "^([0-9]{6,10})$") , + Quicksy_Country(name: NSLocalizedString("Iraq", comment:"quicksy country"), code: "+964", pattern: "^([0-9]{8,10})$") , + Quicksy_Country(name: NSLocalizedString("Ireland", comment:"quicksy country"), code: "+353", pattern: "^([0-9]{7,11})$") , + Quicksy_Country(name: NSLocalizedString("Israel", comment:"quicksy country"), code: "+972", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Italy", comment:"quicksy country"), code: "+39", pattern: "^([0-9]{1,11})$") , + Quicksy_Country(name: NSLocalizedString("Jamaica", comment:"quicksy country"), code: "+1", pattern: "^(876)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Japan", comment:"quicksy country"), code: "+81", pattern: "^([0-9]{5,13})$") , + Quicksy_Country(name: NSLocalizedString("Jordan", comment:"quicksy country"), code: "+962", pattern: "^([0-9]{5,9})$") , + Quicksy_Country(name: NSLocalizedString("Kazakhstan", comment:"quicksy country"), code: "+7", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Kenya", comment:"quicksy country"), code: "+254", pattern: "^([0-9]{6,10})$") , + Quicksy_Country(name: NSLocalizedString("Kiribati", comment:"quicksy country"), code: "+686", pattern: "^([0-9]{5})$") , + Quicksy_Country(name: NSLocalizedString("Korea (Rep. of)", comment:"quicksy country"), code: "+82", pattern: "^([0-9]{8,11})$") , + Quicksy_Country(name: NSLocalizedString("Kuwait", comment:"quicksy country"), code: "+965", pattern: "^([0-9]{7}|[0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Kyrgyzstan", comment:"quicksy country"), code: "+996", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Lao P.D.R.", comment:"quicksy country"), code: "+856", pattern: "^([0-9]{8,10})$") , + Quicksy_Country(name: NSLocalizedString("Latvia", comment:"quicksy country"), code: "+371", pattern: "^([0-9]{7}|[0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Lebanon", comment:"quicksy country"), code: "+961", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Lesotho", comment:"quicksy country"), code: "+266", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Liberia", comment:"quicksy country"), code: "+231", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Libya", comment:"quicksy country"), code: "+218", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Liechtenstein", comment:"quicksy country"), code: "+423", pattern: "^([0-9]{7,9})$") , + Quicksy_Country(name: NSLocalizedString("Lithuania", comment:"quicksy country"), code: "+370", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Luxembourg", comment:"quicksy country"), code: "+352", pattern: "^([0-9]{4,11})$") , + Quicksy_Country(name: NSLocalizedString("Macao, China", comment:"quicksy country"), code: "+853", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Madagascar", comment:"quicksy country"), code: "+261", pattern: "^([0-9]{9,10})$") , + Quicksy_Country(name: NSLocalizedString("Malawi", comment:"quicksy country"), code: "+265", pattern: "^([0-9]{7}|[0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Malaysia", comment:"quicksy country"), code: "+60", pattern: "^([0-9]{7,9})$") , + Quicksy_Country(name: NSLocalizedString("Maldives", comment:"quicksy country"), code: "+960", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Mali", comment:"quicksy country"), code: "+223", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Malta", comment:"quicksy country"), code: "+356", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Marshall Islands", comment:"quicksy country"), code: "+692", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Martinique", comment:"quicksy country"), code: "+596", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Mauritania", comment:"quicksy country"), code: "+222", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Mauritius", comment:"quicksy country"), code: "+230", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Mexico", comment:"quicksy country"), code: "+52", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Micronesia", comment:"quicksy country"), code: "+691", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Moldova (Republic of)", comment:"quicksy country"), code: "+373", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Monaco", comment:"quicksy country"), code: "+377", pattern: "^([0-9]{5,9})$") , + Quicksy_Country(name: NSLocalizedString("Mongolia", comment:"quicksy country"), code: "+976", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Montenegro", comment:"quicksy country"), code: "+382", pattern: "^([0-9]{4,12})$") , + Quicksy_Country(name: NSLocalizedString("Montserrat", comment:"quicksy country"), code: "+1", pattern: "^(664)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Morocco", comment:"quicksy country"), code: "+212", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Mozambique", comment:"quicksy country"), code: "+258", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Myanmar", comment:"quicksy country"), code: "+95", pattern: "^([0-9]{7,9})$") , + Quicksy_Country(name: NSLocalizedString("Namibia", comment:"quicksy country"), code: "+264", pattern: "^([0-9]{6,10})$") , + Quicksy_Country(name: NSLocalizedString("Nauru", comment:"quicksy country"), code: "+674", pattern: "^([0-9]{4}|[0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Nepal", comment:"quicksy country"), code: "+977", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Netherlands", comment:"quicksy country"), code: "+31", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("New Caledonia", comment:"quicksy country"), code: "+687", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("New Zealand", comment:"quicksy country"), code: "+64", pattern: "^([0-9]{3,10})$") , + Quicksy_Country(name: NSLocalizedString("Nicaragua", comment:"quicksy country"), code: "+505", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Niger", comment:"quicksy country"), code: "+227", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Nigeria", comment:"quicksy country"), code: "+234", pattern: "^([0-9]{7,10})$") , + Quicksy_Country(name: NSLocalizedString("Niue", comment:"quicksy country"), code: "+683", pattern: "^([0-9]{4})$") , + Quicksy_Country(name: NSLocalizedString("Northern Marianas", comment:"quicksy country"), code: "+1", pattern: "^(670)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Norway", comment:"quicksy country"), code: "+47", pattern: "^([0-9]{5}|[0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Oman", comment:"quicksy country"), code: "+968", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Pakistan", comment:"quicksy country"), code: "+92", pattern: "^([0-9]{8,11})$") , + Quicksy_Country(name: NSLocalizedString("Palau", comment:"quicksy country"), code: "+680", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Panama", comment:"quicksy country"), code: "+507", pattern: "^([0-9]{7}|[0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Papua New Guinea", comment:"quicksy country"), code: "+675", pattern: "^([0-9]{4,11})$") , + Quicksy_Country(name: NSLocalizedString("Paraguay", comment:"quicksy country"), code: "+595", pattern: "^([0-9]{5,9})$") , + Quicksy_Country(name: NSLocalizedString("Peru", comment:"quicksy country"), code: "+51", pattern: "^([0-9]{8,11})$") , + Quicksy_Country(name: NSLocalizedString("Philippines", comment:"quicksy country"), code: "+63", pattern: "^([0-9]{8,10})$") , + Quicksy_Country(name: NSLocalizedString("Poland", comment:"quicksy country"), code: "+48", pattern: "^([0-9]{6,9})$") , + Quicksy_Country(name: NSLocalizedString("Portugal", comment:"quicksy country"), code: "+351", pattern: "^([0-9]{9}|[0-9]{11})$") , + Quicksy_Country(name: NSLocalizedString("Puerto Rico", comment:"quicksy country"), code: "+1", pattern: "^(787|939)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Qatar", comment:"quicksy country"), code: "+974", pattern: "^([0-9]{3,8})$") , + Quicksy_Country(name: NSLocalizedString("Romania", comment:"quicksy country"), code: "+40", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Russian Federation", comment:"quicksy country"), code: "+7", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Rwanda", comment:"quicksy country"), code: "+250", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Saint Helena, Ascension and Tristan da Cunha", comment:"quicksy country"), code: "+247", pattern: "^([0-9]{4})$") , + Quicksy_Country(name: NSLocalizedString("Saint Helena, Ascension and Tristan da Cunha", comment:"quicksy country"), code: "+290", pattern: "^([0-9]{4})$") , + Quicksy_Country(name: NSLocalizedString("Saint Kitts and Nevis", comment:"quicksy country"), code: "+1", pattern: "^(869)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Saint Lucia", comment:"quicksy country"), code: "+1", pattern: "^(758)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Saint Pierre and Miquelon", comment:"quicksy country"), code: "+508", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Saint Vincent and the Grenadines", comment:"quicksy country"), code: "+1", pattern: "^(784)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Samoa", comment:"quicksy country"), code: "+685", pattern: "^([0-9]{3,7})$") , + Quicksy_Country(name: NSLocalizedString("San Marino", comment:"quicksy country"), code: "+378", pattern: "^([0-9]{6,10})$") , + Quicksy_Country(name: NSLocalizedString("Sao Tome and Principe", comment:"quicksy country"), code: "+239", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Saudi Arabia", comment:"quicksy country"), code: "+966", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Senegal", comment:"quicksy country"), code: "+221", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Serbia", comment:"quicksy country"), code: "+381", pattern: "^([0-9]{4,12})$") , + Quicksy_Country(name: NSLocalizedString("Seychelles", comment:"quicksy country"), code: "+248", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Sierra Leone", comment:"quicksy country"), code: "+232", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Singapore", comment:"quicksy country"), code: "+65", pattern: "^([0-9]{8,12})$") , + Quicksy_Country(name: NSLocalizedString("Sint Maarten (Dutch part)", comment:"quicksy country"), code: "+1", pattern: "^(721)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Slovakia", comment:"quicksy country"), code: "+421", pattern: "^([0-9]{4,9})$") , + Quicksy_Country(name: NSLocalizedString("Slovenia", comment:"quicksy country"), code: "+386", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Solomon Islands", comment:"quicksy country"), code: "+677", pattern: "^([0-9]{5})$") , + Quicksy_Country(name: NSLocalizedString("Somalia", comment:"quicksy country"), code: "+252", pattern: "^([0-9]{5,8})$") , + Quicksy_Country(name: NSLocalizedString("South Africa", comment:"quicksy country"), code: "+27", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Spain", comment:"quicksy country"), code: "+34", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Sri Lanka", comment:"quicksy country"), code: "+94", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Sudan", comment:"quicksy country"), code: "+249", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Suriname", comment:"quicksy country"), code: "+597", pattern: "^([0-9]{6,7})$") , + Quicksy_Country(name: NSLocalizedString("Swaziland", comment:"quicksy country"), code: "+268", pattern: "^([0-9]{7,8})$") , + Quicksy_Country(name: NSLocalizedString("Sweden", comment:"quicksy country"), code: "+46", pattern: "^([0-9]{7,13})$") , + Quicksy_Country(name: NSLocalizedString("Switzerland", comment:"quicksy country"), code: "+41", pattern: "^([0-9]{4,12})$") , + Quicksy_Country(name: NSLocalizedString("Syrian Arab Republic", comment:"quicksy country"), code: "+963", pattern: "^([0-9]{8,10})$") , + Quicksy_Country(name: NSLocalizedString("Taiwan, China", comment:"quicksy country"), code: "+886", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("Tajikistan", comment:"quicksy country"), code: "+992", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Tanzania", comment:"quicksy country"), code: "+255", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Thailand", comment:"quicksy country"), code: "+66", pattern: "^([0-9]{8}|[0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("The Former Yugoslav Republic of Macedonia", comment:"quicksy country"), code: "+389", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Timor-Leste", comment:"quicksy country"), code: "+670", pattern: "^([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Togo", comment:"quicksy country"), code: "+228", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Tokelau", comment:"quicksy country"), code: "+690", pattern: "^([0-9]{4})$") , + Quicksy_Country(name: NSLocalizedString("Tonga", comment:"quicksy country"), code: "+676", pattern: "^([0-9]{5}|[0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Trinidad and Tobago", comment:"quicksy country"), code: "+1", pattern: "^(868)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Tunisia", comment:"quicksy country"), code: "+216", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Turkey", comment:"quicksy country"), code: "+90", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Turkmenistan", comment:"quicksy country"), code: "+993", pattern: "^([0-9]{8})$") , + Quicksy_Country(name: NSLocalizedString("Turks and Caicos Islands", comment:"quicksy country"), code: "+1", pattern: "^(649)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Tuvalu", comment:"quicksy country"), code: "+688", pattern: "^([0-9]{5}|[0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Uganda", comment:"quicksy country"), code: "+256", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Ukraine", comment:"quicksy country"), code: "+380", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("United Arab Emirates", comment:"quicksy country"), code: "+971", pattern: "^([0-9]{8,9})$") , + Quicksy_Country(name: NSLocalizedString("United Kingdom", comment:"quicksy country"), code: "+44", pattern: "^([0-9]{7,10})$") , + Quicksy_Country(name: NSLocalizedString("United States", comment:"quicksy country"), code: "+1", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("United States Virgin Islands", comment:"quicksy country"), code: "+1", pattern: "^(340)([0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Uruguay", comment:"quicksy country"), code: "+598", pattern: "^([0-9]{4,11})$") , + Quicksy_Country(name: NSLocalizedString("Uzbekistan", comment:"quicksy country"), code: "+998", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Vanuatu", comment:"quicksy country"), code: "+678", pattern: "^([0-9]{5}|[0-9]{7})$") , + Quicksy_Country(name: NSLocalizedString("Vatican", comment:"quicksy country"), code: "+39", pattern: "^([0-9]{1,11})$") , + Quicksy_Country(name: NSLocalizedString("Venezuela (Bolivarian Republic of)", comment:"quicksy country"), code: "+58", pattern: "^([0-9]{10})$") , + Quicksy_Country(name: NSLocalizedString("Viet Nam", comment:"quicksy country"), code: "+84", pattern: "^([0-9]{7,10})$") , + Quicksy_Country(name: NSLocalizedString("Wallis and Futuna", comment:"quicksy country"), code: "+681", pattern: "^([0-9]{6})$") , + Quicksy_Country(name: NSLocalizedString("Yemen", comment:"quicksy country"), code: "+967", pattern: "^([0-9]{6,9})$") , + Quicksy_Country(name: NSLocalizedString("Zambia", comment:"quicksy country"), code: "+260", pattern: "^([0-9]{9})$") , + Quicksy_Country(name: NSLocalizedString("Zimbabwe", comment:"quicksy country"), code: "+263", pattern: "^([0-9]{5,10})$") , +] diff --git a/Monal/Classes/Quicksy_RegisterAccount.swift b/Monal/Classes/Quicksy_RegisterAccount.swift index 19b02bb33b..14dc493544 100644 --- a/Monal/Classes/Quicksy_RegisterAccount.swift +++ b/Monal/Classes/Quicksy_RegisterAccount.swift @@ -50,23 +50,9 @@ class Quicksy_State: ObservableObject { var countryCode: String? } -struct Quicksy_Country: Identifiable, Hashable { - let id = UUID() - let name: String - let code: String - let mobilePattern: String -} - struct Quicksy_RegisterAccount: View { var delegate: SheetDismisserProtocol - let countries: [Quicksy_Country] = [ - Quicksy_Country(name: NSLocalizedString("Germany", comment:"quicksy country"), code: "+49", mobilePattern: "^1\\d{10}$") , - Quicksy_Country(name: NSLocalizedString("United States", comment:"quicksy country"), code: "+1", mobilePattern: "^\\d{10}$"), - Quicksy_Country(name: NSLocalizedString("Canada", comment:"quicksy country"), code: "+1", mobilePattern: "^\\d{10}$"), - Quicksy_Country(name: NSLocalizedString("United Kingdom", comment:"quicksy country"), code: "+44", mobilePattern: "^7\\d{9}$"), - Quicksy_Country(name: NSLocalizedString("Australia", comment:"quicksy country"), code: "+61", mobilePattern: "^4\\d{8}$"), - Quicksy_Country(name: NSLocalizedString("India", comment:"quicksy country"), code: "+91", mobilePattern: "^[789]\\d{9}$"), - ] + let countries: [Quicksy_Country] = COUNTRY_CODES @StateObject private var overlay = LoadingOverlayState() @ObservedObject var state = Quicksy_State() @State private var currentIndex = 0 @@ -141,7 +127,7 @@ struct Quicksy_RegisterAccount: View { guard let selectedCountry = selectedCountry else { return false } - let phonePredicate = NSPredicate(format: "SELF MATCHES %@", selectedCountry.mobilePattern) + let phonePredicate = NSPredicate(format: "SELF MATCHES %@", selectedCountry.pattern) return phoneNumber.allSatisfy { $0.isNumber } && phoneNumber.count > 0 && phonePredicate.evaluate(with: phoneNumber) } diff --git a/Monal/Monal.xcodeproj/project.pbxproj b/Monal/Monal.xcodeproj/project.pbxproj index 2fc1ceb74d..d7a76444df 100644 --- a/Monal/Monal.xcodeproj/project.pbxproj +++ b/Monal/Monal.xcodeproj/project.pbxproj @@ -150,6 +150,7 @@ 845EFFBE2918723D00C1E03E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1E4654624EE517000CA5AAF /* Localizable.strings */; }; 846DF27C2937FAA600AAB9C0 /* ChatPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */; }; 848227912C4A6194003CCA33 /* MLPlaceholderViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */; }; + 848501342C4F2B6D00C1B693 /* CountryCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 848501332C4F2B6D00C1B693 /* CountryCodes.swift */; }; 848717F3295ED64600B8D288 /* MLCall.m in Sources */ = {isa = PBXBuildFile; fileRef = 848717F1295ED64500B8D288 /* MLCall.m */; }; 848904A9289C82C30097E19C /* SCRAM.m in Sources */ = {isa = PBXBuildFile; fileRef = 848904A8289C82C30097E19C /* SCRAM.m */; }; 848C73E02BDF2014007035C9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 848C73DF2BDF2014007035C9 /* PrivacyInfo.xcprivacy */; }; @@ -586,6 +587,7 @@ 845D636A2AD4AEDA0066EFFB /* ImageViewer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = ""; }; 846DF27B2937FAA600AAB9C0 /* ChatPlaceholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPlaceholder.swift; sourceTree = ""; }; 848227902C4A6194003CCA33 /* MLPlaceholderViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MLPlaceholderViewController.m; sourceTree = ""; }; + 848501332C4F2B6D00C1B693 /* CountryCodes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryCodes.swift; sourceTree = ""; }; 848717F1295ED64500B8D288 /* MLCall.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MLCall.m; path = Classes/MLCall.m; sourceTree = SOURCE_ROOT; }; 848717F2295ED64500B8D288 /* MLCall.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MLCall.h; path = Classes/MLCall.h; sourceTree = SOURCE_ROOT; }; 848904A8289C82C30097E19C /* SCRAM.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SCRAM.m; sourceTree = ""; }; @@ -1269,6 +1271,7 @@ 84C1CD532A8F6196007076ED /* MLStreamRedirect.h */, 844921E92C29F9A000B99A9C /* MLDelayableTimer.m */, 844921EB2C29F9BE00B99A9C /* MLDelayableTimer.h */, + 848501332C4F2B6D00C1B693 /* CountryCodes.swift */, ); name = tools; sourceTree = ""; @@ -2188,6 +2191,7 @@ 542CF3FF2763314F002C3710 /* hsluv.c in Sources */, 8420EA9D2915E5100038FF40 /* OmemoState.m in Sources */, 389E298C25E901CA009A5268 /* MLAudioRecoderManager.m in Sources */, + 848501342C4F2B6D00C1B693 /* CountryCodes.swift in Sources */, 84C1CD522A8F617F007076ED /* MLStreamRedirect.m in Sources */, 26CC57C923A0892800ABB92A /* MLMessageProcessor.m in Sources */, C18E757C245E8AE900AE8FB7 /* MLPipe.m in Sources */, diff --git a/scripts/itu_pdf_to_swift.py b/scripts/itu_pdf_to_swift.py new file mode 100755 index 0000000000..b7d9c9f029 --- /dev/null +++ b/scripts/itu_pdf_to_swift.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +import requests +import io +from pypdf import PdfReader +import re +import logging + +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s [%(levelname)-7s] %(name)s {%(threadName)s} %(filename)s:%(lineno)d: %(message)s") +logger = logging.getLogger(__name__) + +class Quicksy_Country: + def __init__(self, name, code, pattern): + self.name = name + self.code = code + self.pattern = pattern + + def __repr__(self): + return f"Quicksy_Country(name: NSLocalizedString(\"{self.name}\", comment:\"quicksy country\"), code: \"{self.code}\", pattern: \"{self.pattern}\") ," + +def parse_pdf(pdf_data): + country_regex = re.compile(r'^(?P[^0-9]+)[ ]{32}(?P[0-9]+)[ ]{32}(?P.+)[ ]{32}(?P.+)[ ]{32}(?P.+ digits)[ ]{32}(?P.*)$') + country_end_regex = re.compile(r'^(?P.*)([ ]{32}(?P.+))?$') + countries = {} + pdf = PdfReader(io.BytesIO(pdf_data)) + pagenum = 0 + last_entry = None + for page in pdf.pages: + pagenum += 1 + countries[pagenum] = [] + logger.info(f"Starting to analyze page {pagenum}...") + text = page.extract_text(extraction_mode="layout", layout_mode_space_vertically=False) + if text and "Country/geographical area" in text and "Country" in text and "International" in text and "National" in text and "National (Significant)" in text and "UTC/DST" in text and "Note" in text: + for line in text.split("\n"): + #this is faster than having a "{128,} in the compiled country_regex + match = country_regex.match(re.sub("[ ]{128,}", " "*32, line)) + if match == None: + # check if this is just a linebreak in the country name and append the value to the previous country + if re.sub("[ ]{128,}", " "*32, line) == line.strip() and last_entry != None and "Annex to ITU" not in line: + logger.debug(f"Adding to last country name: {line=}") + countries[pagenum][last_entry].name += f" {line.strip()}" + else: + last_entry = None # don't append line continuations of non-real countries to a real country + else: + match = match.groupdict() | {"dst": None, "notes": None} + if match["end"] and match["end"].strip() != "": + end_splitting = match["end"].split(" "*32) + if len(end_splitting) >= 1: + match["dst"] = end_splitting[0] + if len(end_splitting) >= 2: + match["notes"] = end_splitting[1] + match = {key: (value.strip() if value != None else None) for key, value in match.items()} + # logger.debug("****************") + # logger.debug(f"{match['country'] = }") + # logger.debug(f"{match['code'] = }") + # logger.debug(f"{match['international_prefix'] = }") + # logger.debug(f"{match['national_prefix'] = }") + # logger.debug(f"{match['format'] = }") + # logger.debug(f"{match['dst'] = }") + # logger.debug(f"{match['notes'] = }") + + if match["dst"] == None: # all real countries have a dst entry + last_entry = None # don't append line continuations of non-real countries to a real country + else: + country_code = f"+{match['code']}" + pattern = subpattern_matchers(match['format'], True) + superpattern = matcher(pattern, r"(\([0-9/]+\))[ ]*\+[ ]*(.+)[ ]+digits", match['format'], lambda result: result) + if pattern == None and superpattern != None: + #logger.debug(f"Trying superpattern: '{match['format']}' --> '{superpattern.group(1)}' ## '{superpattern.group(2)}'") + subpattern = subpattern_matchers(superpattern.group(2), False) + if subpattern != None: + pattern = re.sub("/", "|", superpattern.group(1)) + subpattern + if pattern == None: + logger.warning(f"Unknown format description for {match['country']} ({country_code}): '{match['format']}'") + pattern = "[0-9]*" + country = Quicksy_Country(match['country'], country_code, f"^{pattern}$") + countries[pagenum].append(country) + last_entry = len(countries[pagenum]) - 1 + logger.info(f"Page {pagenum}: Found {len(countries[pagenum])} countries so far...") + + return [c for cs in countries.values() for c in cs] + +def matcher(previous_result, regex, text, closure): + if previous_result != None: + return previous_result + matches = re.match(regex, text) + if matches == None: + return None + else: + return closure(matches) + +def subpattern_matchers(text, should_end_with_unit): + if should_end_with_unit: + if text[-6:] != "digits": + logger.error(f"should_end_with_unit set but not ending in 'digits': {text[-6:] = }") + return None + text = text[:-6] + + def subdef(result): + retval = f"[0-9]{{" + grp1 = result.group(1) if result.group(1) != "up" else "1" + retval += f"{grp1}" + if result.group(3) != None: + retval += f",{result.group(3)}" + retval += f"}}" + return retval + pattern = [] + parts = [x.strip() for x in text.split(",")] + for part in parts: + result = matcher(None, r"(up|[0-9]+)([ ]*to[ ]*([0-9]+)[ ]*)?", part, subdef) + #logger.debug(f"{part=} --> {result=}") + if result != None: + pattern.append(result) + if len(pattern) == 0: + return None + return "(" + "|".join(pattern) + ")" + +logger.info("Downloading PDF...") +response = requests.get("https://www.itu.int/dms_pub/itu-t/opb/sp/T-SP-E.164C-2011-PDF-E.pdf") +logger.info("Parsing PDF...") +countries = parse_pdf(response.content) +print("""// This file was automatically generated by scripts/itu_pdf_to_swift.py +// Please run this python script again to update this file +// Example ../scripts/itu_pdf_to_swift.py > Classes/CountryCodes.swift + +public struct Quicksy_Country: Identifiable, Hashable { + public let id = UUID() + public let name: String + public let code: String + public let pattern: String +} +""") +print(f"public let COUNTRY_CODES: [Quicksy_Country] = [") +for country in countries: + print(f" {country}") +print(f"]") diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000000..8e809ad01a --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +PyPDF @ git+https://github.com/py-pdf/pypdf@4.3.1 \ No newline at end of file From 105766eee5c7538170e71fcd12b80e54aa57cc09 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 23 Jul 2024 02:47:25 +0200 Subject: [PATCH 127/131] Autoselect country code based on current locale --- Monal/Classes/Quicksy_RegisterAccount.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Monal/Classes/Quicksy_RegisterAccount.swift b/Monal/Classes/Quicksy_RegisterAccount.swift index 14dc493544..430946c63c 100644 --- a/Monal/Classes/Quicksy_RegisterAccount.swift +++ b/Monal/Classes/Quicksy_RegisterAccount.swift @@ -266,6 +266,13 @@ struct Quicksy_RegisterAccount: View { }) .onAppear { selectedCountry = countries[0] + print("######## \(String(describing:Locale.current.regionCode))") + print("######## \(String(describing:Locale(identifier: "en_US").localizedString(forRegionCode:Locale.current.regionCode ?? "en")))") + for country in countries { + if country.name == Locale.current.localizedString(forRegionCode:Locale.current.regionCode ?? "en") || country.name == Locale(identifier: "en_US").localizedString(forRegionCode:Locale.current.regionCode ?? "en") { + selectedCountry = country + } + } //ios>=15 //phoneNumberFocused = true } From 830532c06cb067cd18531cd80e9d8ef5a09c2eb2 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 23 Jul 2024 03:08:34 +0200 Subject: [PATCH 128/131] --- 925 --- 6.4.1-rc4 From ff0b3fe546de990b706802d12016dcb9ee33c0d2 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 23 Jul 2024 03:09:31 +0200 Subject: [PATCH 129/131] Use an uncompressed PNG if image upload quality is set to 100% That's better than 100% jpeg quality. --- Monal/Classes/MLFiletransfer.m | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Monal/Classes/MLFiletransfer.m b/Monal/Classes/MLFiletransfer.m index 8a99d8f486..43e99b53f4 100644 --- a/Monal/Classes/MLFiletransfer.m +++ b/Monal/Classes/MLFiletransfer.m @@ -623,8 +623,17 @@ +(MLHandler*) prepareUIImageUpload:(UIImage*) image NSString* tempname = [NSString stringWithFormat:@"tmp.%@", [[NSUUID UUID] UUIDString]]; NSError* error; NSString* file = [_documentCacheDir stringByAppendingPathComponent:tempname]; - DDLogDebug(@"Tempstoring jpeg encoded file having quality %f at %@", imageQuality, file); - NSData* imageData = UIImageJPEGRepresentation(image, imageQuality); + NSData* imageData = nil; + if(imageQuality == 1.0) + { + DDLogDebug(@"Image upload quality was set to 100%, tempstoring png encoded file at %@", file); + imageData = UIImagePNGRepresentation(image); + } + else + { + DDLogDebug(@"Tempstoring jpeg encoded file having quality %f at %@", imageQuality, file); + imageData = UIImageJPEGRepresentation(image, imageQuality); + } [imageData writeToFile:file options:NSDataWritingAtomic error:&error]; if(error) { From 80b91f3379042563b6952316b60e9dd2595c1a17 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 23 Jul 2024 15:01:45 +0200 Subject: [PATCH 130/131] Fix syntax error --- Monal/Classes/MLFiletransfer.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Monal/Classes/MLFiletransfer.m b/Monal/Classes/MLFiletransfer.m index 43e99b53f4..78ef4f6347 100644 --- a/Monal/Classes/MLFiletransfer.m +++ b/Monal/Classes/MLFiletransfer.m @@ -626,7 +626,7 @@ +(MLHandler*) prepareUIImageUpload:(UIImage*) image NSData* imageData = nil; if(imageQuality == 1.0) { - DDLogDebug(@"Image upload quality was set to 100%, tempstoring png encoded file at %@", file); + DDLogDebug(@"Image upload quality was set to 100%%, tempstoring png encoded file at %@", file); imageData = UIImagePNGRepresentation(image); } else From 95e9dde68925ecdcd3dcce2a3687bbf22d09fdd8 Mon Sep 17 00:00:00 2001 From: Thilo Molitor Date: Tue, 23 Jul 2024 17:10:34 +0200 Subject: [PATCH 131/131] Fix stable build workflow to use correct ios/macos releasenotes --- .github/workflows/publish-stable-release.yml | 1 - .github/workflows/stable.build-push.yml | 19 ++++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish-stable-release.yml b/.github/workflows/publish-stable-release.yml index c05348d385..13daf45042 100644 --- a/.github/workflows/publish-stable-release.yml +++ b/.github/workflows/publish-stable-release.yml @@ -85,7 +85,6 @@ jobs: with: access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} url: ${{ secrets.MASTODON_URL }} - message: "${{ needs.extractChangelog.outputs.release-name }} released.\n\n${{ steps.changelog.outputs.notes }}\n\n#Monal #ios #macos #xmpp #im #chat #messaging" visibility: "public" language: "en" diff --git a/.github/workflows/stable.build-push.yml b/.github/workflows/stable.build-push.yml index 7270e066d7..8c3cfcd809 100644 --- a/.github/workflows/stable.build-push.yml +++ b/.github/workflows/stable.build-push.yml @@ -125,15 +125,19 @@ jobs: - name: Create fastlane metadata directory id: metadata env: - CHANGELOG: ${{ steps.releasenotes.outputs.notes_ios }} + CHANGELOG_IOS: ${{ steps.releasenotes.outputs.notes_ios }} + CHANGELOG_MACOS: ${{ steps.releasenotes.outputs.notes_macos }} run: | - path="$(mktemp -d)" - echo -n "$CHANGELOG" > "$path/release_notes.txt" - echo "path=$path" | tee /dev/stderr >> "$GITHUB_OUTPUT" + path_ios="$(mktemp -d)" + echo -n "$CHANGELOG_IOS" > "$path_ios/release_notes.txt" + echo "path_ios=$path_ios" | tee /dev/stderr >> "$GITHUB_OUTPUT" + path_macos="$(mktemp -d)" + echo -n "$CHANGELOG_MACOS" > "$path_macos/release_notes.txt" + echo "path_macos=$path_macos" | tee /dev/stderr >> "$GITHUB_OUTPUT" - name: Publish ios to appstore connect #run: xcrun altool --upload-app --file ./Monal/build/ipa/Monal.ipa --type ios --asc-provider S8D843U34Y --team-id S8D843U34Y -u $(cat /Users/ci/apple_connect_upload_mail.txt) -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" env: - DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path }} + DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path_ios }} run: | fastlane run upload_to_app_store api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" ipa:"./Monal/build/ipa/Monal.ipa" app_version:"${{ steps.releasenotes.outputs.version }}" reject_if_possible:true submit_for_review:true automatic_release:true skip_metadata: true skip_screenshots: true - name: Notarize catalyst @@ -160,14 +164,15 @@ jobs: - name: Publish catalyst to appstore connect #run: xcrun altool --upload-app --file ./Monal/build/app/Monal.pkg --type macos --asc-provider S8D843U34Y -u "$(cat /Users/ci/apple_connect_upload_mail.txt)" -p "$(cat /Users/ci/apple_connect_upload_secret.txt)" --primary-bundle-id maccatalyst.G7YU7X7KRJ.SworIM env: - DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path }} + DELIVER_METADATA_PATH: ${{ steps.metadata.outputs.path_macos }} run: | fastlane run upload_to_app_store api_key_path:"/Users/ci/appstoreconnect/key.json" team_id:"S8D843U34Y" pkg:"./Monal/build/app/Monal.pkg" app_version:"${{ steps.releasenotes.outputs.version }}" reject_if_possible:true submit_for_review:true automatic_release:true skip_metadata: true skip_screenshots: true # - name: Update xmpp.org client list with new timestamp # run: ./scripts/push_xmpp.org.sh - name: Remove fastlane metadata directory run: | - rm -rf "${{ steps.metadata.outputs.path }}" + rm -rf "${{ steps.metadata.outputs.path_ios }}" + rm -rf "${{ steps.metadata.outputs.path_macos }}" - name: Create Draft Release id: draftrelease uses: softprops/action-gh-release@v2