753 lines
30 KiB
Swift
753 lines
30 KiB
Swift
//
|
||
// Copyright 2023 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
//
|
||
|
||
/// Manages the local username and username link.
|
||
public protocol LocalUsernameManager {
|
||
|
||
// MARK: Local state
|
||
|
||
/// Returns the state of the local username.
|
||
func usernameState(tx: DBReadTransaction) -> Usernames.LocalUsernameState
|
||
|
||
/// Sets the local username and username link.
|
||
func setLocalUsername(
|
||
username: String,
|
||
usernameLink: Usernames.UsernameLink,
|
||
tx: DBWriteTransaction
|
||
)
|
||
|
||
/// Sets the local username, and marks that the local username link is
|
||
/// corrupted.
|
||
///
|
||
/// Corruption indicates that the username link values we have locally may
|
||
/// or do not decrypt the encrypted username stored by the service. This may
|
||
/// occur due to an interrupted "update my username link" request, race
|
||
/// between two devices simultaneously updating our username, and possibly
|
||
/// other reasons.
|
||
func setLocalUsernameWithCorruptedLink(
|
||
username: String,
|
||
tx: DBWriteTransaction
|
||
)
|
||
|
||
/// Sets that the local username and username link are corrupted.
|
||
///
|
||
/// Corruption indicates that the username value we have locally may or does
|
||
/// not match the hash of our username stored by the service. This may occur
|
||
/// due to an interrupted "update my username" request, race between two
|
||
/// devices simultaneously updating our username, and possibly other
|
||
/// reasons.
|
||
func setLocalUsernameCorrupted(tx: DBWriteTransaction)
|
||
|
||
/// Clears the local username and username link, whether they were corrupted
|
||
/// or not.
|
||
func clearLocalUsername(tx: DBWriteTransaction)
|
||
|
||
/// Returns the color to be used for the local user's username link QR code.
|
||
func usernameLinkQRCodeColor(tx: DBReadTransaction) -> QRCodeColor
|
||
|
||
/// Sets the color to be used for the local user's username link QR code.
|
||
func setUsernameLinkQRCodeColor(
|
||
color: QRCodeColor,
|
||
tx: DBWriteTransaction
|
||
)
|
||
|
||
// MARK: Usernames and the service
|
||
|
||
/// Reserve a username from the given set of candidates.
|
||
func reserveUsername(
|
||
usernameCandidates: Usernames.HashedUsername.GeneratedCandidates
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Usernames.ReservationResult>>
|
||
|
||
/// Set the local user's username to the given reserved username, on the
|
||
/// service and locally. Note that setting a new username also sets a
|
||
/// corresponding username link.
|
||
func confirmUsername(
|
||
reservedUsername: Usernames.HashedUsername,
|
||
tx: DBWriteTransaction
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Usernames.ConfirmationResult>>
|
||
|
||
/// Delete the local user's username and username link.
|
||
func deleteUsername(
|
||
tx: DBWriteTransaction
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Void>>
|
||
|
||
// MARK: Username links and the service
|
||
|
||
/// Rotate the local user's username link, without modifying their username.
|
||
func rotateUsernameLink(
|
||
tx: DBWriteTransaction
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Usernames.UsernameLink>>
|
||
|
||
/// Update the case of the local user's existing username, as it will be
|
||
/// visibly presented.
|
||
///
|
||
/// This updates the local store to reflect the new username casing. It also
|
||
/// performs an in-place update of the encrypted username used in the user's
|
||
/// username link without modifying the link handle or entropy. The existing
|
||
/// username link is consequently unaffected, but the username that is
|
||
/// available via the link will reflect the new casing.
|
||
///
|
||
/// - Important
|
||
/// The new username must case-insensitively match the existing username
|
||
/// when calling this API.
|
||
func updateVisibleCaseOfExistingUsername(
|
||
newUsername: String,
|
||
tx: DBWriteTransaction
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Void>>
|
||
}
|
||
|
||
public extension Usernames {
|
||
static let localUsernameStateChangedNotification = NSNotification.Name(
|
||
"localUsernameStateChanged"
|
||
)
|
||
|
||
/// Represents the states that the local user's username and username link
|
||
/// can be in.
|
||
enum LocalUsernameState: Equatable {
|
||
/// The user deliberately has no username nor username link.
|
||
case unset
|
||
/// The user has both a username and username link.
|
||
case available(username: String, usernameLink: UsernameLink)
|
||
/// The user has a username, but something is wrong with the
|
||
/// corresponding username link and it cannot be used.
|
||
case linkCorrupted(username: String)
|
||
/// The user has a username, but something is wrong with it and neither
|
||
/// it nor the corresponding username link can be used.
|
||
case usernameAndLinkCorrupted
|
||
|
||
/// Whether the user explicitly does not have a username set.
|
||
public var isExplicitlyUnset: Bool {
|
||
switch self {
|
||
case .unset:
|
||
return true
|
||
case .available, .linkCorrupted, .usernameAndLinkCorrupted:
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// The username, if there is one available.
|
||
public var username: String? {
|
||
switch self {
|
||
case let .available(username, _):
|
||
return username
|
||
case let .linkCorrupted(username):
|
||
return username
|
||
case .unset, .usernameAndLinkCorrupted:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
/// The username link, if there is one available.
|
||
public var usernameLink: Usernames.UsernameLink? {
|
||
switch self {
|
||
case let .available(_, usernameLink):
|
||
return usernameLink
|
||
case .unset, .linkCorrupted, .usernameAndLinkCorrupted:
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Errors related to the local user updating remote state pertaining to
|
||
/// their username.
|
||
enum RemoteMutationError: Error {
|
||
case networkError
|
||
case otherError
|
||
}
|
||
|
||
typealias RemoteMutationResult<T> = Result<T, RemoteMutationError>
|
||
|
||
typealias ReservationResult = ApiClientReservationResult
|
||
|
||
enum ConfirmationResult: Equatable {
|
||
case success(
|
||
username: String,
|
||
usernameLink: UsernameLink
|
||
)
|
||
case rejected
|
||
case rateLimited
|
||
}
|
||
}
|
||
|
||
// MARK: - Impl
|
||
|
||
class LocalUsernameManagerImpl: LocalUsernameManager {
|
||
private struct CorruptionStore {
|
||
private enum Constants {
|
||
static let collection = "LocalUsernameCorruption"
|
||
static let usernameKey = "username"
|
||
static let usernameLinkKey = "link"
|
||
}
|
||
|
||
private let kvStore: KeyValueStore
|
||
|
||
init() {
|
||
kvStore = KeyValueStore(collection: Constants.collection)
|
||
}
|
||
|
||
func isUsernameCorrupted(tx: DBReadTransaction) -> Bool {
|
||
return kvStore.getBool(Constants.usernameKey, defaultValue: false, transaction: tx)
|
||
}
|
||
|
||
func isUsernameLinkCorrupted(tx: DBReadTransaction) -> Bool {
|
||
return kvStore.getBool(Constants.usernameLinkKey, defaultValue: false, transaction: tx)
|
||
}
|
||
|
||
func setUsernameCorrupted(_ value: Bool, tx: DBWriteTransaction) {
|
||
kvStore.setBool(value, key: Constants.usernameKey, transaction: tx)
|
||
}
|
||
|
||
func setUsernameLinkCorrupted(_ value: Bool, tx: DBWriteTransaction) {
|
||
kvStore.setBool(value, key: Constants.usernameLinkKey, transaction: tx)
|
||
}
|
||
}
|
||
|
||
private struct UsernameStore {
|
||
private enum Constants {
|
||
static let collection = "LocalUsername"
|
||
static let usernameKey = "username"
|
||
static let usernameLinkHandleKey = "linkHandle"
|
||
static let usernameLinkEntropyKey = "linkEntropy"
|
||
static let usernameLinkQRCodeColorKey = "linkColor"
|
||
}
|
||
|
||
private let kvStore: KeyValueStore
|
||
|
||
init() {
|
||
kvStore = KeyValueStore(collection: Constants.collection)
|
||
}
|
||
|
||
func username(tx: DBReadTransaction) -> String? {
|
||
return kvStore.getString(Constants.usernameKey, transaction: tx)
|
||
}
|
||
|
||
func usernameLink(tx: DBReadTransaction) -> Usernames.UsernameLink? {
|
||
if
|
||
let linkHandleData = kvStore.getData(Constants.usernameLinkHandleKey, transaction: tx),
|
||
let linkHandle = UUID(data: linkHandleData),
|
||
let linkEntropy = kvStore.getData(Constants.usernameLinkEntropyKey, transaction: tx),
|
||
let link = Usernames.UsernameLink(handle: linkHandle, entropy: linkEntropy)
|
||
{
|
||
return link
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func usernameLinkColor(tx: DBReadTransaction) -> QRCodeColor {
|
||
return (try? kvStore.getCodableValue(
|
||
forKey: Constants.usernameLinkQRCodeColorKey,
|
||
transaction: tx
|
||
)) ?? .unknown
|
||
}
|
||
|
||
func setUsername(username: String?, tx: DBWriteTransaction) {
|
||
kvStore.setString(username, key: Constants.usernameKey, transaction: tx)
|
||
}
|
||
|
||
func setUsernameLink(usernameLink: Usernames.UsernameLink?, tx: DBWriteTransaction) {
|
||
kvStore.setData(usernameLink?.handle.data, key: Constants.usernameLinkHandleKey, transaction: tx)
|
||
kvStore.setData(usernameLink?.entropy, key: Constants.usernameLinkEntropyKey, transaction: tx)
|
||
}
|
||
|
||
func setUsernameLinkColor(color: QRCodeColor, tx: DBWriteTransaction) {
|
||
try? kvStore.setCodable(color, key: Constants.usernameLinkQRCodeColorKey, transaction: tx)
|
||
}
|
||
}
|
||
|
||
/// Thrown when ``SSKReachability`` indicates we do not have network access,
|
||
/// and that consequently we will not succeed in a usernames-related
|
||
/// network request.
|
||
///
|
||
/// Because we mark the username/link as corrupted while mutation requests
|
||
/// are in-flight it's preferable to bail out early if we believe the
|
||
/// request is doomed to fail, rather than unnecessarily leaving the
|
||
/// username/link corrupted when the request fails.
|
||
private struct NoReachabilityError: Error {}
|
||
|
||
private let db: any DB
|
||
private let reachabilityManager: SSKReachabilityManager
|
||
private let schedulers: Schedulers
|
||
private let storageServiceManager: StorageServiceManager
|
||
private let usernameApiClient: UsernameApiClient
|
||
private let usernameLinkManager: UsernameLinkManager
|
||
|
||
private let corruptionStore: CorruptionStore
|
||
private let usernameStore: UsernameStore
|
||
|
||
private let maxNetworkRequestRetries: Int
|
||
|
||
private var logger: PrefixedLogger { UsernameLogger.shared }
|
||
|
||
init(
|
||
db: any DB,
|
||
reachabilityManager: SSKReachabilityManager,
|
||
schedulers: Schedulers,
|
||
storageServiceManager: StorageServiceManager,
|
||
usernameApiClient: UsernameApiClient,
|
||
usernameLinkManager: UsernameLinkManager,
|
||
maxNetworkRequestRetries: Int = 2
|
||
) {
|
||
self.db = db
|
||
self.reachabilityManager = reachabilityManager
|
||
self.schedulers = schedulers
|
||
self.storageServiceManager = storageServiceManager
|
||
self.usernameApiClient = usernameApiClient
|
||
self.usernameLinkManager = usernameLinkManager
|
||
|
||
corruptionStore = CorruptionStore()
|
||
usernameStore = UsernameStore()
|
||
|
||
self.maxNetworkRequestRetries = maxNetworkRequestRetries
|
||
}
|
||
|
||
// MARK: - Local state
|
||
|
||
func usernameState(
|
||
tx: DBReadTransaction
|
||
) -> Usernames.LocalUsernameState {
|
||
if corruptionStore.isUsernameCorrupted(tx: tx) {
|
||
return .usernameAndLinkCorrupted
|
||
} else if let username = usernameStore.username(tx: tx) {
|
||
if
|
||
!corruptionStore.isUsernameLinkCorrupted(tx: tx),
|
||
let usernameLink = usernameStore.usernameLink(tx: tx)
|
||
{
|
||
return .available(username: username, usernameLink: usernameLink)
|
||
}
|
||
|
||
return .linkCorrupted(username: username)
|
||
}
|
||
|
||
return .unset
|
||
}
|
||
|
||
func setLocalUsername(
|
||
username: String,
|
||
usernameLink: Usernames.UsernameLink,
|
||
tx: DBWriteTransaction
|
||
) {
|
||
corruptionStore.setUsernameCorrupted(false, tx: tx)
|
||
usernameStore.setUsername(username: username, tx: tx)
|
||
|
||
corruptionStore.setUsernameLinkCorrupted(false, tx: tx)
|
||
usernameStore.setUsernameLink(usernameLink: usernameLink, tx: tx)
|
||
|
||
postLocalUsernameStateChangedNotification(tx: tx)
|
||
}
|
||
|
||
func setLocalUsernameWithCorruptedLink(
|
||
username: String,
|
||
tx: DBWriteTransaction
|
||
) {
|
||
corruptionStore.setUsernameCorrupted(false, tx: tx)
|
||
usernameStore.setUsername(username: username, tx: tx)
|
||
|
||
corruptionStore.setUsernameLinkCorrupted(true, tx: tx)
|
||
|
||
postLocalUsernameStateChangedNotification(tx: tx)
|
||
}
|
||
|
||
func setLocalUsernameCorrupted(tx: DBWriteTransaction) {
|
||
markUsernameCorrupted(true, tx: tx)
|
||
}
|
||
|
||
func clearLocalUsername(tx: DBWriteTransaction) {
|
||
corruptionStore.setUsernameCorrupted(false, tx: tx)
|
||
corruptionStore.setUsernameLinkCorrupted(false, tx: tx)
|
||
|
||
usernameStore.setUsername(username: nil, tx: tx)
|
||
usernameStore.setUsernameLink(usernameLink: nil, tx: tx)
|
||
|
||
postLocalUsernameStateChangedNotification(tx: tx)
|
||
}
|
||
|
||
func usernameLinkQRCodeColor(
|
||
tx: DBReadTransaction
|
||
) -> QRCodeColor {
|
||
return usernameStore.usernameLinkColor(tx: tx)
|
||
}
|
||
|
||
func setUsernameLinkQRCodeColor(
|
||
color: QRCodeColor,
|
||
tx: DBWriteTransaction
|
||
) {
|
||
usernameStore.setUsernameLinkColor(color: color, tx: tx)
|
||
}
|
||
|
||
private func markUsernameCorrupted(_ value: Bool, tx: DBWriteTransaction) {
|
||
corruptionStore.setUsernameCorrupted(value, tx: tx)
|
||
corruptionStore.setUsernameLinkCorrupted(value, tx: tx)
|
||
|
||
postLocalUsernameStateChangedNotification(tx: tx)
|
||
}
|
||
|
||
private func markUsernameLinkCorrupted(_ value: Bool, tx: DBWriteTransaction) {
|
||
corruptionStore.setUsernameLinkCorrupted(value, tx: tx)
|
||
|
||
postLocalUsernameStateChangedNotification(tx: tx)
|
||
}
|
||
|
||
private func postLocalUsernameStateChangedNotification(tx: DBWriteTransaction) {
|
||
tx.addAsyncCompletion(on: schedulers.main) {
|
||
NotificationCenter.default.postNotificationNameAsync(
|
||
Usernames.localUsernameStateChangedNotification,
|
||
object: nil
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: Usernames and the service
|
||
|
||
func reserveUsername(
|
||
usernameCandidates: Usernames.HashedUsername.GeneratedCandidates
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Usernames.ReservationResult>> {
|
||
guard reachabilityManager.isReachable else {
|
||
logger.warn("Not attempting to reserve username – Reachability indicates we will fail.")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
return self.makeRequestWithNetworkRetries(requestBlock: {
|
||
return self.usernameApiClient.reserveUsernameCandidates(
|
||
usernameCandidates: usernameCandidates
|
||
)
|
||
}).map(on: schedulers.sync) { reservationResult -> Usernames.RemoteMutationResult<Usernames.ReservationResult> in
|
||
return .success(reservationResult)
|
||
}.recover(on: schedulers.sync) { error -> Guarantee<Usernames.RemoteMutationResult<Usernames.ReservationResult>> in
|
||
if error.isNetworkFailureOrTimeout {
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
return .value(.failure(.otherError))
|
||
}
|
||
}
|
||
|
||
/// Confirm the given reserved username, setting it as our username.
|
||
func confirmUsername(
|
||
reservedUsername: Usernames.HashedUsername,
|
||
tx syncTx: DBWriteTransaction
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Usernames.ConfirmationResult>> {
|
||
guard reachabilityManager.isReachable else {
|
||
logger.warn("Not attempting to confirm username – Reachability indicates we will fail.")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
let linkEntropy: Data
|
||
let linkEncryptedUsername: Data
|
||
do {
|
||
(
|
||
linkEntropy,
|
||
linkEncryptedUsername
|
||
) = try self.usernameLinkManager.generateEncryptedUsername(
|
||
username: reservedUsername.usernameString,
|
||
existingEntropy: nil
|
||
)
|
||
} catch let error {
|
||
UsernameLogger.shared.error("Failed to generate encrypted username! \(error)")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
|
||
// Mark as corrupted in case we encounter an unexpected error while
|
||
// confirming. If that happens we can't be sure if our new username was
|
||
// set or not, so we conservatively leave it in the corrupted state.
|
||
// If, however, we get a response we understand (affirmative or
|
||
// negative), we remove the corrupted flag.
|
||
markUsernameCorrupted(true, tx: syncTx)
|
||
|
||
return firstly(on: schedulers.global()) { () throws -> Promise<Usernames.ApiClientConfirmationResult> in
|
||
return self.makeRequestWithNetworkRetries(requestBlock: {
|
||
return self.usernameApiClient.confirmReservedUsername(
|
||
reservedUsername: reservedUsername,
|
||
encryptedUsernameForLink: linkEncryptedUsername,
|
||
chatServiceAuth: .implicit()
|
||
)
|
||
})
|
||
}.map(on: schedulers.global()) { apiClientConfirmationResult -> Usernames.RemoteMutationResult<Usernames.ConfirmationResult> in
|
||
let confirmationResult = self.db.write { tx -> Usernames.ConfirmationResult in
|
||
switch apiClientConfirmationResult {
|
||
case let .success(linkHandle):
|
||
guard let usernameLink = Usernames.UsernameLink(
|
||
handle: linkHandle,
|
||
entropy: linkEntropy
|
||
) else {
|
||
owsFail("This link should always be valid - we just generated the entropy ourselves!")
|
||
}
|
||
|
||
let username = reservedUsername.usernameString
|
||
|
||
self.setLocalUsername(
|
||
username: username,
|
||
usernameLink: usernameLink,
|
||
tx: tx
|
||
)
|
||
|
||
// We back up the username and link in StorageService, so
|
||
// trigger a backup now.
|
||
self.storageServiceManager.recordPendingLocalAccountUpdates()
|
||
|
||
return .success(
|
||
username: username,
|
||
usernameLink: usernameLink
|
||
)
|
||
case .rejected:
|
||
self.markUsernameCorrupted(false, tx: tx)
|
||
return .rejected
|
||
case .rateLimited:
|
||
self.markUsernameCorrupted(false, tx: tx)
|
||
return .rateLimited
|
||
}
|
||
}
|
||
|
||
return .success(confirmationResult)
|
||
}.recover(on: schedulers.global()) { error -> Guarantee<Usernames.RemoteMutationResult<Usernames.ConfirmationResult>> in
|
||
if error.isNetworkFailureOrTimeout {
|
||
UsernameLogger.shared.error("Network error while confirming username. Username now assumed corrupted!")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
UsernameLogger.shared.error("Unknown error while confirming username. Username now assumed corrupted!")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
}
|
||
|
||
func deleteUsername(tx syncTx: DBWriteTransaction) -> Guarantee<Usernames.RemoteMutationResult<Void>> {
|
||
guard reachabilityManager.isReachable else {
|
||
logger.warn("Not attempting to delete username – Reachability indicates we will fail.")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
// Mark as corrupted in case we encounter an unexpected error while
|
||
// deleting. If that happens we can't be sure if our new username was
|
||
// deleted or not, so we conservatively leave it in the corrupted state.
|
||
// If, however, we get a response, we remove the corrupted flag.
|
||
markUsernameCorrupted(true, tx: syncTx)
|
||
|
||
return firstly(on: schedulers.global()) { () -> Promise<Void> in
|
||
return self.makeRequestWithNetworkRetries(requestBlock: {
|
||
return self.usernameApiClient.deleteCurrentUsername()
|
||
})
|
||
}.map(on: schedulers.global()) { () -> Usernames.RemoteMutationResult<Void> in
|
||
self.db.write { tx in
|
||
self.clearLocalUsername(tx: tx)
|
||
}
|
||
|
||
// We back up the username and link in StorageService, so
|
||
// trigger a backup now.
|
||
self.storageServiceManager.recordPendingLocalAccountUpdates()
|
||
|
||
return .success(())
|
||
}.recover(on: schedulers.sync) { error -> Guarantee<Usernames.RemoteMutationResult<Void>> in
|
||
if error.isNetworkFailureOrTimeout {
|
||
UsernameLogger.shared.error("Network error while deleting username. Username now assumed corrupted!")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
UsernameLogger.shared.error("Unknown error while deleting username. Username now assumed corrupted!")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
}
|
||
|
||
// MARK: Username links and the service
|
||
|
||
func rotateUsernameLink(
|
||
tx syncTx: DBWriteTransaction
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Usernames.UsernameLink>> {
|
||
guard reachabilityManager.isReachable else {
|
||
logger.warn("Not attempting to rotate username link – Reachability indicates we will fail.")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
guard let currentUsername = usernameState(tx: syncTx).username else {
|
||
owsFailDebug("Tried to rotate link, but missing current username!")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
|
||
let newEntropy: Data
|
||
let newEncryptedUsername: Data
|
||
do {
|
||
(
|
||
newEntropy,
|
||
newEncryptedUsername
|
||
) = try self.usernameLinkManager.generateEncryptedUsername(
|
||
username: currentUsername,
|
||
existingEntropy: nil
|
||
)
|
||
} catch let error {
|
||
UsernameLogger.shared.error("Failed to generate encrypted username! \(error)")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
|
||
// Mark as corrupted in case we encounter an unexpected error while
|
||
// rotating. If that happens we can't be sure if our username link was
|
||
// rotated or not, so we conservatively leave it in the corrupted state.
|
||
// If, however, we get a response, we remove the corrupted flag.
|
||
markUsernameLinkCorrupted(true, tx: syncTx)
|
||
|
||
return firstly(on: schedulers.global()) { () -> Promise<UUID> in
|
||
return self.makeRequestWithNetworkRetries(requestBlock: {
|
||
return self.usernameApiClient.setUsernameLink(
|
||
encryptedUsername: newEncryptedUsername,
|
||
keepLinkHandle: false
|
||
)
|
||
})
|
||
}.map(on: schedulers.global()) { newHandle -> Usernames.RemoteMutationResult<Usernames.UsernameLink> in
|
||
guard let newUsernameLink = Usernames.UsernameLink(
|
||
handle: newHandle,
|
||
entropy: newEntropy
|
||
) else {
|
||
owsFail("This link should always be valid - we just generated the entropy ourselves!")
|
||
}
|
||
|
||
self.db.write { tx in
|
||
self.setLocalUsername(
|
||
username: currentUsername,
|
||
usernameLink: newUsernameLink,
|
||
tx: tx
|
||
)
|
||
}
|
||
|
||
// We back up the username and link in StorageService, so
|
||
// trigger a backup now.
|
||
self.storageServiceManager.recordPendingLocalAccountUpdates()
|
||
|
||
return .success(newUsernameLink)
|
||
}.recover(on: schedulers.sync) { error -> Guarantee<Usernames.RemoteMutationResult<Usernames.UsernameLink>> in
|
||
if error.isNetworkFailureOrTimeout {
|
||
UsernameLogger.shared.error("Network error while rotating username link. Username link now assumed corrupted!")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
UsernameLogger.shared.error("Error while rotating username link. Username link now assumed corrupted!")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
}
|
||
|
||
func updateVisibleCaseOfExistingUsername(
|
||
newUsername: String,
|
||
tx syncTx: DBWriteTransaction
|
||
) -> Guarantee<Usernames.RemoteMutationResult<Void>> {
|
||
guard reachabilityManager.isReachable else {
|
||
logger.warn("Not attempting to update visible username case – Reachability indicates we will fail.")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
let currentUsernameState = usernameState(tx: syncTx)
|
||
|
||
guard
|
||
let currentUsernameLink = currentUsernameState.usernameLink,
|
||
let currentUsername = currentUsernameState.username,
|
||
newUsername.lowercased() == currentUsername.lowercased()
|
||
else {
|
||
owsFailDebug("Attempting to change username case, but new nickname does not match existing username!")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
|
||
let newEncryptedUsername: Data
|
||
do {
|
||
(_, newEncryptedUsername) = try usernameLinkManager.generateEncryptedUsername(
|
||
username: newUsername,
|
||
existingEntropy: currentUsernameLink.entropy
|
||
)
|
||
} catch let error {
|
||
UsernameLogger.shared.error("Failed to generate encrypted username! \(error)")
|
||
return .value(.failure(.otherError))
|
||
}
|
||
|
||
// Mark as corrupted in case we encounter an unexpected error while
|
||
// setting the new encrypted username. If that happens we can't be sure
|
||
// if our encrypted username was updated or not, so we conservatively
|
||
// leave it in the corrupted state. If, however, we get a response, we
|
||
// remove the corrupted flag.
|
||
markUsernameLinkCorrupted(true, tx: syncTx)
|
||
|
||
return firstly(on: schedulers.global()) { () -> Promise<UUID> in
|
||
return self.makeRequestWithNetworkRetries(requestBlock: {
|
||
/// Pass `keepLinkHandle = true` here, to ask the service not to
|
||
/// rotate the username link handle. That's key to keeping the
|
||
/// existing link unaffected while updating the case of the
|
||
/// visible username the link points to.
|
||
return self.usernameApiClient.setUsernameLink(
|
||
encryptedUsername: newEncryptedUsername,
|
||
keepLinkHandle: true
|
||
)
|
||
})
|
||
}.map(on: schedulers.global()) { newHandle throws -> Void in
|
||
guard currentUsernameLink.handle == newHandle else {
|
||
UsernameLogger.shared.error("Handle received while changing username case did not match existing! Is this a server bug?")
|
||
throw OWSGenericError("")
|
||
}
|
||
|
||
self.db.write { tx in
|
||
self.setLocalUsername(
|
||
username: newUsername,
|
||
usernameLink: currentUsernameLink,
|
||
tx: tx
|
||
)
|
||
}
|
||
}.map(on: schedulers.sync) { () -> Usernames.RemoteMutationResult<Void> in
|
||
return .success(())
|
||
}.recover(on: schedulers.global()) { error -> Guarantee<Usernames.RemoteMutationResult<Void>> in
|
||
// Even though we failed to update the link, we can save the new
|
||
// nickname locally. If the user rotates their link to fix the
|
||
// issue, the new link will reflect the updated nickname.
|
||
self.db.write { tx in
|
||
self.setLocalUsernameWithCorruptedLink(
|
||
username: newUsername,
|
||
tx: tx
|
||
)
|
||
}
|
||
|
||
if error.isNetworkFailureOrTimeout {
|
||
UsernameLogger.shared.error("Network error while updating username link for nickname case change. Username updated locally, but link now assumed corrupted!")
|
||
return .value(.failure(.networkError))
|
||
}
|
||
|
||
UsernameLogger.shared.error("Unknown error while updating username link for nickname case change. Username updated locally, but link now assumed corrupted!")
|
||
return .value(.failure(.otherError))
|
||
}.map(on: schedulers.global()) { (remoteMutationResult) -> Usernames.RemoteMutationResult<Void> in
|
||
// We back up the username and link in StorageService, and in all
|
||
// codepaths we've updated the username, so trigger a backup now.
|
||
self.storageServiceManager.recordPendingLocalAccountUpdates()
|
||
|
||
return remoteMutationResult
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Network retries
|
||
|
||
private extension LocalUsernameManagerImpl {
|
||
/// Make the request in the given block, with retries.
|
||
///
|
||
/// Because a failed username mutation request leaves us in a corrupted
|
||
/// state, add retries for network errors to avoid unnecessary corruption
|
||
/// where possible.
|
||
func makeRequestWithNetworkRetries<T>(
|
||
requestBlock: @escaping () -> Promise<T>,
|
||
retriesRemaining: Int? = nil
|
||
) -> Promise<T> {
|
||
firstly(on: schedulers.sync) { () -> Promise<T> in
|
||
return requestBlock()
|
||
}
|
||
.recover(on: schedulers.global()) { error throws -> Promise<T> in
|
||
let retriesRemaining = retriesRemaining ?? self.maxNetworkRequestRetries
|
||
|
||
guard error.isNetworkFailureOrTimeout else {
|
||
UsernameLogger.shared.error("Non-network error during username request!")
|
||
throw error
|
||
}
|
||
|
||
guard retriesRemaining > 0 else {
|
||
UsernameLogger.shared.error("Exhausted retries during username request!")
|
||
throw error
|
||
}
|
||
|
||
return self.makeRequestWithNetworkRetries(
|
||
requestBlock: requestBlock,
|
||
retriesRemaining: retriesRemaining - 1
|
||
)
|
||
}
|
||
}
|
||
}
|