diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 00e0a978eb..67cb4e800f 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -536,6 +536,7 @@ 768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; }; 76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; 76EB054018170B33006006FC /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 76EB03C318170B33006006FC /* AppDelegate.m */; }; + 8811CF842295D8DA00FF6549 /* VolumeButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8811CF832295D8DA00FF6549 /* VolumeButtons.swift */; }; 954AEE6A1DF33E01002E5410 /* ContactsPickerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 954AEE681DF33D32002E5410 /* ContactsPickerTest.swift */; }; A10FDF79184FB4BB007FF963 /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; @@ -1317,6 +1318,7 @@ 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; 76EB03C218170B33006006FC /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 76EB03C318170B33006006FC /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 8811CF832295D8DA00FF6549 /* VolumeButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtons.swift; sourceTree = ""; }; 8981C8F64D94D3C52EB67A2C /* Pods-SignalTests.test.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalTests.test.xcconfig"; path = "Pods/Target Support Files/Pods-SignalTests/Pods-SignalTests.test.xcconfig"; sourceTree = ""; }; 8EEE74B0753448C085B48721 /* Pods-SignalMessaging.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.app store release.xcconfig"; sourceTree = ""; }; 948239851C08032C842937CC /* Pods-SignalMessaging.test.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalMessaging.test.xcconfig"; path = "Pods/Target Support Files/Pods-SignalMessaging/Pods-SignalMessaging.test.xcconfig"; sourceTree = ""; }; @@ -2511,6 +2513,7 @@ 34E5DC8020D8050D00C08145 /* RegistrationUtils.h */, 34E5DC8120D8050D00C08145 /* RegistrationUtils.m */, 4521C3BF1F59F3BA00B4C582 /* TextFieldHelper.swift */, + 8811CF832295D8DA00FF6549 /* VolumeButtons.swift */, FCFA64B11A24F29E0007FB87 /* UI Categories */, ); path = util; @@ -3701,6 +3704,7 @@ 450D19131F85236600970622 /* RemoteVideoView.m in Sources */, 34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, + 8811CF842295D8DA00FF6549 /* VolumeButtons.swift in Sources */, 45DF5DF21DDB843F00C936C7 /* CompareSafetyNumbersActivity.swift in Sources */, 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, diff --git a/Signal/Signal-Info.plist b/Signal/Signal-Info.plist index a21a55beb6..88ce1a9052 100644 --- a/Signal/Signal-Info.plist +++ b/Signal/Signal-Info.plist @@ -47,7 +47,7 @@ CFBundleVersion - 2.40.0.11 + 2.40.0.12 ITSAppUsesNonExemptEncryption LOGS_EMAIL diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index ce843ed46a..75e8e6eed5 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -277,9 +277,23 @@ const CGFloat kMaxTextViewHeight = 98; [vStackWrapper setContentHuggingHorizontalLow]; [vStackWrapper setCompressionResistanceHorizontalLow]; + // Media Stack + UIStackView *mediaStack = [[UIStackView alloc] initWithArrangedSubviews:@[ + self.sendButton, + self.cameraButton, + self.voiceMemoButton, + ]]; + mediaStack.axis = UILayoutConstraintAxisHorizontal; + mediaStack.alignment = UIStackViewAlignmentCenter; + [mediaStack setContentHuggingHorizontalHigh]; + [mediaStack setCompressionResistanceHorizontalHigh]; + // H Stack - UIStackView *hStack = [[UIStackView alloc] - initWithArrangedSubviews:@[ self.cameraButton, vStackWrapper, self.attachmentButton, self.sendButton ]]; + UIStackView *hStack = [[UIStackView alloc] initWithArrangedSubviews:@[ + self.attachmentButton, + vStackWrapper, + mediaStack, + ]]; hStack.axis = UILayoutConstraintAxisHorizontal; hStack.layoutMarginsRelativeArrangement = YES; hStack.layoutMargins = UIEdgeInsetsMake(6, 6, 6, 6); @@ -289,8 +303,9 @@ const CGFloat kMaxTextViewHeight = 98; // Suggested Stickers const CGFloat suggestedStickerSize = 48; const CGFloat suggestedStickerSpacing = 12; - _suggestedStickerView = - [[StickerHorizontalListView alloc] initWithCellSize:suggestedStickerSize spacing:suggestedStickerSpacing]; + _suggestedStickerView = [[StickerHorizontalListView alloc] initWithCellSize:suggestedStickerSize + cellInset:0 + spacing:suggestedStickerSpacing]; self.suggestedStickerView.backgroundColor = UIColor.clearColor; self.suggestedStickerView.contentInset = UIEdgeInsetsMake( suggestedStickerSpacing, suggestedStickerSpacing, suggestedStickerSpacing, suggestedStickerSpacing); @@ -323,12 +338,9 @@ const CGFloat kMaxTextViewHeight = 98; self.preservesSuperviewLayoutMargins = NO; // Input buttons - [self addSubview:self.voiceMemoButton]; - [self.voiceMemoButton autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.inputTextView]; - [self.voiceMemoButton autoPinEdge:ALEdgeTrailing toEdge:ALEdgeTrailing ofView:vStackWrapper withOffset:-4]; [self addSubview:self.stickerButton]; - [self.stickerButton autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.voiceMemoButton]; - [self.voiceMemoButton autoPinLeadingToTrailingEdgeOfView:self.stickerButton offset:0]; + [self.stickerButton autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.inputTextView]; + [self.stickerButton autoPinEdge:ALEdgeTrailing toEdge:ALEdgeTrailing ofView:vStackWrapper withOffset:-4]; // Border // @@ -570,41 +582,31 @@ const CGFloat kMaxTextViewHeight = 98; - (void)ensureButtonVisibilityWithIsAnimated:(BOOL)isAnimated doLayout:(BOOL)doLayout { + void (^ensureViewHiddenState)(UIView *, BOOL) = ^(UIView *subview, BOOL hidden) { + if (subview.isHidden != hidden) { + subview.hidden = hidden; + } + }; + void (^updateBlock)(void) = ^{ + ensureViewHiddenState(self.attachmentButton, NO); + BOOL hasTextInput = self.inputTextView.trimmedText.length > 0; if (hasTextInput) { - if (!self.attachmentButton.isHidden) { - self.attachmentButton.hidden = YES; - } - if (!self.voiceMemoButton.isHidden) { - self.voiceMemoButton.hidden = YES; - } - if (self.sendButton.isHidden) { - self.sendButton.hidden = NO; - } + ensureViewHiddenState(self.cameraButton, YES); + ensureViewHiddenState(self.voiceMemoButton, YES); + ensureViewHiddenState(self.sendButton, NO); } else { - if (self.attachmentButton.isHidden) { - self.attachmentButton.hidden = NO; - } - if (self.voiceMemoButton.isHidden) { - self.voiceMemoButton.hidden = NO; - } - if (!self.sendButton.isHidden) { - self.sendButton.hidden = YES; - } + ensureViewHiddenState(self.cameraButton, NO); + ensureViewHiddenState(self.voiceMemoButton, NO); + ensureViewHiddenState(self.sendButton, YES); } BOOL hideStickerButton = hasTextInput || self.quotedReply != nil || !StickerManager.shared.isStickerSendEnabled; - if (hideStickerButton) { - if (!self.stickerButton.isHidden) { - self.stickerButton.hidden = YES; - } - } else { - if (self.stickerButton.isHidden) { - self.stickerButton.hidden = NO; - } + ensureViewHiddenState(self.stickerButton, hideStickerButton); + if (!hideStickerButton) { self.stickerButton.imageView.tintColor - = (self.isStickerKeyboardActive ? Theme.primaryColor : Theme.navbarIconColor); + = (self.isStickerKeyboardActive ? UIColor.ows_signalBlueColor : Theme.navbarIconColor); } [self updateSuggestedStickers]; @@ -1365,13 +1367,13 @@ const CGFloat kMaxTextViewHeight = 98; } __weak __typeof(self) weakSelf = self; BOOL shouldReset = self.suggestedStickerView.isHidden; - NSMutableArray *items = [NSMutableArray new]; + NSMutableArray> *items = [NSMutableArray new]; for (StickerInfo *stickerInfo in self.suggestedStickerInfos) { - [items addObject:[[StickerHorizontalListViewItem alloc] initWithStickerInfo:stickerInfo - selectedBlock:^{ - [weakSelf - didSelectSuggestedSticker:stickerInfo]; - }]]; + [items addObject:[[StickerHorizontalListViewItemSticker alloc] + initWithStickerInfo:stickerInfo + didSelectBlock:^{ + [weakSelf didSelectSuggestedSticker:stickerInfo]; + }]]; } self.suggestedStickerView.items = items; self.suggestedStickerView.hidden = NO; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index dd8349fbf0..f88963a388 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -1270,6 +1270,11 @@ typedef enum : NSUInteger { break; case ConversationViewActionCompose: [self popKeyBoard]; + // When we programmatically pop the keyboard here, + // the scroll position gets into a weird state and + // content is hidden behind the keyboard so we restore + // it to the default position. + [self scrollToDefaultPosition:YES]; break; case ConversationViewActionAudioCall: [self startAudioCall]; @@ -3617,15 +3622,12 @@ typedef enum : NSUInteger { - (void)markVisibleMessagesAsRead { if (self.presentedViewController) { - OWSLogInfo(@"Not marking messages as read; another view is presented."); return; } if (OWSWindowManager.sharedManager.shouldShowCallView) { - OWSLogInfo(@"Not marking messages as read; call view is presented."); return; } if (self.navigationController.topViewController != self) { - OWSLogInfo(@"Not marking messages as read; another view is pushed."); return; } diff --git a/Signal/src/ViewControllers/Photos/PhotoCapture.swift b/Signal/src/ViewControllers/Photos/PhotoCapture.swift index 01cb5d4f11..4e738fd6ee 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCapture.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCapture.swift @@ -25,6 +25,9 @@ protocol PhotoCaptureDelegate: AnyObject { func photoCaptureDidTryToCaptureTooMany(_ photoCapture: PhotoCapture) var zoomScaleReferenceHeight: CGFloat? { get } var captureOrientation: AVCaptureVideoOrientation { get } + + func beginCaptureButtonAnimation(_ duration: TimeInterval) + func endCaptureButtonAnimation(_ duration: TimeInterval) } class PhotoCapture: NSObject { @@ -332,15 +335,10 @@ class PhotoCapture: NSObject { private func clampZoom(_ factor: CGFloat, device: AVCaptureDevice) -> CGFloat { return min(factor.clamp(minimumZoom, maximumZoom), device.activeFormat.videoMaxZoomFactor) } -} - -extension PhotoCapture: CaptureButtonDelegate { // MARK: - Photo - - func didTapCaptureButton(_ captureButton: CaptureButton) { + private func handleTap() { Logger.verbose("") - guard let delegate = delegate else { return } guard delegate.photoCaptureCanCaptureMoreItems(self) else { delegate.photoCaptureDidTryToCaptureTooMany(self) @@ -355,7 +353,7 @@ extension PhotoCapture: CaptureButtonDelegate { // MARK: - Video - func didBeginLongPressCaptureButton(_ captureButton: CaptureButton) { + private func handleLongPressBegin() { AssertIsOnMainThread() Logger.verbose("") @@ -375,7 +373,7 @@ extension PhotoCapture: CaptureButtonDelegate { }.retainUntilComplete() } - func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) { + private func handleLongPressComplete() { Logger.verbose("") sessionQueue.async { self.captureOutput.completeVideo(delegate: self) @@ -386,7 +384,7 @@ extension PhotoCapture: CaptureButtonDelegate { delegate?.photoCaptureDidCompleteVideo(self) } - func didCancelLongPressCaptureButton(_ captureButton: CaptureButton) { + private func handleLongPressCancel() { Logger.verbose("") AssertIsOnMainThread() sessionQueue.async { @@ -394,6 +392,50 @@ extension PhotoCapture: CaptureButtonDelegate { } delegate?.photoCaptureDidCancelVideo(self) } +} + +extension PhotoCapture: VolumeButtonObserver { + func didPressVolumeButton(with identifier: VolumeButtons.Identifier) { + delegate?.beginCaptureButtonAnimation(0.5) + } + + func didReleaseVolumeButton(with identifier: VolumeButtons.Identifier) { + delegate?.endCaptureButtonAnimation(0.2) + } + + func didTapVolumeButton(with identifier: VolumeButtons.Identifier) { + handleTap() + } + + func didBeginLongPressVolumeButton(with identifier: VolumeButtons.Identifier) { + handleLongPressBegin() + } + + func didCompleteLongPressVolumeButton(with identifier: VolumeButtons.Identifier) { + handleLongPressComplete() + } + + func didCancelLongPressVolumeButton(with identifier: VolumeButtons.Identifier) { + handleLongPressCancel() + } +} + +extension PhotoCapture: CaptureButtonDelegate { + func didTapCaptureButton(_ captureButton: CaptureButton) { + handleTap() + } + + func didBeginLongPressCaptureButton(_ captureButton: CaptureButton) { + handleLongPressBegin() + } + + func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) { + handleLongPressComplete() + } + + func didCancelLongPressCaptureButton(_ captureButton: CaptureButton) { + handleLongPressCancel() + } var zoomScaleReferenceHeight: CGFloat? { return delegate?.zoomScaleReferenceHeight @@ -449,8 +491,11 @@ extension PhotoCapture: CaptureOutputDelegate { AssertIsOnMainThread() if let error = error { - delegate?.photoCapture(self, processingDidError: error) - return + guard didSucceedDespiteError(error) else { + delegate?.photoCapture(self, processingDidError: error) + return + } + Logger.info("Ignoring error, since capture succeeded.") } guard let dataSource = DataSourcePath.dataSource(with: outputFileURL, shouldDeleteOnDeallocation: true) else { @@ -467,6 +512,19 @@ extension PhotoCapture: CaptureOutputDelegate { self.delegate?.photoCapture(self, didFinishProcessingAttachment: attachment) }.retainUntilComplete() } + + /// The AVCaptureFileOutput can return an error even though recording succeeds. + /// I can't find useful documentation on this, but Apple's example AVCam app silently + /// discards these errors, so we do the same. + /// These spurious errors can be reproduced 1/3 of the time when making a series of short videos. + private func didSucceedDespiteError(_ error: Error) -> Bool { + let nsError = error as NSError + guard let successfullyFinished = nsError.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool else { + return false + } + + return successfullyFinished + } } // MARK: - Capture Adapter diff --git a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift index 097c395adc..13619d93dd 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift @@ -70,7 +70,6 @@ class PhotoCaptureViewController: OWSViewController { view.addGestureRecognizer(doubleTapToSwitchCameraGesture) tapToFocusGesture.require(toFail: doubleTapToSwitchCameraGesture) - doubleTapToSwitchCameraGesture.require(toFail: captureButton.tapGesture) } override func viewWillAppear(_ animated: Bool) { @@ -82,8 +81,14 @@ class PhotoCaptureViewController: OWSViewController { super.viewDidAppear(animated) if hasCaptureStarted { BenchEventComplete(eventId: "Show-Camera") + VolumeButtons.shared?.addObserver(observer: photoCapture) } } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillAppear(animated) + VolumeButtons.shared?.removeObserver(photoCapture) + } override var prefersStatusBarHidden: Bool { guard !OWSWindowManager.shared().hasCall() else { @@ -333,6 +338,8 @@ class PhotoCaptureViewController: OWSViewController { view.addSubview(captureButton) captureButton.autoHCenterInSuperview() captureButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: SendMediaNavigationController.bottomButtonsCenterOffset).isActive = true + + VolumeButtons.shared?.addObserver(observer: photoCapture) } private func showFailureUI(error: Error) { @@ -368,6 +375,13 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { captureFeedbackView.backgroundColor = .black view.insertSubview(captureFeedbackView, aboveSubview: previewView) captureFeedbackView.autoPinEdgesToSuperviewEdges() + + // Ensure the capture feedback is laid out before we remove it, + // depending on where we're coming from a layout pass might not + // trigger in 0.05 seconds otherwise. + view.setNeedsLayout() + view.layoutIfNeeded() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { captureFeedbackView.removeFromSuperview() } @@ -419,6 +433,14 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { var captureOrientation: AVCaptureVideoOrientation { return lastKnownCaptureOrientation } + + func beginCaptureButtonAnimation(_ duration: TimeInterval) { + captureButton.beginRecordingAnimation(duration: duration) + } + + func endCaptureButtonAnimation(_ duration: TimeInterval) { + captureButton.endRecordingAnimation(duration: duration) + } } // MARK: - Views @@ -440,8 +462,6 @@ class CaptureButton: UIView { let innerButton = CircleView() - var tapGesture: UITapGestureRecognizer! - var longPressGesture: UILongPressGestureRecognizer! let longPressDuration = 0.5 @@ -457,11 +477,10 @@ class CaptureButton: UIView { override init(frame: CGRect) { super.init(frame: frame) - tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - innerButton.addGestureRecognizer(tapGesture) - + // The long press handles both the tap and the hold interaction, as well as the animation + // the presents as the user begins to hold (and the button begins to grow prior to recording) longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) - longPressGesture.minimumPressDuration = longPressDuration + longPressGesture.minimumPressDuration = 0 innerButton.addGestureRecognizer(longPressGesture) addSubview(innerButton) @@ -485,14 +504,39 @@ class CaptureButton: UIView { fatalError("init(coder:) has not been implemented") } - // MARK: - Gestures - - @objc - func didTap(_ gesture: UITapGestureRecognizer) { - delegate?.didTapCaptureButton(self) + func beginRecordingAnimation(duration: TimeInterval, delay: TimeInterval = 0) { + UIView.animate( + withDuration: duration, + delay: delay, + options: [.beginFromCurrentState, .curveLinear], + animations: { + self.innerButtonSizeConstraints.forEach { $0.constant = type(of: self).recordingDiameter } + self.zoomIndicatorSizeConstraints.forEach { $0.constant = type(of: self).recordingDiameter } + self.superview?.layoutIfNeeded() + }, + completion: nil + ) } + func endRecordingAnimation(duration: TimeInterval, delay: TimeInterval = 0) { + UIView.animate( + withDuration: duration, + delay: delay, + options: [.beginFromCurrentState, .curveEaseIn], + animations: { + self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter } + self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter } + self.superview?.layoutIfNeeded() + }, + completion: nil + ) + } + + // MARK: - Gestures + var initialTouchLocation: CGPoint? + var touchTimer: Timer? + var isLongPressing = false @objc func didLongPress(_ gesture: UILongPressGestureRecognizer) { @@ -507,13 +551,24 @@ class CaptureButton: UIView { case .possible: break case .began: initialTouchLocation = gesture.location(in: gesture.view) - delegate?.didBeginLongPressCaptureButton(self) - UIView.animate(withDuration: 0.2) { - self.innerButtonSizeConstraints.forEach { $0.constant = type(of: self).recordingDiameter } - self.zoomIndicatorSizeConstraints.forEach { $0.constant = type(of: self).recordingDiameter } - self.superview?.layoutIfNeeded() + beginRecordingAnimation(duration: 0.4, delay: 0.1) + + isLongPressing = false + + touchTimer?.invalidate() + touchTimer = WeakTimer.scheduledTimer( + timeInterval: longPressDuration, + target: self, + userInfo: nil, + repeats: false + ) { [weak self] _ in + guard let `self` = self else { return } + self.isLongPressing = true + self.delegate?.didBeginLongPressCaptureButton(self) } case .changed: + guard isLongPressing else { break } + guard let referenceHeight = delegate?.zoomScaleReferenceHeight else { owsFailDebug("referenceHeight was unexpectedly nil") return @@ -545,22 +600,25 @@ class CaptureButton: UIView { delegate?.longPressCaptureButton(self, didUpdateZoomAlpha: alpha) case .ended: - UIView.animate(withDuration: 0.2) { - self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter } - self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter } + endRecordingAnimation(duration: 0.2) - self.superview?.layoutIfNeeded() + if isLongPressing { + delegate?.didCompleteLongPressCaptureButton(self) + } else { + delegate?.didTapCaptureButton(self) } - delegate?.didCompleteLongPressCaptureButton(self) + + touchTimer?.invalidate() + touchTimer = nil case .cancelled, .failed: + endRecordingAnimation(duration: 0.2) - UIView.animate(withDuration: 0.2) { - self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter } - self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter } - - self.superview?.layoutIfNeeded() + if isLongPressing { + delegate?.didCancelLongPressCaptureButton(self) } - delegate?.didCancelLongPressCaptureButton(self) + + touchTimer?.invalidate() + touchTimer = nil } } } @@ -672,6 +730,7 @@ class RecordingTimerView: UIView { UIView.animate(withDuration: 0.4) { self.icon.alpha = 0 } + label.text = nil } // MARK: - diff --git a/Signal/src/util/VolumeButtons.swift b/Signal/src/util/VolumeButtons.swift new file mode 100644 index 0000000000..7d2cef5b23 --- /dev/null +++ b/Signal/src/util/VolumeButtons.swift @@ -0,0 +1,247 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +protocol VolumeButtonObserver: class { + func didPressVolumeButton(with identifier: VolumeButtons.Identifier) + func didReleaseVolumeButton(with identifier: VolumeButtons.Identifier) + + func didTapVolumeButton(with identifier: VolumeButtons.Identifier) + + func didBeginLongPressVolumeButton(with identifier: VolumeButtons.Identifier) + func didCompleteLongPressVolumeButton(with identifier: VolumeButtons.Identifier) + func didCancelLongPressVolumeButton(with identifier: VolumeButtons.Identifier) +} + +class VolumeButtons { + static let shared = VolumeButtons() + + enum Identifier { + case up, down + } + + private init?() { + // If for some reason the API we’re using goes away (for example, in + // a future iOS version) this class will never instantiate. + guard VolumeButtons.supportsListeningToEvents else { return nil } + } + + deinit { + stopObservation() + } + + // MARK: Observer Management + + private var observers: [Weak] = [] + func addObserver(observer: VolumeButtonObserver) { + AssertIsOnMainThread() + + if observers.firstIndex(where: { $0.value === observer }) == nil { + observers.append(Weak(value: observer)) + } + + guard !observers.isEmpty else { return } + startObservation() + } + + func removeObserver(_ observer: VolumeButtonObserver) { + AssertIsOnMainThread() + + observers = observers.filter { $0.value !== observer } + + guard observers.isEmpty else { return } + stopObservation() + } + + func removeAllObservers() { + AssertIsOnMainThread() + observers = [] + stopObservation() + } + + private func startObservation() { + guard !VolumeButtons.isRegisteredForEvents else { return } + VolumeButtons.isRegisteredForEvents = true + registerForNotifications() + } + + private func stopObservation() { + VolumeButtons.isRegisteredForEvents = false + unregisterForNotifications() + + defer { resetLongPress() } + guard let longPressingButton = longPressingButton else { return } + notifyObserversOfCancelLongPress(with: longPressingButton) + } + + private func notifyObserversOfTap(with identifier: Identifier) { + observers.forEach { observer in + observer.value?.didTapVolumeButton(with: identifier) + } + } + + private func notifyObserversOfBeginLongPress(with identifier: Identifier) { + observers.forEach { observer in + observer.value?.didBeginLongPressVolumeButton(with: identifier) + } + } + + private func notifyObserversOfCompleteLongPress(with identifier: Identifier) { + observers.forEach { observer in + observer.value?.didCompleteLongPressVolumeButton(with: identifier) + } + } + + private func notifyObserversOfCancelLongPress(with identifier: Identifier) { + observers.forEach { observer in + observer.value?.didCancelLongPressVolumeButton(with: identifier) + } + } + + private func notifyObserversOfPress(with identifier: Identifier) { + observers.forEach { observer in + observer.value?.didPressVolumeButton(with: identifier) + } + } + + private func notifyObserversOfRelease(with identifier: Identifier) { + observers.forEach { observer in + observer.value?.didReleaseVolumeButton(with: identifier) + } + } + + // MARK: Tap / long press handling + + private var longPressTimer: Timer? + private var longPressingButton: Identifier? + + // It's not possible for up and down to be pressed simulataneously + // (if you press the second button, the OS will end the press on + // the first), so it allows for simplified handling here. + private func didPressButton(with identifier: Identifier) { + longPressingButton = nil + + longPressTimer?.invalidate() + longPressTimer = WeakTimer.scheduledTimer( + timeInterval: longPressDuration, + target: self, + userInfo: nil, + repeats: false + ) { [weak self] _ in + self?.longPressingButton = identifier + self?.notifyObserversOfBeginLongPress(with: identifier) + self?.longPressTimer?.invalidate() + self?.longPressTimer = nil + } + + notifyObserversOfPress(with: identifier) + } + + private func didReleaseButton(with identifier: Identifier) { + if longPressingButton == identifier { + notifyObserversOfCompleteLongPress(with: identifier) + } else { + notifyObserversOfTap(with: identifier) + } + + resetLongPress() + + notifyObserversOfRelease(with: identifier) + } + + private func resetLongPress() { + longPressTimer?.invalidate() + longPressTimer = nil + longPressingButton = nil + } + + // MARK: Volume Event Registration + + // let encodedSelectorString = "setWantsVolumeButtonEvents:".encodedForSelector + private static let volumeEventsSelector = Selector("BXYGaHIABgVnAX0HfnZTBwYGAQBWCHYABgVL".decodedForSelector!) + + private(set) static var isRegisteredForEvents = false { + didSet { + setEventRegistration(isRegisteredForEvents) + } + } + + private static func setEventRegistration(_ active: Bool) { + typealias Type = @convention(c) (AnyObject, Selector, Bool) -> Void + let implementation = class_getMethodImplementation(UIApplication.self, volumeEventsSelector) + let setRegistration = unsafeBitCast(implementation, to: Type.self) + setRegistration(UIApplication.shared, volumeEventsSelector, active) + } + + private static var supportsListeningToEvents: Bool { + return UIApplication.shared.responds(to: volumeEventsSelector) + } + + // MARK: Notification Handling + + // let encodedDownDownNotificationName = "_UIApplicationVolumeDownButtonDownNotification".encodedForSelector + private let downDownNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZVAQkAUwcGBgEAVQEJAF8BBnp3enRyBnoBAA==".decodedForSelector!) + + // let encodedDownUpNotificationName = "_UIApplicationVolumeDownButtonUpNotification".encodedForSelector + private let downUpNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZVAQkAUwcGBgEAZgJfAQZ6d3p0cgZ6AQA=".decodedForSelector!) + + // let encodedUpDownNotificationName = "_UIApplicationVolumeUpButtonDownNotification".encodedForSelector + private let upDownNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZmAlMHBgYBAFUBCQBfAQZ6d3p0cgZ6AQA=".decodedForSelector!) + + // let encodedUpUpNotificationName = "_UIApplicationVolumeUpButtonUpNotification".encodedForSelector + private let upUpNotificationName = Notification.Name("cGZaUgICfXp0cgZ6AQBnAX0HfnZmAlMHBgYBAGYCXwEGend6dHIGegEA".decodedForSelector!) + + private let longPressDuration: TimeInterval = 0.5 + + private func registerForNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(didPressVolumeUp), + name: upDownNotificationName, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(didReleaseVolumeUp), + name: upUpNotificationName, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(didPressVolumeDown), + name: downDownNotificationName, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(didReleaseVolumeDown), + name: downUpNotificationName, + object: nil + ) + } + + private func unregisterForNotifications() { + NotificationCenter.default.removeObserver(self) + } + + @objc func didPressVolumeUp(_ notification: Notification) { + didPressButton(with: .up) + } + + @objc func didReleaseVolumeUp(_ notification: Notification) { + didReleaseButton(with: .up) + } + + @objc func didPressVolumeDown(_ notification: Notification) { + didPressButton(with: .down) + } + + @objc func didReleaseVolumeDown(_ notification: Notification) { + didReleaseButton(with: .down) + } +} diff --git a/Signal/src/views/LinkPreviewView.swift b/Signal/src/views/LinkPreviewView.swift index fcccd97e85..c431852948 100644 --- a/Signal/src/views/LinkPreviewView.swift +++ b/Signal/src/views/LinkPreviewView.swift @@ -103,7 +103,7 @@ public class LinkPreviewDraft: NSObject, LinkPreviewState { } public func imageState() -> LinkPreviewImageState { - if linkPreviewDraft.jpegImageData != nil { + if linkPreviewDraft.imageData != nil { return .loaded } else { return .none @@ -113,11 +113,11 @@ public class LinkPreviewDraft: NSObject, LinkPreviewState { public func image() -> UIImage? { assert(imageState() == .loaded) - guard let jpegImageData = linkPreviewDraft.jpegImageData else { + guard let imageData = linkPreviewDraft.imageData else { return nil } - guard let image = UIImage(data: jpegImageData) else { - owsFailDebug("Could not load image: \(jpegImageData.count)") + guard let image = UIImage(data: imageData) else { + owsFailDebug("Could not load image: \(imageData.count)") return nil } return image @@ -843,7 +843,7 @@ public class LinkPreviewView: UIStackView { } if let sentBodyView = self.sentBodyView { let borderView = OWSBubbleShapeView(draw: ()) - let borderColor = UIColor(rgbHex: Theme.isDarkThemeEnabled ? 0x0F1012 : 0xD5D6D6) + let borderColor = (Theme.isDarkThemeEnabled ? UIColor.ows_gray60 : UIColor.ows_gray15) borderView.strokeColor = borderColor borderView.strokeThickness = CGHairlineWidth() sentBodyView.addSubview(borderView) diff --git a/SignalMessaging/ViewControllers/Stickers/StickerHorizontalListView.swift b/SignalMessaging/ViewControllers/Stickers/StickerHorizontalListView.swift index 5015f86581..640e75a85c 100644 --- a/SignalMessaging/ViewControllers/Stickers/StickerHorizontalListView.swift +++ b/SignalMessaging/ViewControllers/Stickers/StickerHorizontalListView.swift @@ -5,14 +5,67 @@ import Foundation @objc -public class StickerHorizontalListViewItem: NSObject { - let stickerInfo: StickerInfo - let selectedBlock: () -> Void +public protocol StickerHorizontalListViewItem { + var view: UIView { get } + var didSelectBlock: () -> Void { get } + var isSelected: Bool { get } +} + +// MARK: - + +@objc +public class StickerHorizontalListViewItemSticker: NSObject, StickerHorizontalListViewItem { + private let stickerInfo: StickerInfo + public let didSelectBlock: () -> Void + public let isSelectedBlock: () -> Bool + + // This initializer can be used for cells which are never selected. + @objc + public init(stickerInfo: StickerInfo, didSelectBlock: @escaping () -> Void) { + self.stickerInfo = stickerInfo + self.didSelectBlock = didSelectBlock + self.isSelectedBlock = { + false + } + } @objc - public init(stickerInfo: StickerInfo, selectedBlock: @escaping () -> Void) { + public init(stickerInfo: StickerInfo, didSelectBlock: @escaping () -> Void, isSelectedBlock: @escaping () -> Bool) { self.stickerInfo = stickerInfo - self.selectedBlock = selectedBlock + self.didSelectBlock = didSelectBlock + self.isSelectedBlock = isSelectedBlock + } + + public var view: UIView { + return StickerView(stickerInfo: stickerInfo) + } + + public var isSelected: Bool { + return isSelectedBlock() + } +} + +// MARK: - + +@objc +public class StickerHorizontalListViewItemRecents: NSObject, StickerHorizontalListViewItem { + public let didSelectBlock: () -> Void + public let isSelectedBlock: () -> Bool + + @objc + public init(didSelectBlock: @escaping () -> Void, isSelectedBlock: @escaping () -> Bool) { + self.didSelectBlock = didSelectBlock + self.isSelectedBlock = isSelectedBlock + } + + public var view: UIView { + let imageView = UIImageView() + imageView.setTemplateImageName("recent-outline-24", tintColor: Theme.secondaryColor) + return imageView + } + + public var isSelected: Bool { + return isSelectedBlock() } } @@ -22,6 +75,7 @@ public class StickerHorizontalListViewItem: NSObject { public class StickerHorizontalListView: UICollectionView { private let cellSize: CGFloat + private let cellInset: CGFloat public typealias Item = StickerHorizontalListViewItem @@ -47,8 +101,9 @@ public class StickerHorizontalListView: UICollectionView { private var heightConstraint: NSLayoutConstraint? @objc - public required init(cellSize: CGFloat, spacing: CGFloat = 0) { + public required init(cellSize: CGFloat, cellInset: CGFloat, spacing: CGFloat = 0) { self.cellSize = cellSize + self.cellInset = cellInset let layout = LinearHorizontalLayout(itemSize: CGSize(width: cellSize, height: cellSize), spacing: spacing) super.init(frame: .zero, collectionViewLayout: layout) @@ -92,7 +147,10 @@ extension StickerHorizontalListView: UICollectionViewDelegate { return } - item.selectedBlock() + item.didSelectBlock() + + // Selection has changed; update cells to reflect that. + self.reloadData() } } @@ -122,10 +180,19 @@ extension StickerHorizontalListView: UICollectionViewDataSource { return cell } - let stickerView = StickerView(stickerInfo: item.stickerInfo) - cell.contentView.addSubview(stickerView) - stickerView.autoPinEdgesToSuperviewEdges() + if item.isSelected { + let selectionView = UIView() + selectionView.backgroundColor = (Theme.isDarkThemeEnabled + ? UIColor(rgbHex: 0x1e1d1c) + : UIColor(rgbHex: 0xe1e2e3)) + selectionView.layer.cornerRadius = 8 + cell.contentView.addSubview(selectionView) + selectionView.autoPinEdgesToSuperviewEdges() + } + let itemView = item.view + cell.contentView.addSubview(itemView) + itemView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: cellInset, leading: cellInset, bottom: cellInset, trailing: cellInset)) return cell } } diff --git a/SignalMessaging/ViewControllers/Stickers/StickerKeyboard.swift b/SignalMessaging/ViewControllers/Stickers/StickerKeyboard.swift index e658e7fe86..13661d9a52 100644 --- a/SignalMessaging/ViewControllers/Stickers/StickerKeyboard.swift +++ b/SignalMessaging/ViewControllers/Stickers/StickerKeyboard.swift @@ -81,14 +81,14 @@ public class StickerKeyboard: UIStackView { autoresizingMask = .flexibleHeight alignment = .fill - addBackgroundView(withBackgroundColor: Theme.offBackgroundColor) + addBackgroundView(withBackgroundColor: keyboardBackgroundColor) addArrangedSubview(headerView) headerView.setContentHuggingVerticalHigh() headerView.setCompressionResistanceVerticalHigh() stickerCollectionView.stickerDelegate = self - stickerCollectionView.backgroundColor = Theme.offBackgroundColor + stickerCollectionView.backgroundColor = keyboardBackgroundColor addArrangedSubview(stickerCollectionView) stickerCollectionView.setContentHuggingVerticalLow() stickerCollectionView.setCompressionResistanceVerticalLow() @@ -96,6 +96,12 @@ public class StickerKeyboard: UIStackView { populateHeaderView() } + private var keyboardBackgroundColor: UIColor { + return (Theme.isDarkThemeEnabled + ? UIColor.ows_gray90 + : UIColor.ows_gray02) + } + @objc public func wasPresented() { // If there are no recents, default to showing the first sticker pack. @@ -117,12 +123,21 @@ public class StickerKeyboard: UIStackView { } } - let packItems = stickerPacks.map { (stickerPack) in - StickerHorizontalListView.Item(stickerInfo: stickerPack.coverInfo) { [weak self] in - self?.stickerPack = stickerPack - } + var items = [StickerHorizontalListViewItem]() + items.append(StickerHorizontalListViewItemRecents(didSelectBlock: { [weak self] in + self?.recentsButtonWasTapped() + }, isSelectedBlock: { [weak self] in + self?.stickerPack == nil + })) + items += stickerPacks.map { (stickerPack) in + StickerHorizontalListViewItemSticker(stickerInfo: stickerPack.coverInfo, + didSelectBlock: { [weak self] in + self?.stickerPack = stickerPack + }, isSelectedBlock: { [weak self] in + self?.stickerPack?.info == stickerPack.info + }) } - packsCollectionView.items = packItems + packsCollectionView.items = items guard stickerPacks.count > 0 else { stickerPack = nil @@ -130,18 +145,19 @@ public class StickerKeyboard: UIStackView { } } - private static let packCoverSize: CGFloat = 24 - private static let packCoverSpacing: CGFloat = 12 - private let packsCollectionView = StickerHorizontalListView(cellSize: StickerKeyboard.packCoverSize, spacing: StickerKeyboard.packCoverSpacing) + private static let packCoverSize: CGFloat = 32 + private static let packCoverInset: CGFloat = 4 + private static let packCoverSpacing: CGFloat = 4 + private let packsCollectionView = StickerHorizontalListView(cellSize: StickerKeyboard.packCoverSize, + cellInset: StickerKeyboard.packCoverInset, + spacing: StickerKeyboard.packCoverSpacing) private func populateHeaderView() { - backgroundColor = Theme.offBackgroundColor - headerView.spacing = StickerKeyboard.packCoverSpacing headerView.axis = .horizontal headerView.alignment = .center - headerView.backgroundColor = Theme.offBackgroundColor - headerView.layoutMargins = UIEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + headerView.backgroundColor = keyboardBackgroundColor + headerView.layoutMargins = UIEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6) headerView.isLayoutMarginsRelativeArrangement = true if FeatureFlags.stickerSearch { @@ -151,12 +167,7 @@ public class StickerKeyboard: UIStackView { headerView.addArrangedSubview(searchButton) } - let recentsButton = buildHeaderButton("recent-outline-24") { [weak self] in - self?.recentsButtonWasTapped() - } - headerView.addArrangedSubview(recentsButton) - - packsCollectionView.backgroundColor = Theme.offBackgroundColor + packsCollectionView.backgroundColor = keyboardBackgroundColor headerView.addArrangedSubview(packsCollectionView) let manageButton = buildHeaderButton("plus-24") { [weak self] in diff --git a/SignalMessaging/ViewControllers/Stickers/StickerPackViewController.swift b/SignalMessaging/ViewControllers/Stickers/StickerPackViewController.swift index 5f98696c47..4ada6e142d 100644 --- a/SignalMessaging/ViewControllers/Stickers/StickerPackViewController.swift +++ b/SignalMessaging/ViewControllers/Stickers/StickerPackViewController.swift @@ -37,6 +37,8 @@ public class StickerPackViewController: OWSViewController { super.init(nibName: nil, bundle: nil) + self.modalPresentationStyle = .overFullScreen + stickerCollectionView.stickerDelegate = self stickerCollectionView.show(dataSource: dataSource) dataSource.add(delegate: self) @@ -58,8 +60,9 @@ public class StickerPackViewController: OWSViewController { view.backgroundColor = Theme.darkThemeBackgroundColor } else { view.backgroundColor = .clear + view.isOpaque = false - let blurEffect = Theme.darkThemeBarBlurEffect + let blurEffect = Theme.barBlurEffect let blurEffectView = UIVisualEffectView(effect: blurEffect) view.addSubview(blurEffectView) blurEffectView.autoPinEdgesToSuperviewEdges() @@ -250,6 +253,10 @@ public class StickerPackViewController: OWSViewController { return true } + override public var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + // - MARK: Events @objc diff --git a/SignalMessaging/categories/UIColor+OWS.h b/SignalMessaging/categories/UIColor+OWS.h index 7aece5c18b..82a8f5116f 100644 --- a/SignalMessaging/categories/UIColor+OWS.h +++ b/SignalMessaging/categories/UIColor+OWS.h @@ -1,5 +1,5 @@ // -// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. // #import @@ -40,10 +40,13 @@ NS_ASSUME_NONNULL_BEGIN @property (class, readonly, nonatomic) UIColor *ows_whiteColor; @property (class, readonly, nonatomic) UIColor *ows_gray02Color; @property (class, readonly, nonatomic) UIColor *ows_gray05Color; +@property (class, readonly, nonatomic) UIColor *ows_gray10Color; +@property (class, readonly, nonatomic) UIColor *ows_gray15Color; @property (class, readonly, nonatomic) UIColor *ows_gray25Color; @property (class, readonly, nonatomic) UIColor *ows_gray45Color; @property (class, readonly, nonatomic) UIColor *ows_gray60Color; @property (class, readonly, nonatomic) UIColor *ows_gray75Color; +@property (class, readonly, nonatomic) UIColor *ows_gray85Color; @property (class, readonly, nonatomic) UIColor *ows_gray90Color; @property (class, readonly, nonatomic) UIColor *ows_gray95Color; @property (class, readonly, nonatomic) UIColor *ows_blackColor; diff --git a/SignalMessaging/categories/UIColor+OWS.m b/SignalMessaging/categories/UIColor+OWS.m index 8dadbc7955..c9cb8caa1d 100644 --- a/SignalMessaging/categories/UIColor+OWS.m +++ b/SignalMessaging/categories/UIColor+OWS.m @@ -160,6 +160,16 @@ NS_ASSUME_NONNULL_BEGIN return [UIColor colorWithRGBHex:0xEEEFEF]; } ++ (UIColor *)ows_gray10Color +{ + return [UIColor colorWithRGBHex:0xE1E2E3]; +} + ++ (UIColor *)ows_gray15Color +{ + return [UIColor colorWithRGBHex:0xD5D6D6]; +} + + (UIColor *)ows_gray25Color { return [UIColor colorWithRGBHex:0xBBBDBE]; @@ -172,7 +182,7 @@ NS_ASSUME_NONNULL_BEGIN + (UIColor *)ows_gray60Color { - return [UIColor colorWithRGBHex:0x636467]; + return [UIColor colorWithRGBHex:0x6B6D70]; } + (UIColor *)ows_gray75Color @@ -180,6 +190,11 @@ NS_ASSUME_NONNULL_BEGIN return [UIColor colorWithRGBHex:0x3D3E44]; } ++ (UIColor *)ows_gray85Color +{ + return [UIColor colorWithRGBHex:0x23252A]; +} + + (UIColor *)ows_gray90Color { return [UIColor colorWithRGBHex:0x17191D]; diff --git a/SignalServiceKit/src/Messages/Attachments/OWSThumbnailService.swift b/SignalServiceKit/src/Messages/Attachments/OWSThumbnailService.swift index 9e481ca19e..ce2e022ebc 100644 --- a/SignalServiceKit/src/Messages/Attachments/OWSThumbnailService.swift +++ b/SignalServiceKit/src/Messages/Attachments/OWSThumbnailService.swift @@ -138,6 +138,9 @@ private struct OWSThumbnailRequest { guard canThumbnailAttachment(attachment: attachment) else { throw OWSThumbnailError.failure(description: "Cannot thumbnail attachment.") } + + let isWebp = attachment.contentType == OWSMimeTypeImageWebp + let thumbnailPath = attachment.path(forThumbnailDimensionPoints: thumbnailRequest.thumbnailDimensionPoints) if FileManager.default.fileExists(atPath: thumbnailPath) { guard let image = UIImage(contentsOfFile: thumbnailPath) else { @@ -157,7 +160,7 @@ private struct OWSThumbnailRequest { } let maxDimension = CGFloat(thumbnailRequest.thumbnailDimensionPoints) let thumbnailImage: UIImage - if attachment.contentType == OWSMimeTypeImageWebp { + if isWebp { thumbnailImage = try OWSMediaUtils.thumbnail(forWebpAtPath: originalFilePath, maxDimension: maxDimension) } else if attachment.isImage || attachment.isAnimated { thumbnailImage = try OWSMediaUtils.thumbnail(forImageAtPath: originalFilePath, maxDimension: maxDimension) @@ -166,8 +169,17 @@ private struct OWSThumbnailRequest { } else { throw OWSThumbnailError.assertionFailure(description: "Invalid attachment type.") } - guard let thumbnailData = thumbnailImage.jpegData(compressionQuality: 0.85) else { - throw OWSThumbnailError.failure(description: "Could not convert thumbnail to JPEG.") + let thumbnailData: Data + if isWebp { + guard let pngThumbnailData = thumbnailImage.pngData() else { + throw OWSThumbnailError.failure(description: "Could not convert thumbnail to PNG.") + } + thumbnailData = pngThumbnailData + } else { + guard let jpegThumbnailData = thumbnailImage.jpegData(compressionQuality: 0.85) else { + throw OWSThumbnailError.failure(description: "Could not convert thumbnail to JPEG.") + } + thumbnailData = jpegThumbnailData } do { try thumbnailData.write(to: URL(fileURLWithPath: thumbnailPath), options: .atomic) @@ -177,4 +189,10 @@ private struct OWSThumbnailRequest { OWSFileSystem.protectFileOrFolder(atPath: thumbnailPath) return OWSLoadedThumbnail(image: thumbnailImage, data: thumbnailData) } + + @objc + public class func thumbnailFileExtension(forContentType contentType: String) -> String { + let isWebp = contentType == OWSMimeTypeImageWebp + return isWebp ? "png" : "jpg" + } } diff --git a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m index 33e584f002..23cd9201fc 100644 --- a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m +++ b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m @@ -345,7 +345,9 @@ typedef void (^OWSLoadedThumbnailSuccess)(OWSLoadedThumbnail *loadedThumbnail); - (NSString *)pathForThumbnailDimensionPoints:(NSUInteger)thumbnailDimensionPoints { - NSString *filename = [NSString stringWithFormat:@"thumbnail-%lu.jpg", (unsigned long)thumbnailDimensionPoints]; + NSString *fileExtension = [OWSThumbnailService thumbnailFileExtensionForContentType:self.contentType]; + NSString *filename = + [NSString stringWithFormat:@"thumbnail-%lu.%@", (unsigned long)thumbnailDimensionPoints, fileExtension]; return [self.thumbnailsDirPath stringByAppendingPathComponent:filename]; } diff --git a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift index 40d393c24e..9343252a25 100644 --- a/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift +++ b/SignalServiceKit/src/Messages/Interactions/OWSLinkPreview.swift @@ -41,12 +41,16 @@ public class OWSLinkPreviewDraft: NSObject { public var title: String? @objc - public var jpegImageData: Data? + public var imageData: Data? - public init(urlString: String, title: String?, jpegImageData: Data? = nil) { + @objc + public var imageMimeType: String? + + public init(urlString: String, title: String?, imageData: Data? = nil, imageMimeType: String? = nil) { self.urlString = urlString self.title = title - self.jpegImageData = jpegImageData + self.imageData = imageData + self.imageMimeType = imageMimeType super.init() } @@ -56,7 +60,7 @@ public class OWSLinkPreviewDraft: NSObject { if let titleValue = title { hasTitle = titleValue.count > 0 } - let hasImage = jpegImageData != nil + let hasImage = imageData != nil && imageMimeType != nil return hasTitle || hasImage } @@ -181,8 +185,9 @@ public class OWSLinkPreview: MTLModel { guard SSKPreferences.areLinkPreviewsEnabled(transaction: transaction.asAnyRead) else { throw LinkPreviewError.noPreview } - let imageAttachmentId = OWSLinkPreview.saveAttachmentIfPossible(jpegImageData: info.jpegImageData, - transaction: transaction) + let imageAttachmentId = OWSLinkPreview.saveAttachmentIfPossible(imageData: info.imageData, + imageMimeType: info.imageMimeType, + transaction: transaction) let linkPreview = OWSLinkPreview(urlString: info.urlString, title: info.title, imageAttachmentId: imageAttachmentId) @@ -194,22 +199,28 @@ public class OWSLinkPreview: MTLModel { return linkPreview } - private class func saveAttachmentIfPossible(jpegImageData: Data?, + private class func saveAttachmentIfPossible(imageData: Data?, + imageMimeType: String?, transaction: YapDatabaseReadWriteTransaction) -> String? { - guard let jpegImageData = jpegImageData else { + guard let imageData = imageData else { return nil } - let fileSize = jpegImageData.count + guard let imageMimeType = imageMimeType else { + return nil + } + guard let fileExtension = MIMETypeUtil.fileExtension(forMIMEType: imageMimeType) else { + return nil + } + let fileSize = imageData.count guard fileSize > 0 else { owsFailDebug("Invalid file size for image data.") return nil } - let fileExtension = "jpg" - let contentType = OWSMimeTypeImageJpeg + let contentType = imageMimeType let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension) do { - try jpegImageData.write(to: NSURL.fileURL(withPath: filePath)) + try imageData.write(to: NSURL.fileURL(withPath: filePath)) } catch let error as NSError { owsFailDebug("file write failed: \(filePath), \(error)") return nil @@ -807,7 +818,10 @@ public class OWSLinkPreviewManager: NSObject { return downloadImage(url: imageUrlString, imageMimeType: imageMimeType) .map(on: DispatchQueue.global()) { (imageData: Data) -> OWSLinkPreviewDraft in // We always recompress images to Jpeg. - let linkPreviewDraft = OWSLinkPreviewDraft(urlString: linkUrlString, title: title, jpegImageData: imageData) + let linkPreviewDraft = OWSLinkPreviewDraft(urlString: linkUrlString, + title: title, + imageData: imageData, + imageMimeType: OWSMimeTypeImageJpeg) return linkPreviewDraft } .recover(on: DispatchQueue.global()) { (_) -> Promise in @@ -919,7 +933,7 @@ public class OWSLinkPreviewManager: NSObject { // tryToDownloadSticker will use locally saved data if possible. return StickerManager.tryToDownloadSticker(stickerPack: stickerPack, stickerInfo: coverInfo).map(on: DispatchQueue.global()) { (coverData) -> OWSLinkPreviewDraft in // Try to build thumbnail from cover webp. - var jpegImageData: Data? + var pngImageData: Data? if let stillImage = (coverData as NSData).stillForWebpData() { var stillThumbnail = stillImage let maxImageSize: CGFloat = 1024 @@ -933,8 +947,8 @@ public class OWSLinkPreviewManager: NSObject { } } - if let stillData = stillThumbnail.jpegData(compressionQuality: 0.85) { - jpegImageData = stillData + if let stillData = stillThumbnail.pngData() { + pngImageData = stillData } else { owsFailDebug("Could not encode as JPEG.") } @@ -942,7 +956,10 @@ public class OWSLinkPreviewManager: NSObject { owsFailDebug("Could not extract still.") } - return OWSLinkPreviewDraft(urlString: url.absoluteString, title: stickerPack.title, jpegImageData: jpegImageData) + return OWSLinkPreviewDraft(urlString: url.absoluteString, + title: stickerPack.title, + imageData: pngImageData, + imageMimeType: OWSMimeTypeImagePng) } } } diff --git a/SignalServiceKit/src/Messages/Stickers/DefaultStickers.swift b/SignalServiceKit/src/Messages/Stickers/DefaultStickers.swift index 3687d159c9..705e91f257 100644 --- a/SignalServiceKit/src/Messages/Stickers/DefaultStickers.swift +++ b/SignalServiceKit/src/Messages/Stickers/DefaultStickers.swift @@ -19,23 +19,7 @@ class DefaultStickerPack { } private class func parseAll() -> [StickerPackInfo: DefaultStickerPack] { - guard FeatureFlags.testBuiltInStickerPacks else { - return [:] - } - - // TODO: Replace with production values. - let packs = [ - DefaultStickerPack(packIdHex: "0123456789abcdef0123456789abcdef", - packKeyHex: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - shouldAutoInstall: true), - DefaultStickerPack(packIdHex: "aaaaaaaabbbbbbbbcccccccc00000000", - packKeyHex: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - shouldAutoInstall: false), - DefaultStickerPack(packIdHex: "aaaaaaaabbbbbbbbcccccccc11111111", - packKeyHex: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", - shouldAutoInstall: true) - ].compactMap { $0 } - + let packs = [DefaultStickerPack]() var result = [StickerPackInfo: DefaultStickerPack]() for pack in packs { result[pack.info] = pack diff --git a/SignalServiceKit/src/Util/FeatureFlags.swift b/SignalServiceKit/src/Util/FeatureFlags.swift index 437a0fc627..a6ee15cef5 100644 --- a/SignalServiceKit/src/Util/FeatureFlags.swift +++ b/SignalServiceKit/src/Util/FeatureFlags.swift @@ -65,9 +65,6 @@ public class FeatureFlags: NSObject { @objc public static let stickerPackOrdering = false - @objc - public static let testBuiltInStickerPacks = false - // Don't enable this flag until the Desktop changes have been in production for a while. @objc public static let strictSyncTranscriptTimestamps = false diff --git a/SignalShareExtension/Info.plist b/SignalShareExtension/Info.plist index 2b4a70d6e6..7d1cc627bd 100644 --- a/SignalShareExtension/Info.plist +++ b/SignalShareExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 2.40.0 CFBundleVersion - 2.40.0.11 + 2.40.0.12 ITSAppUsesNonExemptEncryption NSAppTransportSecurity