From bd255d370c83cf9d0b28c43c77b74bfc4fd29488 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 4 Aug 2021 16:51:22 +0300 Subject: [PATCH 01/14] #4655 - Allowing voice message playback to be controlled from the lock screen or the control center. --- Riot/Modules/Room/RoomViewController.m | 2 + .../VoiceMessageAudioPlayer.swift | 4 +- .../VoiceMessageMediaServiceProvider.swift | 121 +++++++++++++++++- .../VoiceMessagePlaybackController.swift | 6 +- 4 files changed, 128 insertions(+), 5 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 56542e5cc..757f187e0 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -998,6 +998,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } [self refreshRoomInputToolbar]; + + [VoiceMessageMediaServiceProvider.sharedProvider setCurrentRoomSummary:dataSource.room.summary]; } - (void)onRoomDataSourceReady diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift index c4bb0880e..ff74e995b 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift @@ -45,6 +45,7 @@ class VoiceMessageAudioPlayer: NSObject { private let delegateContainer = DelegateContainer() private(set) var url: URL? + private(set) var displayName: String? var isPlaying: Bool { guard let audioPlayer = audioPlayer else { @@ -68,12 +69,13 @@ class VoiceMessageAudioPlayer: NSObject { removeObservers() } - func loadContentFromURL(_ url: URL) { + func loadContentFromURL(_ url: URL, displayName: String? = nil) { if self.url == url { return } self.url = url + self.displayName = displayName removeObservers() diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift index 7def8d7e1..7d1ee37fb 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift @@ -15,20 +15,44 @@ // import Foundation +import MediaPlayer @objc public class VoiceMessageMediaServiceProvider: NSObject, VoiceMessageAudioPlayerDelegate, VoiceMessageAudioRecorderDelegate { + private enum Constants { + static let roomAvatarImageSize: CGFloat = 100.0 + static let roomAvatarFontSize: CGFloat = 40.0 + } + private let audioPlayers: NSMapTable private let audioRecorders: NSHashTable - // Retain currently playing audio player so it doesn't stop playing on timeline cell reusage + private var displayLink: CADisplayLink! + + // Retain currently playing audio player so it doesn't stop playing on timeline cell reuse private var currentlyPlayingAudioPlayer: VoiceMessageAudioPlayer? @objc public static let sharedProvider = VoiceMessageMediaServiceProvider() + private var roomAvatar: UIImage? + @objc public var currentRoomSummary: MXRoomSummary? { + didSet { + roomAvatar = AvatarGenerator.generateAvatar(forMatrixItem: currentRoomSummary?.roomId, + withDisplayName: currentRoomSummary?.displayname, + size: Constants.roomAvatarImageSize, + andFontSize: Constants.roomAvatarFontSize) + } + } + private override init() { audioPlayers = NSMapTable(valueOptions: .weakMemory) audioRecorders = NSHashTable(options: .weakMemory) + + super.init() + + displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector) + displayLink.isPaused = true + displayLink.add(to: .current, forMode: .common) } @objc func audioPlayerForIdentifier(_ identifier: String) -> VoiceMessageAudioPlayer { @@ -57,12 +81,21 @@ import Foundation func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { currentlyPlayingAudioPlayer = audioPlayer + setUpRemoteCommandCenter() stopAllServicesExcept(audioPlayer) } func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { if currentlyPlayingAudioPlayer == audioPlayer { currentlyPlayingAudioPlayer = nil + tearDownRemoteCommandCenter() + } + } + + func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { + if currentlyPlayingAudioPlayer == audioPlayer { + currentlyPlayingAudioPlayer = nil + tearDownRemoteCommandCenter() } } @@ -96,4 +129,90 @@ import Foundation audioPlayer.unloadContent() } } + + @objc private func handleDisplayLinkTick() { + updateNowPlayingInfoCenter() + } + + private func setUpRemoteCommandCenter() { + displayLink.isPaused = false + + UIApplication.shared.beginReceivingRemoteControlEvents() + + let commandCenter = MPRemoteCommandCenter.shared() + + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.removeTarget(nil) + commandCenter.playCommand.addTarget { [weak self] event in + guard let audioPlayer = self?.currentlyPlayingAudioPlayer else { + return MPRemoteCommandHandlerStatus.commandFailed + } + + audioPlayer.play() + + return MPRemoteCommandHandlerStatus.success + } + + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.removeTarget(nil) + commandCenter.pauseCommand.addTarget { [weak self] event in + guard let audioPlayer = self?.currentlyPlayingAudioPlayer else { + return MPRemoteCommandHandlerStatus.commandFailed + } + + audioPlayer.pause() + + return MPRemoteCommandHandlerStatus.success + } + + commandCenter.skipForwardCommand.isEnabled = true + commandCenter.skipForwardCommand.removeTarget(nil) + commandCenter.skipForwardCommand.addTarget { [weak self] event in + guard let audioPlayer = self?.currentlyPlayingAudioPlayer, let skipEvent = event as? MPSkipIntervalCommandEvent else { + return MPRemoteCommandHandlerStatus.commandFailed + } + + audioPlayer.seekToTime(audioPlayer.currentTime + skipEvent.interval) + + return MPRemoteCommandHandlerStatus.success + } + + commandCenter.skipBackwardCommand.isEnabled = true + commandCenter.skipBackwardCommand.removeTarget(nil) + commandCenter.skipBackwardCommand.addTarget { [weak self] event in + guard let audioPlayer = self?.currentlyPlayingAudioPlayer, let skipEvent = event as? MPSkipIntervalCommandEvent else { + return MPRemoteCommandHandlerStatus.commandFailed + } + + audioPlayer.seekToTime(audioPlayer.currentTime - skipEvent.interval) + + return MPRemoteCommandHandlerStatus.success + } + } + + private func tearDownRemoteCommandCenter() { + displayLink.isPaused = true + + UIApplication.shared.endReceivingRemoteControlEvents() + + let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() + nowPlayingInfoCenter.nowPlayingInfo = nil + } + + private func updateNowPlayingInfoCenter() { + guard let audioPlayer = currentlyPlayingAudioPlayer else { + return + } + + let artwork = MPMediaItemArtwork(boundsSize: .init(width: Constants.roomAvatarImageSize, height: Constants.roomAvatarImageSize)) { [weak self] size in + return self?.roomAvatar ?? UIImage() + } + + let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() + nowPlayingInfoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: audioPlayer.displayName ?? "Voice message", + MPMediaItemPropertyArtist: currentRoomSummary?.displayname as Any, + MPMediaItemPropertyArtwork: artwork, + MPMediaItemPropertyPlaybackDuration: audioPlayer.duration as Any, + MPNowPlayingInfoPropertyElapsedPlaybackTime: audioPlayer.currentTime as Any] + } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift index 8fcbc55e4..be0479777 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift @@ -56,8 +56,7 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess let playbackView: VoiceMessagePlaybackView - init(mediaServiceProvider: VoiceMessageMediaServiceProvider, - cacheManager: VoiceMessageAttachmentCacheManager) { + init(mediaServiceProvider: VoiceMessageMediaServiceProvider, cacheManager: VoiceMessageAttachmentCacheManager) { self.mediaServiceProvider = mediaServiceProvider self.cacheManager = cacheManager @@ -93,7 +92,7 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess audioPlayer.play() } } else if let url = urlToLoad { - audioPlayer.loadContentFromURL(url) + audioPlayer.loadContentFromURL(url, displayName: attachment?.originalFileName) audioPlayer.play() } } @@ -153,6 +152,7 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess details.progress = (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0) } } + details.loading = self.loading playbackView.configureWithDetails(details) From c323f0a9eb93c8bce05401a6a4adaad74a4b3a1b Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 4 Aug 2021 16:55:57 +0300 Subject: [PATCH 02/14] #4656 - Stop recording voice message and go into locked mode when the application becomes inactive. Updated changes.rst. --- CHANGES.rst | 2 ++ .../Modules/Room/VoiceMessages/VoiceMessageController.swift | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index ac9740e4b..65f5aa0ba 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,8 @@ Changes to be released in next version * Settings: The notifications toggle no longer detects the system's "Deliver Quietly" configuration as disabled (#2368). * Settings: Adds a link to open the Settings app to quickly configure app notifications. * VoIP: Text & icon changes on call tiles (#4642). + * Voice messages: Stop recording and go into locked mode when the application becomes inactive (#4656) + * Voice messages: Allow voice message playback control from the iOS lock screen and control center (#4655) 🐛 Bugfix * diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift index 40460f990..b61df8c92 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift @@ -88,6 +88,8 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: .themeServiceDidChangeTheme, object: nil) updateTheme() + NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + updateUI() } @@ -312,6 +314,10 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, _voiceMessageToolbarView.update(theme: themeService.theme) } + @objc private func applicationWillResignActive() { + finishRecording() + } + @objc private func handleDisplayLinkTick() { updateUI() } From 0ecd5b4945b84569562f1305f455a2c95485deb3 Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Thu, 5 Aug 2021 14:52:06 +0300 Subject: [PATCH 03/14] Fix room avatar --- .../VoiceMessageMediaServiceProvider.swift | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift index 7d1ee37fb..e17103b20 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift @@ -20,10 +20,12 @@ import MediaPlayer @objc public class VoiceMessageMediaServiceProvider: NSObject, VoiceMessageAudioPlayerDelegate, VoiceMessageAudioRecorderDelegate { private enum Constants { - static let roomAvatarImageSize: CGFloat = 100.0 + static let roomAvatarImageSize: CGSize = CGSize(width: 100, height: 100) static let roomAvatarFontSize: CGFloat = 40.0 + static let roomAvatarMimetype: String = "image/jpeg" } + private var roomAvatarLoader: MXMediaLoader? private let audioPlayers: NSMapTable private let audioRecorders: NSHashTable @@ -37,10 +39,48 @@ import MediaPlayer private var roomAvatar: UIImage? @objc public var currentRoomSummary: MXRoomSummary? { didSet { + // set avatar placeholder for now roomAvatar = AvatarGenerator.generateAvatar(forMatrixItem: currentRoomSummary?.roomId, withDisplayName: currentRoomSummary?.displayname, - size: Constants.roomAvatarImageSize, + size: Constants.roomAvatarImageSize.width, andFontSize: Constants.roomAvatarFontSize) + + guard let avatarUrl = currentRoomSummary?.avatar else { + return + } + + if let cachePath = MXMediaManager.thumbnailCachePath(forMatrixContentURI: avatarUrl, + andType: Constants.roomAvatarMimetype, + inFolder: currentRoomSummary?.roomId, + toFitViewSize: Constants.roomAvatarImageSize, + with: MXThumbnailingMethodCrop), + FileManager.default.fileExists(atPath: cachePath) { + // found in the cache, load it + roomAvatar = MXMediaManager.loadThroughCache(withFilePath: cachePath) + } else { + // cancel previous loader first + roomAvatarLoader?.cancel() + roomAvatarLoader = nil + + guard let mediaManager = currentRoomSummary?.mxSession.mediaManager else { + return + } + + // not found in the cache, download it + roomAvatarLoader = mediaManager.downloadThumbnail(fromMatrixContentURI: avatarUrl, + withType: Constants.roomAvatarMimetype, + inFolder: currentRoomSummary?.roomId, + toFitViewSize: Constants.roomAvatarImageSize, + with: MXThumbnailingMethodCrop, + success: { filePath in + if let filePath = filePath { + self.roomAvatar = MXMediaManager.loadThroughCache(withFilePath: filePath) + } + self.roomAvatarLoader = nil + }, failure: { error in + self.roomAvatarLoader = nil + }) + } } } @@ -204,7 +244,7 @@ import MediaPlayer return } - let artwork = MPMediaItemArtwork(boundsSize: .init(width: Constants.roomAvatarImageSize, height: Constants.roomAvatarImageSize)) { [weak self] size in + let artwork = MPMediaItemArtwork(boundsSize: Constants.roomAvatarImageSize) { [weak self] size in return self?.roomAvatar ?? UIImage() } From 662ce3870d7883709e2a67cfcdbdc06116b6513b Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Thu, 5 Aug 2021 15:26:58 +0300 Subject: [PATCH 04/14] Update Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift Co-authored-by: Stefan Ceriu --- .../Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift index e17103b20..be2778ab8 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift @@ -20,7 +20,7 @@ import MediaPlayer @objc public class VoiceMessageMediaServiceProvider: NSObject, VoiceMessageAudioPlayerDelegate, VoiceMessageAudioRecorderDelegate { private enum Constants { - static let roomAvatarImageSize: CGSize = CGSize(width: 100, height: 100) + static let roomAvatarImageSize: CGSize = CGSize(width: 600, height: 600) static let roomAvatarFontSize: CGFloat = 40.0 static let roomAvatarMimetype: String = "image/jpeg" } From d19e069ca3f54699fe34581682b9d609bc6bee39 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 5 Aug 2021 15:34:36 +0300 Subject: [PATCH 05/14] #4655 - Localized lock screen voice message name placeholder. --- Riot/Assets/en.lproj/Vector.strings | 1 + Riot/Generated/Strings.swift | 4 ++++ .../Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 44084ea76..4193d23ca 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -1691,3 +1691,4 @@ Tap the + to start adding people."; "voice_message_release_to_send" = "Hold to record, release to send"; "voice_message_remaining_recording_time" = "%@s left"; "voice_message_stop_locked_mode_recording" = "Tap on your recording to stop or listen"; +"voice_message_lock_screen_placeholder" = "Voice message"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index aaf25d400..3598c2003 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -4898,6 +4898,10 @@ internal enum VectorL10n { internal static var voice: String { return VectorL10n.tr("Vector", "voice") } + /// Voice message + internal static var voiceMessageLockScreenPlaceholder: String { + return VectorL10n.tr("Vector", "voice_message_lock_screen_placeholder") + } /// Hold to record, release to send internal static var voiceMessageReleaseToSend: String { return VectorL10n.tr("Vector", "voice_message_release_to_send") diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift index be2778ab8..1cca80dd3 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageMediaServiceProvider.swift @@ -249,7 +249,7 @@ import MediaPlayer } let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() - nowPlayingInfoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: audioPlayer.displayName ?? "Voice message", + nowPlayingInfoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: audioPlayer.displayName ?? VectorL10n.voiceMessageLockScreenPlaceholder, MPMediaItemPropertyArtist: currentRoomSummary?.displayname as Any, MPMediaItemPropertyArtwork: artwork, MPMediaItemPropertyPlaybackDuration: audioPlayer.duration as Any, From 4f51cf09921bf989e0000851c63a595a4601f220 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 6 Aug 2021 12:04:19 +0100 Subject: [PATCH 06/14] Remove green border from DM avatars. --- .../Recents/Views/RecentTableViewCell.h | 1 - .../Recents/Views/RecentTableViewCell.m | 17 ---------- .../Recents/Views/RecentTableViewCell.xib | 15 ++------ .../Details/Views/RoomTableViewCell.h | 1 - .../Details/Views/RoomTableViewCell.m | 24 ------------- .../Details/Views/RoomTableViewCell.xib | 34 ++++++------------- .../Home/Views/RoomCollectionViewCell.h | 1 - .../Home/Views/RoomCollectionViewCell.m | 17 ---------- .../Home/Views/RoomCollectionViewCell.xib | 25 ++++---------- 9 files changed, 20 insertions(+), 115 deletions(-) diff --git a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.h b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.h index d2e776b92..e3feee4e4 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.h +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.h @@ -23,7 +23,6 @@ @property (weak, nonatomic) IBOutlet UIView *missedNotifAndUnreadIndicator; @property (weak, nonatomic) IBOutlet MXKImageView *roomAvatar; -@property (weak, nonatomic) IBOutlet UIView *directRoomBorderView; @property (weak, nonatomic) IBOutlet UIImageView *encryptedRoomIcon; @property (weak, nonatomic) IBOutlet UILabel *missedNotifAndUnreadBadgeLabel; diff --git a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m index 37e70eed6..337c39cba 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.m @@ -27,11 +27,6 @@ #import "MXRoomSummary+Riot.h" -#pragma mark - Defines & Constants - -static const CGFloat kDirectRoomBorderColorAlpha = 0.75; -static const CGFloat kDirectRoomBorderWidth = 3.0; - @implementation RecentTableViewCell #pragma mark - Class methods @@ -54,16 +49,6 @@ static const CGFloat kDirectRoomBorderWidth = 3.0; self.lastEventDate.textColor = ThemeService.shared.theme.textSecondaryColor; self.missedNotifAndUnreadBadgeLabel.textColor = ThemeService.shared.theme.baseTextPrimaryColor; - // Prepare direct room border - CGColorRef directRoomBorderColor = CGColorCreateCopyWithAlpha(ThemeService.shared.theme.tintColor.CGColor, kDirectRoomBorderColorAlpha); - - [self.directRoomBorderView.layer setCornerRadius:self.directRoomBorderView.frame.size.width / 2]; - self.directRoomBorderView.clipsToBounds = YES; - self.directRoomBorderView.layer.borderColor = directRoomBorderColor; - self.directRoomBorderView.layer.borderWidth = kDirectRoomBorderWidth; - - CFRelease(directRoomBorderColor); - self.roomAvatar.defaultBackgroundColor = [UIColor clearColor]; } @@ -138,8 +123,6 @@ static const CGFloat kDirectRoomBorderWidth = 3.0; // The room title is not bold anymore self.roomTitle.font = [UIFont systemFontOfSize:17 weight:UIFontWeightMedium]; } - - self.directRoomBorderView.hidden = !roomCellData.roomSummary.room.isDirect; [roomCellData.roomSummary setRoomAvatarImageIn:self.roomAvatar]; } diff --git a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.xib b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.xib index 8765ab55e..dec130d7d 100644 --- a/Riot/Modules/Common/Recents/Views/RecentTableViewCell.xib +++ b/Riot/Modules/Common/Recents/Views/RecentTableViewCell.xib @@ -1,9 +1,9 @@ - + - + @@ -34,14 +34,6 @@ -