Added additional developer settings and fixed some bugs

• Added a setting to show the string keys instead of localised values
• Added settings to test the updated groups delete all messages settings
• Updated the onboarding UI to indicate whether it's currently pointing at Testnet
• Tweaked the 'isRTL' dependency setting to be clearer that it's not an injected value
• Fixed a bug with dependency name collision between the different storage types
• Fixed a bug with attachment file path creation
pull/894/head
Morgan Pretty 7 months ago
parent a5c565cacb
commit 42721db399

@ -62,7 +62,7 @@ final class VoiceMessageRecordingView: UIView {
private lazy var chevronImageView: UIImageView = { private lazy var chevronImageView: UIImageView = {
let result: UIImageView = UIImageView( let result: UIImageView = UIImageView(
image: (Dependencies.isRTL ? image: (Dependencies.unsafeNonInjected.isRTL ?
UIImage(named: "small_chevron_left")?.withHorizontallyFlippedOrientation() : UIImage(named: "small_chevron_left")?.withHorizontallyFlippedOrientation() :
UIImage(named: "small_chevron_left") UIImage(named: "small_chevron_left")
)? )?
@ -276,9 +276,9 @@ final class VoiceMessageRecordingView: UIView {
// MARK: - Interaction // MARK: - Interaction
func handleLongPressMoved(to location: CGPoint) { func handleLongPressMoved(to location: CGPoint) {
if ((!Dependencies.isRTL && location.x < bounds.center.x) || (Dependencies.isRTL && location.x > bounds.center.x)) { if ((!Dependencies.unsafeNonInjected.isRTL && location.x < bounds.center.x) || (Dependencies.unsafeNonInjected.isRTL && location.x > bounds.center.x)) {
let translationX = location.x - bounds.center.x let translationX = location.x - bounds.center.x
let sign: CGFloat = (Dependencies.isRTL ? 1 : -1) let sign: CGFloat = (Dependencies.unsafeNonInjected.isRTL ? 1 : -1)
let chevronDamping: CGFloat = 4 let chevronDamping: CGFloat = 4
let labelDamping: CGFloat = 3 let labelDamping: CGFloat = 3
let chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign let chevronX = (chevronDamping * (sqrt(abs(translationX)) / sqrt(chevronDamping))) * sign

@ -364,7 +364,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
// Flip horizontally for RTL languages // Flip horizontally for RTL languages
replyIconImageView.transform = CGAffineTransform.identity replyIconImageView.transform = CGAffineTransform.identity
.scaledBy( .scaledBy(
x: (Dependencies.isRTL ? -1 : 1), x: (Dependencies.unsafeNonInjected.isRTL ? -1 : 1),
y: 1 y: 1
) )
@ -802,7 +802,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
let v = panGestureRecognizer.velocity(in: self) let v = panGestureRecognizer.velocity(in: self)
// Only allow swipes to the left; allowing swipes to the right gets in the way of // Only allow swipes to the left; allowing swipes to the right gets in the way of
// the default iOS swipe to go back gesture // the default iOS swipe to go back gesture
guard (Dependencies.isRTL && v.x > 0) || (!Dependencies.isRTL && v.x < 0) else { return false } guard
(Dependencies.unsafeNonInjected.isRTL && v.x > 0) ||
(!Dependencies.unsafeNonInjected.isRTL && v.x < 0)
else { return false }
return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical return abs(v.x) > abs(v.y) // It has to be more horizontal than vertical
} }
@ -938,8 +941,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
.translation(in: self) .translation(in: self)
.x .x
.clamp( .clamp(
(Dependencies.isRTL ? 0 : -CGFloat.greatestFiniteMagnitude), (Dependencies.unsafeNonInjected.isRTL ? 0 : -CGFloat.greatestFiniteMagnitude),
(Dependencies.isRTL ? CGFloat.greatestFiniteMagnitude : 0) (Dependencies.unsafeNonInjected.isRTL ? CGFloat.greatestFiniteMagnitude : 0)
) )
switch gestureRecognizer.state { switch gestureRecognizer.state {
@ -948,7 +951,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
case .changed: case .changed:
// The idea here is to asymptotically approach a maximum drag distance // The idea here is to asymptotically approach a maximum drag distance
let damping: CGFloat = 20 let damping: CGFloat = 20
let sign: CGFloat = (Dependencies.isRTL ? 1 : -1) let sign: CGFloat = (Dependencies.unsafeNonInjected.isRTL ? 1 : -1)
let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign
viewsToMoveForReply.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } viewsToMoveForReply.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) }
@ -1203,7 +1206,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
// we only highlight those cases) // we only highlight those cases)
normalizedBody normalizedBody
.ranges( .ranges(
of: (Dependencies.isRTL ? of: (Dependencies.unsafeNonInjected.isRTL ?
"(\(part.lowercased()))(^|[^a-zA-Z0-9])" : "(\(part.lowercased()))(^|[^a-zA-Z0-9])" :
"(^|[^a-zA-Z0-9])(\(part.lowercased()))" "(^|[^a-zA-Z0-9])(\(part.lowercased()))"
), ),

@ -60,6 +60,15 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
public enum Section: SessionTableSection { public enum Section: SessionTableSection {
case conversationInfo case conversationInfo
case content case content
case adminActions
case destructiveActions
public var style: SessionTableSectionStyle {
switch self {
case .destructiveActions: return .padding
default: return .none
}
}
} }
public enum TableItem: Differentiable { public enum TableItem: Differentiable {
@ -81,6 +90,9 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
case notificationMentionsOnly case notificationMentionsOnly
case notificationMute case notificationMute
case blockUser case blockUser
case debugDeleteBeforeNow
case debugDeleteAttachmentsBeforeNow
} }
// MARK: - Content // MARK: - Content
@ -693,10 +705,71 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
) )
].compactMap { $0 } ].compactMap { $0 }
) )
let adminActionsSection: SectionModel? = nil
let destructiveActionsSection: SectionModel?
if dependencies[feature: .updatedGroupsDeleteBeforeNow] || dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] {
destructiveActionsSection = SectionModel(
model: .destructiveActions,
elements: [
// FIXME: [GROUPS REBUILD] Need to build this properly in a future release
(!dependencies[feature: .updatedGroupsDeleteBeforeNow] || threadViewModel.threadVariant != .group ? nil :
SessionCell.Info(
id: .debugDeleteBeforeNow,
leadingAccessory: .icon(
UIImage(named: "ic_bin")?
.withRenderingMode(.alwaysTemplate),
customTint: .danger
),
title: "[DEBUG] Delete all messages before now", // stringlint:disable
styling: SessionCell.StyleInfo(
tintColor: .danger
),
confirmationInfo: ConfirmationModal.Info(
title: "delete".localized(),
body: .text("Are you sure you want to delete all messages sent before now for all group members?"), // stringlint:disable
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self] in self?.deleteAllMessagesBeforeNow() }
)
),
// FIXME: [GROUPS REBUILD] Need to build this properly in a future release
(!dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow] || threadViewModel.threadVariant != .group ? nil :
SessionCell.Info(
id: .debugDeleteAttachmentsBeforeNow,
leadingAccessory: .icon(
UIImage(named: "ic_bin")?
.withRenderingMode(.alwaysTemplate),
customTint: .danger
),
title: "[DEBUG] Delete all arrachments before now", // stringlint:disable
styling: SessionCell.StyleInfo(
tintColor: .danger
),
confirmationInfo: ConfirmationModal.Info(
title: "delete".localized(),
body: .text("Are you sure you want to delete all attachments (and their associated messages) sent before now for all group members?"), // stringlint:disable
confirmTitle: "delete".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text
),
onTap: { [weak self] in self?.deleteAllAttachmentsBeforeNow() }
)
)
].compactMap { $0 }
)
}
else {
destructiveActionsSection = nil
}
return [ return [
conversationInfoSection, conversationInfoSection,
standardActionsSection standardActionsSection,
adminActionsSection,
destructiveActionsSection
].compactMap { $0 } ].compactMap { $0 }
} }
@ -1341,4 +1414,30 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob
) )
} }
} }
private func deleteAllMessagesBeforeNow() {
guard threadVariant == .group else { return }
dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in
try LibSession.deleteMessagesBefore(
db,
groupSessionId: SessionId(.group, hex: threadId),
timestamp: dependencies.dateNow.timeIntervalSince1970,
using: dependencies
)
}
}
private func deleteAllAttachmentsBeforeNow() {
guard threadVariant == .group else { return }
dependencies[singleton: .storage].writeAsync { [threadId, dependencies] db in
try LibSession.deleteAttachmentsBefore(
db,
groupSessionId: SessionId(.group, hex: threadId),
timestamp: dependencies.dateNow.timeIntervalSince1970,
using: dependencies
)
}
}
} }

@ -579,10 +579,13 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi
threadVariant: .contact, threadVariant: .contact,
displayPictureFilename: nil, displayPictureFilename: nil,
profile: userProfile, profile: userProfile,
profileIcon: (viewModel.dependencies[feature: .serviceNetwork] == .testnet ? profileIcon: {
.letter("T") : // stringlint:disable switch (viewModel.dependencies[feature: .serviceNetwork], viewModel.dependencies[feature: .forceOffline]) {
.none case (.testnet, false): return .letter("T", false) // stringlint:disable
), case (.testnet, true): return .letter("T", true) // stringlint:disable
default: return .none
}
}(),
additionalProfile: nil, additionalProfile: nil,
using: viewModel.dependencies using: viewModel.dependencies
) )
@ -612,10 +615,13 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi
threadVariant: .contact, threadVariant: .contact,
displayPictureFilename: nil, displayPictureFilename: nil,
profile: userProfile, profile: userProfile,
profileIcon: (value == .testnet ? profileIcon: {
.letter("T") : // stringlint:disable switch (dependencies[feature: .serviceNetwork], dependencies[feature: .forceOffline]) {
.none case (.testnet, false): return .letter("T", false) // stringlint:disable
), case (.testnet, true): return .letter("T", true) // stringlint:disable
default: return .none
}
}(),
additionalProfile: nil, additionalProfile: nil,
using: dependencies using: dependencies
) )

@ -647,7 +647,7 @@ private class DoneButton: UIView {
private lazy var chevron: UIView = { private lazy var chevron: UIView = {
let image: UIImage = { let image: UIImage = {
guard Dependencies.isRTL else { return #imageLiteral(resourceName: "small_chevron_right") } guard Dependencies.unsafeNonInjected.isRTL else { return #imageLiteral(resourceName: "small_chevron_right") }
return #imageLiteral(resourceName: "small_chevron_left") return #imageLiteral(resourceName: "small_chevron_left")
}() }()

@ -642,7 +642,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self, dependencies] rootViewController in let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self, dependencies] rootViewController in
/// `MainAppContext.determineDeviceRTL` uses UIKit to retrime `isRTL` so must be run on the main thread to prevent /// `MainAppContext.determineDeviceRTL` uses UIKit to retrime `isRTL` so must be run on the main thread to prevent
/// lag/crashes on background threads /// lag/crashes on background threads
Dependencies.setIsRTLRetriever(requiresMainThread: true) { MainAppContext.determineDeviceRTL() } dependencies.setIsRTLRetriever(requiresMainThread: true) { MainAppContext.determineDeviceRTL() }
/// Setup the `TopBannerController` /// Setup the `TopBannerController`
let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController
@ -706,7 +706,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
let viewController = SessionHostingViewController(rootView: LandingScreen(using: dependencies) { [weak self] in let viewController = SessionHostingViewController(rootView: LandingScreen(using: dependencies) { [weak self] in
self?.handleActivation() self?.handleActivation()
}) })
viewController.setUpNavBarSessionIcon() viewController.setUpNavBarSessionIcon(using: dependencies)
longRunningStartupTimoutCancellable.cancel() longRunningStartupTimoutCancellable.cancel()
rootViewControllerSetupComplete(viewController) rootViewControllerSetupComplete(viewController)
} }
@ -714,7 +714,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
case .missingName: case .missingName:
DispatchQueue.main.async { [dependencies] in DispatchQueue.main.async { [dependencies] in
let viewController = SessionHostingViewController(rootView: DisplayNameScreen(using: dependencies)) let viewController = SessionHostingViewController(rootView: DisplayNameScreen(using: dependencies))
viewController.setUpNavBarSessionIcon() viewController.setUpNavBarSessionIcon(using: dependencies)
longRunningStartupTimoutCancellable.cancel() longRunningStartupTimoutCancellable.cancel()
rootViewControllerSetupComplete(viewController) rootViewControllerSetupComplete(viewController)

@ -142,7 +142,7 @@ struct DisplayNameScreen: View {
let viewController: SessionHostingViewController = SessionHostingViewController( let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: PNModeScreen(using: dependencies) rootView: PNModeScreen(using: dependencies)
) )
viewController.setUpNavBarSessionIcon() viewController.setUpNavBarSessionIcon(using: dependencies)
self.host.controller?.navigationController?.pushViewController(viewController, animated: true) self.host.controller?.navigationController?.pushViewController(viewController, animated: true)
} }
} }

@ -163,7 +163,7 @@ struct LandingScreen: View {
let viewController: SessionHostingViewController = SessionHostingViewController( let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: DisplayNameScreen(using: viewModel.dependencies) rootView: DisplayNameScreen(using: viewModel.dependencies)
) )
viewController.setUpNavBarSessionIcon() viewController.setUpNavBarSessionIcon(using: viewModel.dependencies)
self.host.controller?.navigationController?.pushViewController(viewController, animated: true) self.host.controller?.navigationController?.pushViewController(viewController, animated: true)
} }
} }

@ -75,7 +75,7 @@ struct LoadAccountScreen: View {
let viewController: SessionHostingViewController = SessionHostingViewController( let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: PNModeScreen(using: dependencies) rootView: PNModeScreen(using: dependencies)
) )
viewController.setUpNavBarSessionIcon() viewController.setUpNavBarSessionIcon(using: dependencies)
self.host.controller?.navigationController?.pushViewController(viewController, animated: true) self.host.controller?.navigationController?.pushViewController(viewController, animated: true)
} }

@ -127,11 +127,12 @@ struct LoadingScreen: View {
let viewController: SessionHostingViewController = SessionHostingViewController( let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: DisplayNameScreen(using: viewModel.dependencies) rootView: DisplayNameScreen(using: viewModel.dependencies)
) )
viewController.setUpNavBarSessionIcon() viewController.setUpNavBarSessionIcon(using: viewModel.dependencies)
if let navigationController = self.host.controller?.navigationController { if let navigationController = self.host.controller?.navigationController {
let index = navigationController.viewControllers.count - 1 let updatedViewControllers: [UIViewController] = navigationController.viewControllers
navigationController.pushViewController(viewController, animated: true) .filter { !$0.isKind(of: SessionHostingViewController<LoadingScreen>.self) }
navigationController.viewControllers.remove(at: index) .appending(viewController)
navigationController.setViewControllers(updatedViewControllers, animated: true)
} }
return return
} }

@ -145,7 +145,7 @@ struct PNModeScreen: View {
let viewController: SessionHostingViewController = SessionHostingViewController( let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: LoadingScreen(using: dependencies) rootView: LoadingScreen(using: dependencies)
) )
viewController.setUpNavBarSessionIcon() viewController.setUpNavBarSessionIcon(using: dependencies)
self.host.controller?.navigationController?.pushViewController(viewController, animated: true) self.host.controller?.navigationController?.pushViewController(viewController, animated: true)
} }

@ -108,7 +108,7 @@ final class AppearanceViewController: BaseVC {
trailing: Values.largeSpacing trailing: Values.largeSpacing
) )
if Dependencies.isRTL { if Dependencies.unsafeNonInjected.isRTL {
result.transform = CGAffineTransform.identity.scaledBy(x: -1, y: 1) result.transform = CGAffineTransform.identity.scaledBy(x: -1, y: 1)
} }

@ -30,6 +30,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
public enum Section: SessionTableSection { public enum Section: SessionTableSection {
case developerMode case developerMode
case general
case logging case logging
case network case network
case disappearingMessages case disappearingMessages
@ -39,6 +40,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
var title: String? { var title: String? {
switch self { switch self {
case .developerMode: return nil case .developerMode: return nil
case .general: return "General"
case .logging: return "Logging" case .logging: return "Logging"
case .network: return "Network" case .network: return "Network"
case .disappearingMessages: return "Disappearing Messages" case .disappearingMessages: return "Disappearing Messages"
@ -58,11 +60,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
public enum TableItem: Hashable, Differentiable, CaseIterable { public enum TableItem: Hashable, Differentiable, CaseIterable {
case developerMode case developerMode
case showStringKeys
case defaultLogLevel case defaultLogLevel
case advancedLogging case advancedLogging
case loggingCategory(String) case loggingCategory(String)
case serviceNetwork case serviceNetwork
case forceOffline
case resetSnodeCache case resetSnodeCache
case updatedDisappearingMessages case updatedDisappearingMessages
@ -76,6 +81,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
case updatedGroupsAllowDescriptionEditing case updatedGroupsAllowDescriptionEditing
case updatedGroupsAllowPromotions case updatedGroupsAllowPromotions
case updatedGroupsAllowInviteById case updatedGroupsAllowInviteById
case updatedGroupsDeleteBeforeNow
case updatedGroupsDeleteAttachmentsBeforeNow
case exportDatabase case exportDatabase
@ -86,11 +93,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
public var differenceIdentifier: String { public var differenceIdentifier: String {
switch self { switch self {
case .developerMode: return "developerMode" case .developerMode: return "developerMode"
case .showStringKeys: return "showStringKeys"
case .defaultLogLevel: return "defaultLogLevel" case .defaultLogLevel: return "defaultLogLevel"
case .advancedLogging: return "advancedLogging" case .advancedLogging: return "advancedLogging"
case .loggingCategory(let categoryIdentifier): return "loggingCategory-\(categoryIdentifier)" case .loggingCategory(let categoryIdentifier): return "loggingCategory-\(categoryIdentifier)"
case .serviceNetwork: return "serviceNetwork" case .serviceNetwork: return "serviceNetwork"
case .forceOffline: return "forceOffline"
case .resetSnodeCache: return "resetSnodeCache" case .resetSnodeCache: return "resetSnodeCache"
case .updatedDisappearingMessages: return "updatedDisappearingMessages" case .updatedDisappearingMessages: return "updatedDisappearingMessages"
@ -104,6 +114,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
case .updatedGroupsAllowDescriptionEditing: return "updatedGroupsAllowDescriptionEditing" case .updatedGroupsAllowDescriptionEditing: return "updatedGroupsAllowDescriptionEditing"
case .updatedGroupsAllowPromotions: return "updatedGroupsAllowPromotions" case .updatedGroupsAllowPromotions: return "updatedGroupsAllowPromotions"
case .updatedGroupsAllowInviteById: return "updatedGroupsAllowInviteById" case .updatedGroupsAllowInviteById: return "updatedGroupsAllowInviteById"
case .updatedGroupsDeleteBeforeNow: return "updatedGroupsDeleteBeforeNow"
case .updatedGroupsDeleteAttachmentsBeforeNow: return "updatedGroupsDeleteAttachmentsBeforeNow"
case .exportDatabase: return "exportDatabase" case .exportDatabase: return "exportDatabase"
} }
@ -117,11 +129,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
var result: [TableItem] = [] var result: [TableItem] = []
switch TableItem.developerMode { switch TableItem.developerMode {
case .developerMode: result.append(.developerMode); fallthrough case .developerMode: result.append(.developerMode); fallthrough
case .showStringKeys: result.append(.showStringKeys); fallthrough
case .defaultLogLevel: result.append(.defaultLogLevel); fallthrough case .defaultLogLevel: result.append(.defaultLogLevel); fallthrough
case .advancedLogging: result.append(.advancedLogging); fallthrough case .advancedLogging: result.append(.advancedLogging); fallthrough
case .loggingCategory: result.append(.loggingCategory("")); fallthrough case .loggingCategory: result.append(.loggingCategory("")); fallthrough
case .serviceNetwork: result.append(.serviceNetwork); fallthrough case .serviceNetwork: result.append(.serviceNetwork); fallthrough
case .forceOffline: result.append(.forceOffline); fallthrough
case .resetSnodeCache: result.append(.resetSnodeCache); fallthrough case .resetSnodeCache: result.append(.resetSnodeCache); fallthrough
case .updatedDisappearingMessages: result.append(.updatedDisappearingMessages); fallthrough case .updatedDisappearingMessages: result.append(.updatedDisappearingMessages); fallthrough
@ -136,6 +151,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
case .updatedGroupsAllowDescriptionEditing: result.append(.updatedGroupsAllowDescriptionEditing); fallthrough case .updatedGroupsAllowDescriptionEditing: result.append(.updatedGroupsAllowDescriptionEditing); fallthrough
case .updatedGroupsAllowPromotions: result.append(.updatedGroupsAllowPromotions); fallthrough case .updatedGroupsAllowPromotions: result.append(.updatedGroupsAllowPromotions); fallthrough
case .updatedGroupsAllowInviteById: result.append(.updatedGroupsAllowInviteById); fallthrough case .updatedGroupsAllowInviteById: result.append(.updatedGroupsAllowInviteById); fallthrough
case .updatedGroupsDeleteBeforeNow: result.append(.updatedGroupsDeleteBeforeNow); fallthrough
case .updatedGroupsDeleteAttachmentsBeforeNow: result.append(.updatedGroupsDeleteAttachmentsBeforeNow); fallthrough
case .exportDatabase: result.append(.exportDatabase) case .exportDatabase: result.append(.exportDatabase)
} }
@ -149,11 +166,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
private struct State: Equatable { private struct State: Equatable {
let developerMode: Bool let developerMode: Bool
let showStringKeys: Bool
let defaultLogLevel: Log.Level let defaultLogLevel: Log.Level
let advancedLogging: Bool let advancedLogging: Bool
let loggingCategories: [Log.Category: Log.Level] let loggingCategories: [Log.Category: Log.Level]
let serviceNetwork: ServiceNetwork let serviceNetwork: ServiceNetwork
let forceOffline: Bool
let debugDisappearingMessageDurations: Bool let debugDisappearingMessageDurations: Bool
let updatedDisappearingMessages: Bool let updatedDisappearingMessages: Bool
@ -166,6 +186,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
let updatedGroupsAllowDescriptionEditing: Bool let updatedGroupsAllowDescriptionEditing: Bool
let updatedGroupsAllowPromotions: Bool let updatedGroupsAllowPromotions: Bool
let updatedGroupsAllowInviteById: Bool let updatedGroupsAllowInviteById: Bool
let updatedGroupsDeleteBeforeNow: Bool
let updatedGroupsDeleteAttachmentsBeforeNow: Bool
} }
let title: String = "Developer Settings" let title: String = "Developer Settings"
@ -174,12 +196,14 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
.refreshableData(self) { [weak self, dependencies] () -> State in .refreshableData(self) { [weak self, dependencies] () -> State in
State( State(
developerMode: dependencies[singleton: .storage, key: .developerModeEnabled], developerMode: dependencies[singleton: .storage, key: .developerModeEnabled],
showStringKeys: dependencies[feature: .showStringKeys],
defaultLogLevel: dependencies[feature: .logLevel(cat: .default)], defaultLogLevel: dependencies[feature: .logLevel(cat: .default)],
advancedLogging: (self?.showAdvancedLogging == true), advancedLogging: (self?.showAdvancedLogging == true),
loggingCategories: dependencies[feature: .allLogLevels].currentValues(using: dependencies), loggingCategories: dependencies[feature: .allLogLevels].currentValues(using: dependencies),
serviceNetwork: dependencies[feature: .serviceNetwork], serviceNetwork: dependencies[feature: .serviceNetwork],
forceOffline: dependencies[feature: .forceOffline],
debugDisappearingMessageDurations: dependencies[feature: .debugDisappearingMessageDurations], debugDisappearingMessageDurations: dependencies[feature: .debugDisappearingMessageDurations],
updatedDisappearingMessages: dependencies[feature: .updatedDisappearingMessages], updatedDisappearingMessages: dependencies[feature: .updatedDisappearingMessages],
@ -191,7 +215,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
updatedGroupsAllowDisplayPicture: dependencies[feature: .updatedGroupsAllowDisplayPicture], updatedGroupsAllowDisplayPicture: dependencies[feature: .updatedGroupsAllowDisplayPicture],
updatedGroupsAllowDescriptionEditing: dependencies[feature: .updatedGroupsAllowDescriptionEditing], updatedGroupsAllowDescriptionEditing: dependencies[feature: .updatedGroupsAllowDescriptionEditing],
updatedGroupsAllowPromotions: dependencies[feature: .updatedGroupsAllowPromotions], updatedGroupsAllowPromotions: dependencies[feature: .updatedGroupsAllowPromotions],
updatedGroupsAllowInviteById: dependencies[feature: .updatedGroupsAllowInviteById] updatedGroupsAllowInviteById: dependencies[feature: .updatedGroupsAllowInviteById],
updatedGroupsDeleteBeforeNow: dependencies[feature: .updatedGroupsDeleteBeforeNow],
updatedGroupsDeleteAttachmentsBeforeNow: dependencies[feature: .updatedGroupsDeleteAttachmentsBeforeNow]
) )
} }
.compactMapWithPrevious { [weak self] prev, current -> [SectionModel]? in self?.content(prev, current) } .compactMapWithPrevious { [weak self] prev, current -> [SectionModel]? in self?.content(prev, current) }
@ -223,6 +249,32 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
) )
] ]
), ),
SectionModel(
model: .general,
elements: [
SessionCell.Info(
id: .showStringKeys,
title: "Show String Keys",
subtitle: """
Controls whether localised strings should render using their keys rather than the localised value (strings will be rendered as "[{key}]")
Notes:
This change will only apply to newly created screens (eg. the Settings screen will need to be closed and reopened before it gets updated
The "Home" screen won't update as it never gets recreated
""",
trailingAccessory: .toggle(
current.showStringKeys,
oldValue: previous?.showStringKeys
),
onTap: { [weak self] in
self?.updateFlag(
for: .showStringKeys,
to: !current.showStringKeys
)
}
)
]
),
SectionModel( SectionModel(
model: .logging, model: .logging,
elements: [ elements: [
@ -304,7 +356,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
The environment used for sending requests and storing messages. The environment used for sending requests and storing messages.
<b>Warning:</b> <b>Warning:</b>
Changing this setting will result in all conversation and snode data being cleared and any pending network requests being cancelled. Changing between some of these options can result in all conversation and snode data being cleared and any pending network requests being cancelled.
""", """,
trailingAccessory: .dropDown { current.serviceNetwork.title }, trailingAccessory: .dropDown { current.serviceNetwork.title },
onTap: { [weak self, dependencies] in onTap: { [weak self, dependencies] in
@ -323,6 +375,18 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
) )
} }
), ),
SessionCell.Info(
id: .forceOffline,
title: "Force Offline",
subtitle: """
Shut down the current network and cause all future network requests to fail after a 1 second delay with a 'serviceUnavailable' error.
""",
trailingAccessory: .toggle(
current.forceOffline,
oldValue: previous?.forceOffline
),
onTap: { [weak self] in self?.updateForceOffline(current: current.forceOffline) }
),
SessionCell.Info( SessionCell.Info(
id: .resetSnodeCache, id: .resetSnodeCache,
title: "Reset Service Node Cache", title: "Reset Service Node Cache",
@ -513,7 +577,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
id: .updatedGroupsAllowInviteById, id: .updatedGroupsAllowInviteById,
title: "Allow Invite by ID", title: "Allow Invite by ID",
subtitle: """ subtitle: """
Controls whether the UI allows group admins to invlide other group members directly by their Account ID. Controls whether the UI allows group admins to invite other group members directly by their Account ID.
<b>Note:</b> In a future release we will offer this functionality but it's not included in the initial release. <b>Note:</b> In a future release we will offer this functionality but it's not included in the initial release.
""", """,
@ -527,6 +591,44 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
to: !current.updatedGroupsAllowInviteById to: !current.updatedGroupsAllowInviteById
) )
} }
),
SessionCell.Info(
id: .updatedGroupsDeleteBeforeNow,
title: "Show button to delete messages before now",
subtitle: """
Controls whether the UI allows group admins to delete all messages in the group that were sent before the button was pressed.
<b>Note:</b> In a future release we will offer this functionality but it's not included in the initial release.
""",
trailingAccessory: .toggle(
current.updatedGroupsDeleteBeforeNow,
oldValue: previous?.updatedGroupsDeleteBeforeNow
),
onTap: { [weak self] in
self?.updateFlag(
for: .updatedGroupsDeleteBeforeNow,
to: !current.updatedGroupsDeleteBeforeNow
)
}
),
SessionCell.Info(
id: .updatedGroupsDeleteAttachmentsBeforeNow,
title: "Show button to delete attachments before now",
subtitle: """
Controls whether the UI allows group admins to delete all attachments (and their associated messages) in the group that were sent before the button was pressed.
<b>Note:</b> In a future release we will offer this functionality but it's not included in the initial release.
""",
trailingAccessory: .toggle(
current.updatedGroupsDeleteAttachmentsBeforeNow,
oldValue: previous?.updatedGroupsDeleteAttachmentsBeforeNow
),
onTap: { [weak self] in
self?.updateFlag(
for: .updatedGroupsDeleteAttachmentsBeforeNow,
to: !current.updatedGroupsDeleteAttachmentsBeforeNow
)
}
) )
] ]
), ),
@ -559,6 +661,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
TableItem.allCases.forEach { item in TableItem.allCases.forEach { item in
switch item { switch item {
case .developerMode: break // Not a feature case .developerMode: break // Not a feature
case .showStringKeys: updateFlag(for: .showStringKeys, to: nil)
case .resetSnodeCache: break // Not a feature case .resetSnodeCache: break // Not a feature
case .exportDatabase: break // Not a feature case .exportDatabase: break // Not a feature
case .advancedLogging: break // Not a feature case .advancedLogging: break // Not a feature
@ -567,6 +671,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
case .loggingCategory: resetLoggingCategories() case .loggingCategory: resetLoggingCategories()
case .serviceNetwork: updateServiceNetwork(to: nil) case .serviceNetwork: updateServiceNetwork(to: nil)
case .forceOffline: updateFlag(for: .forceOffline, to: nil)
case .debugDisappearingMessageDurations: case .debugDisappearingMessageDurations:
updateFlag(for: .debugDisappearingMessageDurations, to: nil) updateFlag(for: .debugDisappearingMessageDurations, to: nil)
@ -582,6 +687,8 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
updateFlag(for: .updatedGroupsAllowDescriptionEditing, to: nil) updateFlag(for: .updatedGroupsAllowDescriptionEditing, to: nil)
case .updatedGroupsAllowPromotions: updateFlag(for: .updatedGroupsAllowPromotions, to: nil) case .updatedGroupsAllowPromotions: updateFlag(for: .updatedGroupsAllowPromotions, to: nil)
case .updatedGroupsAllowInviteById: updateFlag(for: .updatedGroupsAllowInviteById, to: nil) case .updatedGroupsAllowInviteById: updateFlag(for: .updatedGroupsAllowInviteById, to: nil)
case .updatedGroupsDeleteBeforeNow: updateFlag(for: .updatedGroupsDeleteBeforeNow, to: nil)
case .updatedGroupsDeleteAttachmentsBeforeNow: updateFlag(for: .updatedGroupsDeleteAttachmentsBeforeNow, to: nil)
} }
} }
@ -738,6 +845,17 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
forceRefresh(type: .databaseQuery) forceRefresh(type: .databaseQuery)
} }
private func updateForceOffline(current: Bool) {
updateFlag(for: .forceOffline, to: !current)
// Reset the network cache
dependencies.mutate(cache: .libSessionNetwork) {
$0.setPaths(paths: [])
$0.setNetworkStatus(status: current ? .unknown : .disconnected)
}
dependencies.remove(cache: .libSessionNetwork)
}
private func resetServiceNodeCache() { private func resetServiceNodeCache() {
self.transitionToScreen( self.transitionToScreen(
ConfirmationModal( ConfirmationModal(
@ -894,5 +1012,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder,
// MARK: - Listable Conformance // MARK: - Listable Conformance
extension ServiceNetwork: Listable {} extension ServiceNetwork: @retroactive ContentIdentifiable {}
extension ServiceNetwork: @retroactive ContentEquatable {}
extension ServiceNetwork: @retroactive Listable {}
extension Log.Level: @retroactive ContentIdentifiable {}
extension Log.Level: @retroactive ContentEquatable {}
extension Log.Level: Listable {} extension Log.Level: Listable {}

@ -143,9 +143,13 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
id: state.profile.id, id: state.profile.id,
size: .hero, size: .hero,
profile: state.profile, profile: state.profile,
profileIcon: (dependencies[feature: .serviceNetwork] == .mainnet ? .none : profileIcon: {
.letter("T") // stringlint:disable switch (dependencies[feature: .serviceNetwork], dependencies[feature: .forceOffline]) {
) case (.testnet, false): return .letter("T", false) // stringlint:disable
case (.testnet, true): return .letter("T", true) // stringlint:disable
default: return .none
}
}()
), ),
styling: SessionCell.StyleInfo( styling: SessionCell.StyleInfo(
alignment: .centerHugging, alignment: .centerHugging,

@ -69,14 +69,4 @@ public class BaseVC: UIViewController {
navigationItem.titleView = headingImageView navigationItem.titleView = headingImageView
} }
internal func setUpNavBarSessionIcon() {
let logoImageView = UIImageView()
logoImageView.image = #imageLiteral(resourceName: "SessionGreen32")
logoImageView.contentMode = .scaleAspectFit
logoImageView.set(.width, to: 32)
logoImageView.set(.height, to: 32)
navigationItem.titleView = logoImageView
}
} }

@ -741,7 +741,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC
// with the term so we use the regex below to ensure we only highlight those cases) // with the term so we use the regex below to ensure we only highlight those cases)
normalizedSnippet normalizedSnippet
.ranges( .ranges(
of: (Dependencies.isRTL ? of: (Dependencies.unsafeNonInjected.isRTL ?
"(\(part.lowercased()))(^|[^a-zA-Z0-9])" : // stringlint:disable "(\(part.lowercased()))(^|[^a-zA-Z0-9])" : // stringlint:disable
"(^|[^a-zA-Z0-9])(\(part.lowercased()))" // stringlint:disable "(^|[^a-zA-Z0-9])(\(part.lowercased()))" // stringlint:disable
), ),

@ -111,14 +111,70 @@ public class SessionHostingViewController<Content>: UIHostingController<Modified
navigationItem.titleView = headingImageView navigationItem.titleView = headingImageView
} }
internal func setUpNavBarSessionIcon() { internal func setUpNavBarSessionIcon(using dependencies: Dependencies) {
let logoImageView = UIImageView() let logoImageView = UIImageView()
logoImageView.image = #imageLiteral(resourceName: "SessionGreen32") logoImageView.image = #imageLiteral(resourceName: "SessionGreen32")
logoImageView.contentMode = .scaleAspectFit logoImageView.contentMode = .scaleAspectFit
logoImageView.set(.width, to: 32) logoImageView.set(.width, to: 32)
logoImageView.set(.height, to: 32) logoImageView.set(.height, to: 32)
navigationItem.titleView = logoImageView switch (dependencies[feature: .serviceNetwork], dependencies[feature: .forceOffline]) {
case (.mainnet, false): navigationItem.titleView = logoImageView
case (.testnet, _), (.mainnet, true):
let containerView: UIView = UIView()
containerView.clipsToBounds = false
containerView.addSubview(logoImageView)
logoImageView.pin(to: containerView)
let labelStackView: UIStackView = UIStackView()
labelStackView.axis = .vertical
containerView.addSubview(labelStackView)
labelStackView.center(in: containerView)
labelStackView.transform = CGAffineTransform.identity.rotated(by: -(CGFloat.pi / 6))
let testnetLabel: UILabel = UILabel()
testnetLabel.font = Fonts.boldSpaceMono(ofSize: 14)
testnetLabel.textAlignment = .center
if dependencies[feature: .serviceNetwork] != .mainnet {
labelStackView.addArrangedSubview(testnetLabel)
}
let offlineLabel: UILabel = UILabel()
offlineLabel.font = Fonts.boldSpaceMono(ofSize: 14)
offlineLabel.textAlignment = .center
labelStackView.addArrangedSubview(offlineLabel)
ThemeManager.onThemeChange(observer: testnetLabel) { [weak testnetLabel, weak offlineLabel] theme, primaryColor in
guard
let textColor: UIColor = theme.color(for: .textPrimary),
let strokeColor: UIColor = theme.color(for: .backgroundPrimary)
else { return }
if dependencies[feature: .serviceNetwork] != .mainnet {
testnetLabel?.attributedText = NSAttributedString(
string: dependencies[feature: .serviceNetwork].title,
attributes: [
.foregroundColor: textColor,
.strokeColor: strokeColor,
.strokeWidth: -3
]
)
}
offlineLabel?.attributedText = NSAttributedString(
string: "Offline", // stringlint:disable
attributes: [
.foregroundColor: textColor,
.strokeColor: strokeColor,
.strokeWidth: -3
]
)
}
navigationItem.titleView = containerView
}
} }
internal func setUpDismissingButton(on postion: NavigationItemPosition) { internal func setUpDismissingButton(on postion: NavigationItemPosition) {

@ -615,10 +615,6 @@ extension Attachment {
} }
public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?, using dependencies: Dependencies) -> String? { public static func originalFilePath(id: String, mimeType: String, sourceFilename: String?, using dependencies: Dependencies) -> String? {
// Store the file in a subdirectory whose name is the uniqueId of this attachment,
// to avoid collisions between multiple attachments with the same name
let attachmentFolder: String = Attachment.attachmentsFolder(using: dependencies).appending("/\(id)")
if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty { if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty {
// Ensure that the filename is a valid filesystem name, // Ensure that the filename is a valid filesystem name,
// replacing invalid characters with an underscore. // replacing invalid characters with an underscore.
@ -652,6 +648,10 @@ extension Attachment {
targetFileExtension = targetFileExtension.lowercased() targetFileExtension = targetFileExtension.lowercased()
if !targetFileExtension.isEmpty { if !targetFileExtension.isEmpty {
// Store the file in a subdirectory whose name is the uniqueId of this attachment,
// to avoid collisions between multiple attachments with the same name
let attachmentFolder: String = Attachment.attachmentsFolder(using: dependencies).appending("/\(id)")
guard case .success = Result(try FileSystem.ensureDirectoryExists(at: attachmentFolder, using: dependencies)) else { guard case .success = Result(try FileSystem.ensureDirectoryExists(at: attachmentFolder, using: dependencies)) else {
return nil return nil
} }
@ -665,7 +665,7 @@ extension Attachment {
UTType.fileExtensionDefault UTType.fileExtensionDefault
).lowercased() ).lowercased()
return attachmentFolder.appending("/\(id).\(targetFileExtension)") return Attachment.attachmentsFolder(using: dependencies).appending("/\(id).\(targetFileExtension)")
} }
public static func localRelativeFilePath(from originalFilePath: String?, using dependencies: Dependencies) -> String? { public static func localRelativeFilePath(from originalFilePath: String?, using dependencies: Dependencies) -> String? {

@ -34,6 +34,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
self.contentHandler = contentHandler self.contentHandler = contentHandler
self.request = request self.request = request
/// Create a new `Dependencies` instance each time so we don't need to worry about state from previous
/// notifications causing issues with new notifications
self.dependencies = Dependencies.createEmpty()
// It's technically possible for 'completeSilently' to be called twice due to the NSE timeout so // It's technically possible for 'completeSilently' to be called twice due to the NSE timeout so
self.hasCompleted.mutate { $0 = false } self.hasCompleted.mutate { $0 = false }
@ -51,15 +55,12 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension
/// Create the context if we don't have it (needed before _any_ interaction with the database) /// Create the context if we don't have it (needed before _any_ interaction with the database)
if !dependencies[singleton: .appContext].isValid { if !dependencies[singleton: .appContext].isValid {
dependencies.set(singleton: .appContext, to: NotificationServiceExtensionContext(using: dependencies)) dependencies.set(singleton: .appContext, to: NotificationServiceExtensionContext(using: dependencies))
Dependencies.setIsRTLRetriever(requiresMainThread: false) { dependencies.setIsRTLRetriever(requiresMainThread: false) {
NotificationServiceExtensionContext.determineDeviceRTL() NotificationServiceExtensionContext.determineDeviceRTL()
} }
} }
/// Perform main setup (create a new `Dependencies` instance each time so we don't need to worry about state from previous /// Actually perform the setup
/// notifications causing issues with new notifications
self.dependencies = Dependencies.createEmpty()
DispatchQueue.main.sync { DispatchQueue.main.sync {
self.performSetup { [weak self] in self.performSetup { [weak self] in
self?.handleNotification(notificationContent, isPerformingResetup: false) self?.handleNotification(notificationContent, isPerformingResetup: false)

@ -37,7 +37,7 @@ final class ShareNavController: UINavigationController, ShareViewDelegate {
/// to override it results in the share context crashing so ensure it doesn't exist first) /// to override it results in the share context crashing so ensure it doesn't exist first)
if !dependencies[singleton: .appContext].isValid { if !dependencies[singleton: .appContext].isValid {
dependencies.set(singleton: .appContext, to: ShareAppExtensionContext(rootViewController: self, using: dependencies)) dependencies.set(singleton: .appContext, to: ShareAppExtensionContext(rootViewController: self, using: dependencies))
Dependencies.setIsRTLRetriever(requiresMainThread: false) { ShareAppExtensionContext.determineDeviceRTL() } dependencies.setIsRTLRetriever(requiresMainThread: false) { ShareAppExtensionContext.determineDeviceRTL() }
} }
guard !SNUtilitiesKit.isRunningTests else { return } guard !SNUtilitiesKit.isRunningTests else { return }

@ -664,6 +664,14 @@ public extension LibSession {
// Create the network object // Create the network object
getOrCreateNetwork().sinkUntilComplete() getOrCreateNetwork().sinkUntilComplete()
// If the app has been set to 'forceOffline' then we need to explicitly set the network status
// to disconnected (because it'll never be set otherwise)
if dependencies[feature: .forceOffline] {
DispatchQueue.global(qos: .default).async { [dependencies] in
dependencies.mutate(cache: .libSessionNetwork) { $0.setNetworkStatus(status: .disconnected) }
}
}
} }
deinit { deinit {
@ -713,13 +721,18 @@ public extension LibSession {
return Fail(error: NetworkError.suspended).eraseToAnyPublisher() return Fail(error: NetworkError.suspended).eraseToAnyPublisher()
} }
switch network { switch (network, dependencies[feature: .forceOffline]) {
case .some(let existingNetwork): case (_, true):
return Fail(error: NetworkError.serviceUnavailable)
.delay(for: .seconds(1), scheduler: DispatchQueue.main)
.eraseToAnyPublisher()
case (.some(let existingNetwork), _):
return Just(existingNetwork) return Just(existingNetwork)
.setFailureType(to: Error.self) .setFailureType(to: Error.self)
.eraseToAnyPublisher() .eraseToAnyPublisher()
case .none: case (.none, _):
let useTestnet: Bool = (dependencies[feature: .serviceNetwork] == .testnet) let useTestnet: Bool = (dependencies[feature: .serviceNetwork] == .testnet)
let isMainApp: Bool = dependencies[singleton: .appContext].isMainApp let isMainApp: Bool = dependencies[singleton: .appContext].isMainApp
var error: [CChar] = [CChar](repeating: 0, count: 256) var error: [CChar] = [CChar](repeating: 0, count: 256)

@ -76,7 +76,7 @@ public final class ProfilePictureView: UIView {
case none case none
case crown case crown
case rightPlus case rightPlus
case letter(Character) case letter(Character, Bool)
func iconVerticalInset(for size: Size) -> CGFloat { func iconVerticalInset(for size: Size) -> CGFloat {
switch (self, size) { switch (self, size) {
@ -438,9 +438,9 @@ public final class ProfilePictureView: UIView {
imageView.isHidden = false imageView.isHidden = false
label.isHidden = true label.isHidden = true
case .letter(let character): case .letter(let character, let dangerMode):
label.themeTextColor = .backgroundPrimary label.themeTextColor = (dangerMode ? .textPrimary : .backgroundPrimary)
backgroundView.themeBackgroundColor = .textPrimary backgroundView.themeBackgroundColor = (dangerMode ? .danger : .textPrimary)
label.isHidden = false label.isHidden = false
label.text = "\(character)" label.text = "\(character)"
} }

@ -8,7 +8,7 @@ import Combine
public class Dependencies { public class Dependencies {
static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "session.dependencies.codingOptions")! static let userInfoKey: CodingUserInfoKey = CodingUserInfoKey(rawValue: "session.dependencies.codingOptions")!
private static var _isRTLRetriever: Atomic<(Bool, () -> Bool)> = Atomic((false, { false })) private static var _lastCreatedInstance: Atomic<Dependencies?> = Atomic(nil)
private let featureChangeSubject: PassthroughSubject<(String, String?, Any?), Never> = PassthroughSubject() private let featureChangeSubject: PassthroughSubject<(String, String?, Any?), Never> = PassthroughSubject()
private var storage: Atomic<DependencyStorage> = Atomic(DependencyStorage()) private var storage: Atomic<DependencyStorage> = Atomic(DependencyStorage())
@ -21,8 +21,12 @@ public class Dependencies {
// MARK: - Global Values, Timing and Async Handling // MARK: - Global Values, Timing and Async Handling
public static var isRTL: Bool { /// We should avoid using this value wherever possible because it's not properly injected (which means unit tests won't work correctly
let (requiresMainThread, retriever): (Bool, () -> Bool) = _isRTLRetriever.wrappedValue /// for anything accessed via this value)
public static var unsafeNonInjected: Dependencies { _lastCreatedInstance.wrappedValue ?? Dependencies() }
public var isRTL: Bool {
let (requiresMainThread, retriever): (Bool, () -> Bool) = storage.wrappedValue.isRTLRetriever
/// Determining `isRTL` might require running on the main thread (it may need to accesses UIKit), if it requires the main thread but /// Determining `isRTL` might require running on the main thread (it may need to accesses UIKit), if it requires the main thread but
/// we are on a different thread then just default to `false` to prevent the background thread from potentially lagging and/or crashing /// we are on a different thread then just default to `false` to prevent the background thread from potentially lagging and/or crashing
@ -37,7 +41,9 @@ public class Dependencies {
// MARK: - Initialization // MARK: - Initialization
private init() {} private init() {
Dependencies._lastCreatedInstance.mutate { $0 = self }
}
internal init(forTesting: Bool) {} internal init(forTesting: Bool) {}
public static func createEmpty() -> Dependencies { return Dependencies() } public static func createEmpty() -> Dependencies { return Dependencies() }
@ -104,26 +110,26 @@ public class Dependencies {
} }
public func set<S>(singleton: SingletonConfig<S>, to instance: S) { public func set<S>(singleton: SingletonConfig<S>, to instance: S) {
threadSafeChange(for: singleton.identifier) { threadSafeChange(for: singleton.identifier, of: .singleton) {
setValue(instance, typedStorage: .singleton(instance), key: singleton.identifier) setValue(instance, typedStorage: .singleton(instance), key: singleton.identifier)
} }
} }
public func set<M, I>(cache: CacheConfig<M, I>, to instance: M) { public func set<M, I>(cache: CacheConfig<M, I>, to instance: M) {
threadSafeChange(for: cache.identifier) { threadSafeChange(for: cache.identifier, of: .cache) {
let value: Atomic<MutableCacheType> = Atomic(cache.mutableInstance(instance)) let value: Atomic<MutableCacheType> = Atomic(cache.mutableInstance(instance))
setValue(value, typedStorage: .cache(value), key: cache.identifier) setValue(value, typedStorage: .cache(value), key: cache.identifier)
} }
} }
public func remove<M, I>(cache: CacheConfig<M, I>) { public func remove<M, I>(cache: CacheConfig<M, I>) {
threadSafeChange(for: cache.identifier) { threadSafeChange(for: cache.identifier, of: .cache) {
removeValue(cache.identifier) removeValue(cache.identifier, of: .cache)
} }
} }
public static func setIsRTLRetriever(requiresMainThread: Bool, isRTLRetriever: @escaping () -> Bool) { public func setIsRTLRetriever(requiresMainThread: Bool, isRTLRetriever: @escaping () -> Bool) {
_isRTLRetriever.mutate { $0 = (requiresMainThread, isRTLRetriever) } storage.mutate { $0.isRTLRetriever = (requiresMainThread, isRTLRetriever) }
} }
} }
@ -172,10 +178,10 @@ public extension Dependencies {
} }
func set<T: FeatureOption>(feature: FeatureConfig<T>, to updatedFeature: T?) { func set<T: FeatureOption>(feature: FeatureConfig<T>, to updatedFeature: T?) {
threadSafeChange(for: feature.identifier) { threadSafeChange(for: feature.identifier, of: .feature) {
/// Update the cached & in-memory values /// Update the cached & in-memory values
let instance: Feature<T> = ( let instance: Feature<T> = (
getValue(feature.identifier) ?? getValue(feature.identifier, of: .feature) ??
feature.createInstance(self) feature.createInstance(self)
) )
instance.setValue(to: updatedFeature, using: self) instance.setValue(to: updatedFeature, using: self)
@ -187,11 +193,11 @@ public extension Dependencies {
} }
func reset<T: FeatureOption>(feature: FeatureConfig<T>) { func reset<T: FeatureOption>(feature: FeatureConfig<T>) {
threadSafeChange(for: feature.identifier) { threadSafeChange(for: feature.identifier, of: .feature) {
/// Reset the cached and in-memory values /// Reset the cached and in-memory values
let instance: Feature<T>? = getValue(feature.identifier) let instance: Feature<T>? = getValue(feature.identifier, of: .feature)
instance?.setValue(to: nil, using: self) instance?.setValue(to: nil, using: self)
removeValue(feature.identifier) removeValue(feature.identifier, of: .feature)
} }
/// Notify observers /// Notify observers
@ -272,8 +278,31 @@ public enum DependenciesError: Error {
private extension Dependencies { private extension Dependencies {
struct DependencyStorage { struct DependencyStorage {
var initializationLocks: [String: NSLock] = [:] var initializationLocks: [Key: NSLock] = [:]
var instances: [String: Value] = [:] var instances: [Key: Value] = [:]
var isRTLRetriever: (Bool, () -> Bool) = (false, { false })
struct Key: Hashable, CustomStringConvertible {
enum Variant: String {
case singleton
case cache
case userDefaults
case feature
func key(_ identifier: String) -> Key {
return Key(identifier, of: self)
}
}
let identifier: String
let variant: Variant
var description: String { "\(variant): \(identifier)" }
init(_ identifier: String, of variant: Variant) {
self.identifier = identifier
self.variant = variant
}
}
enum Value { enum Value {
case singleton(Any) case singleton(Any)
@ -281,6 +310,15 @@ private extension Dependencies {
case userDefaults(UserDefaultsType) case userDefaults(UserDefaultsType)
case feature(any FeatureType) case feature(any FeatureType)
func distinctKey(for identifier: String) -> Key {
switch self {
case .singleton: return Key(identifier, of: .singleton)
case .cache: return Key(identifier, of: .cache)
case .userDefaults: return Key(identifier, of: .userDefaults)
case .feature: return Key(identifier, of: .feature)
}
}
func value<T>(as type: T.Type) -> T? { func value<T>(as type: T.Type) -> T? {
switch self { switch self {
case .singleton(let value): return value as? T case .singleton(let value): return value as? T
@ -329,14 +367,14 @@ private extension Dependencies {
constructor: DependencyStorage.Constructor<Value> constructor: DependencyStorage.Constructor<Value>
) -> Value { ) -> Value {
/// If we already have an instance then just return that /// If we already have an instance then just return that
if let existingValue: Value = getValue(identifier) { if let existingValue: Value = getValue(identifier, of: constructor.variant) {
return existingValue return existingValue
} }
return threadSafeChange(for: identifier) { return threadSafeChange(for: identifier, of: constructor.variant) {
/// Now that we are within a synchronized group, check to make sure an instance wasn't created while we were waiting to /// Now that we are within a synchronized group, check to make sure an instance wasn't created while we were waiting to
/// enter the group /// enter the group
if let existingValue: Value = getValue(identifier) { if let existingValue: Value = getValue(identifier, of: constructor.variant) {
return existingValue return existingValue
} }
@ -347,11 +385,11 @@ private extension Dependencies {
} }
/// Convenience method to retrieve the existing dependency instance from memory in a thread-safe way /// Convenience method to retrieve the existing dependency instance from memory in a thread-safe way
private func getValue<T>(_ key: String) -> T? { private func getValue<T>(_ key: String, of variant: DependencyStorage.Key.Variant) -> T? {
guard let typedValue: DependencyStorage.Value = storage.wrappedValue.instances[key] else { return nil } guard let typedValue: DependencyStorage.Value = storage.wrappedValue.instances[variant.key(key)] else { return nil }
guard let result: T = typedValue.value(as: T.self) else { guard let result: T = typedValue.value(as: T.self) else {
/// If there is a value stored for the key, but it's not the right type then something has gone wrong, and we should log /// If there is a value stored for the key, but it's not the right type then something has gone wrong, and we should log
Log.critical("Failed to convert stored dependency '\(key)' to expected type: \(T.self)") Log.critical("Failed to convert stored dependency '\(variant.key(key))' to expected type: \(T.self)")
return nil return nil
} }
@ -360,13 +398,13 @@ private extension Dependencies {
/// Convenience method to store a dependency instance in memory in a thread-safe way /// Convenience method to store a dependency instance in memory in a thread-safe way
@discardableResult private func setValue<T>(_ value: T, typedStorage: DependencyStorage.Value, key: String) -> T { @discardableResult private func setValue<T>(_ value: T, typedStorage: DependencyStorage.Value, key: String) -> T {
storage.mutate { $0.instances[key] = typedStorage } storage.mutate { $0.instances[typedStorage.distinctKey(for: key)] = typedStorage }
return value return value
} }
/// Convenience method to remove a dependency instance from memory in a thread-safe way /// Convenience method to remove a dependency instance from memory in a thread-safe way
private func removeValue(_ key: String) { private func removeValue(_ key: String, of variant: DependencyStorage.Key.Variant) {
storage.mutate { $0.instances.removeValue(forKey: key) } storage.mutate { $0.instances.removeValue(forKey: variant.key(key)) }
} }
/// This function creates an `NSLock` for the given identifier which allows us to block instance creation on a per-identifier basis /// This function creates an `NSLock` for the given identifier which allows us to block instance creation on a per-identifier basis
@ -374,14 +412,14 @@ private extension Dependencies {
/// ///
/// **Note:** This `NSLock` is an additional mechanism on top of the `Atomic<T>` because the interface is a little simpler /// **Note:** This `NSLock` is an additional mechanism on top of the `Atomic<T>` because the interface is a little simpler
/// and we don't need to wrap every instance within `Atomic<T>` this way /// and we don't need to wrap every instance within `Atomic<T>` this way
@discardableResult private func threadSafeChange<T>(for identifier: String, change: () -> T) -> T { @discardableResult private func threadSafeChange<T>(for identifier: String, of variant: DependencyStorage.Key.Variant, change: () -> T) -> T {
let lock: NSLock = storage.mutate { storage in let lock: NSLock = storage.mutate { storage in
if let existing = storage.initializationLocks[identifier] { if let existing = storage.initializationLocks[variant.key(identifier)] {
return existing return existing
} }
let lock: NSLock = NSLock() let lock: NSLock = NSLock()
storage.initializationLocks[identifier] = lock storage.initializationLocks[variant.key(identifier)] = lock
return lock return lock
} }
lock.lock() lock.lock()
@ -395,10 +433,11 @@ private extension Dependencies {
private extension Dependencies.DependencyStorage { private extension Dependencies.DependencyStorage {
struct Constructor<T> { struct Constructor<T> {
let variant: Key.Variant
let create: () -> (typedStorage: Dependencies.DependencyStorage.Value, value: T) let create: () -> (typedStorage: Dependencies.DependencyStorage.Value, value: T)
static func singleton(_ constructor: @escaping () -> T) -> Constructor<T> { static func singleton(_ constructor: @escaping () -> T) -> Constructor<T> {
return Constructor { return Constructor(variant: .singleton) {
let instance: T = constructor() let instance: T = constructor()
return (.singleton(instance), instance) return (.singleton(instance), instance)
@ -406,7 +445,7 @@ private extension Dependencies.DependencyStorage {
} }
static func cache(_ constructor: @escaping () -> T) -> Constructor<T> where T: Atomic<MutableCacheType> { static func cache(_ constructor: @escaping () -> T) -> Constructor<T> where T: Atomic<MutableCacheType> {
return Constructor { return Constructor(variant: .cache) {
let instance: T = constructor() let instance: T = constructor()
return (.cache(instance), instance) return (.cache(instance), instance)
@ -414,7 +453,7 @@ private extension Dependencies.DependencyStorage {
} }
static func userDefaults(_ constructor: @escaping () -> T) -> Constructor<T> where T == UserDefaultsType { static func userDefaults(_ constructor: @escaping () -> T) -> Constructor<T> where T == UserDefaultsType {
return Constructor { return Constructor(variant: .userDefaults) {
let instance: T = constructor() let instance: T = constructor()
return (.userDefaults(instance), instance) return (.userDefaults(instance), instance)
@ -422,7 +461,7 @@ private extension Dependencies.DependencyStorage {
} }
static func feature(_ constructor: @escaping () -> T) -> Constructor<T> where T: FeatureType { static func feature(_ constructor: @escaping () -> T) -> Constructor<T> where T: FeatureType {
return Constructor { return Constructor(variant: .feature) {
let instance: T = constructor() let instance: T = constructor()
return (.feature(instance), instance) return (.feature(instance), instance)

@ -9,6 +9,14 @@ public final class Features {
} }
public extension FeatureStorage { public extension FeatureStorage {
static let showStringKeys: FeatureConfig<Bool> = Dependencies.create(
identifier: "showStringKeys"
)
static let forceOffline: FeatureConfig<Bool> = Dependencies.create(
identifier: "forceOffline"
)
static let debugDisappearingMessageDurations: FeatureConfig<Bool> = Dependencies.create( static let debugDisappearingMessageDurations: FeatureConfig<Bool> = Dependencies.create(
identifier: "debugDisappearingMessageDurations" identifier: "debugDisappearingMessageDurations"
) )
@ -57,6 +65,14 @@ public extension FeatureStorage {
static let updatedGroupsAllowInviteById: FeatureConfig<Bool> = Dependencies.create( static let updatedGroupsAllowInviteById: FeatureConfig<Bool> = Dependencies.create(
identifier: "updatedGroupsAllowInviteById" identifier: "updatedGroupsAllowInviteById"
) )
static let updatedGroupsDeleteBeforeNow: FeatureConfig<Bool> = Dependencies.create(
identifier: "updatedGroupsDeleteBeforeNow"
)
static let updatedGroupsDeleteAttachmentsBeforeNow: FeatureConfig<Bool> = Dependencies.create(
identifier: "updatedGroupsDeleteAttachmentsBeforeNow"
)
} }
// MARK: - FeatureOption // MARK: - FeatureOption

@ -30,6 +30,10 @@ final public class LocalizationHelper: CustomStringConvertible {
} }
public func localized() -> String { public func localized() -> String {
guard !Dependencies.unsafeNonInjected[feature: .showStringKeys] else {
return "[\(template)]"
}
// Use English as the default string if the translation is empty // Use English as the default string if the translation is empty
let defaultString: String = { let defaultString: String = {
if let englishPath = Bundle.main.path(forResource: "en", ofType: "lproj"), let englishBundle = Bundle(path: englishPath) { if let englishPath = Bundle.main.path(forResource: "en", ofType: "lproj"), let englishBundle = Bundle(path: englishPath) {

@ -8,9 +8,9 @@ extension UIEdgeInsets {
public init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) { public init(top: CGFloat, leading: CGFloat, bottom: CGFloat, trailing: CGFloat) {
self.init( self.init(
top: top, top: top,
left: (Dependencies.isRTL ? trailing : leading), left: (Dependencies.unsafeNonInjected.isRTL ? trailing : leading),
bottom: bottom, bottom: bottom,
right: (Dependencies.isRTL ? leading : trailing) right: (Dependencies.unsafeNonInjected.isRTL ? leading : trailing)
) )
} }
} }

@ -123,7 +123,7 @@ public extension UTType {
let mimeType: String = preferredMIMEType, let mimeType: String = preferredMIMEType,
let fileExtension: String = UTType.genericExtensionTypesToMimeTypes let fileExtension: String = UTType.genericExtensionTypesToMimeTypes
.first(where: { _, value in value == mimeType })? .first(where: { _, value in value == mimeType })?
.value .key
else { return preferredFilenameExtension } else { return preferredFilenameExtension }
return fileExtension return fileExtension

@ -65,12 +65,12 @@ public extension UIViewController {
let backButton: UIButton = UIButton(type: .custom) let backButton: UIButton = UIButton(type: .custom)
// Nudge closer to the left edge to match default back button item. // Nudge closer to the left edge to match default back button item.
let extraLeftPadding: CGFloat = (Dependencies.isRTL ? 0 : -8) let extraLeftPadding: CGFloat = (Dependencies.unsafeNonInjected.isRTL ? 0 : -8)
// Give some extra hit area to the back button. This is a little smaller // Give some extra hit area to the back button. This is a little smaller
// than the default back button, but makes sense for our left aligned title // than the default back button, but makes sense for our left aligned title
// view in the MessagesViewController // view in the MessagesViewController
let extraRightPadding: CGFloat = (Dependencies.isRTL ? -0 : 10) let extraRightPadding: CGFloat = (Dependencies.unsafeNonInjected.isRTL ? -0 : 10)
// Extra hit area above/below // Extra hit area above/below
let extraHeightPadding: CGFloat = 8 let extraHeightPadding: CGFloat = 8

Loading…
Cancel
Save