Require ImageQuality when sending all attachments

This commit is contained in:
Max Radermacher 2025-12-09 13:38:54 -06:00 committed by GitHub
parent 4f541fc61b
commit e4ceece74b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 207 additions and 190 deletions

View File

@ -397,8 +397,9 @@ extension ConversationViewController: ConversationInputToolbarDelegate {
}
let imageQuality = approvedAttachments.imageQuality
let imageQualityLevel = ImageQualityLevel.resolvedValue(imageQuality: imageQuality)
let sendableAttachments = try await approvedAttachments.attachments.mapAsync {
return try await SendableAttachment.forPreviewableAttachment($0, imageQuality: imageQuality)
return try await SendableAttachment.forPreviewableAttachment($0, imageQualityLevel: imageQualityLevel)
}
if self.isBlockedConversation() {

View File

@ -253,10 +253,9 @@ extension ConversationViewController: ConversationInputTextViewDelegate {
// If the thing we pasted is sticker-like, send it immediately
// and render it borderless.
if attachments.count == 1, let a = attachments.first, a.rawValue.isBorderless {
// [15M] TODO: We should compress images here to ensure they're valid.
Task {
await self.sendAttachments(
ApprovedAttachments(nonViewOnceAttachments: [a], imageQuality: nil),
ApprovedAttachments(nonViewOnceAttachments: [a], imageQuality: .standard),
messageBody: nil,
from: self,
)

View File

@ -109,7 +109,7 @@ extension ConversationViewController {
let attachment = try voiceMemoDraft.prepareAttachment()
Task { @MainActor in
await self.sendAttachments(
ApprovedAttachments(nonViewOnceAttachments: [attachment], imageQuality: nil),
ApprovedAttachments(nonViewOnceAttachments: [attachment], imageQuality: .standard),
messageBody: nil,
from: self,
)

View File

@ -106,7 +106,7 @@ class DataSettingsTableViewController: OWSTableViewController2 {
"SETTINGS_DATA_SENT_MEDIA_QUALITY_ITEM_TITLE",
comment: "Item title for the sent media quality setting"
),
accessoryText: SSKEnvironment.shared.databaseStorageRef.read(block: ImageQualityLevel.resolvedQuality(tx:)).localizedString,
accessoryText: SSKEnvironment.shared.databaseStorageRef.read(block: ImageQuality.fetchValue(tx:)).localizedString,
actionBlock: { [weak self] in
self?.showSentMediaQualityPreferences()
}
@ -170,11 +170,10 @@ class DataSettingsTableViewController: OWSTableViewController2 {
}
private func showSentMediaQualityPreferences() {
let vc = SentMediaQualitySettingsViewController { [weak self] isHighQuality in
let vc = SentMediaQualitySettingsViewController.loadWithSneakyTransaction { [weak self] imageQuality in
guard let self else { return }
SSKEnvironment.shared.databaseStorageRef.write { tx in
ImageQualityLevel.setUserSelectedHighQuality(isHighQuality, tx: tx)
}
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
databaseStorage.write { tx in ImageQuality.setValue(imageQuality, tx: tx) }
self.updateTableContents()
}
navigationController?.pushViewController(vc, animated: true)

View File

@ -6,16 +6,27 @@
import SignalServiceKit
import SignalUI
class SentMediaQualitySettingsViewController: OWSTableViewController2 {
private let updateHandler: (_ isHighQuality: Bool) -> Void
init(updateHandler: @escaping (_ isHighQuality: Bool) -> Void) {
final class SentMediaQualitySettingsViewController: OWSTableViewController2 {
private var imageQuality: ImageQuality
private let updateHandler: (_ imageQuality: ImageQuality) -> Void
static func loadWithSneakyTransaction(updateHandler: @escaping (_ imageQuality: ImageQuality) -> Void) -> Self {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
return Self(
imageQuality: databaseStorage.read(block: ImageQuality.fetchValue(tx:)),
updateHandler: updateHandler,
)
}
init(
imageQuality: ImageQuality,
updateHandler: @escaping (_ imageQuality: ImageQuality) -> Void,
) {
self.imageQuality = imageQuality
self.updateHandler = updateHandler
super.init()
}
private var remoteDefaultLevel: ImageQualityLevel!
private var currentQualityLevel: ImageQualityLevel!
override func viewDidLoad() {
super.viewDidLoad()
@ -24,13 +35,6 @@ class SentMediaQualitySettingsViewController: OWSTableViewController2 {
comment: "Item title for the sent media quality setting"
)
SSKEnvironment.shared.databaseStorageRef.read { tx in
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let localPhoneNumber = tsAccountManager.localIdentifiers(tx: tx)?.phoneNumber
remoteDefaultLevel = ImageQualityLevel.remoteDefault(localPhoneNumber: localPhoneNumber)
currentQualityLevel = ImageQualityLevel.resolvedQuality(tx: tx)
}
updateTableContents()
}
@ -48,24 +52,24 @@ class SentMediaQualitySettingsViewController: OWSTableViewController2 {
comment: "The footer for the photos and videos section in the sent media quality settings."
)
section.add(qualityItem(remoteDefaultLevel))
section.add(qualityItem(.standard))
section.add(qualityItem(.high))
contents.add(section)
}
func qualityItem(_ level: ImageQualityLevel) -> OWSTableItem {
func qualityItem(_ imageQuality: ImageQuality) -> OWSTableItem {
return OWSTableItem(
text: level.localizedString,
text: imageQuality.localizedString,
actionBlock: { [weak self] in
self?.changeLevel(isHighQuality: level == .high)
self?.changeImageQuality(imageQuality)
},
accessoryType: currentQualityLevel == level ? .checkmark : .none
accessoryType: self.imageQuality == imageQuality ? .checkmark : .none
)
}
func changeLevel(isHighQuality: Bool) {
updateHandler(isHighQuality)
func changeImageQuality(_ imageQuality: ImageQuality) {
updateHandler(imageQuality)
navigationController?.popViewController(animated: true)
}
}

View File

@ -345,7 +345,7 @@ extension ForwardMessageViewController {
_ = try await AttachmentMultisend.enqueueApprovedMedia(
conversations: conversations,
approvedMessageBody: item.messageBody,
approvedAttachments: ApprovedAttachments(nonViewOnceAttachments: item.attachments, imageQuality: nil),
approvedAttachments: ApprovedAttachments(nonViewOnceAttachments: item.attachments, imageQuality: .high),
)
} else if let textAttachment = item.textAttachment {
// TODO: we want to reuse the uploaded link preview image attachment instead of re-uploading

View File

@ -35,9 +35,9 @@ extension GifPickerNavigationViewController: GifPickerViewControllerDelegate {
AssertIsOnMainThread()
let attachmentApprovalItem = AttachmentApprovalItem(attachment: attachment, canSave: false)
let attachmentApproval = AttachmentApprovalViewController(
options: self.hasQuotedReplyDraft ? [.disallowViewOnce] : [],
let attachmentApproval = AttachmentApprovalViewController.loadWithSneakyTransaction(
attachmentApprovalItems: [attachmentApprovalItem],
options: self.hasQuotedReplyDraft ? [.disallowViewOnce] : [],
)
attachmentApproval.approvalDataSource = self
attachmentApproval.setMessageBody(initialMessageBody, txProvider: DependenciesBridge.shared.db.readTxProvider)

View File

@ -484,7 +484,10 @@ public class Location: NSObject {
guard let dataSource else {
throw SignalAttachmentError.missingData
}
return try await SendableAttachment.forPreviewableAttachment(PreviewableAttachment(rawValue: SignalAttachment.imageAttachment(dataSource: dataSource, dataUTI: UTType.jpeg.identifier)))
return try await SendableAttachment.forPreviewableAttachment(
PreviewableAttachment(rawValue: SignalAttachment.imageAttachment(dataSource: dataSource, dataUTI: UTType.jpeg.identifier)),
imageQualityLevel: .one,
)
}
public var messageText: String {

View File

@ -206,7 +206,10 @@ class SendMediaNavigationController: OWSNavigationController {
if hasQuotedReplyDraft {
options.insert(.disallowViewOnce)
}
let approvalViewController = AttachmentApprovalViewController(options: options, attachmentApprovalItems: pendingAttachments.map(\.approvalItem))
let approvalViewController = AttachmentApprovalViewController.loadWithSneakyTransaction(
attachmentApprovalItems: pendingAttachments.map(\.approvalItem),
options: options,
)
approvalViewController.approvalDelegate = self
approvalViewController.approvalDataSource = self
approvalViewController.stickerSheetDelegate = self

View File

@ -44,23 +44,23 @@ public struct SendableAttachment {
@concurrent
public static func forPreviewableAttachment(
_ previewableAttachment: PreviewableAttachment,
imageQuality: ImageQualityLevel? = nil,
imageQualityLevel: ImageQualityLevel,
) async throws(SignalAttachmentError) -> Self {
// We only bother converting/compressing non-animated images
if let imageQuality, previewableAttachment.isImage, !previewableAttachment.isAnimatedImage {
if previewableAttachment.isImage, !previewableAttachment.isAnimatedImage {
let dataSource = previewableAttachment.dataSource
guard let imageMetadata = try? dataSource.imageSource().imageMetadata(ignorePerTypeFileSizeLimits: true) else {
throw .invalidData
}
let isValidOriginal = SignalAttachment.isOriginalImageValid(
forImageQuality: imageQuality,
forImageQuality: imageQualityLevel,
fileSize: UInt64(safeCast: dataSource.dataLength),
dataUTI: previewableAttachment.dataUTI,
imageMetadata: imageMetadata,
)
if !isValidOriginal {
let (dataSource, containerType) = try SignalAttachment.convertAndCompressImage(
toImageQuality: imageQuality,
toImageQuality: imageQualityLevel,
dataSource: dataSource,
imageMetadata: imageMetadata,
)

View File

@ -676,7 +676,7 @@ public class SignalAttachment: CustomDebugStringConvertible {
// standard or high quality. We will do the final convert and compress before uploading.
let isOriginalValid = self.isOriginalImageValid(
forImageQuality: .maximumForCurrentAppContext,
forImageQuality: .maximumForCurrentAppContext(),
fileSize: UInt64(safeCast: dataSource.dataLength),
dataUTI: dataUTI,
imageMetadata: imageMetadata,
@ -693,7 +693,7 @@ public class SignalAttachment: CustomDebugStringConvertible {
// Otherwise, resize & convert to a PNG or JPG before previewing it.
let containerType: ContainerType
(newDataSource, containerType) = try convertAndCompressImage(
toImageQuality: .maximumForCurrentAppContext,
toImageQuality: .maximumForCurrentAppContext(),
dataSource: dataSource,
imageMetadata: imageMetadata,
)

View File

@ -314,7 +314,7 @@ public class BackupArchiveAccountDataArchiver: BackupArchiveProtoStreamWriter {
}
accountSettings.allowSealedSenderFromAnyone = udManager.shouldAllowUnrestrictedAccessLocal(tx: context.tx)
accountSettings.defaultSentMediaQuality = (imageQuality.resolvedQuality(tx: context.tx) == .high ? .high : .standard)
accountSettings.defaultSentMediaQuality = imageQuality.fetchValue(tx: context.tx) == .high ? .high : .standard
var downloadSettings = BackupProto_AccountData.AutoDownloadSettings()
for type in MediaBandwidthPreferences.MediaType.allCases {
@ -568,10 +568,10 @@ public class BackupArchiveAccountDataArchiver: BackupArchiveProtoStreamWriter {
udManager.setShouldAllowUnrestrictedAccessLocal(settings.allowSealedSenderFromAnyone, tx: context.tx)
switch settings.defaultSentMediaQuality {
case .unknownQuality, .UNRECOGNIZED, .standard:
imageQuality.setUserSelectedHighQuality(false, tx: context.tx)
case .high:
imageQuality.setUserSelectedHighQuality(true, tx: context.tx)
imageQuality.setValue(.high, tx: context.tx)
case .unknownQuality, .UNRECOGNIZED, .standard:
imageQuality.setValue(.standard, tx: context.tx)
}
if settings.hasAutoDownloadSettings {

View File

@ -14,7 +14,7 @@ extension BackupArchive {
public typealias BlockingManager = _MessageBackup_BlockingManagerShim
public typealias ContactManager = _MessageBackup_ContactManagerShim
public typealias DonationSubscriptionManager = _MessageBackup_DonationSubscriptionManagerShim
public typealias ImageQuality = _MessageBackup_ImageQualityLevelShim
public typealias ImageQuality = _MessageBackup_ImageQualityShim
public typealias OWS2FAManager = _MessageBackup_OWS2FAManagerShim
public typealias Preferences = _MessageBackup_PreferencesShim
public typealias ProfileManager = _MessageBackup_ProfileManagerShim
@ -32,7 +32,7 @@ extension BackupArchive {
public typealias BlockingManager = _MessageBackup_BlockingManagerWrapper
public typealias ContactManager = _MessageBackup_ContactManagerWrapper
public typealias DonationSubscriptionManager = _MessageBackup_DonationSubscriptionManagerWrapper
public typealias ImageQuality = _MessageBackup_ImageQualityLevelWrapper
public typealias ImageQuality = _MessageBackup_ImageQualityWrapper
public typealias OWS2FAManager = _MessageBackup_OWS2FAManagerWrapper
public typealias Preferences = _MessageBackup_PreferencesWrapper
public typealias ProfileManager = _MessageBackup_ProfileManagerWrapper
@ -159,18 +159,18 @@ public class _MessageBackup_DonationSubscriptionManagerWrapper: _MessageBackup_D
// MARK: - ImageQuality
public protocol _MessageBackup_ImageQualityLevelShim {
func setUserSelectedHighQuality(_ isHighQuality: Bool, tx: DBWriteTransaction)
func resolvedQuality(tx: DBReadTransaction) -> ImageQualityLevel
public protocol _MessageBackup_ImageQualityShim {
func setValue(_ imageQuality: ImageQuality, tx: DBWriteTransaction)
func fetchValue(tx: DBReadTransaction) -> ImageQuality
}
public class _MessageBackup_ImageQualityLevelWrapper: _MessageBackup_ImageQualityLevelShim {
public func setUserSelectedHighQuality(_ isHighQuality: Bool, tx: DBWriteTransaction) {
ImageQualityLevel.setUserSelectedHighQuality(isHighQuality, tx: tx)
public class _MessageBackup_ImageQualityWrapper: _MessageBackup_ImageQualityShim {
public func setValue(_ imageQuality: ImageQuality, tx: DBWriteTransaction) {
ImageQuality.setValue(imageQuality, tx: tx)
}
public func resolvedQuality(tx: DBReadTransaction) -> ImageQualityLevel {
ImageQualityLevel.resolvedQuality(tx: tx)
public func fetchValue(tx: DBReadTransaction) -> ImageQuality {
return ImageQuality.fetchValue(tx: tx)
}
}

View File

@ -165,11 +165,10 @@ public class RemoteConfig {
!isEnabled(.paypalMonthlyDonationKillSwitch)
}
public func standardMediaQualityLevel(localPhoneNumber: String?) -> ImageQualityLevel? {
let rawValue: String = ValueFlag.standardMediaQualityLevel.rawValue
public func standardMediaQualityLevel(callingCode: Int?) -> ImageQualityLevel? {
guard
let csvString = valueFlags[rawValue],
let stringValue = Self.countryCodeValue(csvString: csvString, csvDescription: rawValue, localPhoneNumber: localPhoneNumber),
let csvString = self.value(.standardMediaQualityLevel),
let stringValue = Self.countryCodeValue(csvString: csvString, callingCode: callingCode),
let uintValue = UInt(stringValue),
let defaultMediaQuality = ImageQualityLevel(rawValue: uintValue)
else {
@ -404,9 +403,11 @@ public class RemoteConfig {
///
/// - Parameter csvString: a CSV containing `<country-code>:<parts-per-million>` pairs
/// - Parameter key: a key to use as part of bucketing
static func isCountryCodeBucketEnabled(csvString: String, key: String, csvDescription: String, localIdentifiers: LocalIdentifiers) -> Bool {
static func isCountryCodeBucketEnabled(csvString: String, key: String, localIdentifiers: LocalIdentifiers) -> Bool {
let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
let callingCode = phoneNumberUtil.parseE164(localIdentifiers.phoneNumber)?.getCallingCode()
guard
let countryCodeValue = countryCodeValue(csvString: csvString, csvDescription: csvDescription, localPhoneNumber: localIdentifiers.phoneNumber),
let countryCodeValue = countryCodeValue(csvString: csvString, callingCode: callingCode),
let countEnabled = UInt64(countryCodeValue)
else {
return false
@ -415,44 +416,27 @@ public class RemoteConfig {
return isBucketEnabled(key: key, countEnabled: countEnabled, bucketSize: 1_000_000, localAci: localIdentifiers.aci)
}
private static func isCountryCodeBucketEnabled(flag: ValueFlag, valueFlags: [String: String], localIdentifiers: LocalIdentifiers) -> Bool {
let rawValue = flag.rawValue
guard let csvString = valueFlags[rawValue] else { return false }
return isCountryCodeBucketEnabled(csvString: csvString, key: rawValue, csvDescription: rawValue, localIdentifiers: localIdentifiers)
}
/// Given a CSV of `<country-code>:<value>` pairs, extract the `<value>`
/// corresponding to the current user's country.
private static func countryCodeValue(csvString: String, csvDescription: String, localPhoneNumber: String?) -> String? {
guard !csvString.isEmpty else { return nil }
// The value should always be a comma-separated list of country codes
// colon-separated from a value. There all may be an optional be a wildcard
// "*" country code that any unspecified country codes should use. If
// neither the local country code or the wildcard is specified, we assume
// the value is not set.
/// corresponding to the current user's country. The value should always be
/// a comma-separated list of country codes colon-separated from a value.
/// There may be an optional "*" wildcard country code that any unspecified
/// country codes should use. If we can't parse the country code from our
/// own phone number, we fall back to this wildcard value.
private static func countryCodeValue(csvString: String, callingCode: Int?) -> String? {
let callingCodeToValueMap = csvString
.components(separatedBy: ",")
.reduce(into: [String: String]()) { result, value in
let components = value.components(separatedBy: ":")
guard components.count == 2 else { return owsFailDebug("Invalid \(csvDescription) value \(value)") }
guard components.count == 2 else {
owsFailDebug("malformed country-code:value remote config value")
return
}
let callingCode = components[0]
let countryValue = components[1]
result[callingCode] = countryValue
}
guard !callingCodeToValueMap.isEmpty else { return nil }
guard
let localPhoneNumber,
let localCallingCode = SSKEnvironment.shared.phoneNumberUtilRef.parseE164(localPhoneNumber)?.getCallingCode()
else {
owsFailDebug("Invalid local number")
return nil
}
return callingCodeToValueMap[String(localCallingCode)] ?? callingCodeToValueMap["*"]
return callingCode.flatMap({ callingCodeToValueMap[String($0)] }) ?? callingCodeToValueMap["*"]
}
private static func isBucketEnabled(key: String, countEnabled: UInt64, bucketSize: UInt64, localAci: Aci) -> Bool {

View File

@ -786,7 +786,6 @@ extension ExperienceUpgradeManifest {
guard RemoteConfig.isCountryCodeBucketEnabled(
csvString: megaphone.manifest.countries,
key: megaphone.manifest.id,
csvDescription: "remoteMegaphoneCountries_\(megaphone.manifest.id)",
localIdentifiers: localIdentifiers
) else {
return false

View File

@ -5,20 +5,65 @@
import Foundation
/// The user-selected quality for images. Users are offered a choice between
/// "standard" and "high" quality. The former may correspond to level "one"
/// or "two" (depending on a remote config); the latter always corresponds
/// to level "three". Most callers should use ImageQuality; typically only
/// the compression logic needs access to ImageQualityLevel.
public enum ImageQuality {
/// Indirectly translates to ImageQualityLevel.one or ImageQualityLevel.two.
case standard
/// Always translates to ImageQualityLevel.three.
case high
private static let keyValueStore = KeyValueStore(collection: "ImageQualityLevel")
private static let userSelectedHighQualityKey = "defaultQuality"
private static let userSelectedHighQualityValue = 3 as UInt
public static func fetchValue(tx: DBReadTransaction) -> Self {
let highQualityValue = keyValueStore.getUInt(userSelectedHighQualityKey, transaction: tx)
return highQualityValue == userSelectedHighQualityValue ? .high : .standard
}
public static func setValue(_ imageQuality: Self, tx: DBWriteTransaction) {
switch imageQuality {
case .high:
keyValueStore.setUInt(userSelectedHighQualityValue, key: userSelectedHighQualityKey, transaction: tx)
case .standard:
keyValueStore.removeValue(forKey: userSelectedHighQualityKey, transaction: tx)
}
}
public var localizedString: String {
switch self {
case .standard:
return OWSLocalizedString("SENT_MEDIA_QUALITY_STANDARD", comment: "String describing standard quality sent media")
case .high:
return OWSLocalizedString("SENT_MEDIA_QUALITY_HIGH", comment: "String describing high quality sent media")
}
}
}
public enum ImageQualityLevel: UInt, Comparable {
case one = 1
case two = 2
case three = 3
public static let high: ImageQualityLevel = .three
// We calculate the "standard" media quality remotely
// based on country code. For some regions, we use
// a lower "standard" quality than others. High quality
// is always level three. If not remotely specified,
// standard uses quality level two.
public static func remoteDefault(localPhoneNumber: String?) -> ImageQualityLevel {
return RemoteConfig.current.standardMediaQualityLevel(localPhoneNumber: localPhoneNumber) ?? .two
// We calculate the "standard" media quality remotely based on country
// code. For some regions, we use a lower "standard" quality than others.
// High quality is always level three. If not remotely specified, standard
// uses quality level two.
public static func standardQualityLevel(
remoteConfig: RemoteConfig = .current,
callingCode: Int? = { () -> Int? in
let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction
return localIdentifiers.flatMap({ phoneNumberUtil.parseE164($0.phoneNumber) })?.getCallingCode()
}(),
) -> ImageQualityLevel {
return remoteConfig.standardMediaQualityLevel(callingCode: callingCode) ?? .two
}
public var startingTier: ImageQualityTier {
@ -51,8 +96,8 @@ public enum ImageQualityLevel: UInt, Comparable {
}
}
public static var maximumForCurrentAppContext: ImageQualityLevel {
if CurrentAppContext().isMainApp {
public static func maximumForCurrentAppContext(_ currentAppContext: any AppContext = CurrentAppContext()) -> Self {
if currentAppContext.isMainApp {
return .three
} else {
// Outside of the main app (like in the share extension)
@ -62,49 +107,21 @@ public enum ImageQualityLevel: UInt, Comparable {
}
}
private static let keyValueStore = KeyValueStore(collection: "ImageQualityLevel")
private static var userSelectedHighQualityKey: String { "defaultQuality" }
public static func resolvedQuality(tx: DBReadTransaction) -> ImageQualityLevel {
public static func resolvedValue(
imageQuality: ImageQuality,
standardQualityLevel: @autoclosure () -> Self = .standardQualityLevel(),
maximumForCurrentAppContext: Self = .maximumForCurrentAppContext(),
) -> ImageQualityLevel {
let targetQualityLevel: Self
switch imageQuality {
case .high:
targetQualityLevel = .three
case .standard:
targetQualityLevel = standardQualityLevel()
}
// If the max quality we allow is less than the stored preference,
// we have to restrict ourselves to the max allowed.
return min(_resolvedQuality(tx: tx), maximumForCurrentAppContext)
}
private static func _resolvedQuality(tx: DBReadTransaction) -> ImageQualityLevel {
let isHighQuality: Bool = {
// All that matters is "did the user choose high quality explicity?". If
// they didn't, we always fall back to the current server-provided value
// for standard quality. In the past, we stored low/medium values
// explicitly, but this was wrong.
guard let rawValue = keyValueStore.getUInt(userSelectedHighQualityKey, transaction: tx) else {
return false
}
return ImageQualityLevel(rawValue: rawValue) == .high
}()
if isHighQuality {
return .high
}
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let localPhoneNumber = tsAccountManager.localIdentifiers(tx: tx)?.phoneNumber
return remoteDefault(localPhoneNumber: localPhoneNumber)
}
public static func setUserSelectedHighQuality(_ isHighQuality: Bool, tx: DBWriteTransaction) {
if isHighQuality {
keyValueStore.setUInt(ImageQualityLevel.three.rawValue, key: userSelectedHighQualityKey, transaction: tx)
} else {
keyValueStore.removeValue(forKey: userSelectedHighQualityKey, transaction: tx)
}
}
public var localizedString: String {
switch self {
case .one, .two:
return OWSLocalizedString("SENT_MEDIA_QUALITY_STANDARD", comment: "String describing standard quality sent media")
case .three:
return OWSLocalizedString("SENT_MEDIA_QUALITY_HIGH", comment: "String describing high quality sent media")
}
return min(targetQualityLevel, maximumForCurrentAppContext)
}
public static func < (lhs: ImageQualityLevel, rhs: ImageQualityLevel) -> Bool {

View File

@ -165,7 +165,7 @@ class SharingThreadPickerViewController: ConversationPickerViewController {
if self.selection.conversations.contains(where: \.isStory) {
approvalVCOptions.insert(.disallowViewOnce)
}
let approvalView = AttachmentApprovalViewController(options: approvalVCOptions, attachmentApprovalItems: approvalItems)
let approvalView = AttachmentApprovalViewController.loadWithSneakyTransaction(attachmentApprovalItems: approvalItems, options: approvalVCOptions)
approvalVC = approvalView
approvalView.approvalDelegate = self
approvalView.approvalDataSource = self

View File

@ -13,28 +13,23 @@ public import SignalServiceKit
public struct ApprovedAttachments {
public let isViewOnce: Bool
// [15M] TODO: Make this non-optional and always downsample in the same location.
public let imageQuality: ImageQualityLevel?
public let imageQuality: ImageQuality
public let attachments: [PreviewableAttachment]
private init(isViewOnce: Bool, imageQuality: ImageQualityLevel?, attachments: [PreviewableAttachment]) {
private init(isViewOnce: Bool, imageQuality: ImageQuality, attachments: [PreviewableAttachment]) {
owsPrecondition(!isViewOnce || attachments.count <= 1)
self.isViewOnce = isViewOnce
self.imageQuality = imageQuality
self.attachments = attachments
}
public init(viewOnceAttachment: PreviewableAttachment, imageQuality: ImageQualityLevel?) {
public init(viewOnceAttachment: PreviewableAttachment, imageQuality: ImageQuality) {
self.init(isViewOnce: true, imageQuality: imageQuality, attachments: [viewOnceAttachment])
}
public init(nonViewOnceAttachments: [PreviewableAttachment], imageQuality: ImageQualityLevel?) {
public init(nonViewOnceAttachments: [PreviewableAttachment], imageQuality: ImageQuality) {
self.init(isViewOnce: false, imageQuality: imageQuality, attachments: nonViewOnceAttachments)
}
public static func empty() -> Self {
return Self(isViewOnce: false, imageQuality: nil, attachments: [])
}
}
public protocol AttachmentApprovalViewControllerDelegate: AnyObject {
@ -92,7 +87,7 @@ public struct AttachmentApprovalViewControllerOptions: OptionSet {
// MARK: -
public class AttachmentApprovalViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSNavigationChildController {
public final class AttachmentApprovalViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, OWSNavigationChildController {
// MARK: - Properties
@ -111,8 +106,9 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
}
if
ImageQualityLevel.maximumForCurrentAppContext == .high,
attachmentApprovalItemCollection.attachmentApprovalItems.contains(where: { $0.attachment.isImage }) {
ImageQualityLevel.maximumForCurrentAppContext() == .three,
attachmentApprovalItemCollection.attachmentApprovalItems.contains(where: { $0.attachment.isImage })
{
options.insert(.canChangeQualityLevel)
}
@ -129,7 +125,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
}
}
lazy var outputQualityLevel: ImageQualityLevel = SSKEnvironment.shared.databaseStorageRef.read { .resolvedQuality(tx: $0) }
private var outputImageQuality: ImageQuality
public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate?
public weak var approvalDataSource: AttachmentApprovalViewControllerDataSource?
@ -155,9 +151,26 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
}
}
public init(options: AttachmentApprovalViewControllerOptions, attachmentApprovalItems: [AttachmentApprovalItem]) {
public static func loadWithSneakyTransaction(
attachmentApprovalItems: [AttachmentApprovalItem],
options: AttachmentApprovalViewControllerOptions,
) -> Self {
let databaseStorage = SSKEnvironment.shared.databaseStorageRef
return Self(
attachmentApprovalItems: attachmentApprovalItems,
defaultImageQuality: databaseStorage.read(block: ImageQuality.fetchValue(tx:)),
options: options,
)
}
private init(
attachmentApprovalItems: [AttachmentApprovalItem],
defaultImageQuality: ImageQuality,
options: AttachmentApprovalViewControllerOptions,
) {
assert(attachmentApprovalItems.count > 0)
self.outputImageQuality = defaultImageQuality
self.receivedOptions = options
let pageOptions: [UIPageViewController.OptionsKey: Any] = [.interPageSpacing: kSpacingBetweenItems]
@ -166,7 +179,10 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
let isAddMoreVisibleBlock = { [weak self] in
return self?.isAddMoreVisible ?? false
}
self.attachmentApprovalItemCollection = AttachmentApprovalItemCollection(attachmentApprovalItems: attachmentApprovalItems, isAddMoreVisible: isAddMoreVisibleBlock)
self.attachmentApprovalItemCollection = AttachmentApprovalItemCollection(
attachmentApprovalItems: attachmentApprovalItems,
isAddMoreVisible: isAddMoreVisibleBlock,
)
self.dataSource = self
self.delegate = self
@ -205,7 +221,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
if hasQuotedReplyDraft {
options.insert(.disallowViewOnce)
}
let vc = AttachmentApprovalViewController(options: options, attachmentApprovalItems: attachmentApprovalItems)
let vc = AttachmentApprovalViewController.loadWithSneakyTransaction(attachmentApprovalItems: attachmentApprovalItems, options: options)
// The data source needs to be set before the message body because it is needed to hydrate mentions.
vc.approvalDataSource = approvalDataSource
vc.setMessageBody(initialMessageBody, txProvider: DependenciesBridge.shared.db.readTxProvider)
@ -423,7 +439,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
let configuration = AttachmentApprovalToolbar.Configuration(
isAddMoreVisible: isAddMoreVisible,
isMediaStripVisible: attachmentApprovalItems.count > 1,
isMediaHighQualityEnabled: outputQualityLevel == .high,
isMediaHighQualityEnabled: outputImageQuality == .high,
isViewOnceOn: isViewOnceEnabled,
canToggleViewOnce: options.contains(.canToggleViewOnce),
canChangeMediaQuality: options.contains(.canChangeQualityLevel),
@ -873,7 +889,7 @@ extension AttachmentApprovalViewController {
// make below are reflected afterwards.
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false, asyncBlock: { modalVC in
do {
let imageQuality = self.outputQualityLevel
let imageQuality = self.outputImageQuality
let attachments = try await self.prepareAttachments()
modalVC.dismiss {
let isViewOnce = self.options.contains(.canToggleViewOnce) && self.isViewOnceEnabled
@ -1068,16 +1084,9 @@ extension AttachmentApprovalViewController {
actionSheet.overrideUserInterfaceStyle = .dark
actionSheet.isCancelable = true
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let localPhoneNumber = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.phoneNumber
let standardQualityLevel = ImageQualityLevel.remoteDefault(localPhoneNumber: localPhoneNumber)
let selectionControl = MediaQualitySelectionControl(
standardQualityLevel: standardQualityLevel,
currentQualityLevel: outputQualityLevel
)
let selectionControl = MediaQualitySelectionControl(currentQuality: outputImageQuality)
selectionControl.callback = { [weak self, weak actionSheet] qualityLevel in
self?.outputQualityLevel = qualityLevel
self?.outputImageQuality = qualityLevel
self?.updateBottomToolView(animated: false)
if UIAccessibility.isVoiceOverRunning {
@ -1119,24 +1128,22 @@ extension AttachmentApprovalViewController {
private let buttonQualityStandard: MediaQualityButton
private let buttonQualityHigh = MediaQualityButton(
title: ImageQualityLevel.high.localizedString,
title: ImageQuality.high.localizedString,
subtitle: OWSLocalizedString(
"ATTACHMENT_APPROVAL_MEDIA_QUALITY_HIGH_OPTION_SUBTITLE",
comment: "Subtitle for the 'high' option for media quality."
)
)
private let standardQualityLevel: ImageQualityLevel
private(set) var qualityLevel: ImageQualityLevel
private(set) var imageQuality: ImageQuality
var callback: ((ImageQualityLevel) -> Void)?
var callback: ((ImageQuality) -> Void)?
init(standardQualityLevel: ImageQualityLevel, currentQualityLevel: ImageQualityLevel) {
self.standardQualityLevel = standardQualityLevel
self.qualityLevel = currentQualityLevel
init(currentQuality: ImageQuality) {
self.imageQuality = currentQuality
self.buttonQualityStandard = MediaQualityButton(
title: standardQualityLevel.localizedString,
title: ImageQuality.standard.localizedString,
subtitle: OWSLocalizedString(
"ATTACHMENT_APPROVAL_MEDIA_QUALITY_STANDARD_OPTION_SUBTITLE",
comment: "Subtitle for the 'standard' option for media quality."
@ -1146,7 +1153,7 @@ extension AttachmentApprovalViewController {
super.init(frame: .zero)
buttonQualityStandard.block = { [weak self] in
self?.didSelectQualityLevel(standardQualityLevel)
self?.didSelectQualityLevel(.standard)
}
addSubview(buttonQualityStandard)
buttonQualityStandard.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .trailing)
@ -1167,15 +1174,15 @@ extension AttachmentApprovalViewController {
fatalError("init(coder:) has not been implemented")
}
private func didSelectQualityLevel(_ qualityLevel: ImageQualityLevel) {
self.qualityLevel = qualityLevel
private func didSelectQualityLevel(_ imageQuality: ImageQuality) {
self.imageQuality = imageQuality
updateButtonAppearance()
callback?(qualityLevel)
callback?(imageQuality)
}
private func updateButtonAppearance() {
buttonQualityStandard.isSelected = qualityLevel == standardQualityLevel
buttonQualityHigh.isSelected = qualityLevel == .high
buttonQualityStandard.isSelected = imageQuality == .standard
buttonQualityHigh.isSelected = imageQuality == .high
}
private class MediaQualityButton: OWSButton {
@ -1256,7 +1263,7 @@ extension AttachmentApprovalViewController {
override var accessibilityValue: String? {
get {
let selectedButton = qualityLevel == .high ? buttonQualityHigh : buttonQualityStandard
let selectedButton = imageQuality == .high ? buttonQualityHigh : buttonQualityStandard
return [ selectedButton.topLabel, selectedButton.bottomLabel ].compactMap { $0.text }.joined(separator: ",")
}
set { super.accessibilityValue = newValue }
@ -1268,20 +1275,20 @@ extension AttachmentApprovalViewController {
}
override func accessibilityActivate() -> Bool {
callback?(qualityLevel)
callback?(imageQuality)
return true
}
override func accessibilityIncrement() {
if qualityLevel == standardQualityLevel {
qualityLevel = .high
if imageQuality == .standard {
imageQuality = .high
updateButtonAppearance()
}
}
override func accessibilityDecrement() {
if qualityLevel == .high {
qualityLevel = standardQualityLevel
if imageQuality == .high {
imageQuality = .standard
updateButtonAppearance()
}
}

View File

@ -40,8 +40,9 @@ public class AttachmentMultisend {
}
let imageQuality = approvedAttachments.imageQuality
let imageQualityLevel = ImageQualityLevel.resolvedValue(imageQuality: imageQuality)
let sendableAttachments = try await approvedAttachments.attachments.mapAsync {
return try await SendableAttachment.forPreviewableAttachment($0, imageQuality: imageQuality)
return try await SendableAttachment.forPreviewableAttachment($0, imageQualityLevel: imageQualityLevel)
}
let segmentedAttachments = try await segmentAttachmentsIfNecessary(