Add OWSBase2ByteCountFudger to format GiB as GB

This commit is contained in:
Sasha Weiss
2025-10-31 14:15:15 -07:00
committed by GitHub
parent 5f8e6fc4ed
commit 11610ba1b8
5 changed files with 143 additions and 22 deletions

View File

@@ -2803,6 +2803,7 @@
D9C4C0852E0A75F200B74696 /* BackupAttachmentUploadEraStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C4C0842E0A75EF00B74696 /* BackupAttachmentUploadEraStore.swift */; };
D9C544292B8578B50036F274 /* CallRecord+CallStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C544282B8578B50036F274 /* CallRecord+CallStatus.swift */; };
D9C5442B2B8578F30036F274 /* CallRecordMissedCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5442A2B8578F30036F274 /* CallRecordMissedCallManager.swift */; };
D9C5634F2EB438500001626A /* OWSByteCountFormatStyleTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C5634D2EB438300001626A /* OWSByteCountFormatStyleTest.swift */; };
D9C7CEB428EB8495001E87B6 /* ExperienceUpgrade.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C7CEB328EB8495001E87B6 /* ExperienceUpgrade.swift */; };
D9C7CECB28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C7CECA28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift */; };
D9C7CECF28ECC043001E87B6 /* NSAttributedString+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C7CECE28ECC043001E87B6 /* NSAttributedString+SSK.swift */; };
@@ -6789,6 +6790,7 @@
D9C4C0842E0A75EF00B74696 /* BackupAttachmentUploadEraStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupAttachmentUploadEraStore.swift; sourceTree = "<group>"; };
D9C544282B8578B50036F274 /* CallRecord+CallStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallRecord+CallStatus.swift"; sourceTree = "<group>"; };
D9C5442A2B8578F30036F274 /* CallRecordMissedCallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallRecordMissedCallManager.swift; sourceTree = "<group>"; };
D9C5634D2EB438300001626A /* OWSByteCountFormatStyleTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSByteCountFormatStyleTest.swift; sourceTree = "<group>"; };
D9C7CEB328EB8495001E87B6 /* ExperienceUpgrade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperienceUpgrade.swift; sourceTree = "<group>"; };
D9C7CECA28EBC09C001E87B6 /* ExperienceUpgradeManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperienceUpgradeManifest.swift; sourceTree = "<group>"; };
D9C7CECE28ECC043001E87B6 /* NSAttributedString+SSK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+SSK.swift"; sourceTree = "<group>"; };
@@ -11240,6 +11242,7 @@
05104D172C8A151100F8851F /* AsyncViewTask.swift */,
05594CCF2C98A00200CCBFF6 /* HostingController.swift */,
D9B2E1172E748DFB00A823E4 /* OWSByteCountFormatStyle.swift */,
D9C5634D2EB438300001626A /* OWSByteCountFormatStyleTest.swift */,
D9DE35002DF0B886005099D7 /* ScrollableContentPinnedFooterView.swift */,
05594CCD2C989F1900CCBFF6 /* ScrollableWhenCompact.swift */,
0510F69D2C91EB2800FA3FDE /* ScrollBounceBehaviorIfAvailable.swift */,
@@ -16738,6 +16741,7 @@
5073EACB2C4F3A16001FBB3E /* LinkPreviewFetcherTest.swift in Sources */,
50BF510A2BB2031600C2C309 /* LinkPreviewFetchStateTest.swift in Sources */,
50BF510C2BB2032500C2C309 /* MobileCoinHelperSDKTest.swift in Sources */,
D9C5634F2EB438500001626A /* OWSByteCountFormatStyleTest.swift in Sources */,
50BF510E2BB2033800C2C309 /* RecipientPickerViewControllerTest.swift in Sources */,
50BF51102BB2035400C2C309 /* UIStackView+SignalUITest.swift in Sources */,
);

View File

@@ -1470,12 +1470,15 @@ struct BackupSettingsView: View {
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_PAUSED_OUT_OF_STORAGE_SPACE_FORMAT",
comment: "Subtitle for a progress bar tracking uploads that are paused because the user is out of remote storage space. Embeds 1:{{ total storage space provided, e.g. 100 GB }}; 2:{{ space the user needs to free up by deleting media, e.g. 1 GB }}."
),
viewModel.backupSubscriptionConfiguration.storageAllowanceBytes.formatted(.owsByteCount),
viewModel.backupSubscriptionConfiguration.storageAllowanceBytes.formatted(.owsByteCount(
fudgeBase2ToBase10: true,
zeroPadFractionDigits: false,
)),
max(
// Always display at least 5 MB
1000 * 1000 * 5,
Int64(clamping: mediaTierCapacityOverflow)
).formatted(.owsByteCount)
).formatted(.owsByteCount())
)
)
.appendLink(CommonStrings.learnMore, useBold: true, tint: .Signal.label) {
@@ -2016,7 +2019,7 @@ private struct BackupAttachmentDownloadProgressView: View {
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_SUSPENDED",
comment: "Subtitle for a view explaining that downloads are available but not running. Embeds {{ the amount available to download as a file size, e.g. 100 MB }}."
),
latestDownloadUpdate.totalBytesToDownload.formatted(.owsByteCount)
latestDownloadUpdate.totalBytesToDownload.formatted(.owsByteCount())
)
case .disabling, .paidExpiringSoon:
String(
@@ -2024,7 +2027,7 @@ private struct BackupAttachmentDownloadProgressView: View {
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_SUSPENDED_PAID_SUBSCRIPTION_EXPIRING",
comment: "Subtitle for a view explaining that downloads are available but not running, and the user's paid subscription is expiring. Embeds {{ the amount available to download as a file size, e.g. 100 MB }}."
),
latestDownloadUpdate.totalBytesToDownload.formatted(.owsByteCount)
latestDownloadUpdate.totalBytesToDownload.formatted(.owsByteCount())
)
}
case .running:
@@ -2033,8 +2036,8 @@ private struct BackupAttachmentDownloadProgressView: View {
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_RUNNING",
comment: "Subtitle for a progress bar tracking active downloading. Embeds 1:{{ the amount downloaded as a file size, e.g. 100 MB }}; 2:{{ the total amount to download as a file size, e.g. 1 GB }}; 3:{{ the amount downloaded as a percentage, e.g. 10% }}."
),
latestDownloadUpdate.bytesDownloaded.formatted(.owsByteCount),
latestDownloadUpdate.totalBytesToDownload.formatted(.owsByteCount),
latestDownloadUpdate.bytesDownloaded.formatted(.owsByteCount()),
latestDownloadUpdate.totalBytesToDownload.formatted(.owsByteCount()),
latestDownloadUpdate.percentageDownloaded.formatted(.percent.precision(.fractionLength(0))),
)
case .pausedLowBattery:
@@ -2063,7 +2066,7 @@ private struct BackupAttachmentDownloadProgressView: View {
"BACKUP_SETTINGS_DOWNLOAD_PROGRESS_SUBTITLE_PAUSED_NEEDS_DISK_SPACE",
comment: "Subtitle for a progress bar tracking downloads that are paused because they need more disk space available. Embeds {{ the amount of space needed as a file size, e.g. 100 MB }}."
),
bytesRequired.formatted(.owsByteCount)
bytesRequired.formatted(.owsByteCount())
)
}
@@ -2166,8 +2169,8 @@ private struct BackupAttachmentUploadProgressView: View {
"BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_RUNNING",
comment: "Subtitle for a progress bar tracking active uploading. Embeds 1:{{ the amount uploaded as a file size, e.g. 100 MB }}; 2:{{ the total amount to upload as a file size, e.g. 1 GB }}; 3:{{ the percentage uploaded as a percent, e.g. 40% }}."
),
bytesUploaded.formatted(.owsByteCount),
totalBytesToUpload.formatted(.owsByteCount),
bytesUploaded.formatted(.owsByteCount()),
totalBytesToUpload.formatted(.owsByteCount()),
percentageUploaded.formatted(.percent.precision(.fractionLength(0)))
)
case .pausedLowBattery:
@@ -2493,7 +2496,7 @@ private struct BackupDetailsView: View {
comment: "Label for a menu item explaining the size of the user's backup."
))
Spacer()
Text(lastBackupSizeBytes.formatted(.owsByteCount))
Text(lastBackupSizeBytes.formatted(.owsByteCount()))
.foregroundStyle(Color.Signal.secondaryLabel)
}
}

View File

@@ -305,7 +305,10 @@ struct ChooseBackupPlanView: View {
"CHOOSE_BACKUP_PLAN_BULLET_STORAGE_AMOUNT",
comment: "Text for a bullet point in a list of Backup features, describing the amount of included storage. Embeds {{ the amount of storage preformatted as a localized byte count, e.g. '100 GB' }}."
),
viewModel.storageAllowanceBytes.formatted(.owsByteCount),
viewModel.storageAllowanceBytes.formatted(.owsByteCount(
fudgeBase2ToBase10: true,
zeroPadFractionDigits: false,
)),
)),
],
isCurrentPlan: viewModel.initialPlanSelection == .paid,

View File

@@ -4,31 +4,114 @@
//
public struct OWSByteCountFormatStyle: FormatStyle {
private let style: ByteCountFormatStyle.Style
private let fudgeBase2ToBase10: Bool
private let zeroPadFractionDigits: Bool
public init() {
self.style = .decimal
/// - Parameter fudgeBase2ToBase10
/// Whether the given byte count should be "fudged" from a base2 to base10
/// value. See `OWSBase2ByteCountFudger` for more.
/// - Parameter zeroPadFractionDigits
/// Whether the formatted string should zero-pad fraction digits to maintain
/// a consistent string length across multiple formatting instances. Callers
/// formatting a single fixed value, rather than a value that may change
/// in-place, may prefer to pass `false` to avoid unnecessary zero-padding.
public init(
fudgeBase2ToBase10: Bool = false,
zeroPadFractionDigits: Bool = true,
) {
self.fudgeBase2ToBase10 = fudgeBase2ToBase10
self.zeroPadFractionDigits = zeroPadFractionDigits
}
public func format(_ byteCount: UInt64) -> String {
public func format(_ byteCountParam: UInt64) -> String {
let byteCount: UInt64
if
fudgeBase2ToBase10,
let fudged = OWSBase2ByteCountFudger.fudgeBase2ToBase10(byteCountParam)
{
byteCount = fudged
} else {
byteCount = byteCountParam
}
let byteFormatter = ByteCountFormatter()
// Use KB, MB, GB, etc as appropriate.
byteFormatter.allowedUnits = .useAll
// Assume the byte count is base-10, i.e. "1000 bytes / 1 KB". See
// OWSBase2ByteCountFudger for more.
byteFormatter.countStyle = .decimal
// Don't use, for example, the word "zero" instead of the numeral "0".
byteFormatter.allowsNonnumericFormatting = false
// Zero-pad fractions (the number of digits is controlled by the
// formatter: see `isAdaptive`, which defaults to true) to keep a fixed
// number of digits. Otherwise, it can look "jumpy" (e.g., going from
// 400 MB to 400.1 MB).
byteFormatter.zeroPadsFractionDigits = true
// Zero-pad fractions to keep a fixed number of digits in the string, if
// we format multiple different values that might otherwise produce
// different fractional lengths. Otherwise, it can look "jumpy"; e.g.,
// going from 400 MB to 400.1 MB.
//
// The number of digits is managed by the formatter: see `isAdaptive`,
// which defaults to true.
byteFormatter.zeroPadsFractionDigits = zeroPadFractionDigits
return byteFormatter.string(fromByteCount: Int64(clamping: byteCount))
}
}
extension FormatStyle where Self == OWSByteCountFormatStyle {
public static var owsByteCount: OWSByteCountFormatStyle {
return OWSByteCountFormatStyle()
public static func owsByteCount(
fudgeBase2ToBase10: Bool = false,
zeroPadFractionDigits: Bool = true,
) -> OWSByteCountFormatStyle {
return OWSByteCountFormatStyle(
fudgeBase2ToBase10: fudgeBase2ToBase10,
zeroPadFractionDigits: zeroPadFractionDigits,
)
}
}
// MARK: -
enum OWSBase2ByteCountFudger {
/// If the given byte count is a multiple of an exponentiation of 1024,
/// returns the byte count as a multiple of the corresponding exponentiation
/// of 1000 instead. In other words, fudges base-2 byte counts to their
/// roughly-approximate base-10 byte count instead.
///
/// For example, given the value `107_374_182_400` (or `100 * 1024^3`, aka
/// "100 gibibytes/GiB"), returns `100_000_000_000` (or `100 * 1000^3`, aka
/// "100 gigabytes/GB").
///
/// See https://simple.wikipedia.org/wiki/Gibibyte if you, like me, were
/// surprised to learn about base-2 byte values.
///
/// - Note
/// At the time of writing, the use case for this is the Backups
/// `storageAllowanceBytes` value, which is configured on the server to be a
/// GiB value rather than GB.
static func fudgeBase2ToBase10(_ byteCount: UInt64) -> UInt64? {
if byteCount == 0 { return 0 }
// Cut byteCount down by 1024s until it can't be cut anymore. At that
// point it'll represent the "multiple"; e.g., we were given 37 MiB and
// are left with 37.
var byteCount = byteCount
var exponentiation = 0
while byteCount >= 1024 {
if byteCount % 1024 != 0 {
// In the end, not roundly divisible by an exponentiation of
// 1024. Bail.
return nil
}
byteCount /= 1024
exponentiation += 1
}
// Then, build it back up using 1000s.
//
// This can't overflow since 1000 < 1024, so the resulting value will
// always be smaller than the passed-in value.
for _ in 0..<exponentiation {
byteCount *= 1000
}
return byteCount
}
}

View File

@@ -0,0 +1,28 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Testing
@testable import SignalUI
struct OWSByteCountFormatStyleTest {
@Test(arguments: [
(0, 0),
(1024, 1000), // 1 KiB
(1_048_576, 1_000_000), // 1 MiB
(1_048_577, nil), // 1 MiB + 1 B
(1_073_741_824, 1_000_000_000), // 1 GiB
(1_074_790_400, nil), // 1 GiB + 2 MiB
(39_728_447_488, 37_000_000_000), // 37 GiB
(107_374_182_400, 100_000_000_000), // 100 GiB
(1_099_511_627_776, 1_000_000_000_000), // 1 TiB
(1_125_899_906_842_624, 1_000_000_000_000_000), // 1 PiB
])
func fudgingBase2ToBase10ByteCount(byteCount: UInt64, expected: UInt64?) {
#expect(
expected == OWSBase2ByteCountFudger.fudgeBase2ToBase10(byteCount)
)
}
}