diff --git a/CHANGES.rst b/CHANGES.rst
index bf77aedef..34b40353d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,31 @@
+Changes in 1.4.9 (2021-08-03)
+=================================================
+
+✨ Features
+ *
+
+🙌 Improvements
+ * Voice Messages: Increased recording state microphone icon size
+ * Voice Messages: Using "Voice message - MM.dd.yyyy HH.mm.ss" as the format for recorded audio files
+
+🐛 Bugfix
+ * Voice Messages: Fixed race conditions when sending voice messages (#4641)
+
+⚠️ API Changes
+ *
+
+🗣 Translations
+ *
+
+🧱 Build
+ *
+
+Others
+ *
+
+Improvements:
+
+
Changes in 1.4.8 (2021-07-29)
=================================================
diff --git a/Config/AppIdentifiers.xcconfig b/Config/AppIdentifiers.xcconfig
index 1628e83d8..c21273565 100644
--- a/Config/AppIdentifiers.xcconfig
+++ b/Config/AppIdentifiers.xcconfig
@@ -22,8 +22,8 @@ APPLICATION_GROUP_IDENTIFIER = group.im.vector
APPLICATION_SCHEME = element
// Version
-MARKETING_VERSION = 1.4.8
-CURRENT_PROJECT_VERSION = 1.4.8
+MARKETING_VERSION = 1.4.9
+CURRENT_PROJECT_VERSION = 1.4.9
// Team
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png
index 8fa147c18..5972e1272 100644
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png
index f00a46204..802268ba0 100644
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@2x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png
index 7fdf91c21..b1def35e1 100644
Binary files a/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png and b/Riot/Assets/Images.xcassets/Room/VoiceMessages/voice_message_record_button_recording.imageset/voice_message_record_button_recording@3x.png differ
diff --git a/Riot/Assets/et.lproj/Vector.strings b/Riot/Assets/et.lproj/Vector.strings
index 1349ade0d..1d3a38634 100644
--- a/Riot/Assets/et.lproj/Vector.strings
+++ b/Riot/Assets/et.lproj/Vector.strings
@@ -1361,7 +1361,7 @@
// Room Notification Settings
"room_notifs_settings_notify_me_for" = "Teavita mind";
"room_details_notifs" = "Teavitused";
-"voice_message_stop_locked_mode_recording" = "Salvestuse peatamiseks ja taasesituseks vajuta lainekese nuppu";
+"voice_message_stop_locked_mode_recording" = "Salvestuse peatamiseks ja taasesituseks vajuta salvestuse vaadet";
"voice_message_remaining_recording_time" = "salvestusaega jäänud %@s";
// Mark: - Voice Messages
diff --git a/Riot/Assets/hu.lproj/Vector.strings b/Riot/Assets/hu.lproj/Vector.strings
index 3e991b86a..fa8d1a78a 100644
--- a/Riot/Assets/hu.lproj/Vector.strings
+++ b/Riot/Assets/hu.lproj/Vector.strings
@@ -1424,7 +1424,7 @@
// Room Notification Settings
"room_notifs_settings_notify_me_for" = "Értesítés ezért:";
"room_details_notifs" = "Értesítések";
-"voice_message_stop_locked_mode_recording" = "Megállításhoz és visszajátszáshoz koppints a hullámhosszra";
+"voice_message_stop_locked_mode_recording" = "Megállításhoz és visszajátszáshoz koppints a felvételre";
"voice_message_remaining_recording_time" = "%@s távozott";
// Mark: - Voice Messages
diff --git a/Riot/Assets/it.lproj/InfoPlist.strings b/Riot/Assets/it.lproj/InfoPlist.strings
index d46035a77..8e49c5075 100644
--- a/Riot/Assets/it.lproj/InfoPlist.strings
+++ b/Riot/Assets/it.lproj/InfoPlist.strings
@@ -1,7 +1,7 @@
// Permissions usage explanations
"NSCameraUsageDescription" = "La fotocamera viene utilizzata per scattare fotografie, registrare video ed eseguire videochiamate.";
"NSPhotoLibraryUsageDescription" = "La libreria fotografica viene utilizzata per inviare foto e video.";
-"NSMicrophoneUsageDescription" = "Il microfono viene utilizzato per registrare video ed effettuare chiamate.";
+"NSMicrophoneUsageDescription" = "Element ha bisogno di accedere al microfono per effettuare e ricevere chiamate, registrare video e messaggi vocali.";
"NSContactsUsageDescription" = "Per scoprire i contatti che già usano Matrix, Element può inviare gli indirizzi email e i numeri di telefono della tua rubrica al server identità che hai scelto. Se supportato, viene fatto un hash dei dati personali prima dell'invio - controlla la politica sulla privacy del tuo server di identità per maggiori informazioni.";
"NSCalendarsUsageDescription" = "Vedi le tue riunioni programmate nell'app.";
"NSFaceIDUsageDescription" = "Face ID viene usato per accedere all'app.";
diff --git a/Riot/Assets/it.lproj/Vector.strings b/Riot/Assets/it.lproj/Vector.strings
index 0a7d64e0e..51cb9ed06 100644
--- a/Riot/Assets/it.lproj/Vector.strings
+++ b/Riot/Assets/it.lproj/Vector.strings
@@ -1395,3 +1395,10 @@
// Room Notification Settings
"room_notifs_settings_notify_me_for" = "Inviami notifiche per";
"room_details_notifs" = "Notifiche";
+"voice_message_stop_locked_mode_recording" = "Tocca la registrazione per fermare o ascoltare";
+"voice_message_remaining_recording_time" = "%@s rimasti";
+
+// Mark: - Voice Messages
+
+"voice_message_release_to_send" = "Tieni premuto per registrare, rilascia per inviare";
+"settings_labs_voice_messages" = "Messaggi vocali";
diff --git a/Riot/Assets/pt_BR.lproj/Vector.strings b/Riot/Assets/pt_BR.lproj/Vector.strings
index 626dbaa96..b8eb3fc94 100644
--- a/Riot/Assets/pt_BR.lproj/Vector.strings
+++ b/Riot/Assets/pt_BR.lproj/Vector.strings
@@ -1393,7 +1393,7 @@
"room_notifs_settings_notify_me_for" = "Notifique-me para";
"room_details_notifs" = "Notificações";
"voice_message_remaining_recording_time" = "%@s restando";
-"voice_message_stop_locked_mode_recording" = "Toque no comprimento de onda para parar e dar playback";
+"voice_message_stop_locked_mode_recording" = "Toque em sua gravação para parar ou escutar";
// Mark: - Voice Messages
diff --git a/Riot/Assets/zh_Hans.lproj/Vector.strings b/Riot/Assets/zh_Hans.lproj/Vector.strings
index 38f1729f1..e8b39a3c8 100644
--- a/Riot/Assets/zh_Hans.lproj/Vector.strings
+++ b/Riot/Assets/zh_Hans.lproj/Vector.strings
@@ -1426,7 +1426,7 @@
"settings_ui_theme_picker_message_invert_colours" = "“自动”使用您设备的“反转颜色”设置";
"room_recents_unknown_room_error_message" = "找不到这个房间。 确保它存在";
"room_creation_dm_error" = "我们无法创建您的 DM。 请检查您要邀请的用户,然后重试。";
-"voice_message_stop_locked_mode_recording" = "轻按波长停止和回放消息";
+"voice_message_stop_locked_mode_recording" = "轻按录音停止或收听";
"voice_message_remaining_recording_time" = "剩 %@s";
// Mark: - Voice Messages
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift
index 73d4df484..f587e0b2d 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAttachmentCacheManager.swift
@@ -84,6 +84,7 @@ class VoiceMessageAttachmentCacheManager {
workQueue.async {
// Run this in the work queue to preserve order
if let finalURL = self.finalURLs[identifier], let duration = self.durations[identifier], let samples = self.samples[identifier]?[numberOfSamples] {
+ MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task - using cached results")
let result = VoiceMessageAttachmentCacheManagerLoadResult(eventIdentifier: identifier, url: finalURL, duration: duration, samples: samples)
DispatchQueue.main.async {
completion(Result.success(result))
@@ -109,22 +110,93 @@ class VoiceMessageAttachmentCacheManager {
completionCallbacks[callbackKey] = [CompletionWrapper(completion)]
}
- let dispatchGroup = DispatchGroup()
+ if let finalURL = finalURLs[identifier], let duration = durations[identifier] {
+ sampleFileAtURL(finalURL, duration: duration, numberOfSamples: numberOfSamples, identifier: identifier)
+ return
+ }
- func sampleFileAtURL(_ url: URL, duration: TimeInterval) {
- let analyser = WaveformAnalyzer(audioAssetURL: url)
-
- dispatchGroup.enter()
- analyser?.samples(count: numberOfSamples, completionHandler: { samples in
- MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished sampling voice message")
-
- dispatchGroup.leave()
-
+ DispatchQueue.main.async { // These don't behave accordingly if called from a background thread
+ if attachment.isEncrypted {
+ attachment.decrypt(toTempFile: { filePath in
+ self.workQueue.async {
+ self.convertFileAtPath(filePath, numberOfSamples: numberOfSamples, identifier: identifier)
+ }
+ }, failure: { error in
+ // A nil error in this case is a cancellation on the MXMediaLoader
+ if let error = error {
+ MXLog.error("Failed decrypting attachment with error: \(String(describing: error))")
+ self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.decryptionError(error))
+ }
+ })
+ } else {
+ attachment.prepare({
+ self.workQueue.async {
+ self.convertFileAtPath(attachment.cacheFilePath, numberOfSamples: numberOfSamples, identifier: identifier)
+ }
+ }, failure: { error in
+ // A nil error in this case is a cancellation on the MXMediaLoader
+ if let error = error {
+ MXLog.error("Failed preparing attachment with error: \(String(describing: error))")
+ self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.preparationError(error))
+ }
+ })
+ }
+ }
+ }
+
+ private func convertFileAtPath(_ path: String?, numberOfSamples: Int, identifier: String) {
+ guard let filePath = path else {
+ return
+ }
+
+ let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ let newURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
+
+ VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL) { result in
+ MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished converting voice message")
+ self.workQueue.async {
+ switch result {
+ case .success:
+ self.finalURLs[identifier] = newURL
+
+ VoiceMessageAudioConverter.mediaDurationAt(newURL) { result in
+ self.workQueue.async {
+ MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished retrieving media duration")
+
+ switch result {
+ case .success:
+ if let duration = try? result.get() {
+ self.durations[identifier] = duration
+ self.sampleFileAtURL(newURL, duration: duration, numberOfSamples: numberOfSamples, identifier: identifier)
+ } else {
+ MXLog.error("[VoiceMessageAttachmentCacheManager] Failed retrieving media duration")
+ }
+ case .failure(let error):
+ MXLog.error("[VoiceMessageAttachmentCacheManager] Failed retrieving audio duration with error: \(error)")
+ }
+ }
+ }
+ case .failure(let error):
+ MXLog.error("[VoiceMessageAttachmentCacheManager] Failed decoding audio message with error: \(error)")
+ self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.conversionError(error))
+ }
+ }
+ }
+ }
+
+ private func sampleFileAtURL(_ url: URL, duration: TimeInterval, numberOfSamples: Int, identifier: String) {
+ let analyser = WaveformAnalyzer(audioAssetURL: url)
+
+ analyser?.samples(count: numberOfSamples, completionHandler: { samples in
+ self.workQueue.async {
guard let samples = samples else {
+ MXLog.debug("[VoiceMessageAttachmentCacheManager] Failed sampling voice message")
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.samplingError)
return
}
+ MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished sampling voice message")
+
if var existingSamples = self.samples[identifier] {
existingSamples[numberOfSamples] = samples
self.samples[identifier] = existingSamples
@@ -133,86 +205,8 @@ class VoiceMessageAttachmentCacheManager {
}
self.invokeSuccessCallbacksForIdentifier(identifier, url: url, duration: duration, samples: samples)
- })
- }
-
- if let finalURL = finalURLs[identifier], let duration = durations[identifier] {
- sampleFileAtURL(finalURL, duration: duration)
- dispatchGroup.wait()
- MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task")
- return
- }
-
- func convertFileAtPath(_ path: String?) {
- guard let filePath = path else {
- return
}
-
- let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
- let newURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
-
- dispatchGroup.enter()
- VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL) { result in
- switch result {
- case .success:
- self.finalURLs[identifier] = newURL
- VoiceMessageAudioConverter.mediaDurationAt(newURL) { result in
- MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished converting voice message")
-
- switch result {
- case .success:
- if let duration = try? result.get() {
- self.durations[identifier] = duration
- sampleFileAtURL(newURL, duration: duration)
- } else {
- MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: Failed to retrieve media duration")
- }
- case .failure(let error):
- MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: failed getting audio duration with: \(error)")
- }
-
- dispatchGroup.leave()
- }
- case .failure(let error):
- self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.conversionError(error))
- MXLog.error("[VoiceMessageAttachmentCacheManager] enqueueLoadAttachment: failed decoding audio message with: \(error)")
- dispatchGroup.leave()
- }
- }
- }
-
- dispatchGroup.enter()
- DispatchQueue.main.async { // These don't behave accordingly if called from a background thread
- if attachment.isEncrypted {
- attachment.decrypt(toTempFile: { filePath in
- convertFileAtPath(filePath)
- dispatchGroup.leave()
- }, failure: { error in
- // A nil error in this case is a cancellation on the MXMediaLoader
- if let error = error {
- MXLog.error("Failed decrypting attachment with error: \(String(describing: error))")
- self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.decryptionError(error))
- }
- dispatchGroup.leave()
- })
- } else {
- attachment.prepare({
- convertFileAtPath(attachment.cacheFilePath)
- dispatchGroup.leave()
- }, failure: { error in
- // A nil error in this case is a cancellation on the MXMediaLoader
- if let error = error {
- MXLog.error("Failed preparing attachment with error: \(String(describing: error))")
- self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.preparationError(error))
- }
- dispatchGroup.leave()
- })
- }
- }
-
- dispatchGroup.wait()
-
- MXLog.debug("[VoiceMessageAttachmentCacheManager] Finished task")
+ })
}
private func invokeSuccessCallbacksForIdentifier(_ identifier: String, url: URL, duration: TimeInterval, samples: [Float]) {
@@ -232,6 +226,8 @@ class VoiceMessageAttachmentCacheManager {
}
self.completionCallbacks[callbackKey] = nil
+
+ MXLog.debug("[VoiceMessageAttachmentCacheManager] Successfully finished task")
}
private func invokeFailureCallbacksForIdentifier(_ identifier: String, error: Error) {
@@ -249,5 +245,7 @@ class VoiceMessageAttachmentCacheManager {
}
self.completionCallbacks[callbackKey] = nil
+
+ MXLog.debug("[VoiceMessageAttachmentCacheManager] Failed task with error: \(error)")
}
}
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift
index f6dc56577..40460f990 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift
@@ -29,12 +29,13 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate,
static let maximumAudioRecordingDuration: TimeInterval = 120.0
static let maximumAudioRecordingLengthReachedThreshold: TimeInterval = 10.0
static let elapsedTimeFormat = "m:ss"
+ static let fileNameFormat = "'Voice message - 'MM.dd.yyyy HH.mm.ss"
static let minimumRecordingDuration = 1.0
}
private let themeService: ThemeService
private let mediaServiceProvider: VoiceMessageMediaServiceProvider
- private let temporaryFileURL: URL
+ private var temporaryFileURL: URL!
private let _voiceMessageToolbarView: VoiceMessageToolbarView
private var displayLink: CADisplayLink!
@@ -48,12 +49,18 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate,
private var isInLockedMode: Bool = false
private var notifiedRemainingTime = false
- private static let timeFormatter: DateFormatter = {
+ private static let elapsedTimeFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = Constants.elapsedTimeFormat
return dateFormatter
}()
+ private static let fileNameDateFormatter: DateFormatter = {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = Constants.fileNameFormat
+ return dateFormatter
+ }()
+
@objc public weak var delegate: VoiceMessageControllerDelegate?
@objc public var isRecordingAudio: Bool {
@@ -68,9 +75,6 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate,
self.themeService = themeService
self.mediaServiceProvider = mediaServiceProvider
- let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
- temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
-
_voiceMessageToolbarView = VoiceMessageToolbarView.loadFromNib()
super.init()
@@ -107,6 +111,11 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate,
audioRecorder = mediaServiceProvider.audioRecorder()
audioRecorder?.registerDelegate(self)
+
+ let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ let fileName = VoiceMessageController.fileNameDateFormatter.string(from: Date())
+ temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(fileName).appendingPathExtension("m4a")
+
audioRecorder?.recordWithOutputURL(temporaryFileURL)
}
@@ -260,7 +269,7 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate,
})
dispatchGroup.enter()
- let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("opus")
+ let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("ogg")
VoiceMessageAudioConverter.convertToOpusOgg(sourceURL: sourceURL, destinationURL: destinationURL) { result in
switch result {
case .success:
@@ -342,7 +351,7 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate,
var details = VoiceMessageToolbarViewDetails()
details.state = (isRecording ? (isInLockedMode ? .lockedModeRecord : .record) : (isInLockedMode ? .lockedModePlayback : .idle))
- details.elapsedTime = VoiceMessageController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: currentTime))
+ details.elapsedTime = VoiceMessageController.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: currentTime))
details.audioSamples = audioSamples
if isRecording {
@@ -391,7 +400,7 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate,
var details = VoiceMessageToolbarViewDetails()
details.state = (audioRecorder?.isRecording ?? false ? (isInLockedMode ? .lockedModeRecord : .record) : (isInLockedMode ? .lockedModePlayback : .idle))
- details.elapsedTime = VoiceMessageController.timeFormatter.string(from: Date(timeIntervalSinceReferenceDate: (audioPlayer.isPlaying ? audioPlayer.currentTime : audioPlayer.duration)))
+ details.elapsedTime = VoiceMessageController.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: (audioPlayer.isPlaying ? audioPlayer.currentTime : audioPlayer.duration)))
details.audioSamples = audioSamples
details.isPlaying = audioPlayer.isPlaying
details.progress = (audioPlayer.isPlaying ? (audioPlayer.duration > 0.0 ? audioPlayer.currentTime / audioPlayer.duration : 0.0) : 0.0)
diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib
index f11674470..52dcce870 100644
--- a/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib
+++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageToolbarView.xib
@@ -23,7 +23,7 @@
-
+
@@ -120,15 +120,15 @@
-
+
@@ -136,14 +136,14 @@
-
+
-
+
@@ -152,7 +152,7 @@
-
+
@@ -280,7 +280,7 @@
-
+