Change license to AGPL
This commit:
- Updates the `LICENSE` file
- Start every file with something like:
// Copyright YEAR_FIRST_PUBLISHED Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
---
First, I removed existing license headers with this Ruby 3.1.2 script:
require 'set'
EXTENSIONS_TO_CHECK = Set['.h', '.hpp', '.cpp', '.m', '.mm', '.pch', '.swift']
same = 0
different = 0
all_files = `git ls-files`.lines.map { |line| line.strip }
all_files.each do |relative_path|
if relative_path == 'Pods'
next
end
unless EXTENSIONS_TO_CHECK.include? File.extname(relative_path)
next
end
path = File.expand_path(relative_path)
contents = File.read(path)
new_contents = contents.sub(/\/\/\n\/\/ Copyright .*\n\/\/\n\n/, '')
if contents == new_contents
same += 1
else
different += 1
end
File.write(path, new_contents)
end
puts "updated #{different} file(s), left #{same} untouched"
I'm sure this script could be improved, but it worked well enough.
Then, I created `Scripts/lint/lint-license-headers` and ran it to auto-
fix a lot of files. This changed the mode of some files, but I think
that's actually desirable. For example,
`SignalServiceKit/src/Util/AppContext.m` previously had a mode of
`0755/-rwxr-xr-x`, and it's now `0644/-rw-r--r--`.
Then I fixed some stragglers and updated the precommit script.
See [a similar change in the Desktop app][0].
[0]: 8bfaf598af
554 lines
21 KiB
Swift
554 lines
21 KiB
Swift
//
|
||
// Copyright 2022 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
import Foundation
|
||
import SignalServiceKit
|
||
|
||
class DatabaseRecoveryViewController: OWSViewController {
|
||
private let setupSskEnvironment: () -> Promise<Void>
|
||
private let launchApp: () -> Void
|
||
|
||
public init(
|
||
setupSskEnvironment: @escaping () -> Promise<Void>,
|
||
launchApp: @escaping () -> Void
|
||
) {
|
||
self.setupSskEnvironment = setupSskEnvironment
|
||
self.launchApp = launchApp
|
||
}
|
||
|
||
// MARK: - State
|
||
|
||
enum State: Equatable {
|
||
case awaitingUserConfirmation
|
||
case showingDeviceSpaceWarning
|
||
case recovering(fractionCompleted: Double)
|
||
case recoveryFailed
|
||
case recoverySucceeded
|
||
}
|
||
|
||
private var state: State = .awaitingUserConfirmation {
|
||
didSet {
|
||
AssertIsOnMainThread()
|
||
render()
|
||
}
|
||
}
|
||
private var previouslyRenderedState: State?
|
||
|
||
private var databaseFileUrl: URL { GRDBDatabaseStorageAdapter.databaseFileUrl() }
|
||
|
||
private var currentDatabaseSize: UInt64 {
|
||
DatabaseRecovery.databaseFileSize(forDatabaseAt: databaseFileUrl)
|
||
}
|
||
|
||
// MARK: - Views
|
||
|
||
private let stackView: UIStackView = {
|
||
let view = UIStackView()
|
||
view.axis = .vertical
|
||
view.distribution = .equalSpacing
|
||
view.alignment = .center
|
||
view.layoutMargins = .init(hMargin: 32, vMargin: 46)
|
||
view.isLayoutMarginsRelativeArrangement = true
|
||
return view
|
||
}()
|
||
|
||
private let headlineLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .ows_dynamicTypeTitle2.ows_semibold
|
||
label.textColor = Theme.primaryTextColor
|
||
label.textAlignment = .center
|
||
label.numberOfLines = 0
|
||
return label
|
||
}()
|
||
|
||
private let descriptionLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .ows_dynamicTypeBody2
|
||
label.textColor = Theme.secondaryTextAndIconColor
|
||
label.textAlignment = .center
|
||
label.numberOfLines = 0
|
||
return label
|
||
}()
|
||
|
||
private var databaseCorruptedImage: UIImageView {
|
||
let view = UIImageView()
|
||
view.image = UIImage(named: "database-corrupted")
|
||
view.autoSetDimensions(to: .init(width: 62, height: 88))
|
||
return view
|
||
}
|
||
|
||
private var databaseRecoveredImage: UIImageView {
|
||
let view = UIImageView()
|
||
view.image = UIImage(named: "database-recovered")
|
||
view.autoSetDimensions(to: .init(width: 62, height: 88))
|
||
return view
|
||
}
|
||
|
||
private lazy var progressStack: UIStackView = {
|
||
let view = UIStackView(arrangedSubviews: [
|
||
progressLabel,
|
||
UIView.spacer(withHeight: 20),
|
||
progressBar
|
||
])
|
||
view.axis = .vertical
|
||
view.distribution = .equalSpacing
|
||
view.alignment = .center
|
||
|
||
progressLabel.autoPinWidthToSuperviewMargins()
|
||
progressBar.autoPinWidthToSuperviewMargins()
|
||
|
||
return view
|
||
}()
|
||
|
||
private lazy var progressLabel: UILabel = {
|
||
let label = UILabel()
|
||
label.font = .ows_dynamicTypeBody2
|
||
label.textAlignment = .center
|
||
label.numberOfLines = 0
|
||
return label
|
||
}()
|
||
|
||
private lazy var progressBar: UIProgressView = {
|
||
let bar = UIProgressView()
|
||
bar.progressTintColor = .ows_accentBlue
|
||
return bar
|
||
}()
|
||
|
||
// MARK: - View callbacks
|
||
|
||
public override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
|
||
view.addSubview(stackView)
|
||
stackView.autoPinEdgesToSuperviewEdges()
|
||
|
||
render()
|
||
}
|
||
|
||
// MARK: - Events
|
||
|
||
@objc
|
||
private func didTapContinueToStartRecovery() {
|
||
switch state {
|
||
case .awaitingUserConfirmation:
|
||
if hasApproximatelyEnoughDiskSpace() {
|
||
attemptRecovery()
|
||
} else {
|
||
state = .showingDeviceSpaceWarning
|
||
}
|
||
default:
|
||
owsFailDebug("Continue was tapped on the wrong screen")
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func didTapContinueToBypassStorageWarning() {
|
||
switch state {
|
||
case .showingDeviceSpaceWarning:
|
||
attemptRecovery()
|
||
default:
|
||
owsFailDebug("Button was tapped on the wrong screen")
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func didTapToResetSignal() {
|
||
OWSActionSheets.showConfirmationAlert(
|
||
title: NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_FAILED_RESET_APP_CONFIRMATION_TITLE",
|
||
value: "Reset Signal",
|
||
comment: "The user has tried to recover their data after it was lost due to corruption. (They have not been hacked.) If they want to delete the app and restart, they will be presented with a confirmation dialog. This is the title of that dialog."
|
||
),
|
||
message: NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_FAILED_RESET_APP_CONFIRMATION_DESCRIPTION",
|
||
value: "Your account info, profile, and all of your messages will be cleared. The app will close after this process is complete.",
|
||
comment: "The user has tried to recover their data after it was lost due to corruption. (They have not been hacked.) If they want to delete the app and restart, they will be presented with a confirmation dialog. This is the description text in that dialog."
|
||
),
|
||
proceedTitle: NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_FAILED_RESET_APP_CONFIRMATION_CONFIRM",
|
||
value: "Reset",
|
||
comment: "The user has tried to recover their data after it was lost due to corruption. (They have not been hacked.) If they want to delete the app and restart, they will be presented with a confirmation dialog. This is the final button they will press before their data is reset."
|
||
),
|
||
proceedStyle: .destructive
|
||
) { _ in
|
||
SignalApp.resetAppDataWithUI()
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func didTapToSubmitDebugLogs() {
|
||
self.dismiss(animated: true) {
|
||
let supportTag = String(describing: LaunchFailure.databaseCorruptedAndMightBeRecoverable)
|
||
Pastelog.submitLogs(withSupportTag: supportTag)
|
||
}
|
||
}
|
||
|
||
private func attemptRecovery() {
|
||
switch state {
|
||
case .recovering:
|
||
owsFailDebug("Already recovering")
|
||
return
|
||
default:
|
||
break
|
||
}
|
||
|
||
state = .recovering(fractionCompleted: 0)
|
||
|
||
let progress = Progress(totalUnitCount: 2)
|
||
let needsDumpAndRestore: Bool
|
||
|
||
switch DatabaseCorruptionState(userDefaults: userDefaults).status {
|
||
case .notCorrupted:
|
||
owsFailDebug("Database was not corrupted! Why are we on this screen?")
|
||
state = .recoverySucceeded
|
||
return
|
||
case .corrupted:
|
||
needsDumpAndRestore = true
|
||
case .corruptedButAlreadyDumpedAndRestored:
|
||
needsDumpAndRestore = false
|
||
}
|
||
|
||
let progressObserver = progress.observe(\.fractionCompleted, options: [.new]) { [weak self] _, _ in
|
||
self?.didFractionCompletedChange(fractionCompleted: progress.fractionCompleted)
|
||
}
|
||
|
||
firstly(on: .sharedUserInitiated) {
|
||
if needsDumpAndRestore {
|
||
let dumpAndRestore = DatabaseRecovery.DumpAndRestore(databaseFileUrl: self.databaseFileUrl)
|
||
progress.addChild(dumpAndRestore.progress, withPendingUnitCount: 1)
|
||
do {
|
||
try dumpAndRestore.run()
|
||
} catch {
|
||
return Promise<Void>(error: error)
|
||
}
|
||
DatabaseCorruptionState.flagCorruptedDatabaseAsDumpedAndRestored(userDefaults: self.userDefaults)
|
||
} else {
|
||
progress.completedUnitCount += 1
|
||
}
|
||
return Promise.value(())
|
||
}.then(on: .sharedUserInitiated) {
|
||
self.setupSskEnvironment()
|
||
}.then(on: .sharedUserInitiated) {
|
||
let manualRecreation = DatabaseRecovery.ManualRecreation(databaseStorage: SDSDatabaseStorage.shared)
|
||
progress.addChild(manualRecreation.progress, withPendingUnitCount: 1)
|
||
manualRecreation.run()
|
||
|
||
DatabaseCorruptionState.flagDatabaseAsRecoveredFromCorruption(userDefaults: self.userDefaults)
|
||
|
||
return Promise.value(())
|
||
}.done(on: .main) { [weak self] in
|
||
guard let self = self else { return }
|
||
self.state = .recoverySucceeded
|
||
}.ensure {
|
||
progressObserver.invalidate()
|
||
}.catch(on: .main) { [weak self] error in
|
||
self?.didRecoveryFail(with: error)
|
||
}
|
||
}
|
||
|
||
private func didFractionCompletedChange(fractionCompleted: Double) {
|
||
switch state {
|
||
case .awaitingUserConfirmation, .showingDeviceSpaceWarning:
|
||
owsFailDebug("Unexpectedly got a progress event")
|
||
fallthrough
|
||
case .recovering:
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
self.state = .recovering(fractionCompleted: fractionCompleted)
|
||
}
|
||
case .recoveryFailed, .recoverySucceeded:
|
||
owsFailDebug("Unexpectedly got a progress event")
|
||
}
|
||
}
|
||
|
||
private func didRecoveryFail(with error: Error) {
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
if let error = error as? DatabaseRecoveryError {
|
||
switch error {
|
||
case .ranOutOfDiskSpace:
|
||
self.state = .showingDeviceSpaceWarning
|
||
case .unrecoverablyCorrupted:
|
||
self.state = .recoveryFailed
|
||
}
|
||
} else {
|
||
owsFailDebug("\(error)")
|
||
self.state = .recoveryFailed
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc
|
||
private func didTapLaunchApp() {
|
||
switch state {
|
||
case .recoverySucceeded:
|
||
dismiss(animated: true) {
|
||
self.launchApp()
|
||
}
|
||
default:
|
||
owsFailDebug("Button was tapped on the wrong screen")
|
||
}
|
||
}
|
||
|
||
// MARK: - Top-level renderers
|
||
|
||
private func render() {
|
||
stackView.backgroundColor = Theme.backgroundColor
|
||
|
||
switch state {
|
||
case .awaitingUserConfirmation:
|
||
renderAwaitingUserConfirmation()
|
||
case .showingDeviceSpaceWarning:
|
||
renderDeviceSpaceWarning()
|
||
case let .recovering(ratioComplete):
|
||
renderRecovering(fractionCompleted: ratioComplete)
|
||
case .recoveryFailed:
|
||
renderRecoveryFailed()
|
||
case .recoverySucceeded:
|
||
renderRecoverySucceeded()
|
||
}
|
||
|
||
previouslyRenderedState = state
|
||
}
|
||
|
||
private func renderAwaitingUserConfirmation() {
|
||
guard previouslyRenderedState != .awaitingUserConfirmation else { return }
|
||
|
||
headlineLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_AWAITING_USER_CONFIRMATION_TITLE",
|
||
value: "Problem Retrieving Data",
|
||
comment: "In some cases, the user's message history can become corrupted, and a recovery interface is shown. The user has not been hacked and may be confused by this interface, so try to avoid using terms like \"database\" or \"corrupted\"—terms like \"message history\" are better. This is the title on the first screen of this interface, which gives them some information and asks them to continue."
|
||
)
|
||
|
||
descriptionLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_AWAITING_USER_CONFIRMATION_DESCRIPTION",
|
||
value: "Some of your message history may be corrupted. There has been no impact to the security of your account or messages. Tap “Continue” to recover your data.",
|
||
comment: "In some cases, the user's message history can become corrupted, and a recovery interface is shown. The user has not been hacked and may be confused by this interface, so keep that in mind. This is the description on the first screen of this interface, which gives them some information and asks them to continue."
|
||
)
|
||
|
||
let continueButton = button(
|
||
title: CommonStrings.continueButton,
|
||
selector: #selector(didTapContinueToStartRecovery)
|
||
)
|
||
|
||
stackView.removeAllSubviews()
|
||
stackView.addArrangedSubviews([
|
||
headlineLabel,
|
||
descriptionLabel,
|
||
databaseCorruptedImage,
|
||
continueButton
|
||
])
|
||
|
||
continueButton.autoPinWidthToSuperviewMargins()
|
||
|
||
UIApplication.shared.isIdleTimerDisabled = false
|
||
}
|
||
|
||
private func renderDeviceSpaceWarning() {
|
||
guard previouslyRenderedState != .showingDeviceSpaceWarning else { return }
|
||
|
||
headlineLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_MORE_STORAGE_SPACE_NEEDED_TITLE",
|
||
value: "More Storage Space Needed",
|
||
comment: "On the database recovery screen, if the user's device storage is nearly full, Signal will not be able to recover the database. A warning screen, which can be bypassed if the user wishes, will be shown. This is the title of that screen."
|
||
)
|
||
|
||
descriptionLabel.text = {
|
||
let labelFormat = NSLocalizedString(
|
||
"DATABASE_RECOVERY_MORE_STORAGE_SPACE_NEEDED_DESCRIPTION",
|
||
value: "In order to complete data recovery, we recommend you have at least %@ of storage space free on your device.",
|
||
comment: "On the database recovery screen, if the user's device storage is nearly full, Signal will not be able to recover the database. A warning screen, which can be bypassed if the user wishes, will be shown. This is the line of text on that screen. Embeds an amount like \"2GB\"."
|
||
)
|
||
let formattedBytes = ByteCountFormatter().string(for: currentDatabaseSize) ?? {
|
||
owsFailDebug("Could not format the database size for some reason")
|
||
return String(currentDatabaseSize)
|
||
}()
|
||
return String(format: labelFormat, formattedBytes)
|
||
}()
|
||
|
||
let continueButton = button(
|
||
title: NSLocalizedString(
|
||
"DATABASE_RECOVERY_MORE_STORAGE_SPACE_NEEDED_CONTINUE_ANYWAY",
|
||
value: "Continue Anyway",
|
||
comment: "On the database recovery screen, if the user's device storage is nearly full, Signal will not be able to recover the database. A warning screen, which can be bypassed if the user wishes, will be shown. This is the text on the button to bypass the warning."
|
||
),
|
||
selector: #selector(didTapContinueToBypassStorageWarning)
|
||
)
|
||
|
||
stackView.removeAllSubviews()
|
||
stackView.addArrangedSubviews([
|
||
headlineLabel,
|
||
descriptionLabel,
|
||
continueButton
|
||
])
|
||
|
||
continueButton.autoPinWidthToSuperviewMargins()
|
||
|
||
UIApplication.shared.isIdleTimerDisabled = false
|
||
}
|
||
|
||
private func renderRecovering(fractionCompleted: Double) {
|
||
switch previouslyRenderedState {
|
||
case .recovering:
|
||
break
|
||
default:
|
||
headlineLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_IN_PROGRESS_TITLE",
|
||
value: "Recovering Data",
|
||
comment: "On the database recovery screen, this is the title shown as the user's data is being recovered."
|
||
)
|
||
|
||
descriptionLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_IN_PROGRESS_DESCRIPTION",
|
||
value: "Keep the app open during the recovery process. It may take several minutes.",
|
||
comment: "On the database recovery screen, this is the description text shown as the user's data is being recovered."
|
||
)
|
||
|
||
progressBar.setProgress(0, animated: false)
|
||
progressBar.trackTintColor = Theme.isDarkThemeEnabled ? .ows_gray90 : .ows_gray05
|
||
|
||
stackView.removeAllSubviews()
|
||
stackView.addArrangedSubviews([
|
||
headlineLabel,
|
||
descriptionLabel,
|
||
progressStack
|
||
])
|
||
|
||
progressStack.autoPinWidthToSuperviewMargins()
|
||
|
||
UIApplication.shared.isIdleTimerDisabled = true
|
||
}
|
||
|
||
progressLabel.text = Self.render(fractionCompleted: fractionCompleted)
|
||
|
||
progressBar.setProgress(Float(fractionCompleted), animated: false)
|
||
}
|
||
|
||
private func renderRecoveryFailed() {
|
||
guard previouslyRenderedState != .recoveryFailed else { return }
|
||
|
||
headlineLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_FAILED_TITLE",
|
||
value: "Data Not Recovered",
|
||
comment: "The user has tried to recover their data after it was lost due to corruption. (They have not been hacked.) This is the title on the screen where we show an error message."
|
||
)
|
||
|
||
descriptionLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_FAILED_DESCRIPTION",
|
||
value: "Your message history couldn’t recover. You can continue to use Signal after resetting. Submit a debug log so we can better understand what went wrong.",
|
||
comment: "The user has tried to recover their data after it was lost due to corruption. (They have not been hacked.) This is the description on the screen where we show an error message."
|
||
)
|
||
|
||
let resetSignalButton = self.button(
|
||
title: NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_FAILED_RESET_APP_BUTTON",
|
||
value: "Reset Signal",
|
||
comment: "The user has tried to recover their data after it was lost due to corruption. (They have not been hacked.) This button lets them delete all of their data."
|
||
),
|
||
selector: #selector(didTapToResetSignal),
|
||
backgroundColor: .ows_accentRed
|
||
)
|
||
|
||
let submitDebugLogsButton = self.button(
|
||
title: NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_FAILED_SUBMIT_DEBUG_LOG_BUTTON",
|
||
value: "Submit Debug Log",
|
||
comment: "The user has tried to recover their data after it was lost due to corruption. (They have not been hacked.) They were asked to submit a debug log. This is the button that submits this log."
|
||
),
|
||
selector: #selector(didTapToSubmitDebugLogs)
|
||
)
|
||
|
||
stackView.removeAllSubviews()
|
||
stackView.addArrangedSubviews([
|
||
headlineLabel,
|
||
descriptionLabel,
|
||
databaseCorruptedImage,
|
||
resetSignalButton,
|
||
submitDebugLogsButton
|
||
])
|
||
|
||
resetSignalButton.autoPinWidthToSuperviewMargins()
|
||
submitDebugLogsButton.autoPinWidthToSuperviewMargins()
|
||
|
||
UIApplication.shared.isIdleTimerDisabled = false
|
||
}
|
||
|
||
private func renderRecoverySucceeded() {
|
||
guard previouslyRenderedState != .recoverySucceeded else { return }
|
||
|
||
headlineLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_SUCCEEDED_TITLE",
|
||
value: "Data Successfully Recovered",
|
||
comment: "The user has successfully recovered their database after it was lost due to corruption. (They have not been hacked.) This is the title on the screen that tells them things worked."
|
||
)
|
||
|
||
descriptionLabel.text = NSLocalizedString(
|
||
"DATABASE_RECOVERY_RECOVERY_SUCCEEDED_DESCRIPTION",
|
||
value: "Recovery was successful, but some messages may not have been recovered.",
|
||
comment: "The user has successfully recovered their database after it was lost due to corruption. (They have not been hacked.) This is the description on the screen that tells them things worked."
|
||
)
|
||
|
||
let launchAppButton = button(
|
||
title: CommonStrings.continueButton,
|
||
selector: #selector(didTapLaunchApp)
|
||
)
|
||
|
||
stackView.removeAllSubviews()
|
||
stackView.addArrangedSubviews([
|
||
headlineLabel,
|
||
descriptionLabel,
|
||
databaseRecoveredImage,
|
||
launchAppButton
|
||
])
|
||
|
||
launchAppButton.autoPinWidthToSuperviewMargins()
|
||
|
||
UIApplication.shared.isIdleTimerDisabled = false
|
||
}
|
||
|
||
// MARK: - Utilities
|
||
|
||
private var userDefaults: UserDefaults { CurrentAppContext().appUserDefaults() }
|
||
|
||
/// Determine whether the user has *approximately* enough space for recovery.
|
||
///
|
||
/// The heuristic: do we have N remaining bytes, where N is the current size of the database?
|
||
///
|
||
/// - Returns: `true` if the user has approximately enough disk space, or if any part of the check fails. `false` if they do not have enough disk space.
|
||
private func hasApproximatelyEnoughDiskSpace() -> Bool {
|
||
do {
|
||
let freeSpace = try OWSFileSystem.freeSpaceInBytes(forPath: databaseFileUrl)
|
||
return freeSpace >= currentDatabaseSize
|
||
} catch {
|
||
owsFailDebug("\(error)")
|
||
return true
|
||
}
|
||
}
|
||
|
||
private func button(title: String, selector: Selector, backgroundColor: UIColor = .ows_accentBlue) -> UIView {
|
||
let button = OWSFlatButton.button(
|
||
title: title,
|
||
font: UIFont.ows_dynamicTypeBody.ows_semibold,
|
||
titleColor: .white,
|
||
backgroundColor: backgroundColor,
|
||
target: self,
|
||
selector: selector
|
||
)
|
||
button.autoSetHeightUsingFont()
|
||
button.cornerRadius = 8
|
||
return button
|
||
}
|
||
|
||
static func render(fractionCompleted: Double) -> String {
|
||
let numberFormatter = NumberFormatter()
|
||
numberFormatter.numberStyle = .percent
|
||
guard let result = numberFormatter.string(for: fractionCompleted) else {
|
||
owsFailDebug("Unable to render ratio with number formatter")
|
||
return ""
|
||
}
|
||
return result
|
||
}
|
||
}
|