Compare commits

..

15 Commits

Author SHA1 Message Date
Max Radermacher
2ead22b6f3 Fix isRetryable for network failure SignalErrors 2026-05-22 16:08:22 -05:00
Max Radermacher
5b4ee2044a Bump version to 8.12.1 2026-05-22 16:07:54 -05:00
sashaweiss-signal
0c15ff347f Fix ES-mx translation 2026-05-19 16:41:38 -07:00
sashaweiss-signal
4c9b2b0a23 Fix ES-es translation 2026-05-19 16:40:32 -07:00
sashaweiss-signal
08f6b2cfdc Fix Catalan translation 2026-05-19 16:39:09 -07:00
sashaweiss-signal
0a04105c36 Feature flags for .production. 2026-05-19 15:28:57 -07:00
sashaweiss-signal
0a4fe237fb Update translations 2026-05-19 14:57:17 -07:00
Sasha Weiss
baa8fbc68f Skip thread-merge events for the Note to Self 2026-05-19 14:46:29 -07:00
Sasha Weiss
5412e40756 Restrict KT to beta 2026-05-18 15:24:07 -07:00
Max Radermacher
53e63f63a4 Update to LibSignal v0.94.1 2026-05-18 14:59:03 -05:00
Max Radermacher
246ce3f0ed Update fastlane 2026-05-18 12:07:42 -05:00
Max Radermacher
0a0ff25925 Add Marathi, Urdu, Gujarati, & Bangla to App Store 2026-05-15 18:19:59 -05:00
Igor Solomennikov
1197943ddb Fix layout issue in ListItemSelectionIndicatorView. 2026-05-14 01:44:44 -05:00
Igor Solomennikov
dd9bf89bb4 Actually update appearance of "Message Request" panel in chat. 2026-05-13 22:30:25 -05:00
sashaweiss-signal
999978dc1c Feature flags for .beta. 2026-05-13 14:02:53 -07:00
769 changed files with 21140 additions and 21872 deletions

View File

@ -18,7 +18,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.5.app
DEVELOPER_DIR: /Applications/Xcode_26.4.app
jobs:
build_and_test:
@ -31,7 +31,7 @@ jobs:
strategy:
matrix:
# Add additional Xcode versions here if necessary.
xcode: ["Xcode_26.5"]
xcode: ["Xcode_26.4"]
steps:
- name: Set Xcode version

View File

@ -34,7 +34,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.5.app
DEVELOPER_DIR: /Applications/Xcode_26.4.app
# v0.60.1
swiftformat-ref: c8e50ff2cfc2eab46246c072a9ae25ab656c6ec3

View File

@ -28,7 +28,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.5.app
DEVELOPER_DIR: /Applications/Xcode_26.4.app
# v1.36.1
swift-protobuf-ref: a008af1a102ff3dd6cc3764bb69bf63226d0f5f6

View File

@ -7,7 +7,7 @@ on:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.5.app
DEVELOPER_DIR: /Applications/Xcode_26.4.app
jobs:
check-strings:

View File

@ -17,7 +17,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.5.app
DEVELOPER_DIR: /Applications/Xcode_26.4.app
jobs:
build:

View File

@ -17,7 +17,7 @@ concurrency:
env:
# Path format pulled from https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md#xcode
DEVELOPER_DIR: /Applications/Xcode_26.5.app
DEVELOPER_DIR: /Applications/Xcode_26.4.app
jobs:
build:

3
.gitignore vendored
View File

@ -36,9 +36,6 @@ Index/
*.sdsjson
Scripts/sds_codegen/sds-includes/*
# Logs
debuglogs/
/.idea
/.vscode

View File

@ -1 +1 @@
3.4.9
3.2.2

View File

@ -1 +1 @@
Xcode 26.5
Xcode 26.4.1

View File

@ -310,4 +310,4 @@ DEPENDENCIES
xcode-install
BUNDLED WITH
2.6.9
2.5.6

View File

@ -11,13 +11,13 @@ source 'https://cdn.cocoapods.org/'
pod 'blurhash', podspec: './ThirdParty/blurhash.podspec'
pod 'SwiftProtobuf', "1.36.1"
ENV['LIBSIGNAL_FFI_PREBUILD_CHECKSUM'] = '79f53932ff82f792b70e30bad3b38801da0b882137adaf65ad54d907a94f3d29'
pod 'LibSignalClient', git: 'https://github.com/signalapp/libsignal.git', tag: 'v0.95.0', testspecs: ["Tests"]
ENV['LIBSIGNAL_FFI_PREBUILD_CHECKSUM'] = 'e3b89de2afc950c9e317f2fff426ae8edc77a397520d2e0afbb717d738213fd5'
pod 'LibSignalClient', git: 'https://github.com/signalapp/libsignal.git', tag: 'v0.94.1', testspecs: ["Tests"]
# pod 'LibSignalClient', path: '../libsignal', testspecs: ["Tests"]
ENV['RINGRTC_PREBUILD_CHECKSUM'] = 'c19c813ab5255aa3cd7c2af36374100f7cc69c2fd794cae23baebd6ec9dae90c'
ENV['RINGRTC_PREBUILD_CHECKSUM'] = '64743212da1c13ab7092ac4ba905c4b629d2ab1935bf3b3b8db7341cc4b5864e'
# ENV['RINGRTC_USE_FILE_BASED_CAMERA'] = '1'
pod 'SignalRingRTC', git: 'https://github.com/signalapp/ringrtc', tag: 'v2.69.1', inhibit_warnings: true
pod 'SignalRingRTC', git: 'https://github.com/signalapp/ringrtc', tag: 'v2.68.1', inhibit_warnings: true
# pod 'SignalRingRTC', path: '../ringrtc', testspecs: ["Tests"]
pod 'GRDB.swift/SQLCipher'

View File

@ -9,8 +9,8 @@ PODS:
- LibMobileCoin/CoreHTTP (6.0.2):
- SwiftProtobuf (~> 1.5)
- libPhoneNumber-iOS (1.2.0)
- LibSignalClient (0.95.0)
- LibSignalClient/Tests (0.95.0)
- LibSignalClient (0.94.1)
- LibSignalClient/Tests (0.94.1)
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0)
@ -35,9 +35,9 @@ PODS:
- SDWebImageWebPCoder (0.14.6):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- SignalRingRTC (2.69.1):
- SignalRingRTC/WebRTC (= 2.69.1)
- SignalRingRTC/WebRTC (2.69.1)
- SignalRingRTC (2.68.1):
- SignalRingRTC/WebRTC (= 2.68.1)
- SignalRingRTC/WebRTC (2.68.1)
- SQLCipher (4.6.1):
- SQLCipher/standard (= 4.6.1)
- SQLCipher/common (4.6.1)
@ -52,15 +52,15 @@ DEPENDENCIES:
- GRDB.swift/SQLCipher
- LibMobileCoin/CoreHTTP (from `https://github.com/signalapp/libmobilecoin-ios-artifacts`, tag `signal/6.0.2`)
- libPhoneNumber-iOS (from `https://github.com/signalapp/libPhoneNumber-iOS`, branch `signal-master`)
- LibSignalClient (from `https://github.com/signalapp/libsignal.git`, tag `v0.95.0`)
- LibSignalClient/Tests (from `https://github.com/signalapp/libsignal.git`, tag `v0.95.0`)
- LibSignalClient (from `https://github.com/signalapp/libsignal.git`, tag `v0.94.1`)
- LibSignalClient/Tests (from `https://github.com/signalapp/libsignal.git`, tag `v0.94.1`)
- libwebp
- lottie-ios
- MobileCoin/CoreHTTP (from `https://github.com/mobilecoinofficial/MobileCoin-Swift`, tag `v6.0.3`)
- PureLayout
- SDWebImage
- SDWebImageWebPCoder
- SignalRingRTC (from `https://github.com/signalapp/ringrtc`, tag `v2.69.1`)
- SignalRingRTC (from `https://github.com/signalapp/ringrtc`, tag `v2.68.1`)
- SQLCipher (from `https://github.com/signalapp/sqlcipher.git`, tag `v4.6.1-f_barrierfsync`)
- SwiftProtobuf (= 1.36.1)
@ -89,13 +89,13 @@ EXTERNAL SOURCES:
:git: https://github.com/signalapp/libPhoneNumber-iOS
LibSignalClient:
:git: https://github.com/signalapp/libsignal.git
:tag: v0.95.0
:tag: v0.94.1
MobileCoin:
:git: https://github.com/mobilecoinofficial/MobileCoin-Swift
:tag: v6.0.3
SignalRingRTC:
:git: https://github.com/signalapp/ringrtc
:tag: v2.69.1
:tag: v2.68.1
SQLCipher:
:git: https://github.com/signalapp/sqlcipher.git
:tag: v4.6.1-f_barrierfsync
@ -113,13 +113,13 @@ CHECKOUT OPTIONS:
:git: https://github.com/signalapp/libPhoneNumber-iOS
LibSignalClient:
:git: https://github.com/signalapp/libsignal.git
:tag: v0.95.0
:tag: v0.94.1
MobileCoin:
:git: https://github.com/mobilecoinofficial/MobileCoin-Swift
:tag: v6.0.3
SignalRingRTC:
:git: https://github.com/signalapp/ringrtc
:tag: v2.69.1
:tag: v2.68.1
SQLCipher:
:git: https://github.com/signalapp/sqlcipher.git
:tag: v4.6.1-f_barrierfsync
@ -131,7 +131,7 @@ SPEC CHECKSUMS:
GRDB.swift: 1395cb3556df6b16ed69dfc74c3886abc75d2825
LibMobileCoin: 8503f567fa32184a5be7bc038fbd727747dd9991
libPhoneNumber-iOS: 1a34106b49dc6e12a7f37eb9aee7c64011509547
LibSignalClient: a98db1d538243e43ecac040005204bd274cbd8c7
LibSignalClient: cf53cea3c6cd2cac3e87d0f5f34c3a1c59fe1b8f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
Logging: beeb016c9c80cf77042d62e83495816847ef108b
lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418
@ -139,10 +139,10 @@ SPEC CHECKSUMS:
PureLayout: f08c01b8dec00bb14a1fefa3de4c7d9c265df85e
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
SignalRingRTC: b907e1c8ef7743926c031810e9655366d7aa3eeb
SignalRingRTC: 0d98294e8b0c95ddb94ab294a59789e280dd72e0
SQLCipher: ff2f045b20d675a73a70f7329395ddd4a2580063
SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38
PODFILE CHECKSUM: ee98007764e1569e9dbe4f25053510725b19fc88
PODFILE CHECKSUM: dc5990819c2ffb78f11f2795a66f8c94ecc05b3d
COCOAPODS: 1.15.2

2
Pods

@ -1 +1 @@
Subproject commit 5e81462d833ad24e8091d7b6ab675c2cdc94af54
Subproject commit e80eecf2f339915be6ae35aa9908051f6211c86b

View File

@ -1,27 +1,76 @@
{
"#comment": "NOTE: This file is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run `sds_codegen.sh`.",
"#max": 80,
"BaseModel": 56,
"ExperienceUpgrade": 55,
"IncomingGroupsV2MessageJob": 63,
"InstalledSticker": 24,
"OWS100RemoveTSRecipientsMigration": 40,
"OWS101ExistingUsersBlockOnIdentityChange": 43,
"OWS102MoveLoggingPreferenceToUserDefaults": 47,
"OWS103EnableVideoCalling": 42,
"OWS104CreateRecipientIdentities": 45,
"OWS105AttachmentFilePaths": 44,
"OWS107LegacySounds": 50,
"OWS108CallLoggingPreference": 48,
"OWS109OutgoingMessageState": 51,
"OWSAddToContactsOfferMessage": 25,
"OWSAddToProfileWhitelistOfferMessage": 7,
"OWSBackupFragment": 32,
"OWSContactOffersInteraction": 22,
"OWSContactQuery": 57,
"OWSDatabaseMigration": 46,
"OWSDevice": 33,
"OWSDisappearingConfigurationUpdateInfoMessage": 28,
"OWSDisappearingMessagesConfiguration": 39,
"OWSGroupCallMessage": 65,
"OWSIncomingArchivedPaymentMessage": 78,
"OWSIncomingContactSyncJobRecord": 61,
"OWSIncomingGroupSyncJobRecord": 60,
"OWSIncomingPaymentMessage": 75,
"OWSLinkedDeviceReadReceipt": 36,
"OWSLocalUserLeaveGroupJobRecord": 74,
"OWSMessageContentJob": 15,
"OWSOutgoingArchivedPaymentMessage": 79,
"OWSOutgoingPaymentMessage": 68,
"OWSPaymentActivationRequestFinishedMessage": 77,
"OWSPaymentActivationRequestMessage": 76,
"OWSReaction": 62,
"OWSReceiptCredentialRedemptionJobRecord": 71,
"OWSRecipientIdentity": 38,
"OWSRecoverableDecryptionPlaceholder": 70,
"OWSResaveCollectionDBMigration": 49,
"OWSSendGiftBadgeJobRecord": 73,
"OWSSessionResetJobRecord": 52,
"OWSUnknownContactBlockOfferMessage": 5,
"OWSUnknownDBObject": 37,
"OWSUnknownProtocolVersionMessage": 54,
"OWSUserProfile": 41,
"OWSVerificationStateChangeMessage": 13,
"SSKJobRecord": 34,
"SSKMessageDecryptJobRecord": 53,
"SSKMessageSenderJobRecord": 35,
"SignalAccount": 30,
"SignalRecipient": 31,
"StickerPack": 14,
"TSCall": 20,
"TSContactThread": 27,
"TSErrorMessage": 9,
"TSGroupMember": 69,
"TSGroupThread": 26,
"TSIncomingMessage": 19,
"TSInfoMessage": 10,
"TSInteraction": 16,
"TSInvalidIdentityKeyErrorMessage": 17,
"TSInvalidIdentityKeyReceivingErrorMessage": 1,
"TSInvalidIdentityKeySendingErrorMessage": 23,
"TSMention": 64,
"TSMessage": 11,
"TSOutgoingMessage": 21,
"TSUnreadIndicatorInteraction": 4
"TSPaymentModel": 67,
"TSPaymentRequestModel": 66,
"TSPrivateStoryThread": 72,
"TSRecipientReadReceipt": 12,
"TSThread": 2,
"TSUnreadIndicatorInteraction": 4,
"TestModel": 59
}

View File

@ -2440,23 +2440,31 @@ record_type_map = {}
# It's critical that our "record type" values are consistent, even if we add/remove/rename model classes.
# Therefore we persist the mapping of known classes in a JSON file that is under source control.
def update_record_type_map(record_type_swift_path, record_type_json_path):
old_record_types = {}
if os.path.exists(record_type_json_path):
with open(record_type_json_path, "r") as f:
old_record_types = json.load(f)
record_type_map_filepath = record_type_json_path
max_record_type = old_record_types.get("#max", 0)
if os.path.exists(record_type_map_filepath):
with open(record_type_map_filepath, "rt") as f:
json_string = f.read()
json_data = json.loads(json_string)
record_type_map.update(json_data)
max_record_type = 0
for class_name in record_type_map:
if class_name.startswith("#"):
continue
record_type = record_type_map[class_name]
max_record_type = max(max_record_type, record_type)
for clazz in global_class_map.values():
if not clazz.should_generate_extensions():
continue
if clazz.name in old_record_types:
record_type_map[clazz.name] = old_record_types[clazz.name]
else:
max_record_type += 1
record_type_map[clazz.name] = max_record_type
if clazz.name not in record_type_map:
if not clazz.should_generate_extensions():
continue
max_record_type = int(max_record_type) + 1
record_type = max_record_type
record_type_map[clazz.name] = record_type
record_type_map["#max"] = max_record_type
record_type_map["#comment"] = (
"NOTE: This file is generated by %s. Do not manually edit it, instead run `sds_codegen.sh`."
% (sds_common.pretty_module_path(__file__),)
@ -2464,7 +2472,7 @@ def update_record_type_map(record_type_swift_path, record_type_json_path):
json_string = json.dumps(record_type_map, sort_keys=True, indent=4)
sds_common.write_text_file_if_changed(record_type_json_path, json_string)
sds_common.write_text_file_if_changed(record_type_map_filepath, json_string)
# TODO: We'll need to import SignalServiceKit for non-SSK classes.

File diff suppressed because it is too large Load Diff

View File

@ -87,7 +87,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
appReadiness.runNowOrWhenAppDidBecomeReadySync {
self.refreshConnection(isAppActive: false)
self.refreshConnection(isAppActive: false, shouldRunCron: false)
}
clearAppropriateNotificationsAndRestoreBadgeCount()
@ -148,6 +148,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
debugLogger.enableFileLogging(appContext: mainAppContext, canLaunchInBackground: true)
DebugLogger.configureSwiftLogging()
if DebugFlags.audibleErrorLogging {
debugLogger.enableErrorReporting()
}
Logger.warn("Launching…")
defer { Logger.info("Launched.") }
@ -369,14 +372,13 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
private func configureGlobalUI(in window: UIWindow) {
let screenLockUI = AppEnvironment.shared.screenLockUI
let windowManager = AppEnvironment.shared.windowManagerRef
private lazy var screenLockUI = ScreenLockUI(appReadiness: appReadiness)
private func configureGlobalUI(in window: UIWindow) {
Theme.setupSignalAppearance()
screenLockUI.setupWithRootWindow(window)
windowManager.setupWithRootWindow(window, screenBlockingWindow: screenLockUI.screenBlockingWindow)
AppEnvironment.shared.windowManagerRef.setupWithRootWindow(window, screenBlockingWindow: screenLockUI.screenBlockingWindow)
screenLockUI.startObserving()
}
@ -398,6 +400,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
let dataMigrationContinuation = globalsContinuation.initGlobals(
appContext: launchContext.appContext,
appReadiness: appReadiness,
backupArchiveErrorPresenterFactory: BackupArchiveErrorPresenterFactoryInternal(),
deviceBatteryLevelManager: DeviceBatteryLevelManagerImpl(),
deviceSleepManager: launchContext.deviceSleepManager,
paymentsEvents: PaymentsEventsMainApp(),
@ -643,16 +646,16 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
let remoteReleaseNotesFetchingManager = RemoteReleaseNotesFetchingManager(
db: DependenciesBridge.shared.db,
remoteReleaseNotesService: DependenciesBridge.shared.remoteReleaseNotesService,
let remoteMegaphoneFetcher = RemoteMegaphoneFetcher(
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
signalService: SSKEnvironment.shared.signalServiceRef,
)
cron.schedulePeriodically(
uniqueKey: .fetchMegaphones,
approximateInterval: 3 * .day,
mustBeRegistered: false,
mustBeConnected: true,
operation: { try await remoteReleaseNotesFetchingManager.syncRemoteReleaseNotes() },
operation: { try await remoteMegaphoneFetcher.syncRemoteMegaphones() },
)
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
@ -717,7 +720,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// element" should call .restart() on the appropriate job.
dependenciesBridge.deletedCallRecordExpirationJob.start()
dependenciesBridge.disappearingMessagesExpirationJob.start()
dependenciesBridge.decryptionPlaceholderExpirationJob.start()
dependenciesBridge.storyMessageExpirationJob.start()
dependenciesBridge.pinnedMessageExpirationJob.start()
@ -779,27 +781,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
operation: { try await blockingManager.syncBlockListIfNecessary(force: false) },
)
let svr = DependenciesBridge.shared.svr
// We must refresh our SVR2 credentials periodically. We typically do this
// when updating to a new version, but we want to refresh it after 14 days
// if we haven't upgraded.
cron.schedulePeriodically(
uniqueKey: .refreshSVRCredentials,
approximateInterval: 14 * .day,
mustBeRegistered: true,
mustBeDeviceType: .primary,
mustBeConnected: true,
operation: { try await svr.refreshCredentialsIfNecessary() },
)
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeDeviceType: .primary,
mustBeConnected: true,
operation: { try await svr.refreshBackupIfNecessary() },
)
// Warm the "available emoji" cache, intentionally off the main thread.
Task.detached {
Emoji.warmAvailableCache()
@ -809,7 +790,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// launching from the background, without this, we end up waiting some extra
// seconds before receiving an actionable push notification.
if !appContext.isMainAppAndActive {
self.refreshConnection(isAppActive: false)
self.refreshConnection(isAppActive: false, shouldRunCron: false)
}
if registeredState != nil {
@ -1252,20 +1233,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
switch action {
case .submitDebugLogsAndCrash:
addSubmitDebugLogsAction {
DebugLogs(dumper: logDumper).promptToSubmitLogs(
from: viewController,
supportTag: supportTag,
) {
DebugLogs.submitLogs(supportTag: supportTag, dumper: logDumper) {
owsFail("Exiting after submitting debug logs")
}
}
case .submitDebugLogsAndLaunchApp(let window, let launchContext):
addSubmitDebugLogsAction { [unowned window] in
DebugLogs(dumper: logDumper).promptToSubmitLogs(
from: viewController,
supportTag: supportTag,
) {
DebugLogs.submitLogs(supportTag: supportTag, dumper: logDumper) {
ignoreErrorAndLaunchApp(in: window, launchContext: launchContext)
}
}
@ -1392,7 +1367,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
refreshConnection(isAppActive: true)
refreshConnection(isAppActive: true, shouldRunCron: true)
// Every time we become active...
if registeredState != nil {
@ -1460,7 +1435,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
/// is in the background.
private var backgroundFetchHandle: BackgroundTaskHandle?
private func refreshConnection(isAppActive: Bool) {
private func refreshConnection(isAppActive: Bool, shouldRunCron: Bool) {
let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager
let oldActiveConnectionTokens = self.activeConnectionTokens
@ -1468,10 +1443,9 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// If we're active, open a connection.
self.activeConnectionTokens = chatConnectionManager.requestConnections()
oldActiveConnectionTokens.forEach { $0.releaseConnection() }
// Start a new Cron task on activate.
self.startCronTask()
if shouldRunCron {
self.startCronTask()
}
// We're back in the foreground. We've passed off connection management to
// the foreground logic, so just tear it down without waiting for anything.
self.backgroundFetchHandle?.interrupt()
@ -1488,14 +1462,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
do {
await backgroundFetcher.start()
oldActiveConnectionTokens.forEach { $0.releaseConnection() }
// If there's a Cron task running that was started in the foreground, wait
// for it to finish.
await withTaskCancellationHandler(
operation: { await cronTask?.value },
onCancel: { cronTask?.cancel() },
)
// If there's a fresh request to run Cron when entering the background,
// start a new Cron instance.
if shouldRunCron {
await self.runCron()
}
// This will usually be limited to 30 seconds rather than 3 minutes.
let waitDeadline = startDate.adding(180)
if isPastRegistration {
@ -1768,24 +1745,8 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
return false
}
let isVideo = isVideoCall(intent)
Task { @MainActor [appReadiness] in
do {
try await appReadiness.waitForAppReady()
} catch {
return
}
let callService = AppEnvironment.shared.callService!
let screenLockUI = AppEnvironment.shared.screenLockUI
appReadiness.runNowOrWhenAppDidBecomeReadySync {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
do {
try await screenLockUI.waitForScreenUnlockThrowingPrevious()
} catch {
return
}
guard tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered else {
Logger.warn("Ignoring user activity; not registered.")
return
@ -1803,6 +1764,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// * It can be received if the user taps the "video" button for a contact
// in the contacts app. If so, the correct response is to try to initiate a
// new call to that user - unless there is another call in progress.
let callService = AppEnvironment.shared.callService!
if let currentCall = callService.callServiceState.currentCall {
if isVideo, case .individual = currentCall.mode, currentCall.mode.matches(callTarget) {
Logger.info("Upgrading existing call to video")
@ -1814,7 +1776,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
}
callService.initiateCall(to: callTarget, isVideo: isVideo)
}
return true
}
@ -1829,12 +1790,17 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
scheduleBgAppRefresh()
let attachmentDownloadmanager = DependenciesBridge.shared.attachmentDownloadManager
let db = DependenciesBridge.shared.db
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let registeredState = try? tsAccountManager.registeredStateWithMaybeSneakyTransaction()
if let registeredState {
Logger.info("localAci: \(registeredState.localIdentifiers.aci)")
db.write { transaction in
ExperienceUpgradeFinder.markAllCompleteForNewUser(transaction: transaction)
}
attachmentDownloadmanager.beginDownloadingIfNecessary()
// Schedule a Cron run if we're in the foreground.
@ -1942,15 +1908,9 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
Task { @MainActor [appReadiness] () -> Void in
defer { completionHandler() }
do {
try await self.appReadiness.waitForAppReady()
} catch {
return
}
try await self.appReadiness.waitForAppReady()
let screenLockUI = AppEnvironment.shared.screenLockUI
let backgroundMessageFetcherFactory = DependenciesBridge.shared.backgroundMessageFetcherFactory
let backgroundMessageFetcher = backgroundMessageFetcherFactory.buildFetcher()
// So that we open up a connection for replies.
await backgroundMessageFetcher.start()
@ -1959,11 +1919,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
let elapsedDuration = (MonotonicDate() - startDate).seconds
try await withCooperativeTimeout(seconds: 27 - elapsedDuration) {
// Do the actual thing we care about.
try await NotificationActionHandler.handleNotificationResponse(
response,
appReadiness: appReadiness,
screenLockUI: screenLockUI,
)
try await NotificationActionHandler.handleNotificationResponse(response, appReadiness: appReadiness)
// Then wait for any enqueued messages (e.g., read receipts) to be sent.
try await backgroundMessageFetcher.waitForFetchingProcessingAndSideEffects()

View File

@ -22,12 +22,12 @@ public class AppEnvironment: NSObject {
@MainActor
var ownedObjects = [AnyObject]()
let cvAudioPlayerRef: CVAudioPlayer
let deviceTransferServiceRef: DeviceTransferService
let pushRegistrationManagerRef: PushRegistrationManager
let screenLockUI: ScreenLockUI
let speechManagerRef: SpeechManager
let windowManagerRef: WindowManager
let cvAudioPlayerRef = CVAudioPlayer()
let speechManagerRef = SpeechManager()
let windowManagerRef = WindowManager()
private(set) var appIconBadgeUpdater: AppIconBadgeUpdater!
private(set) var avatarHistoryManager: AvatarHistoryManager!
@ -44,12 +44,8 @@ public class AppEnvironment: NSObject {
private var registrationIdMismatchManager: RegistrationIdMismatchManager!
init(appReadiness: AppReadiness, deviceTransferService: DeviceTransferService) {
self.cvAudioPlayerRef = CVAudioPlayer()
self.deviceTransferServiceRef = deviceTransferService
self.screenLockUI = ScreenLockUI(appReadiness: appReadiness)
self.pushRegistrationManagerRef = PushRegistrationManager(appReadiness: appReadiness)
self.speechManagerRef = SpeechManager()
self.windowManagerRef = WindowManager()
super.init()
@ -197,51 +193,6 @@ public class AppEnvironment: NSObject {
operation: { try await identityKeyMismatchManager.validateLocalPniIdentityKeyIfNecessary() },
)
let backupSubscriptionManager = DependenciesBridge.shared.backupSubscriptionManager
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeConnected: true,
operation: { try await backupSubscriptionManager.redeemSubscriptionIfNecessary() },
handleResult: {
switch $0 {
case .success, .failure(is CancellationError):
break
case .failure(let error):
Logger.warn("Terminally failed to redeem Backups subscription! \(error)")
}
},
)
let backupTestFlightEntitlementManager = DependenciesBridge.shared.backupTestFlightEntitlementManager
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeConnected: true,
operation: { try await backupTestFlightEntitlementManager.renewEntitlementIfNecessary() },
handleResult: {
switch $0 {
case .success, .failure(is CancellationError):
break
case .failure(let error):
Logger.warn("Terminally failed to redeem Backups TestFlight subscription! \(error)")
}
},
)
let donationSubscriptionManager = DependenciesBridge.shared.donationSubscriptionManager
cron.scheduleFrequently(
mustBeRegistered: true,
mustBeConnected: true,
operation: { try await donationSubscriptionManager.redeemSubscriptionIfNecessary() },
handleResult: {
switch $0 {
case .success, .failure(is CancellationError):
break
case .failure(let error):
Logger.warn("Terminally failed to redeem Donations subscription! \(error)")
}
},
)
appReadiness.runNowOrWhenAppWillBecomeReady {
self.badgeManager.startObservingChanges(in: DependenciesBridge.shared.databaseChangeObserver)
self.appIconBadgeUpdater.startObserving()
@ -252,11 +203,14 @@ public class AppEnvironment: NSObject {
let attachmentBackfillManager = DependenciesBridge.shared.attachmentBackfillManager
let backupExportJobRunner = DependenciesBridge.shared.backupExportJobRunner
let backupIdService = DependenciesBridge.shared.backupIdService
let backupSubscriptionManager = DependenciesBridge.shared.backupSubscriptionManager
let backupTestFlightEntitlementManager = DependenciesBridge.shared.backupTestFlightEntitlementManager
let callRecordStore = DependenciesBridge.shared.callRecordStore
let callRecordQuerier = DependenciesBridge.shared.callRecordQuerier
let db = DependenciesBridge.shared.db
let groupCallPeekClient = SSKEnvironment.shared.groupCallManagerRef.groupCallPeekClient
let interactionStore = DependenciesBridge.shared.interactionStore
let masterKeySyncManager = DependenciesBridge.shared.masterKeySyncManager
let notificationPresenter = SSKEnvironment.shared.notificationPresenterRef
let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
let storageServiceManager = SSKEnvironment.shared.storageServiceManagerRef
@ -287,7 +241,11 @@ public class AppEnvironment: NSObject {
// Things that should run on either the primary or linked devices.
if let registeredState, registeredState.isPrimary {
Task {
await avatarDefaultColorStorageServiceMigrator.performMigrationIfNecessary()
do {
try await avatarDefaultColorStorageServiceMigrator.performMigrationIfNecessary()
} catch {
Logger.warn("Couldn't perform avatar default color migration: \(error)")
}
}
Task {
@ -328,6 +286,12 @@ public class AppEnvironment: NSObject {
} else {
}
Task {
await db.awaitableWrite { tx in
masterKeySyncManager.runStartupJobs(tx: tx)
}
}
Task {
await db.awaitableWrite { tx in
groupCallRecordRingingCleanupManager.cleanupRingingCalls(tx: tx)
@ -341,6 +305,31 @@ public class AppEnvironment: NSObject {
Task {
await self.avatarHistoryManager.cleanupOrphanedImages()
}
Task {
do {
try await backupSubscriptionManager.redeemSubscriptionIfNecessary()
} catch {
owsFailDebug("Failed to redeem Backup subscription in launch job: \(error)")
}
}
Task {
do {
try await backupTestFlightEntitlementManager.renewEntitlementIfNecessary()
} catch {
owsFailDebug("Failed to renew Backup entitlement for TestFlight in launch job: \(error)")
}
}
Task {
await DonationSubscriptionManager.performMigrationToStorageServiceIfNecessary()
do {
try await DonationSubscriptionManager.redeemSubscriptionIfNecessary()
} catch {
owsFailDebug("Failed to redeem subscription in launch job: \(error)")
}
}
}
}
}

View File

@ -128,7 +128,6 @@ public class SignalApp {
owsFailDebug("Missing conversationSplitViewController.")
return
}
conversationSplitViewController.showAppSettingsWithMode(mode, completion: completion)
}
@ -224,6 +223,11 @@ public class SignalApp {
Logger.info("")
// If there's a presented blocking splash, but the user is trying to open a
// thread, dismiss it. We'll try again next time they open the app. We
// don't want to block them from accessing their conversations.
ExperienceUpgradeManager.dismissSplashWithoutCompletingIfNecessary()
if let visibleThread = conversationSplitViewController.visibleThread, visibleThread.uniqueId == threadUniqueId {
AppEnvironment.shared.windowManagerRef.minimizeCallIfNeeded()
conversationSplitViewController.selectedConversationViewController?.scrollToInitialPosition(animated: animated)

View File

@ -32,8 +32,8 @@ struct AvatarDefaultColorStorageServiceMigrator {
self.threadStore = threadStore
}
func performMigrationIfNecessary() async {
await db.awaitableWrite { tx in
func performMigrationIfNecessary() async throws {
try await db.awaitableWrite { tx in
if kvStore.hasValue(StoreKeys.hasEnqueuedMigrationKey, transaction: tx) {
return
}
@ -46,14 +46,15 @@ struct AvatarDefaultColorStorageServiceMigrator {
}
var groupV2MasterKeys = [GroupMasterKey]()
threadStore.enumerateGroupThreads(tx: tx) { groupThread in
if
try threadStore.enumerateGroupThreads(tx: tx) { groupThread in
guard
let groupModelV2 = groupThread.groupModel as? TSGroupModelV2,
let groupMasterKey = try? groupModelV2.masterKey()
{
groupV2MasterKeys.append(groupMasterKey)
else {
return true
}
groupV2MasterKeys.append(groupMasterKey)
return true
}

View File

@ -225,6 +225,7 @@ final class BackupDisablingManager {
accountEntropyPoolManager.setAccountEntropyPool(
newAccountEntropyPool: try! AccountEntropyPool(key: aepBeingRotatedString),
disablePIN: false,
tx: tx,
)
}

View File

@ -263,10 +263,7 @@ final class BackupEnablingManager {
private func enablePaidPlanWithoutStoreKit() async throws(SheetDisplayableError) {
do {
await db.awaitableWrite { tx in
backupTestFlightEntitlementManager.setRenewEntitlementIsNecessary(tx: tx)
}
try await backupTestFlightEntitlementManager.renewEntitlementIfNecessary()
try await backupTestFlightEntitlementManager.acquireEntitlement()
} catch where error.isNetworkFailureOrTimeout {
throw .networkError
} catch {

View File

@ -1,45 +0,0 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
final class BackupNeverShareRecoveryKeySheet: HeroSheetViewController {
init(
primaryButton: HeroSheetViewController.Button,
secondaryButton: HeroSheetViewController.Button?,
) {
let bodyText: NSAttributedString = NSAttributedString.composed(of: [
OWSLocalizedString(
"BACKUP_NEVER_SHARE_RECOVERY_KEY_SHEET_BODY",
comment: "Body for a warning sheet shown to discourage the user from sharing their 'Recovery Key', warning them not to share it with anyone.",
).styled(
with: .xmlRules([.style("bold", StringStyle(.font(.dynamicTypeSubheadline.bold())))]),
),
" ",
CommonStrings.learnMore.styled(
with: .link(.Support.phishingPrevention),
),
])
super.init(
hero: .circleIcon(
icon: .errorTriangle,
iconSize: 40,
tintColor: .Signal.red,
backgroundColor: UIColor(rgbHex: 0xF8E0D9),
),
title: OWSLocalizedString(
"BACKUP_NEVER_SHARE_RECOVERY_KEY_SHEET_TITLE",
comment: "Title for a warning sheet shown to discourage the user from sharing their 'Recovery Key'.",
),
body: HeroSheetViewController.Body(
textContent: .attributed(bodyText),
),
primary: .button(primaryButton),
secondary: secondaryButton.map { .button($0) },
)
}
}

View File

@ -1,105 +0,0 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SwiftUI
struct BackupPlanOptionView: View {
struct BulletPoint {
let icon: UIImage
let text: String
}
let title: String
let subtitle: String
let bullets: [BulletPoint]
let isCurrentPlan: Bool
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(alignment: .top) {
VStack(alignment: .leading) {
if isCurrentPlan {
Label(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_CURRENT_PLAN_LABEL",
comment: "A label indicating that a given Backup plan option is what the user has already enabled.",
),
systemImage: "checkmark",
)
.font(.footnote)
.foregroundStyle(Color.Signal.secondaryLabel)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(subtitle).foregroundStyle(Color.Signal.secondaryLabel)
ForEach(bullets, id: \.text) { bullet in
Label {
Text(bullet.text).font(.subheadline)
} icon: {
Image(uiImage: bullet.icon)
.foregroundStyle(
isSelected
? Color.Signal.ultramarine
: Color.Signal.label,
)
}
.padding(.leading, 20)
.padding(.vertical, 2)
}
}
Spacer()
Group {
if isSelected {
Circle()
.fill(Color.Signal.ultramarine)
.overlay {
Image(systemName: "checkmark")
.resizable()
.foregroundColor(.white)
.padding(6)
}
} else {
Circle()
.stroke(Color.Signal.secondaryLabel, lineWidth: 2)
.opacity(0.3)
}
}
.frame(width: 24, height: 24)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 20)
.padding(.leading, 20)
.padding(.trailing, 16)
.background(Color.Signal.secondaryGroupedBackground)
.cornerRadius(16)
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(
Color.Signal.ultramarine,
lineWidth: isSelected ? 3 : 0,
)
}
.shadow(
color: isSelected ? .black.opacity(0.12) : .clear,
radius: 8,
y: 2,
)
}
.buttonStyle(.plain)
}
}

View File

@ -77,9 +77,6 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro
override func viewDidLoad() {
super.viewDidLoad()
let screenLockUI = AppEnvironment.shared.screenLockUI
screenLockUI.sensitiveContentDidLoad(inViewController: self)
view.backgroundColor = .Signal.groupedBackground
if let onBackPressedBlock {
@ -118,7 +115,7 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro
comment: "Title for a button allowing users to copy their 'Recovery Key' to the clipboard.",
)),
primaryAction: UIAction { [weak self] _ in
self?.copyToClipboardWithConfirmation()
self?.copyToClipboard()
},
),
]
@ -178,26 +175,6 @@ class BackupRecordKeyViewController: OWSViewController, OWSNavigationChildContro
stackView.setCustomSpacing(32, after: aepTextView)
}
private func copyToClipboardWithConfirmation() {
let warningSheet = BackupNeverShareRecoveryKeySheet(
primaryButton: HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_RECORD_KEY_COPY_WARNING_SHEET_PRIMARY_BUTTON_TITLE",
comment: "Title for the primary button in a warning sheet shown before copying the user's 'Recovery Key' to the clipboard, which acknowledges the warning and proceeds with the copy.",
),
action: { sheet in
sheet.dismiss(animated: true) { [weak self] in
guard let self else { return }
copyToClipboard()
}
},
),
secondaryButton: nil,
)
present(warningSheet, animated: true)
}
private func copyToClipboard() {
UIPasteboard.general.setItems(
[[UIPasteboard.typeAutomatic: displayableAEP.displayString]],

View File

@ -16,7 +16,6 @@ class BackupSettingsViewController:
enum OnAppearAction {
case presentWelcomeToBackupsSheet
case automaticallyStartBackup(completion: ((UIViewController) -> Void)?)
case disableOptimizeLocalStorage
}
private let accountEntropyPoolManager: AccountEntropyPoolManager
@ -121,7 +120,7 @@ class BackupSettingsViewController:
self.onAppearAction = onAppearAction
switch onAppearAction {
case nil, .presentWelcomeToBackupsSheet, .disableOptimizeLocalStorage:
case .presentWelcomeToBackupsSheet, nil:
break
case .automaticallyStartBackup(let completion):
self.onBackupComplete = completion
@ -180,8 +179,6 @@ class BackupSettingsViewController:
presentWelcomeToBackupsSheet()
case .automaticallyStartBackup:
performManualBackup()
case .disableOptimizeLocalStorage:
setOptimizeLocalStorage(false)
}
}
@ -621,87 +618,28 @@ class BackupSettingsViewController:
final class WelcomeToBackupsSheet: HeroSheetViewController {
override var canBeDismissed: Bool { false }
init(
optimizeLocalStorage: (isOn: Bool, onValueChanged: (Bool) -> Void)?,
onConfirm: @escaping (HeroSheetViewController) -> Void,
) {
let toggle: HeroSheetViewController.Body.Toggle?
if let (isOn, onValueChanged) = optimizeLocalStorage {
toggle = HeroSheetViewController.Body.Toggle(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_TITLE",
comment: "Title for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.",
),
footer: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_FOOTER",
comment: "Footer for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.",
),
isOn: isOn,
onValueChanged: onValueChanged,
)
} else {
toggle = nil
}
init(onConfirm: @escaping () -> Void) {
super.init(
hero: .image(.backupsSubscribed),
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_TITLE",
comment: "Title for a sheet shown after the user enables backups.",
),
body: HeroSheetViewController.Body(
textContent: .plain(OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE",
comment: "Message for a sheet shown after the user enables backups.",
)),
toggle: toggle,
body: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE",
comment: "Message for a sheet shown after the user enables backups.",
),
primaryButton: HeroSheetViewController.Button(
title: CommonStrings.okButton,
action: { _ in onConfirm() },
),
primary: .button(HeroSheetViewController.Button(
title: OWSLocalizedString(
"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_BUTTON_TITLE",
comment: "Title for a button in a sheet shown after the user enables backups.",
),
action: { onConfirm($0) },
)),
secondary: nil,
)
}
}
let backupPlan = db.read { tx in
backupPlanManager.backupPlan(tx: tx)
}
let welcomeToBackupsSheet: WelcomeToBackupsSheet
switch backupPlan {
case .disabled,
.disabling,
.free:
welcomeToBackupsSheet = WelcomeToBackupsSheet(
optimizeLocalStorage: nil,
onConfirm: { sheet in
sheet.dismiss(animated: true) { [self] in
viewModel.performManualBackup()
}
},
)
case .paid,
.paidAsTester,
.paidExpiringSoon:
var isOptimizeStorageEnabled = false
welcomeToBackupsSheet = WelcomeToBackupsSheet(
optimizeLocalStorage: (
isOn: isOptimizeStorageEnabled,
onValueChanged: { isOptimizeStorageEnabled = $0 },
),
onConfirm: { sheet in
sheet.dismiss(animated: true) { [self] in
setOptimizeLocalStorage(isOptimizeStorageEnabled)
viewModel.performManualBackup()
}
},
)
let welcomeToBackupsSheet = WelcomeToBackupsSheet { [self] in
viewModel.performManualBackup()
dismiss(animated: true)
}
present(welcomeToBackupsSheet, animated: true)
@ -1034,15 +972,6 @@ class BackupSettingsViewController:
))
actionSheet.addAction(.cancel)
case BackupArchive.Response.BackupUploadFormError.tooLarge:
actionSheet = ActionSheetController(
message: OWSLocalizedString(
"BACKUP_SETTINGS_BACKUP_EXPORT_ERROR_SHEET_FILE_TOO_LARGE",
comment: "Message for an action sheet explaining that performing a backup failed because the backup file is too large to upload.",
),
)
actionSheet.addAction(.okay)
case _ where error.isNetworkFailureOrTimeout || error.is5xxServiceResponse:
actionSheet = ActionSheetController(
message: OWSLocalizedString(
@ -1080,38 +1009,35 @@ class BackupSettingsViewController:
// MARK: -
fileprivate func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool) {
let hasMadeAtLeastOneBackup: Bool? = db.write { tx in
let isPaidPlanTester: Bool = db.write { tx in
let currentBackupPlan = backupPlanManager.backupPlan(tx: tx)
let lastBackupDetails = backupSettingsStore.lastBackupDetails(tx: tx)
let newBackupPlan: BackupPlan
let isPaidPlanTester: Bool
switch currentBackupPlan {
case .disabled,
.disabling,
.free,
.paid(optimizeLocalStorage: newOptimizeLocalStorage),
.paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage),
.paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage):
return nil
case .disabled, .disabling, .free:
owsFailDebug("Shouldn't be setting Optimize Local Storage: \(currentBackupPlan)")
return false
case .paid:
newBackupPlan = .paid(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = false
case .paidExpiringSoon:
newBackupPlan = .paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = false
case .paidAsTester:
newBackupPlan = .paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage)
isPaidPlanTester = true
}
backupPlanManager.setBackupPlan(newBackupPlan, tx: tx)
return lastBackupDetails != nil
return isPaidPlanTester
}
if
hasMadeAtLeastOneBackup == true,
!newOptimizeLocalStorage
{
// If disabling Optimize Local Storage with media potentially
// offloaded, offer to start downloads now.
// If disabling Optimize Local Storage, offer to start downloads now.
if !newOptimizeLocalStorage {
showDownloadOffloadedMediaSheet()
} else if isPaidPlanTester {
showOffloadedMediaForTestersWarningSheet(onAcknowledge: {})
}
}
@ -1150,41 +1076,54 @@ class BackupSettingsViewController:
presentActionSheet(actionSheet)
}
private func showOffloadedMediaForTestersWarningSheet(
onAcknowledge: @escaping () -> Void,
) {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_TITLE",
comment: "Title for an action sheet warning users who are testers about the Optimize Local Storage feature.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_MESSAGE",
comment: "Message for an action sheet warning users who are testers about the Optimize Local Storage feature.",
),
)
actionSheet.addAction(ActionSheetAction(
title: CommonStrings.okButton,
handler: { _ in
onAcknowledge()
},
))
presentActionSheet(actionSheet)
}
// MARK: -
fileprivate func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan) {
if isSuspended {
let warningTitle: String?
let warningMessage: String?
switch backupPlan {
case .disabled, .disabling:
warningTitle = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_DISABLING_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning the user about skipping downloads while disabling Backups.",
)
warningMessage = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_DISABLING_WARNING_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about skipping downloads while disabling Backups.",
)
case .free, .paidExpiringSoon:
warningTitle = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_EXPIRING_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning the user about skipping downloads that will expire.",
)
warningMessage = OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_EXPIRING_WARNING_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about skipping downloads that will expire.",
)
case .paid, .paidAsTester:
warningTitle = nil
warningMessage = nil
}
if let warningTitle, let warningMessage {
case .disabled, .disabling, .free, .paid:
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
case .paidAsTester:
showOffloadedMediaForTestersWarningSheet(onAcknowledge: { [self] in
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
})
case .paidExpiringSoon:
let warningSheet = ActionSheetController(
title: warningTitle,
message: warningMessage,
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_WARNING_SHEET_TITLE",
comment: "Title for a sheet warning the user about skipping downloads.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_WARNING_SHEET_MESSAGE",
comment: "Message for a sheet warning the user about skipping downloads.",
),
)
warningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
@ -1193,31 +1132,9 @@ class BackupSettingsViewController:
),
style: .destructive,
handler: { [self] _ in
let secondWarningSheet = ActionSheetController(
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_TITLE",
comment: "Title for a double-confirmation sheet warning the user about skipping downloads.",
),
message: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_MESSAGE",
comment: "Message for a double-confirmation sheet warning the user about skipping downloads.",
),
)
secondWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"BACKUP_SETTINGS_SKIP_DOWNLOADS_SECOND_WARNING_SHEET_ACTION_SKIP",
comment: "Title for an action in a double-confirmation sheet warning the user about skipping downloads.",
),
style: .destructive,
handler: { [self] _ in
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
},
))
secondWarningSheet.addAction(.cancel)
presentActionSheet(secondWarningSheet)
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
},
))
warningSheet.addAction(ActionSheetAction(
@ -1232,10 +1149,6 @@ class BackupSettingsViewController:
warningSheet.addAction(.cancel)
presentActionSheet(warningSheet)
} else {
db.write { tx in
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
}
}
} else {
db.write { tx in
@ -1439,10 +1352,10 @@ class BackupSettingsViewController:
onConfirmed: { [weak self] _ in
guard let self else { return }
self.finalizeNewRecoveryKey(newCandidateAEP: newCandidateAEP)
// Pop all the way back to Backup Settings.
navigationController?.popToViewController(self, animated: true) {
self.finalizeNewRecoveryKey(newCandidateAEP: newCandidateAEP)
self.presentToast(text: OWSLocalizedString(
"BACKUP_SETTINGS_CREATE_NEW_KEY_SUCCESS_TOAST",
comment: "Toast shown when a new Recovery Key has been created successfully.",
@ -1468,6 +1381,7 @@ class BackupSettingsViewController:
accountEntropyPoolManager.setAccountEntropyPool(
newAccountEntropyPool: newCandidateAEP,
disablePIN: false,
tx: tx,
)
case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
@ -1710,21 +1624,16 @@ private class BackupSettingsViewModel: ObservableObject {
// MARK: -
/// Whether the "Optimze Storage" feature is available, per the current
/// `BackupPlan`.
var isOptimizeLocalStorageAvailable: Bool {
var optimizeLocalStorageAvailable: Bool {
switch backupPlan {
case .disabled, .disabling, .free:
false
case .paid, .paidAsTester:
case .paid, .paidExpiringSoon, .paidAsTester:
true
case .paidExpiringSoon(let optimizeLocalStorage):
// Only allow disabling Optimize Storage if expiring soon, not enabling.
optimizeLocalStorage
}
}
var isOptimizeLocalStorageEnabled: Bool {
var optimizeLocalStorage: Bool {
switch backupPlan {
case .disabled, .disabling, .free:
false
@ -1997,32 +1906,44 @@ struct BackupSettingsView: View {
viewModel: viewModel,
)
Toggle(
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE",
comment: "Title for a toggle allowing users to change the Optimize Local Storage setting.",
),
isOn: Binding(
get: { viewModel.isOptimizeLocalStorageEnabled },
set: { viewModel.setOptimizeLocalStorage($0) },
),
).disabled(!viewModel.isOptimizeLocalStorageAvailable)
} footer: {
let footerText: String = if viewModel.isOptimizeLocalStorageAvailable {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available.",
)
} else {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_UNAVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is unavailable.",
)
if BuildFlags.Backups.showOptimizeMedia {
Toggle(
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE",
comment: "Title for a toggle allowing users to change the Optimize Local Storage setting.",
),
isOn: Binding(
get: { viewModel.optimizeLocalStorage },
set: { viewModel.setOptimizeLocalStorage($0) },
),
).disabled(!viewModel.optimizeLocalStorageAvailable)
}
} footer: {
if BuildFlags.Backups.showOptimizeMedia {
let footerText: String = if
viewModel.optimizeLocalStorageAvailable,
viewModel.isPaidPlanTester
{
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE_FOR_TESTERS",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available and they are a tester.",
)
} else if viewModel.optimizeLocalStorageAvailable {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available.",
)
} else {
OWSLocalizedString(
"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_UNAVAILABLE",
comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is unavailable.",
)
}
Text(footerText)
.foregroundStyle(Color.Signal.secondaryLabel)
.font(.caption)
Text(footerText)
.foregroundStyle(Color.Signal.secondaryLabel)
.font(.caption)
}
}
SignalSection {
@ -3279,8 +3200,7 @@ private extension BackupSettingsViewModel {
backupSubscriptionLoadingState: .loaded(.paidButExpiring(
expirationDate: Date().addingTimeInterval(.week),
)),
backupPlan: .paidExpiringSoon(optimizeLocalStorage: true),
latestBackupAttachmentDownloadUpdateState: .suspended,
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
))
}

View File

@ -8,10 +8,7 @@ import SignalUI
import StoreKit
import SwiftUI
class ChooseBackupPlanViewController:
HostingController<ChooseBackupPlanView>,
ChooseBackupPlanViewModel.ActionsDelegate
{
class ChooseBackupPlanViewController: HostingController<ChooseBackupPlanView> {
typealias OnConfirmPlanSelectionBlock = (ChooseBackupPlanViewController, PlanSelection) -> Void
enum StoreKitAvailability {
@ -121,9 +118,11 @@ class ChooseBackupPlanViewController:
onConfirmPlanSelectionBlock: onConfirmPlanSelectionBlock,
)
}
}
// MARK: - ChooseBackupPlanViewModel.ActionsDelegate
// MARK: - ChooseBackupPlanViewModel.ActionsDelegate
extension ChooseBackupPlanViewController: ChooseBackupPlanViewModel.ActionsDelegate {
fileprivate func confirmSelection(_ planSelection: PlanSelection) {
switch (initialPlanSelection, planSelection) {
case (.free, .free), (.paid, .paid):
@ -234,7 +233,7 @@ struct ChooseBackupPlanView: View {
Spacer().frame(height: 20)
BackupPlanOptionView(
PlanOptionView(
title: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_FREE_PLAN_TITLE",
comment: "Title for the free plan option, when choosing a Backup plan.",
@ -248,11 +247,11 @@ struct ChooseBackupPlanView: View {
viewModel.freeMediaTierDays,
),
bullets: [
BackupPlanOptionView.BulletPoint(icon: .thread, text: OWSLocalizedString(
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included.",
)),
BackupPlanOptionView.BulletPoint(icon: .albumTilt, text: String.localizedStringWithFormat(
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: String.localizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_RECENT_MEDIA_BACKUP_%d",
tableName: "PluralAware",
@ -270,7 +269,7 @@ struct ChooseBackupPlanView: View {
Spacer().frame(height: 16)
BackupPlanOptionView(
PlanOptionView(
title: {
switch viewModel.storeKitAvailability {
case .available(let paidPlanDisplayPrice):
@ -293,15 +292,15 @@ struct ChooseBackupPlanView: View {
comment: "Subtitle for the paid plan option, when choosing a Backup plan.",
),
bullets: [
BackupPlanOptionView.BulletPoint(icon: .thread, text: OWSLocalizedString(
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_TEXT_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all text messages are included.",
)),
BackupPlanOptionView.BulletPoint(icon: .albumTilt, text: OWSLocalizedString(
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_FULL_MEDIA_BACKUP",
comment: "Text for a bullet point in a list of Backup features, describing that all media is included.",
)),
BackupPlanOptionView.BulletPoint(icon: .data, text: String.nonPluralLocalizedStringWithFormat(
PlanOptionView.BulletPoint(iconKey: "data", text: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_BULLET_STORAGE_AMOUNT",
comment: "Text for a bullet point in a list of Backup features, describing the amount of included storage. Embeds {{ the amount of storage preformatted as a localized byte count, e.g. '100 GB' }}.",
@ -384,6 +383,106 @@ struct ChooseBackupPlanView: View {
// MARK: -
private struct PlanOptionView: View {
struct BulletPoint {
let iconKey: String
let text: String
}
let title: String
let subtitle: String
let bullets: [BulletPoint]
let isCurrentPlan: Bool
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(alignment: .top) {
VStack(alignment: .leading) {
if isCurrentPlan {
Label(
OWSLocalizedString(
"CHOOSE_BACKUP_PLAN_CURRENT_PLAN_LABEL",
comment: "A label indicating that a given Backup plan option is what the user has already enabled.",
),
systemImage: "checkmark",
)
.font(.footnote)
.foregroundStyle(Color.Signal.secondaryLabel)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background {
Capsule().fill(Color.Signal.secondaryFill)
}
}
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
Text(subtitle).foregroundStyle(Color.Signal.secondaryLabel)
ForEach(bullets, id: \.iconKey) { bullet in
Label {
Text(bullet.text).font(.subheadline)
} icon: {
Image(bullet.iconKey)
.foregroundStyle(
isSelected
? Color.Signal.ultramarine
: Color.Signal.label,
)
}
.padding(.leading, 20)
.padding(.vertical, 2)
}
}
Spacer()
Group {
if isSelected {
Circle()
.fill(Color.Signal.ultramarine)
.overlay {
Image(systemName: "checkmark")
.resizable()
.foregroundColor(.white)
.padding(6)
}
} else {
Circle()
.stroke(Color.Signal.secondaryLabel, lineWidth: 2)
.opacity(0.3)
}
}
.frame(width: 24, height: 24)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 20)
.padding(.leading, 20)
.padding(.trailing, 16)
.background(Color.Signal.secondaryGroupedBackground)
.cornerRadius(16)
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(
Color.Signal.ultramarine,
lineWidth: isSelected ? 3 : 0,
)
}
.shadow(
color: isSelected ? .black.opacity(0.12) : .clear,
radius: 8,
y: 2,
)
}
.buttonStyle(.plain)
}
}
// MARK: -
#if DEBUG
private extension ChooseBackupPlanViewModel {

View File

@ -31,8 +31,9 @@ struct DisplayableAccountEntropyPool {
.uppercased()
.map { char in
switch char {
case "0": "="
case "O", "o": "#"
// TODO: Reenable this once support is available for all platforms
// case "0": "="
// case "O", "o": "#"
default: char
}
},

View File

@ -51,9 +51,6 @@ class EnterAccountEntropyPoolViewController: OWSViewController {
override func viewDidLoad() {
super.viewDidLoad()
let screenLockUI = AppEnvironment.shared.screenLockUI
screenLockUI.sensitiveContentDidLoad(inViewController: self)
view.backgroundColor = colorConfig.background
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: CommonStrings.nextButton,

View File

@ -71,7 +71,6 @@ struct BackupOnboardingIntroView: View {
Image(.backupsLogo)
.frame(width: 80, height: 80)
.accessibilityHidden(true)
Spacer().frame(height: 16)
HStack {

View File

@ -9,7 +9,7 @@ import SignalServiceKit
final class AdHocCallStateObserver {
private let adHocCallRecordManager: any AdHocCallRecordManager
private let callLinkStore: CallLinkRecordStore
private let callLinkStore: any CallLinkRecordStore
private let db: any DB
private let messageSenderJobQueue: MessageSenderJobQueue
@ -32,7 +32,7 @@ final class AdHocCallStateObserver {
init(
callLinkCall: CallLinkCall,
adHocCallRecordManager: any AdHocCallRecordManager,
callLinkStore: CallLinkRecordStore,
callLinkStore: any CallLinkRecordStore,
messageSenderJobQueue: MessageSenderJobQueue,
db: any DB,
) {
@ -62,29 +62,33 @@ final class AdHocCallStateObserver {
}
self.furthestJoinLevel = joinLevel
db.write { tx in
let rootKey = callLinkCall.callLink.rootKey
var (callLink, inserted) = callLinkStore.fetchOrInsert(rootKey: rootKey, tx: tx)
if inserted {
callLink.updateState(callLinkCall.callLinkState)
callLinkStore.update(callLink, tx: tx)
do {
let rootKey = callLinkCall.callLink.rootKey
var (callLink, inserted) = try callLinkStore.fetchOrInsert(rootKey: rootKey, tx: tx)
if inserted {
callLink.updateState(callLinkCall.callLinkState)
try callLinkStore.update(callLink, tx: tx)
}
if callLink.adminPasskey == nil, !callLink.isDeleted {
let updateSender = CallLinkUpdateMessageSender(messageSenderJobQueue: messageSenderJobQueue)
updateSender.sendCallLinkUpdateMessage(rootKey: rootKey, adminPasskey: nil, tx: tx)
}
try adHocCallRecordManager.createOrUpdateRecord(
callId: callIdFromEra(eraId),
callLink: callLink,
status: { () -> CallRecord.CallStatus.CallLinkCallStatus in
switch joinLevel {
case .attempted: return .generic
case .joined: return .joined
}
}(),
timestamp: Date.ows_millisecondTimestamp(),
shouldSendSyncMessge: true,
tx: tx,
)
} catch {
owsFailDebug("Couldn't update CallRecord: \(error)")
}
if callLink.adminPasskey == nil, !callLink.isDeleted {
let updateSender = CallLinkUpdateMessageSender(messageSenderJobQueue: messageSenderJobQueue)
updateSender.sendCallLinkUpdateMessage(rootKey: rootKey, adminPasskey: nil, tx: tx)
}
adHocCallRecordManager.createOrUpdateRecord(
callId: callIdFromEra(eraId),
callLink: callLink,
status: { () -> CallRecord.CallStatus.CallLinkCallStatus in
switch joinLevel {
case .attempted: return .generic
case .joined: return .joined
}
}(),
timestamp: Date.ows_millisecondTimestamp(),
shouldSendSyncMessge: true,
tx: tx,
)
}
}
@ -101,11 +105,15 @@ final class AdHocCallStateObserver {
}
self.activeEraId = .some(peekInfo.eraId)
db.write { tx in
adHocCallRecordManager.handlePeekResult(
eraId: peekInfo.eraId,
rootKey: self.callLinkCall.callLink.rootKey,
tx: tx,
)
do {
try adHocCallRecordManager.handlePeekResult(
eraId: peekInfo.eraId,
rootKey: self.callLinkCall.callLink.rootKey,
tx: tx,
)
} catch {
owsFailDebug("\(error)")
}
}
}
}

View File

@ -8,12 +8,12 @@ import SignalServiceKit
/// Refreshes call links that need to be updated.
actor CallLinkFetchJobRunner: DatabaseChangeDelegate {
private let callLinkStore: CallLinkRecordStore
private let callLinkStore: any CallLinkRecordStore
private let callLinkStateUpdater: CallLinkStateUpdater
private let db: any DB
init(
callLinkStore: CallLinkRecordStore,
callLinkStore: any CallLinkRecordStore,
callLinkStateUpdater: CallLinkStateUpdater,
db: any DB,
) {
@ -52,8 +52,13 @@ actor CallLinkFetchJobRunner: DatabaseChangeDelegate {
var sequentialFailureCount = 0
while true {
let callLinkToFetch = db.read { tx in
callLinkStore.fetchAnyPendingRecord(tx: tx)
let callLinkToFetch: CallLinkRecord?
do {
callLinkToFetch = try db.read(block: callLinkStore.fetchAnyPendingRecord(tx:))
} catch {
owsFailDebug("Can't fetch pending record: \(error)")
mightHavePendingFetch = false
return
}
guard let callLinkToFetch else {
// Nothing to fetch.

View File

@ -18,7 +18,7 @@ actor CallLinkStateUpdater {
private let authCredentialManager: any AuthCredentialManager
private let callLinkFetcher: CallLinkFetcherImpl
private let callLinkManager: any CallLinkManager
private let callLinkStore: CallLinkRecordStore
private let callLinkStore: any CallLinkRecordStore
private let callRecordDeleteManager: any CallRecordDeleteManager
private let callRecordStore: any CallRecordStore
private let db: any DB
@ -30,7 +30,7 @@ actor CallLinkStateUpdater {
authCredentialManager: any AuthCredentialManager,
callLinkFetcher: CallLinkFetcherImpl,
callLinkManager: any CallLinkManager,
callLinkStore: CallLinkRecordStore,
callLinkStore: any CallLinkRecordStore,
callRecordDeleteManager: any CallRecordDeleteManager,
callRecordStore: any CallRecordStore,
db: any DB,
@ -90,8 +90,8 @@ actor CallLinkStateUpdater {
}
let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
let oldRecord = db.read { tx -> CallLinkRecord? in
return callLinkStore.fetch(roomId: roomId, tx: tx)
let oldRecord = try db.read { tx -> CallLinkRecord? in
return try callLinkStore.fetch(roomId: roomId, tx: tx)
}
let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: registeredState.localIdentifiers)
let updateResult = await Result { try await updateAndFetch(authCredential) }
@ -113,8 +113,8 @@ actor CallLinkStateUpdater {
throw error
}
await db.awaitableWrite { tx in
if var newRecord = self.callLinkStore.fetch(roomId: roomId, tx: tx) {
try await db.awaitableWrite { tx in
if var newRecord = try self.callLinkStore.fetch(roomId: roomId, tx: tx) {
if !newRecord.isDeleted {
switch updateAction {
case .update(let newState):
@ -123,7 +123,7 @@ actor CallLinkStateUpdater {
break
case .delete:
newRecord.markDeleted(atTimestampMs: Date.ows_millisecondTimestamp())
self.callRecordDeleteManager.deleteCallRecords(
try self.callRecordDeleteManager.deleteCallRecords(
self.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: newRecord.id), limit: nil, tx: tx),
sendSyncMessageOnDelete: true,
tx: tx,
@ -133,7 +133,7 @@ actor CallLinkStateUpdater {
if newRecord.pendingFetchCounter == oldRecord?.pendingFetchCounter {
newRecord.clearNeedsFetch()
}
self.callLinkStore.update(newRecord, tx: tx)
try self.callLinkStore.update(newRecord, tx: tx)
}
}

View File

@ -192,16 +192,13 @@ class CallQualitySurveyManager {
return proto
}
func submit(
rating: CallQualitySurvey.Rating,
logsToSubmit logs: DebugLogs?,
) {
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
var proto = buildProto(rating: rating)
Task {
if let logs {
if shouldSubmitDebugLogs {
do {
let debugLogURL = try await logs.uploadLogs()
let debugLogURL = try await DebugLogs.uploadLogs(dumper: .fromGlobals())
proto.debugLogURL = debugLogURL.absoluteString
} catch {
logger.error("Failed to submit debug logs: \(error)")

View File

@ -27,7 +27,7 @@ final class CallService: CallServiceStateObserver, CallServiceStateDelegate {
private var adHocCallRecordManager: any AdHocCallRecordManager { DependenciesBridge.shared.adHocCallRecordManager }
private let appReadiness: AppReadiness
private var audioSession: AudioSession { SUIEnvironment.shared.audioSessionRef }
private var callLinkStore: CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private var callLinkStore: any CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private var chatConnectionManager: any ChatConnectionManager { DependenciesBridge.shared.chatConnectionManager }
let authCredentialManager: any AuthCredentialManager
private var databaseStorage: SDSDatabaseStorage { SSKEnvironment.shared.databaseStorageRef }
@ -91,7 +91,7 @@ final class CallService: CallServiceStateObserver, CallServiceStateDelegate {
appReadiness: AppReadiness,
authCredentialManager: any AuthCredentialManager,
callLinkPublicParams: GenericServerPublicParams,
callLinkStore: CallLinkRecordStore,
callLinkStore: any CallLinkRecordStore,
callRecordDeleteManager: any CallRecordDeleteManager,
callRecordStore: any CallRecordStore,
callServiceSettingsStore: CallServiceSettingsStore,
@ -658,8 +658,8 @@ final class CallService: CallServiceStateObserver, CallServiceStateDelegate {
}
let localIdentifiers = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction!
let authCredential = try await authCredentialManager.fetchCallLinkAuthCredential(localIdentifiers: localIdentifiers)
let (adminPasskey, isDeleted) = databaseStorage.read { tx -> (Data?, Bool) in
let callLinkRecord = callLinkStore.fetch(roomId: callLink.rootKey.deriveRoomId(), tx: tx)
let (adminPasskey, isDeleted) = try databaseStorage.read { tx -> (Data?, Bool) in
let callLinkRecord = try callLinkStore.fetch(roomId: callLink.rootKey.deriveRoomId(), tx: tx)
return (callLinkRecord?.adminPasskey, callLinkRecord?.isDeleted == true)
}
let serverPublicParams = CallService.serverPublicParams()

View File

@ -282,16 +282,20 @@ private extension GroupCallRecordManager {
}
logger.info("Creating or updating record for group call join.")
createOrUpdateCallRecord(
callId: callId,
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
callEventTimestamp: joinTimestamp,
shouldSendSyncMessage: true,
tx: tx,
)
do {
try createOrUpdateCallRecord(
callId: callId,
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: callDirection,
groupCallStatus: groupCallStatus,
callEventTimestamp: joinTimestamp,
shouldSendSyncMessage: true,
tx: tx,
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
}
/// Create or update a call record in response to the local declining a ring
@ -310,15 +314,19 @@ private extension GroupCallRecordManager {
}
logger.info("Creating or updating record for group ring decline.")
createOrUpdateCallRecord(
callId: callIdFromRingId(ringId),
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: .incoming,
groupCallStatus: .ringingDeclined,
callEventTimestamp: Date().ows_millisecondsSince1970,
shouldSendSyncMessage: true,
tx: tx,
)
do {
try createOrUpdateCallRecord(
callId: callIdFromRingId(ringId),
groupThread: groupThread,
groupThreadRowId: groupThreadRowId,
callDirection: .incoming,
groupCallStatus: .ringingDeclined,
callEventTimestamp: Date().ows_millisecondsSince1970,
shouldSendSyncMessage: true,
tx: tx,
)
} catch let error {
owsFailBeta("Failed to insert call record: \(error)")
}
}
}

View File

@ -240,7 +240,7 @@ extension CallControlsOverflowView: MessageReactionPickerDelegate {
self.react(with: reaction)
}
func didSelectShowFullEmojiPicker() {
func didSelectAnyEmoji() {
let sheet = EmojiPickerSheet(
message: nil,
reactionPickerConfigurationListener: self,

View File

@ -290,7 +290,7 @@ class IndividualCallSheetDataSource: CallDrawerSheetDataSource {
isLocalUser: false,
isUnknown: false,
isAudioMuted: self.individualCall.isRemoteAudioMuted,
isVideoMuted: !self.individualCall.isRemoteVideoEnabled,
isVideoMuted: self.individualCall.isRemoteVideoEnabled.negated,
isPresenting: self.individualCall.isRemoteSharingScreen,
))
}

View File

@ -14,7 +14,7 @@ final class CallLinkViewController: OWSTableViewController2 {
override var navbarBackgroundColorOverride: UIColor? { tableBackgroundColor }
private var db: any DB { DependenciesBridge.shared.db }
private var callLinkStore: CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private var callLinkStore: any CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
private let callLink: CallLink
@ -259,10 +259,14 @@ final class CallLinkViewController: OWSTableViewController2 {
private func createCallLinkRecord() -> Int64 {
let rowId = SSKEnvironment.shared.databaseStorageRef.write { tx in
var callLinkRecord: CallLinkRecord
(callLinkRecord, _) = callLinkStore.fetchOrInsert(rootKey: callLink.rootKey, tx: tx)
callLinkRecord.adminPasskey = adminPasskey!
callLinkRecord.updateState(callLinkState!)
callLinkStore.update(callLinkRecord, tx: tx)
do {
(callLinkRecord, _) = try callLinkStore.fetchOrInsert(rootKey: callLink.rootKey, tx: tx)
callLinkRecord.adminPasskey = adminPasskey!
callLinkRecord.updateState(callLinkState!)
try callLinkStore.update(callLinkRecord, tx: tx)
} catch {
owsFail("Couldn't create CallLinkRecord: \(error)")
}
CallLinkUpdateMessageSender(
messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef,
@ -329,14 +333,19 @@ final class CallLinkViewController: OWSTableViewController2 {
extension CallLinkViewController: DatabaseChangeDelegate {
private func loadStateAndReloadViewIfNeeded(callLinkRowId: Int64) {
let didChangeVisibleProperty: Bool
let oldState = self.callLinkState
let newState = self.db.read { tx in callLinkStore.fetch(rowId: callLinkRowId, tx: tx)?.state }
didChangeVisibleProperty = (
(oldState == nil) != (newState == nil)
|| (oldState?.name != newState?.name)
|| (oldState?.requiresAdminApproval != newState?.requiresAdminApproval),
)
self.callLinkState = newState
do {
let oldState = self.callLinkState
let newState = try self.db.read { tx in try callLinkStore.fetch(rowId: callLinkRowId, tx: tx)?.state }
didChangeVisibleProperty = (
(oldState == nil) != (newState == nil)
|| (oldState?.name != newState?.name)
|| (oldState?.requiresAdminApproval != newState?.requiresAdminApproval),
)
self.callLinkState = newState
} catch {
owsFailDebug("Couldn't fetch CallLink: \(error)")
return
}
if didChangeVisibleProperty, self.isViewLoaded {
updateContents(shouldReload: true)
}

View File

@ -49,7 +49,7 @@ extension CallsListViewController {
case newer
}
private let callLinkStore: CallLinkRecordStore
private let callLinkStore: any CallLinkRecordStore
private let callRecordLoader: CallRecordLoader
private let callViewModelForCallRecords: CallViewModelForCallRecords
private let callViewModelForUpcomingCallLink: CallViewModelForUpcomingCallLink
@ -59,7 +59,7 @@ extension CallsListViewController {
private let maxCoalescedCallsInOneViewModel: Int
init(
callLinkStore: CallLinkRecordStore,
callLinkStore: any CallLinkRecordStore,
callRecordLoader: CallRecordLoader,
callViewModelForCallRecords: @escaping CallViewModelForCallRecords,
callViewModelForUpcomingCallLink: @escaping CallViewModelForUpcomingCallLink,
@ -233,7 +233,13 @@ extension CallsListViewController {
guard shouldFetchUpcomingCallLinks else {
return
}
let upcomingCallLinks = callLinkStore.fetchUpcoming(earlierThan: nil, limit: 2048, tx: tx)
let upcomingCallLinks: [CallLinkRecord]
do {
upcomingCallLinks = try callLinkStore.fetchUpcoming(earlierThan: nil, limit: 2048, tx: tx)
} catch {
Logger.warn("Couldn't fetch call links to show on the calls tab: \(error)")
return
}
self.upcomingCallLinkReferences = upcomingCallLinks.map {
return UpcomingCallLinkReference(callLinkRowId: $0.id)
}

View File

@ -36,7 +36,7 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
let adHocCallRecordManager: any AdHocCallRecordManager
let badgeManager: BadgeManager
let blockingManager: BlockingManager
let callLinkStore: CallLinkRecordStore
let callLinkStore: any CallLinkRecordStore
let callRecordDeleteAllJobQueue: CallRecordDeleteAllJobQueue
let callRecordDeleteManager: any CallRecordDeleteManager
let callRecordMissedCallManager: CallRecordMissedCallManager
@ -416,12 +416,12 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
// because they must first be deleted on the server. (We delete them
// individually at the end of this method.)
let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)]
callLinksToDelete = self.deps.callLinkStore.fetchAll(tx: tx).compactMap {
callLinksToDelete = (try? self.deps.callLinkStore.fetchAll(tx: tx).compactMap {
guard let adminPasskey = $0.adminPasskey else {
return nil
}
return ($0.rootKey, adminPasskey)
}
}) ?? []
/// Delete-all should use the timestamp of the most-recent call, at
/// the time the action was initiated, as the timestamp we delete
/// before (and include in the outgoing sync message).
@ -720,7 +720,7 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
// Query the database separately when starting & ending calls because the
// row will usually be inserted during the call (ie `rowId` may be nil when
// starting the call but nonnil when ending the very same call).
let rowId = deps.db.read { tx in deps.callLinkStore.fetch(roomId: call.callLink.rootKey.deriveRoomId(), tx: tx)?.id }
let rowId = deps.db.read { tx in try? deps.callLinkStore.fetch(roomId: call.callLink.rootKey.deriveRoomId(), tx: tx)?.id }
guard let rowId else {
// If you open the lobby for an ongoing call that you've never joined,
// we'll call this method after the peek succeeds. However, you haven't
@ -1015,8 +1015,13 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
} else {
return nil
}
return deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
do {
return try deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
owsFail("Couldn't load CallLinkRecord that must exist!")
}()
} catch {
owsFail("Couldn't load CallLinkRecord that must exist: \(error)")
}
}()
if let callLinkRecord {
@ -1231,8 +1236,8 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
} catch CallLinkManagerImpl.PeekError.expired, CallLinkManagerImpl.PeekError.invalid {
eraId = nil
}
await deps.db.awaitableWrite { tx in
deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx)
try await deps.db.awaitableWrite { tx in
try deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx)
}
}
@ -2046,9 +2051,15 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
guard let callLinkRowId else {
return false
}
let callLinkRecord = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
return callLinkRecord.adminPasskey != nil
do {
let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
throw OWSAssertionError("Couldn't fetch CallLink that must exist.")
}()
return callLinkRecord.adminPasskey != nil
} catch {
owsFailDebug("\(error)")
return false
}
}
}
@ -2058,17 +2069,18 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
// First, delete everything that's local only. This includes thread-based
// calls & any call link calls for which we're not the admin. These
// deletions never fail (except for db corruption-level failures).
callLinksToDelete = await deps.databaseStorage.awaitableWrite { tx in
callLinksToDelete = try await deps.databaseStorage.awaitableWrite { tx in
var callLinksToDelete = [(rootKey: CallLinkRootKey, adminPasskey: Data)]()
var callRecordIdsWithInteractions = [CallRecord.ID]()
for modelReferences in modelReferenceses {
if let callLinkRowId = modelReferences.callLinkRowId {
let callLinkRecord = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
throw OWSAssertionError("Couldn't fetch CallLink that must exist.")
}()
if let adminPasskey = callLinkRecord.adminPasskey {
callLinksToDelete.append((callLinkRecord.rootKey, adminPasskey))
} else {
self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx)
try self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx)
}
} else {
callRecordIdsWithInteractions.append(contentsOf: modelReferences.callRecordRowIds)
@ -2095,8 +2107,8 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
try await deleteCallLinks(callLinksToDelete: callLinksToDelete)
}
private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) {
let callRecords = deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx)
private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) throws {
let callRecords = try deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx)
deps.callRecordDeleteManager.deleteCallRecords(callRecords, sendSyncMessageOnDelete: true, tx: tx)
}
@ -2190,8 +2202,13 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
private func showCallInfo(forRootKey rootKey: CallLinkRootKey, callRecords: [CallRecord]) {
let callLinkRecord = deps.db.read { tx -> CallLinkRecord in
return deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx)
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
do {
return try deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx) ?? {
owsFail("Can't fetch CallLinkRecord that must exist.")
}()
} catch {
owsFail("Can't fetch CallLinkRecord: \(error)")
}
}
showCallInfo(viewController: CallLinkViewController.forExisting(callLinkRecord: callLinkRecord, callRecords: callRecords))
}
@ -2302,14 +2319,23 @@ private extension CallsListViewController {
}()
private func makeStartCallButton(viewModel: CallViewModel) -> UIButton {
var config = UIButton.Configuration.gray()
config.cornerStyle = .capsule
config.background.backgroundInsets = .init(margin: 2)
config.baseBackgroundColor = UIColor.Signal.tertiaryFill
config.baseForegroundColor = UIColor.Signal.label
let icon: ThemeIcon = switch viewModel.medium {
case .audio:
.buttonVoiceCall
case .video, .link:
.buttonVideoCall
}
config.image = Theme.iconImage(icon)
let button = UIButton(
configuration: .roundGray(image: Theme.iconImage(icon)),
configuration: config,
primaryAction: UIAction { [weak self] _ in
self?.detailsTapped(viewModel: viewModel)
},

View File

@ -125,7 +125,6 @@ extension NewCallViewController: RecipientContextMenuHelperDelegate {
// MARK: - RecipientPickerDelegate
extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelegate {
func recipientPicker(
_ recipientPickerViewController: RecipientPickerViewController,
selectionStyleForRecipient recipient: PickedRecipient,
@ -134,10 +133,7 @@ extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelega
return .default
}
func recipientPicker(
_ recipientPickerViewController: RecipientPickerViewController,
didSelectRecipient recipient: PickedRecipient,
) {
func recipientPicker(_ recipientPickerViewController: RecipientPickerViewController, didSelectRecipient recipient: PickedRecipient) {
switch recipient.identifier {
case let .address(address):
let thread = TSContactThread.getOrCreateThread(contactAddress: address)
@ -147,12 +143,7 @@ extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelega
}
}
func recipientPicker(
_ recipientPickerViewController: RecipientPickerViewController,
contactCellAccessoryForRecipient recipient: PickedRecipient,
transaction: DBReadTransaction,
) -> ContactCellView.Accessory? {
func recipientPicker(_ recipientPickerViewController: RecipientPickerViewController, accessoryViewForRecipient recipient: PickedRecipient, transaction: DBReadTransaction) -> ContactCellAccessoryView? {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 20

View File

@ -16,12 +16,10 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
private let tableViewController = OWSTableViewController2()
private var shouldSubmitDebugLogs = false
private var logs: DebugLogs
private let rating: CallQualitySurvey.Rating
init(rating: CallQualitySurvey.Rating) {
self.logs = DebugLogs(dumper: .fromGlobals())
self.rating = rating
super.init(nibName: nil, bundle: nil)
}
@ -131,8 +129,7 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
let container = UIView()
let textView = LinkingTextView { [weak self] in
guard let self else { return }
self.logs.showPreview(from: self)
self?.showDebugLogPreview()
}
textView.attributedText = .composed(of: [
OWSLocalizedString(
@ -196,6 +193,12 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
present(nav, animated: true)
}
private func showDebugLogPreview() {
let vc = DebugLogPreviewViewController()
let nav = OWSNavigationController(rootViewController: vc)
present(nav, animated: true)
}
override func customSheetHeight() -> CGFloat? {
let headerHeight = headerContainer.height
let collectionViewHeight = tableViewController.tableView.contentSize.height + tableViewController.tableView.contentInset.totalHeight
@ -206,7 +209,7 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
private func submit() {
sheetNav?.submit(
rating: self.rating,
logsToSubmit: shouldSubmitDebugLogs ? logs : nil,
shouldSubmitDebugLogs: self.shouldSubmitDebugLogs,
)
}
}

View File

@ -82,13 +82,10 @@ final class CallQualitySurveyNavigationController: UINavigationController {
pushViewController(vc, animated: false)
}
func submit(
rating: CallQualitySurvey.Rating,
logsToSubmit: DebugLogs?,
) {
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
callQualitySurveyManager.submit(
rating: rating,
logsToSubmit: logsToSubmit,
shouldSubmitDebugLogs: shouldSubmitDebugLogs,
)
let host = presentingViewController
dismiss(animated: true) {

View File

@ -159,10 +159,11 @@ class MessageUserSubsetSheet: OWSTableSheetViewController {
cell.selectionStyle = .none
var configuration = ContactCellView.Configuration(address: address, localUserDisplayMode: .asLocalUser)
let configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .asLocalUser)
configuration.forceDarkAppearance = self?.forceDarkMode ?? false
if
BuildFlags.MemberLabel.display,
let groupThread = self?.groupThread,
let senderAci = address.aci,
let memberLabelString = groupThread.groupModel.groupMembership.memberLabel(for: senderAci)?.labelForRendering(),

View File

@ -149,8 +149,6 @@ public class CVViewState: NSObject {
var unwrappedGiftMessageIds = Set<String>()
// MARK: - Collapse Sets
/// The set of collapse set IDs that have been expanded by the user.
/// Resets to empty when leaving the conversation.
var expandedCollapseSets = Set<String>()

View File

@ -114,7 +114,9 @@ class CVAttachmentProgressView: ManualLayoutView {
addLayoutBlock { view in
guard let view = view as? CVAttachmentProgressView else { return }
view.loadInitialStateIfNeeded()
DispatchQueue.main.async {
view.loadInitialStateIfNeeded()
}
}
}
@ -192,19 +194,14 @@ class CVAttachmentProgressView: ManualLayoutView {
applyState(.tapToDownload, animated: animateStateChange)
case .enqueuedOrDownloading:
applyState(.unknownProgress, animated: animateStateChange)
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadProgressNotification,
object: nil,
)
}
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadProgressNotification,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(processDownloadStoppedNotification(notification:)),
name: AttachmentDownloads.attachmentDownloadStoppedNotification,
object: nil,
)
}
}
@ -363,22 +360,6 @@ class CVAttachmentProgressView: ManualLayoutView {
applyState(.progress(progress: progress), animated: window != nil)
}
@objc
private func processDownloadStoppedNotification(notification: Notification) {
AssertIsOnMainThread()
guard
let attachmentId = notification.userInfo?[AttachmentDownloads.attachmentDownloadAttachmentIDKey] as? Attachment.IDType
else {
owsFailDebug("Missing notificationAttachmentId.")
return
}
guard attachmentId == self.attachmentId else {
return
}
applyState(.tapToDownload, animated: window != nil)
}
@objc
private func processUploadNotification(notification: Notification) {
AssertIsOnMainThread()

View File

@ -743,8 +743,8 @@ public class CVPollView: ManualStackView {
switch type {
case .pendingVote, .pendingUnvote:
let spinningEllipse = UIImageView(image: Theme.iconImage(.ellipse))
let checkMark = UIImageView(image: Theme.iconImage(.checkmark))
let spinningEllipse = UIImageView(image: UIImage(named: Theme.iconName(.ellipse)))
let checkMark = UIImageView(image: UIImage(named: Theme.iconName(.checkmark)))
checkboxContainer.addSubview(spinningEllipse, withLayoutBlock: { [weak self] _ in
guard let self else { return }
spinView(view: spinningEllipse)
@ -769,7 +769,7 @@ public class CVPollView: ManualStackView {
pollIsEnded: Bool,
pendingVotesCount: Int,
) {
let circle = UIImageView(image: Theme.iconImage(.circle))
let circle = UIImageView(image: UIImage(named: Theme.iconName(.circle)))
let checkBoxSize = pollIsEnded ? configurator.checkBoxEndedSize : configurator.checkBoxSize
checkboxContainer.addSubview(circle, withLayoutBlock: { [weak self] _ in
@ -785,7 +785,7 @@ public class CVPollView: ManualStackView {
switch localUserVoteState {
case .vote:
let checkMarkCircle = UIImageView(image: Theme.iconImage(.checkCircleFill))
let checkMarkCircle = UIImageView(image: UIImage(named: Theme.iconName(.checkCircleFill)))
checkboxContainer.addSubview(checkMarkCircle, withLayoutBlock: { [weak self] _ in
guard let self else { return }
let subviewFrame = CGRect(

View File

@ -6,41 +6,63 @@
import SignalServiceKit
import SignalUI
/// ManualLayoutView wrapper around SelectionIndicatorView.
class MessageSelectionView: ManualLayoutView {
private let selectionIndicatorView = SelectionIndicatorView(style: .list)
var isSelected: Bool {
get {
selectionIndicatorView.isSelected
}
set {
selectionIndicatorView.isSelected = newValue
var isSelected: Bool = false {
didSet {
selectedView.isHidden = !isSelected
unselectedView.isHidden = isSelected
}
}
init() {
super.init(name: "MessageSelectionView")
addSubviewToFillSuperviewEdges(selectionIndicatorView)
addSubviewToCenterOnSuperview(selectedView, size: .square(Self.circleDiameter))
addSubviewToCenterOnSuperview(unselectedView, size: .square(Self.circleDiameter))
addLayoutBlock { view in
guard let selectionView = view as? MessageSelectionView else { return }
selectionView.checkmarkIcon.center = selectionView.selectedView.bounds.center
}
selectedView.isHidden = !isSelected
}
static var preferredSize: CGSize {
CGSize(square: SelectionIndicatorView.preferredSize)
CGSize(square: ConversationStyle.selectionViewWidth)
}
private static var circleDiameter: CGFloat {
// 22 dp as per spec
ConversationStyle.selectionViewWidth - 2
}
private static var emptyCheckmarkStrokeLineWidth: CGFloat { 2 }
private lazy var checkmarkIcon: UIImageView = {
let imageView = UIImageView(image: UIImage(named: "check-compact"))
imageView.contentMode = .scaleAspectFit
imageView.tintColor = .white
return imageView
}()
private lazy var selectedView: UIView = {
let circleView = CircleView(frame: .init(origin: .zero, size: .square(MessageSelectionView.circleDiameter)))
circleView.addSubview(checkmarkIcon)
return circleView
}()
private lazy var unselectedView: UIView = {
let circleView = RingView()
circleView.lineWidth = MessageSelectionView.emptyCheckmarkStrokeLineWidth
return circleView
}()
func updateStyle(conversationStyle: ConversationStyle) {
selectionIndicatorView.fillColor = conversationStyle.chatColorValue.asChatUIElementTintColor()
// Less transparent empty circle when there's a wallpaper and we're in light theme
// to improve legibility over darker wallpapers.
if
conversationStyle.isDarkThemeEnabled == false,
conversationStyle.hasWallpaper
{
selectionIndicatorView.unselectedListIndicatorColor = UIColor(rgbHex: 0x808080, alpha: 0.5)
} else {
selectionIndicatorView.unselectedListIndicatorColor = nil // reset to default
}
AssertIsOnMainThread()
selectedView.backgroundColor = conversationStyle.chatColorValue.asChatUIElementTintColor()
unselectedView.tintColor = UIColor.Signal.tertiaryLabel
}
}

View File

@ -65,7 +65,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
let hasWallpaper = conversationStyle.hasWallpaper
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
let isReusing = componentView.rootView.superview != nil
&& componentView.innerStack.superview != nil
&& componentView.label.superview != nil
&& !wallpaperModeHasChanged
if !isReusing {
@ -75,32 +75,19 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView.hasWallpaper = hasWallpaper
labelConfig.applyForRendering(label: componentView.label)
chevronConfig.applyForRendering(label: componentView.chevronLabel)
if isReusing {
componentView.innerStack.configureForReuse(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
)
componentView.outerStack.configureForReuse(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
)
} else {
componentView.innerStack.configure(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
subviews: [componentView.label, componentView.chevronContainer],
)
componentView.outerStack.configure(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
subviews: [componentView.innerStack],
subviews: [componentView.label],
)
let bubbleView: UIView
@ -123,20 +110,13 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
}
componentView.outerStack.addSubview(bubbleView)
componentView.outerStack.sendSubviewToBack(bubbleView)
componentView.outerStack.addLayoutBlock { [innerStack = componentView.innerStack] _ in
bubbleView.frame = innerStack.frame.inset(by: Self.backgroundLayoutInsets)
}
componentView.innerStack.addLayoutBlock { [chevronContainer = componentView.chevronContainer, chevronLabel = componentView.chevronLabel] _ in
chevronLabel.bounds.size = chevronContainer.bounds.size
chevronLabel.center = CGPoint(x: chevronContainer.bounds.midX, y: chevronContainer.bounds.midY)
// This seemed easier than adding an entirely new ManualStackView
// just to constrain the label and background to
componentView.outerStack.addLayoutBlock { [label = componentView.label] _ in
bubbleView.frame = label.frame.inset(by: Self.backgroundLayoutInsets)
}
}
componentView.isShowingExpanded = collapseSet.isExpanded
componentView.chevronLabel.transform = collapseSet.isExpanded
? CGAffineTransform(rotationAngle: -.pi)
: .identity
if
hasWallpaper,
let wallpaperBlurView = componentView.wallpaperBlurView
@ -148,7 +128,15 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView.outerStack.isAccessibilityElement = true
componentView.outerStack.accessibilityLabel = titleString
componentView.outerStack.accessibilityTraits = .button
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: collapseSet.isExpanded)
componentView.outerStack.accessibilityHint = collapseSet.isExpanded
? OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
comment: "VoiceOver hint for an expanded collapse set button.",
)
: OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
comment: "VoiceOver hint for a collapsed collapse set button.",
)
}
// MARK: - Events
@ -159,35 +147,6 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView: CVComponentView,
renderItem: CVRenderItem,
) -> Bool {
if let componentView = componentView as? CVComponentViewCollapseSet {
let wasExpanded = componentView.isShowingExpanded
let willBeExpanded = !wasExpanded
let expandedRotation: CGFloat = -.pi
let isRTL = componentView.chevronLabel.effectiveUserInterfaceLayoutDirection == .rightToLeft
let fromAngle: CGFloat
let toAngle: CGFloat
if willBeExpanded {
fromAngle = 0
toAngle = isRTL ? CGFloat.pi : -CGFloat.pi
} else {
fromAngle = expandedRotation
toAngle = isRTL ? -2 * CGFloat.pi : 0
}
componentView.isShowingExpanded = willBeExpanded
componentView.chevronLabel.transform = willBeExpanded
? CGAffineTransform(rotationAngle: expandedRotation)
: .identity
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: willBeExpanded)
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = fromAngle
animation.toValue = toAngle
animation.duration = 0.2
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
componentView.chevronLabel.layer.add(animation, forKey: "chevronRotation")
}
componentDelegate.didTapCollapseSet(collapseSetId: interaction.uniqueId)
return true
}
@ -195,7 +154,6 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
// MARK: - Measurement
fileprivate static let measurementKey_outerStack = "CVComponentCollapseSet.outerStack"
fileprivate static let measurementKey_innerStack = "CVComponentCollapseSet.innerStack"
func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
@ -203,38 +161,18 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
0,
maxWidth - outerStackConfig.layoutMargins.totalWidth,
)
let chevronSize = CVText.measureLabel(config: chevronConfig, maxWidth: availableWidth)
let labelMaxWidth = max(0, availableWidth - chevronSize.width - innerStackConfig.spacing)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: labelMaxWidth)
let innerMeasurement = ManualStackView.measure(
config: innerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerStack,
subviewInfos: [
labelSize.asManualSubviewInfo(hasFixedWidth: true),
chevronSize.asManualSubviewInfo(hasFixedSize: true),
],
)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: availableWidth)
let outerMeasurement = ManualStackView.measure(
config: outerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerStack,
subviewInfos: [innerMeasurement.measuredSize.asManualSubviewInfo(hasFixedWidth: true)],
subviewInfos: [labelSize.asManualSubviewInfo(hasFixedWidth: true)],
)
return outerMeasurement.measuredSize
}
// MARK: - Layout
private var innerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .horizontal,
alignment: .center,
spacing: 4,
layoutMargins: .zero,
)
}
private var outerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
@ -295,6 +233,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
)
let nbsp = SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue
let chevron: SignalSymbol = collapseSet.isExpanded ? .chevronUp : .chevronDown
let result = NSMutableAttributedString()
result.append(leadingIcon.attributedString(
@ -303,33 +242,18 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
attributes: [.foregroundColor: UIColor.Signal.label],
))
result.append(NSAttributedString(
string: "\(nbsp)\(labelText)",
string: "\(nbsp)\(labelText)\(nbsp)",
attributes: [
.font: labelFont,
.foregroundColor: UIColor.Signal.label,
],
))
return result
}
private var chevronConfig: CVLabelConfig {
CVLabelConfig(
text: .attributedText(chevronAttributedString),
displayConfig: .forUnstyledText(font: labelFont, textColor: .Signal.label),
font: labelFont,
textColor: .Signal.label,
numberOfLines: 1,
lineBreakMode: .byClipping,
textAlignment: .center,
)
}
private var chevronAttributedString: NSAttributedString {
SignalSymbol.chevronDown.attributedString(
result.append(chevron.attributedString(
for: .footnote,
clamped: false,
attributes: [.foregroundColor: UIColor.Signal.label],
)
))
return result
}
private var titleString: String {
@ -340,18 +264,6 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
)
}
private func accessibilityHint(isExpanded: Bool) -> String {
isExpanded
? OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
comment: "VoiceOver hint for an expanded collapse set button.",
)
: OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
comment: "VoiceOver hint for a collapsed collapse set button.",
)
}
private func summaryLabel(
count: Int,
type: CollapseSetInteraction.MessagesType,
@ -410,14 +322,9 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
class CVComponentViewCollapseSet: NSObject, CVComponentView {
fileprivate let outerStack = ManualStackView(name: "collapseSet.outerStack")
fileprivate let innerStack = ManualStackView(name: "collapseSet.innerStack")
fileprivate let label = CVLabel()
fileprivate let chevronContainer = UIView()
fileprivate let chevronLabel = CVLabel()
fileprivate let solidBackgroundView = UIView()
fileprivate var isShowingExpanded = false
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
if let wallpaperBlurView = self.wallpaperBlurView {
@ -434,24 +341,14 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
var rootView: UIView { outerStack }
override init() {
super.init()
chevronContainer.addSubview(chevronLabel)
}
func setIsCellVisible(_ isCellVisible: Bool) {}
func reset() {
label.reset()
chevronLabel.reset()
chevronLabel.transform = .identity
chevronLabel.layer.removeAnimation(forKey: "chevronRotation")
isShowingExpanded = false
solidBackgroundView.backgroundColor = nil
wallpaperBlurView?.removeFromSuperview()
wallpaperBlurView = nil
hasWallpaper = false
innerStack.reset()
outerStack.reset()
}
}

View File

@ -800,7 +800,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
reactionsFrame.y = contentFrame.maxY - reactionsVOverlap
let leftAlignX = contentFrame.minX + reactionsHInset
let rightAlignX = contentFrame.maxX - (reactionsSize.width + reactionsHInset)
if isIncoming != CurrentAppContext().isRTL {
if isIncoming ^ CurrentAppContext().isRTL {
reactionsFrame.x = max(leftAlignX, rightAlignX)
} else {
reactionsFrame.x = min(leftAlignX, rightAlignX)

View File

@ -150,7 +150,6 @@ private class CVQuotedMessageViewAdapter: CVQuotedMessageViewDelegate {
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .userInitiated,
useThumbnails: false,
tx: tx,
)
}

View File

@ -493,9 +493,6 @@ public struct CVComponentState: Equatable {
let detailsText: NSAttributedString?
/// For mutual groups, lack thereof and note-to-self description.
let mutualGroupsText: NSAttributedString?
/// Populated if `mutualGroupsText` is not suitable for a11y, for
/// example if it embeds an image.
let mutualGroupsAccessibilityText: String?
let threadType: SafetyTipsType
let shouldShowSafetyTipsButton: Bool
let isOfficialChat: Bool
@ -528,6 +525,7 @@ public struct CVComponentState: Equatable {
static func ==(lhs: CollapseSet, rhs: CollapseSet) -> Bool {
return lhs.collapsedInteractions.map(\.uniqueId) == rhs.collapsedInteractions.map(\.uniqueId)
&& lhs.collapseSetType == rhs.collapseSetType
&& lhs.isExpanded == rhs.isExpanded
&& lhs.finalTimerDescription == rhs.finalTimerDescription
}
}
@ -1196,7 +1194,7 @@ private extension CVComponentState.Builder {
self.collapseSet = CVComponentState.CollapseSet(
collapsedInteractions: collapseSetInteraction.collapsedInteractions,
collapseSetType: collapseSetInteraction.collapseSetType,
isExpanded: viewStateSnapshot.expandedCollapseSetIds.contains(collapseSetInteraction.uniqueId),
isExpanded: collapseSetInteraction.isExpanded,
finalTimerDescription: collapseSetInteraction.finalTimerDescription,
)
return build()
@ -1363,33 +1361,7 @@ private extension CVComponentState.Builder {
case .failed:
mediaAlbumHasFailedAttachment = true
case .none:
// If optimize local storage is enabled, and the user has auto-downloads
// disabled, show the 'skipped attachment' download indicator. Otherwise
// render the attachment as normal, using the backup thumbnail for display.
let backupPlan = DependenciesBridge.shared.backupPlanManager.backupPlan(tx: transaction)
switch backupPlan {
case
.paid(let optimizeLocalStorage),
.paidAsTester(let optimizeLocalStorage),
.paidExpiringSoon(let optimizeLocalStorage):
if
optimizeLocalStorage,
canAutoDownloadAttachment(referencedAttachment: attachment),
attachment.attachment.localRelativeFilePathThumbnail != nil
{
// If optimize storage is enabled, auto-downloads are enabled,
// and the backup thumbnail is present, show the backup thumbnail
// as a true attachment (don't show the download icon overlay).
mediaAlbumHasSkippedAttachment = false
} else {
mediaAlbumHasSkippedAttachment = true
}
case
.free,
.disabled,
.disabling:
mediaAlbumHasSkippedAttachment = true
}
mediaAlbumHasSkippedAttachment = true
}
}
@ -1425,28 +1397,6 @@ private extension CVComponentState.Builder {
return result
}
private func canAutoDownloadAttachment(referencedAttachment: ReferencedAttachment) -> Bool {
let mediaBandwidthPreferenceStore = DependenciesBridge.shared.mediaBandwidthPreferenceStore
let autoDownloadableMediaTypes = mediaBandwidthPreferenceStore.autoDownloadableMediaTypes(tx: transaction)
let mimeType = referencedAttachment.attachment.mimeType
if MimeTypeUtil.isSupportedImageMimeType(mimeType) {
return autoDownloadableMediaTypes.contains(.photo)
}
if MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
return autoDownloadableMediaTypes.contains(.video)
}
if MimeTypeUtil.isSupportedAudioMimeType(mimeType) {
if
autoDownloadableMediaTypes.contains(.audio),
referencedAttachment.reference.renderingFlag != .voiceMessage
{
return true
}
return false
}
return autoDownloadableMediaTypes.contains(.document)
}
mutating func buildThreadDetails() -> ThreadDetails {
owsAssertDebug(interaction is ThreadDetailsInteraction)
@ -1681,6 +1631,7 @@ private extension CVComponentState.Builder {
} else if let quotedMessage = message.quotedMessage {
var memberLabel: String?
if
BuildFlags.MemberLabel.display,
let groupThread = thread as? TSGroupThread,
!threadViewModel.hasPendingMessageRequest,
let originalMessageAuthor = quotedMessage.authorAddress.aci
@ -1763,10 +1714,10 @@ private extension CVComponentState.Builder {
let caption = referencedAttachment.reference.legacyMessageCaption
let hasCaption = caption.map {
return !CVComponentState.displayableCaption(
return CVComponentState.displayableCaption(
text: $0,
transaction: transaction,
).fullTextValue.isEmpty
).fullTextValue.isEmpty.negated
} ?? false
switch cvAttachment {
@ -2055,7 +2006,7 @@ private extension CVComponentState.Builder {
self.giftBadge = GiftBadge(
messageUniqueId: messageUniqueId,
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
cachedBadge: DependenciesBridge.shared.donationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
cachedBadge: DonationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
expirationDate: expirationDate,
redemptionState: giftBadge.redemptionState,
)

View File

@ -195,22 +195,16 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if safetySection.shouldShowProfileNamesEducation {
innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel))
var buttonConfiguration = headerButtonConfigurationBase()
buttonConfiguration.baseBackgroundColor = .Signal.warningLabel.withAlphaComponent(0.2)
buttonConfiguration.contentInsets = notVerifierButtonContentInsets
let nameNotVerifiedButton = componentView.profileNamesEducationButton
let nameNotVerifiedButtonLabelConfig = nameNotVerifiedConfig()
nameNotVerifiedButtonLabelConfig.applyForRendering(buttonConfiguration: &buttonConfiguration)
let nameNotVerifiedButton = UIButton(
configuration: buttonConfiguration,
primaryAction: UIAction { _ in
componentDelegate.didTapNameEducation(type: safetySection.threadType)
},
)
nameNotVerifiedButtonLabelConfig.applyForRendering(button: nameNotVerifiedButton)
nameNotVerifiedButton.backgroundColor = UIColor.Signal.warningLabel.withAlphaComponent(0.2)
nameNotVerifiedButton.ows_contentEdgeInsets = .init(hMargin: hPaddingNotVerifiedButton, vMargin: vPaddingNotVerifiedButton)
nameNotVerifiedButton.dimsWhenHighlighted = true
nameNotVerifiedButton.block = {
componentDelegate.didTapNameEducation(type: safetySection.threadType)
}
innerViews.append(nameNotVerifiedButton)
componentView.profileNamesEducationButton = nameNotVerifiedButton
} else if safetySection.isOfficialChat {
innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel))
@ -255,30 +249,23 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
innerViews.append(UIView.spacer(withHeight: vSpacingSafetySectionDefault))
let mutualGroupsLabelConfig = mutualGroupsLabelConfig(attributedText: mutualGroupsText)
mutualGroupsLabelConfig.applyForRendering(label: mutualGroupsLabel)
mutualGroupsLabel.accessibilityLabel = safetySection.mutualGroupsAccessibilityText
innerViews.append(mutualGroupsLabel)
}
if safetySection.shouldShowSafetyTipsButton {
var buttonConfiguration = headerButtonConfigurationBase()
buttonConfiguration.contentInsets = safetyButtonContentInsets
buttonConfiguration.baseBackgroundColor =
let showTipsButton = componentView.showTipsButton
safetyTipsButtonLabelConfig.applyForRendering(buttonConfiguration: &showTipsButton.configuration!)
showTipsButton.configuration?.baseBackgroundColor =
conversationStyle.hasWallpaper ? .Signal.MaterialBase.button : .Signal.secondaryFill
let safetyTipsButtonLabelConfig = safetyTipsButtonLabelConfig()
safetyTipsButtonLabelConfig.applyForRendering(buttonConfiguration: &buttonConfiguration)
let showTipsButton = UIButton(
configuration: buttonConfiguration,
primaryAction: UIAction { _ in
showTipsButton.addAction(
UIAction { _ in
componentDelegate.didTapSafetyTips()
},
for: .primaryActionTriggered,
)
innerViews.append(UIView.spacer(withHeight: vSpacingSafetyButton))
innerViews.append(showTipsButton)
componentView.showTipsButton = showTipsButton
}
}
@ -439,7 +426,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
}
private func officialLabelConfig() -> CVLabelConfig {
let symbol = SignalSymbol.officialBadge.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize)
let symbol = SignalSymbol.checkCircle.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize)
let notVerifiedString = NSAttributedString.composed(
of: [
symbol,
@ -461,7 +448,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
)
}
private func safetyTipsButtonLabelConfig() -> CVLabelConfig {
private var safetyTipsButtonLabelConfig: CVLabelConfig {
CVLabelConfig.unstyledText(
OWSLocalizedString(
"SAFETY_TIPS_BUTTON_ACTION_TITLE",
@ -655,15 +642,11 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
private let vSpacingSafetySectionDefault: CGFloat = 8
private let safetyButtonContentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 5)
private let notVerifierButtonContentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 2)
private func headerButtonConfigurationBase() -> UIButton.Configuration {
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .capsule
return configuration
}
private let hPaddingGroupDetails: CGFloat = 25
private let vPaddingNotVerifiedButton: CGFloat = 2
private let hPaddingNotVerifiedButton: CGFloat = 12
private let vOffsetThreadDetailsOutline: CGFloat = 16
private let minBottomPadding: CGFloat = 4
@ -705,18 +688,20 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if let safetySection = threadDetails.safetySection {
if safetySection.shouldShowProfileNamesEducation {
innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo)
let buttonSize = CVText.measureLabel(
let notVerifiedSize = CVText.measureLabel(
config: nameNotVerifiedConfig(),
maxWidth: maxContentWidth,
) + notVerifierButtonContentInsets.asSize
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
)
let notVerifiedSizeWithPadding = CGSize(width: notVerifiedSize.width + hPaddingNotVerifiedButton * 2, height: notVerifiedSize.height + vPaddingNotVerifiedButton * 2)
innerSubviewInfos.append(notVerifiedSizeWithPadding.asManualSubviewInfo)
} else if safetySection.isOfficialChat {
innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo)
let buttonSize = CVText.measureLabel(
let officialLabelSize = CVText.measureLabel(
config: officialLabelConfig(),
maxWidth: maxContentWidth,
) + notVerifierButtonContentInsets.asSize
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
)
let officialLabelSizeWithPadding = CGSize(width: officialLabelSize.width + hPaddingNotVerifiedButton * 2, height: officialLabelSize.height + vPaddingNotVerifiedButton * 2)
innerSubviewInfos.append(officialLabelSizeWithPadding.asManualSubviewInfo)
}
}
@ -752,7 +737,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if safetySection.shouldShowSafetyTipsButton {
innerSubviewInfos.append(CGSize(square: vSpacingSafetyButton).asManualSubviewInfo)
let buttonSize = CVText.measureLabel(
config: safetyTipsButtonLabelConfig(),
config: safetyTipsButtonLabelConfig,
maxWidth: maxContentWidth,
) + safetyButtonContentInsets.asSize
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
@ -801,8 +786,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if let safetySection = threadDetails.safetySection {
if
safetySection.shouldShowSafetyTipsButton,
let showTipsButton = componentView.showTipsButton,
showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
componentView.showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
{
componentDelegate.didTapSafetyTips()
return true
@ -819,8 +803,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
if
safetySection.shouldShowProfileNamesEducation,
let profileNamesEducationButton = componentView.profileNamesEducationButton,
profileNamesEducationButton.bounds.contains(sender.location(in: componentView.profileNamesEducationButton))
componentView.profileNamesEducationButton.bounds.contains(sender.location(in: componentView.profileNamesEducationButton))
{
componentDelegate.didTapNameEducation(type: safetySection.threadType)
return true
@ -855,13 +838,17 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
fileprivate let titleButton = CVButton()
fileprivate let bioLabel = CVLabel()
fileprivate var profileNamesEducationButton: UIButton?
fileprivate let profileNamesEducationButton = OWSRoundedButton()
fileprivate let officialLabel = CVLabel()
fileprivate let reviewCarefullyLabel = CVLabel()
fileprivate let detailsButton = CVButton()
fileprivate let mutualGroupsLabel = CVLabel()
fileprivate var showTipsButton: UIButton?
fileprivate let showTipsButton: UIButton = {
let button = UIButton(configuration: .gray())
button.configuration?.contentInsets = NSDirectionalEdgeInsets(hMargin: 10, vMargin: 5)
return button
}()
fileprivate let groupDescriptionPreviewView = GroupDescriptionPreviewView(
shouldDeactivateConstraints: true,
@ -919,7 +906,6 @@ extension CVComponentThreadDetails {
shouldShowProfileNamesEducation: false,
detailsText: NSAttributedString(string: OWSLocalizedString("RELEASE_NOTES_DETAILS", comment: "Details text for the thread details view of the release notes channel")),
mutualGroupsText: nil,
mutualGroupsAccessibilityText: nil,
threadType: .contact,
shouldShowSafetyTipsButton: false,
isOfficialChat: true,
@ -1052,7 +1038,6 @@ extension CVComponentThreadDetails {
shouldShowProfileNamesEducation: shouldShowUnknownThreadWarning,
detailsText: membersAttributedText,
mutualGroupsText: nil,
mutualGroupsAccessibilityText: nil,
threadType: .group,
shouldShowSafetyTipsButton: shouldShowUnknownThreadWarning && groupThread.hasPendingMessageRequest(transaction: tx),
isOfficialChat: false,
@ -1085,7 +1070,6 @@ extension CVComponentThreadDetails {
with: .font(.dynamicTypeSubheadline),
.color(UIColor.Signal.label),
),
mutualGroupsAccessibilityText: nil,
threadType: .contact,
shouldShowSafetyTipsButton: false,
isOfficialChat: false,
@ -1190,7 +1174,6 @@ extension CVComponentThreadDetails {
" ",
formattedString,
]),
mutualGroupsAccessibilityText: formattedString,
threadType: .contact,
shouldShowSafetyTipsButton: isMessageRequest,
isOfficialChat: false,

View File

@ -59,7 +59,7 @@ extension TSInfoMessage.PersistableGroupUpdateItem {
)
{
owsAssertDebug(
!isTail,
isTail.negated,
"Collapsed item with a following request shouldn't be a tail!",
)
return nextItemAction

View File

@ -22,11 +22,8 @@ class ConversationBottomPanelView: UIView {
let contentLayoutGuide = UILayoutGuide()
private var backgroundViewEffect: UIVisualEffect {
if UIAccessibility.isReduceTransparencyEnabled {
return UIBlurEffect(style: .systemThinMaterial)
}
guard #available(iOS 26, *), useGlassPanel else {
return Theme.barBlurEffect
return UIBlurEffect(style: .systemThinMaterial)
}
// Same as in ConversationInputToolbar.
let glassEffect = UIGlassEffect(style: .regular)
@ -126,19 +123,6 @@ class ConversationBottomPanelView: UIView {
constant: UIDevice.current.hasIPhoneXNotch ? 0 : -12,
),
])
// Alter the visual effect view's tint to match our background color
// so the bottom panel, when over a solid color background matching UIColor.Signal.background,
// exactly matches the background color. This is brittle, but there is no way to get
// this behavior from UIVisualEffectView otherwise.
if
!UIAccessibility.isReduceTransparencyEnabled,
let tintingView = backgroundView.subviews.first(where: {
String(describing: type(of: $0)) == "_UIVisualEffectSubview"
})
{
tintingView.backgroundColor = UIColor.Signal.background.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
}
}
}

View File

@ -179,23 +179,6 @@ class ConversationHeaderView: UIView {
}
}
// MARK: Spinning Title
func updateTitleSpinning() {
let key = "spin"
if InMemorySettings.spinningConversationTitle {
guard layer.animation(forKey: key) == nil else { return }
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.toValue = NSNumber(value: Double.pi * 2)
animation.duration = 1
animation.isCumulative = true
animation.repeatCount = .greatestFiniteMagnitude
layer.add(animation, forKey: key)
} else {
layer.removeAnimation(forKey: key)
}
}
// MARK: Delegate Methods
@objc

View File

@ -9,7 +9,6 @@ public import UIKit
public protocol ConversationInputTextViewDelegate: AnyObject {
func didAttemptAttachmentPaste()
func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void)
func inputTextViewSendMessagePressed()
func textViewDidChange(_ textView: UITextView)
}
@ -200,50 +199,9 @@ class ConversationInputTextView: BodyRangesTextView {
return
}
if handleAttemptedAccountEntropyPoolPaste() {
return
}
super.paste(sender)
}
private func handleAttemptedAccountEntropyPoolPaste() -> Bool {
let accountKeyStore = DependenciesBridge.shared.accountKeyStore
let db = DependenciesBridge.shared.db
guard let pasteboardString = UIPasteboard.general.strings?.first else {
return false
}
let filteredPasteboardString = pasteboardString.filter { !$0.isWhitespace }
guard
let pastedAEP = try? DisplayableAccountEntropyPool(displayString: filteredPasteboardString),
let localAEP = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }),
pastedAEP.rawValue == localAEP
else {
return false
}
inputTextViewDelegate?.didAttemptAccountEntropyPoolPaste(
completePaste: { [weak self] in
guard let self else { return }
let pasteRange: UITextRange
if let selectedTextRange {
pasteRange = selectedTextRange
} else if let endRange = textRange(from: endOfDocument, to: endOfDocument) {
pasteRange = endRange
} else {
return
}
replace(pasteRange, withText: filteredPasteboardString)
},
)
return true
}
// MARK: - UITextViewDelegate
override func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {

View File

@ -249,7 +249,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
button.configuration?.baseForegroundColor = Style.buttonTintColor
button.accessibilityLabel = accessibilityLabel
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: LayoutMetrics.initialTextBoxHeight),
@ -346,7 +345,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
button.configuration?.cornerStyle = .capsule
button.accessibilityLabel = MessageStrings.sendButton
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
@ -380,7 +378,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
comment: "Accessibility hint describing what you can do with the attachment button",
)
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
@ -406,7 +403,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
button.configuration?.baseForegroundColor = .white
button.configuration?.cornerStyle = .capsule
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
@ -711,10 +707,9 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
// Rounded rect background for the text input field:
// Liquid Glass on iOS 26, gray-ish on earlier iOS versions.
let backgroundView: UIView
let cornerRadius = LayoutMetrics.initialTextBoxHeight / 2
if #available(iOS 26, *) {
let glassEffectView = UIVisualEffectView(effect: Style.glassEffect(isInteractive: true))
glassEffectView.cornerConfiguration = .uniformCorners(radius: .fixed(cornerRadius))
glassEffectView.cornerConfiguration = .uniformCorners(radius: 20)
glassEffectView.contentView.addSubview(messageComponentsView)
backgroundView = glassEffectView
@ -722,7 +717,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
} else {
backgroundView = UIView()
backgroundView.backgroundColor = UIColor.Signal.tertiaryFill
backgroundView.layer.cornerRadius = cornerRadius
backgroundView.layer.cornerRadius = 20
messageContentView.addSubview(backgroundView)
messageContentView.addSubview(messageComponentsView)
@ -1219,7 +1214,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
lazy var sendButton: UIButton = {
let button = UIButton(type: .system)
button.accessibilityLabel = MessageStrings.sendButton
button.isPointerInteractionEnabled = true
button.ows_adjustsImageWhenDisabled = true
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "sendButton")
button.setImage(UIImage(imageLiteralResourceName: "send-blue-28"), for: .normal)
button.bounds.size = CGSize(width: 48, height: LayoutMetrics.initialToolbarHeight)
@ -1229,7 +1224,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
lazy var cameraButton: UIButton = {
let button = UIButton(type: .system)
button.tintColor = Style.buttonTintColor
button.isPointerInteractionEnabled = true
button.accessibilityLabel = OWSLocalizedString(
"CAMERA_BUTTON_LABEL",
comment: "Accessibility label for camera button.",
@ -1247,7 +1241,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
lazy var voiceMemoButton: UIButton = {
let button = UIButton(type: .system)
button.tintColor = Style.buttonTintColor
button.isPointerInteractionEnabled = true
button.accessibilityLabel = OWSLocalizedString(
"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_LABEL",
comment: "accessibility label for the button which records voice memos",
@ -1373,8 +1366,6 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
override private init(frame: CGRect) {
super.init(frame: frame)
isPointerInteractionEnabled = true
addSubview(roundedCornersBackground)
roundedCornersBackground.autoCenterInSuperview()
roundedCornersBackground.autoSetDimensions(to: CGSize(square: 28))

View File

@ -155,7 +155,7 @@ extension ConversationViewController: CVLoadCoordinatorDelegate {
cvCustomAction.messageAction?.block(self)
}
func willUpdateWithNewRenderState(_ update: CVUpdate) -> CVUpdateToken {
func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken {
AssertIsOnMainThread()
// HACK to work around radar #28167779
@ -171,9 +171,7 @@ extension ConversationViewController: CVLoadCoordinatorDelegate {
// Snapshot CVC layout state before we land the load;
// we use this to ensure scroll continuity when landing the load.
let scrollContinuityToken = layout.buildScrollContinuityToken(
preferredAnchorInteractionId: update.loadRequest.preferredScrollContinuityAnchorInteractionId,
)
let scrollContinuityToken = layout.buildScrollContinuityToken()
// CVC will often use this state to ensure scroll continuity
// when landing loads, so ensure the value is updated before
@ -545,14 +543,6 @@ extension ConversationViewController: CVLoadCoordinatorDelegate {
}
}
if
scrollAction.action == .none,
update.loadRequest.preferredScrollContinuityAnchorInteractionId != nil,
isScrolledToBottom
{
scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
}
if .loadOlder == renderState.loadType {
scrollAction = .none
}

View File

@ -32,11 +32,7 @@ extension ConversationViewController: CVComponentDelegate {
} else {
viewState.expandedCollapseSets.insert(collapseSetId)
}
loadCoordinator.enqueueReload(
updatedInteractionIds: [collapseSetId],
deletedInteractionIds: [],
preferredScrollContinuityAnchorInteractionId: collapseSetId,
)
loadCoordinator.enqueueReload()
}
// MARK: - Double-Tap
@ -183,7 +179,7 @@ extension ConversationViewController: CVComponentDelegate {
// If any of the failed or pending downloads were enqueued by a Backup
// restore, immediately attempt to download those attachments.
Task.detached {
Task {
let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
let attachmentStore = DependenciesBridge.shared.attachmentStore
let backupAttachmentDownloadStore = DependenciesBridge.shared.backupAttachmentDownloadStore
@ -194,22 +190,17 @@ extension ConversationViewController: CVComponentDelegate {
return
}
enum DownloadTypeToEnqueue {
case thumbnail
case fullsize
}
let messageTypeToDownload: DownloadTypeToEnqueue? = db.read { tx in
let messageHasAnyEnqueuedBackupDownloads = db.read { tx in
let referencedAttachments = attachmentStore.fetchReferencedAttachmentsOwnedByMessage(
messageRowId: messageRowId,
tx: tx,
)
let downloadTypes: [DownloadTypeToEnqueue] = referencedAttachments.compactMap { referencedAttachment in
return referencedAttachments.contains { referencedAttachment in
// We only auto-download on appear if we've got a cdn number to try.
// The user can still manual download if there isn't one (using fallback cdn).
guard referencedAttachment.attachment.mediaTierInfo?.cdnNumber != nil else {
return nil
return false
}
// Otherwise use presence in the backup download queue to indicate
// downloadability; this just functionally bumps the priority so the
@ -220,60 +211,22 @@ extension ConversationViewController: CVComponentDelegate {
tx: tx,
)
switch enqueuedDownload?.state {
case .ineligible:
if referencedAttachment.attachment.localRelativeFilePathThumbnail != nil {
return nil
}
let enqueuedThumbnail = backupAttachmentDownloadStore.getEnqueuedDownload(
attachmentRowId: referencedAttachment.attachment.id,
thumbnail: true,
tx: tx,
)
switch enqueuedThumbnail?.state {
case .ready:
return .thumbnail
case .done, .ineligible, nil:
// There is already a thumbnail, or never will be a thumbnail to display here.
// Either way, no need to re-enqueue the thumbnail
return nil
}
case nil, .done:
return nil
case .ready:
return .fullsize
return false
case .ineligible, .ready:
return true
}
}
if downloadTypes.contains(.fullsize) {
return .fullsize
} else if downloadTypes.contains(.thumbnail) {
return .thumbnail
} else {
return nil
}
}
switch messageTypeToDownload {
case .fullsize:
if messageHasAnyEnqueuedBackupDownloads {
await db.awaitableWrite { tx in
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .default,
useThumbnails: false,
tx: tx,
)
}
case .thumbnail:
await db.awaitableWrite { tx in
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .default,
useThumbnails: true,
tx: tx,
)
}
case .none:
break
}
}
}
@ -287,7 +240,6 @@ extension ConversationViewController: CVComponentDelegate {
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .userInitiated,
useThumbnails: false,
tx: tx,
)
}
@ -1454,16 +1406,17 @@ extension ConversationViewController: CVComponentDelegate {
}
public func didTapSafetyTips() {
let viewController = SafetyTipsViewController(
mode: .messageRequest,
primaryButton: SafetyTipsViewController.Button(
title: CommonStrings.viewMoreButton,
action: { [weak self] in
let viewController = MoreSafetyTipsViewController()
self?.present(viewController, animated: true)
},
),
)
let viewController = SafetyTipsViewController()
viewController.delegate = self
present(viewController, animated: true)
}
}
// MARK: - SafetyTipsViewControllerDelegate
extension ConversationViewController: SafetyTipsViewControllerDelegate {
public func didTapViewMoreSafetyTips() {
let viewController = MoreSafetyTipsViewController()
present(viewController, animated: true)
}
}

View File

@ -295,58 +295,6 @@ extension ConversationViewController: ConversationInputTextViewDelegate {
}
}
public func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void) {
let warningSheet = BackupNeverShareRecoveryKeySheet(
primaryButton: .dismissing(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that dismisses the sheet without pasting the key.",
),
),
secondaryButton: HeroSheetViewController.Button(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_SHARE_BUTTON_TITLE",
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that acknowledges the warning and proceeds with the paste.",
),
style: .secondaryDestructive,
action: .custom({ [weak self] sheet in
sheet.dismiss(animated: true) {
let doubleWarningSheet = ActionSheetController(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_TITLE",
comment: "Title for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway.",
),
message: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_MESSAGE",
comment: "Message body for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, warning them not to share it.",
),
)
doubleWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_PASTE_BUTTON_TITLE",
comment: "Title for the destructive button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that proceeds with the paste.",
),
style: .destructive,
handler: { _ in
completePaste()
},
))
doubleWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
comment: "Title for the button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that dismisses the sheet without pasting the key.",
),
))
self?.present(doubleWarningSheet, animated: true)
}
}),
),
)
present(warningSheet, animated: true)
}
public func inputTextViewSendMessagePressed() {
AssertIsOnMainThread()

View File

@ -83,7 +83,7 @@ extension ConversationViewController {
let mode: BadgeIssueSheetState.Mode
if isRedeemed {
let hasCurrentSubscription = SSKEnvironment.shared.databaseStorageRef.read { tx -> Bool in
return DependenciesBridge.shared.donationSubscriptionManager.probablyHasCurrentSubscription(tx: tx)
return DonationSubscriptionManager.probablyHasCurrentSubscription(tx: tx)
}
mode = .giftBadgeExpired(hasCurrentSubscription: hasCurrentSubscription)
} else {

View File

@ -372,8 +372,8 @@ extension ConversationViewController {
timer.invalidate()
return
}
// If the view isn't visible, return
guard self.view.window != nil else {
owsFailDebug("Read timer fired when ConversationViewController is not in a view hierarchy")
timer.invalidate()
return
}

View File

@ -78,14 +78,6 @@ extension ConversationViewController {
object: AVAudioSession.sharedInstance(),
)
NotificationCenter.default.addObserver(
self,
selector: #selector(smsVerificationCodeRequested),
name: .smsVerificationCodeRequested,
object: nil,
)
SafetyTipsManager.startObservingDarwinNotifications()
AppEnvironment.shared.callService.callServiceState.addObserver(self, syncStateImmediately: false)
}
@ -211,28 +203,6 @@ extension ConversationViewController {
AssertIsOnMainThread()
ensureBottomViewType()
}
@objc
private func smsVerificationCodeRequested(_ notification: NSNotification) {
AssertIsOnMainThread()
let db = DependenciesBridge.shared.db
let safetyTipsManager = SafetyTipsManager()
let timestamp: UInt64? = db.read { tx in
safetyTipsManager.lastVerificationCodeTimestampMsWithinExpiryTime(transaction: tx)
}
guard let timestamp else { return }
let actionSheetController = SafetyTipsSheet.makeSmsCodeRequestedSheet(
timestampMs: timestamp,
fromViewController: self,
)
present(actionSheetController, animated: true, completion: {
db.write { tx in
safetyTipsManager.removeVerificationCodeRequestedTimestampMs(transaction: tx)
}
})
}
}
// MARK: -

View File

@ -31,8 +31,6 @@ extension ConversationViewController {
} else {
headerView.titleLabel.text = title
}
headerView.updateTitleSpinning()
}
public func createHeaderViews() {

View File

@ -893,21 +893,6 @@ public class ConversationViewLayout: UICollectionViewLayout {
return contentOffsetAdjustment
}
if
let anchorInteractionId = scrollContinuityToken.anchorInteractionId,
let beforeItemLayout = beforeItemLayoutMap[anchorInteractionId],
let afterItemLayout = afterItemLayoutMap[anchorInteractionId]
{
if beforeItemLayout.canBeUsedForContinuity, afterItemLayout.canBeUsedForContinuity {
return calculateAdjustment(
beforeItemLayout: beforeItemLayout,
afterItemLayout: afterItemLayout,
)
} else {
owsFailDebug("Invalid scroll continuity anchor.")
}
}
// Prefer to maintain continuity with visible interactions.
//
// Honor the scroll continuity bias. If we prefer continuity with regard
@ -982,7 +967,7 @@ public class ConversationViewLayout: UICollectionViewLayout {
delegateScrollContinuityMode = .disabled
}
public func buildScrollContinuityToken(preferredAnchorInteractionId: String? = nil) -> CVScrollContinuityToken {
public func buildScrollContinuityToken() -> CVScrollContinuityToken {
AssertIsOnMainThread()
let layoutInfo = ensureCurrentLayoutInfo()
@ -1007,7 +992,6 @@ public class ConversationViewLayout: UICollectionViewLayout {
layoutInfo: layoutInfo,
contentOffset: contentOffset,
visibleUniqueIds: visibleUniqueIds,
anchorInteractionId: preferredAnchorInteractionId,
)
}
@ -1111,18 +1095,15 @@ public class CVScrollContinuityToken: NSObject {
fileprivate let layoutInfo: ConversationViewLayout.LayoutInfo
fileprivate let contentOffset: CGPoint
fileprivate let visibleUniqueIds: [String]
fileprivate let anchorInteractionId: String?
fileprivate init(
layoutInfo: ConversationViewLayout.LayoutInfo,
contentOffset: CGPoint,
visibleUniqueIds: [String],
anchorInteractionId: String? = nil,
) {
self.layoutInfo = layoutInfo
self.contentOffset = contentOffset
self.visibleUniqueIds = visibleUniqueIds
self.anchorInteractionId = anchorInteractionId
}
}

View File

@ -5,7 +5,7 @@
import SignalServiceKit
final class CollapseSetInteraction: TSInteraction {
class CollapseSetInteraction: TSInteraction {
enum MessagesType: Equatable {
case groupUpdates
@ -18,6 +18,8 @@ final class CollapseSetInteraction: TSInteraction {
let collapseSetType: MessagesType
let isExpanded: Bool
let finalTimerDescription: String?
override var isDynamicInteraction: Bool { true }
@ -30,10 +32,12 @@ final class CollapseSetInteraction: TSInteraction {
thread: TSThread,
collapsedInteractions: [TSInteraction],
collapseSetType: MessagesType,
isExpanded: Bool = false,
) {
owsPrecondition(!collapsedInteractions.isEmpty)
self.collapsedInteractions = collapsedInteractions
self.collapseSetType = collapseSetType
self.isExpanded = isExpanded
self.finalTimerDescription = Self.disappearingTimerDescription(
for: collapsedInteractions,
type: collapseSetType,
@ -41,17 +45,13 @@ final class CollapseSetInteraction: TSInteraction {
let firstInteraction = collapsedInteractions[0]
super.init(
customUniqueId: Self.id(firstInteraction: firstInteraction),
customUniqueId: "CollapseSet_\(firstInteraction.timestamp)",
timestamp: firstInteraction.timestamp,
receivedAtTimestamp: firstInteraction.receivedAtTimestamp,
thread: thread,
)
}
static func id(firstInteraction: TSInteraction) -> String {
"CollapseSet_\(firstInteraction.timestamp)"
}
private static func disappearingTimerDescription(
for interactions: [TSInteraction],
type: MessagesType,

View File

@ -423,6 +423,7 @@ struct CVItemModelBuilder: CVItemBuilding {
var memberLabel: String?
if
BuildFlags.MemberLabel.display,
let groupThread = thread as? TSGroupThread,
!threadViewModel.hasPendingMessageRequest,
let senderAci = incomingSenderAddress.aci

View File

@ -11,7 +11,7 @@ import UIKit
protocol CVLoadCoordinatorDelegate: UIScrollViewDelegate {
var viewState: CVViewState { get }
func willUpdateWithNewRenderState(_ update: CVUpdate) -> CVUpdateToken
func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken
func updateWithNewRenderState(
update: CVUpdate,
@ -385,13 +385,6 @@ public class CVLoadCoordinator: NSObject {
loadIfNecessary()
}
public func enqueueReload(preferredScrollContinuityAnchorInteractionId: String) {
loadRequestBuilder.reload(
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
)
loadIfNecessary()
}
public func enqueueReload(scrollAction: CVScrollAction) {
loadRequestBuilder.reload(scrollAction: scrollAction)
loadIfNecessary()
@ -427,23 +420,6 @@ public class CVLoadCoordinator: NSObject {
loadIfNecessary()
}
public func enqueueReload(
updatedInteractionIds: Set<String>,
deletedInteractionIds: Set<String>,
preferredScrollContinuityAnchorInteractionId: String,
) {
AssertIsOnMainThread()
loadRequestBuilder.reload(
updatedInteractionIds: updatedInteractionIds,
deletedInteractionIds: deletedInteractionIds,
)
loadRequestBuilder.reload(
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
)
loadIfNecessary()
}
public func enqueueReloadWithoutCaches() {
AssertIsOnMainThread()
@ -640,7 +616,7 @@ public class CVLoadCoordinator: NSObject {
}
let renderState = update.renderState
let updateToken = delegate.willUpdateWithNewRenderState(update)
let updateToken = delegate.willUpdateWithNewRenderState(renderState)
self.renderState = renderState

View File

@ -87,7 +87,6 @@ struct CVLoadRequest {
let canReuseInteractionModels: Bool
let canReuseComponentStates: Bool
let didReset: Bool
let preferredScrollContinuityAnchorInteractionId: String?
var isInitialLoad: Bool {
switch loadType {
@ -148,7 +147,6 @@ struct CVLoadRequest {
private var canReuseInteractionModels = true
private var canReuseComponentStates = true
private var didReset = false
private var preferredScrollContinuityAnchorInteractionId: String?
mutating func reload(
updatedInteractionIds: Set<String>,
@ -234,13 +232,6 @@ struct CVLoadRequest {
shouldLoad = true
}
mutating func reload(preferredScrollContinuityAnchorInteractionId: String) {
AssertIsOnMainThread()
self.preferredScrollContinuityAnchorInteractionId = preferredScrollContinuityAnchorInteractionId
reload()
}
mutating func reloadWithoutCaches() {
reload(canReuseInteractionModels: false, canReuseComponentStates: false, didReset: true)
}
@ -274,7 +265,6 @@ struct CVLoadRequest {
canReuseInteractionModels: canReuseInteractionModels,
canReuseComponentStates: canReuseComponentStates,
didReset: didReset,
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
)
}
}

View File

@ -80,10 +80,6 @@ public class CVLoader: NSObject {
localAci: localAci,
transaction: transaction,
)
let preprocessingContext = MessageLoaderPreprocessingContext(
thread: loadContext.thread,
oldestUnreadSortId: viewStateSnapshot.oldestUnreadMessageSortId,
)
// Don't cache in the reset() case.
let canReuseInteractions = loadRequest.canReuseInteractionModels && !loadRequest.didReset
@ -136,35 +132,30 @@ public class CVLoader: NSObject {
focusMessageId: focusMessageIdOnOpen,
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadSameLocation:
try messageLoader.loadSameLocation(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadOlder:
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadNewer:
try messageLoader.loadNewerMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadNewest:
try messageLoader.loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadPageAroundInteraction(let interactionId, _):
@ -172,7 +163,6 @@ public class CVLoader: NSObject {
aroundInteractionId: interactionId,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
}
@ -181,18 +171,36 @@ public class CVLoader: NSObject {
throw error
}
let expandedInteractions = messageLoader.loadedDisplayableInteractions.flatMap { interaction in
if
let collapseSet = interaction as? CollapseSetInteraction,
viewStateSnapshot.expandedCollapseSetIds.contains(collapseSet.uniqueId)
let initialLoadCount = messageLoader.loadedInteractions.count
var processedInteractions = Self.preprocessInteractions(
messageLoader.loadedInteractions,
loadContext: loadContext,
)
if case .loadInitialMapping = loadRequest.loadType {
let maxExtraLoads = 5
var extraLoads = 0
while
processedInteractions.count < initialLoadCount,
messageLoader.canLoadOlder,
extraLoads < maxExtraLoads
{
return [collapseSet] + collapseSet.collapsedInteractions
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
processedInteractions = Self.preprocessInteractions(
messageLoader.loadedInteractions,
loadContext: loadContext,
)
extraLoads += 1
}
return [interaction]
}
let itemModels = self.buildItemModels(
interactions: expandedInteractions,
interactions: processedInteractions,
loadContext: loadContext,
updatedInteractionIds: updatedInteractionIds,
localAci: localAci,
@ -264,6 +272,214 @@ public class CVLoader: NSObject {
return itemModelBuilder.buildItems(localAci: localAci, interactions: interactions)
}
// MARK: - Interaction Preprocessing
private static let maxCollapseSetSize = 50
/// Takes a list of interactions and applies preprocessing before the expensive task of creating `CVItemModel`s via `CVItemModelBuilder.buildItems`.
///
/// 1. Inserts date headers
/// 2. Inserts unread indicator
/// 3. Collapses chat events
private static func preprocessInteractions(
_ interactions: [TSInteraction],
loadContext: CVLoadContext,
) -> [TSInteraction] {
let thread = loadContext.thread
let isGroupThread = thread.isGroupThread
let expandedCollapseSets = loadContext.viewStateSnapshot.expandedCollapseSets
let oldestUnreadSortId = loadContext.viewStateSnapshot.oldestUnreadMessageSortId
let todayDate = Date()
var result = [TSInteraction]()
var currentRun = [TSInteraction]()
var currentRunType: CollapseSetInteraction.MessagesType?
var pastUnreadIndicator = false
var shouldShowDateOnNextViewItem = true
var previousDaysBeforeToday: Int?
func finalizeSet() {
defer {
currentRun.removeAll()
currentRunType = nil
}
guard currentRun.count >= 2, let runType = currentRunType else {
result.append(contentsOf: currentRun)
return
}
let collapseId = "CollapseSet_\(currentRun[0].timestamp)"
let isExpanded = expandedCollapseSets.contains(collapseId)
let collapseSetInteraction = CollapseSetInteraction(
thread: thread,
collapsedInteractions: currentRun,
collapseSetType: runType,
isExpanded: isExpanded,
)
result.append(collapseSetInteraction)
if isExpanded {
result.append(contentsOf: currentRun)
}
}
for interaction in interactions {
let timestamp = interaction.timestamp
let daysBeforeToday = DateUtil.daysFrom(
firstDate: Date(millisecondsSince1970: timestamp),
toSecondDate: todayDate,
)
if let previousDaysBeforeToday {
if daysBeforeToday != previousDaysBeforeToday {
shouldShowDateOnNextViewItem = true
}
} else {
// Only show for the first item if the date is not today
shouldShowDateOnNextViewItem = daysBeforeToday != 0
}
if
shouldShowDateOnNextViewItem,
canShowDateHeader(before: interaction)
{
// Collapse sets shouldn't cross date boundaries
finalizeSet()
result.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
shouldShowDateOnNextViewItem = false
}
previousDaysBeforeToday = daysBeforeToday
// Only insert one unread indicator and don't collapse unread events
if pastUnreadIndicator {
result.append(interaction)
continue
}
if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
finalizeSet()
let unreadIndicatorInteraction = UnreadIndicatorInteraction(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: interaction.receivedAtTimestamp,
)
result.append(unreadIndicatorInteraction)
pastUnreadIndicator = true
result.append(interaction)
continue
}
guard BuildFlags.collapsingChatEvents else {
result.append(interaction)
continue
}
let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
if let collapseType {
let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
let exceededCurrentRunLimit = currentRun.count >= maxCollapseSetSize
if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
finalizeSet()
}
currentRun.append(interaction)
currentRunType = collapseType
} else {
finalizeSet()
result.append(interaction)
}
}
finalizeSet()
return result
}
private static func canShowDateHeader(before interaction: TSInteraction) -> Bool {
switch interaction.interactionType {
case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet:
return false
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("Invalid interaction.")
return false
}
// Only show the date for non-synced thread messages;
return infoMessage.messageType != .syncedThread
case .unreadIndicator, .incomingMessage, .outgoingMessage, .error, .call:
return true
}
}
private static func collapseSetType(
for interaction: TSInteraction,
isGroupThread: Bool,
) -> CollapseSetInteraction.MessagesType? {
switch interaction.interactionType {
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("info interaction is not TSInfoMessage")
return nil
}
switch infoMessage.messageType {
case .typeDisappearingMessagesUpdate:
return .timerChanges
case .typeGroupUpdate:
if
let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems]
as? TSInfoMessage.PersistableGroupUpdateItemsWrapper
{
for event in wrapper.updateItems {
switch event {
case
.groupTerminatedByLocalUser,
.groupTerminatedByOtherUser,
.groupTerminatedByUnknownUser:
return nil
case
.disappearingMessagesEnabledByLocalUser,
.disappearingMessagesEnabledByOtherUser,
.disappearingMessagesEnabledByUnknownUser,
.disappearingMessagesDisabledByLocalUser,
.disappearingMessagesDisabledByOtherUser,
.disappearingMessagesDisabledByUnknownUser:
return .timerChanges
default:
break
}
}
}
return isGroupThread ? .groupUpdates : .chatUpdates
case .verificationStateChange,
.profileUpdate,
.phoneNumberChange,
.typeEndPoll,
.typePinnedMessage:
return isGroupThread ? .groupUpdates : .chatUpdates
default:
return nil
}
case .error:
guard let errorMessage = interaction as? TSErrorMessage else {
owsFailDebug("error interaction is not TSErrorMessage")
return nil
}
if errorMessage.errorType == .nonBlockingIdentityChange {
return isGroupThread ? .groupUpdates : .chatUpdates
}
return nil
case .call:
// Don't collapse an active group call.
if
let groupCallMessage = interaction as? OWSGroupCallMessage,
!groupCallMessage.hasEnded
{
return nil
}
return .callEvents
default:
return nil
}
}
// MARK: -
#if USE_DEBUG_UI
public static func debugui_buildStandaloneRenderItem(

View File

@ -42,7 +42,7 @@ struct CVViewStateSnapshot {
let hasActiveCall: Bool
let currentGroupThreadCallGroupId: GroupIdentifier?
let expandedCollapseSetIds: Set<String>
let expandedCollapseSets: Set<String>
private static var currentCallProvider: any CurrentCallProvider { DependenciesBridge.shared.currentCallProvider }
@ -64,7 +64,7 @@ struct CVViewStateSnapshot {
oldestUnreadMessageSortId: oldestUnreadMessageSortId,
hasActiveCall: currentCallProvider.hasCurrentCall,
currentGroupThreadCallGroupId: currentCallProvider.currentGroupThreadCallGroupId,
expandedCollapseSetIds: viewState.expandedCollapseSets,
expandedCollapseSets: viewState.expandedCollapseSets,
)
}
@ -84,7 +84,7 @@ struct CVViewStateSnapshot {
oldestUnreadMessageSortId: nil,
hasActiveCall: false,
currentGroupThreadCallGroupId: nil,
expandedCollapseSetIds: [],
expandedCollapseSets: [],
)
}
}

View File

@ -7,13 +7,11 @@ import Foundation
import SignalServiceKit
private enum Constants {
/// The maximum number of top-level interactions to keep in memory. We start
/// dropping interactions (in an LRU fashion) once we've exceeded this value.
/// The maximum number of interactions to keep in memory. We start dropping
/// interactions (in an LRU fashion) once we've exceeded this value.
///
/// TODO: Should we reduce this value?
static let maxDisplayableInteractionCount = 500
static let maxCollapseSetSize = 50
static let maxInteractionCount = 500
}
protocol MessageLoaderBatchFetcher {
@ -30,19 +28,11 @@ protocol MessageLoaderInteractionFetcher {
// MARK: -
struct MessageLoaderPreprocessingContext {
let thread: TSThread
let oldestUnreadSortId: UInt64?
}
// MARK: -
class MessageLoader {
private let batchFetcher: MessageLoaderBatchFetcher
private let interactionFetchers: [MessageLoaderInteractionFetcher]
private(set) var loadedInteractions: [TSInteraction] = []
private(set) var loadedDisplayableInteractions: [TSInteraction] = []
/// If true, there might be older messages that could be loaded. If false,
/// we believe we've reached the beginning of the chat.
@ -100,61 +90,10 @@ class MessageLoader {
case sameLocation
}
/// A single display unit: one standalone interaction or a collapse set.
private struct LoadedSegment {
/// Either a single item to be displayed or multiple updates to be
/// grouped in a collapse set.
var rawInteractions: [TSInteraction]
/// Zero or more generated elements (date header or unread indicator)
/// followed by the elements to be displayed. The single raw item
/// itself, or a collapse set which would be followed by
/// `rawInteractions` if expanded.
var displayableInteractions: [TSInteraction]
}
/// Groups raw interactions with the displayable interactions they produce
/// during preprocessing, so trimming can drop complete display units.
private struct LoadedPage {
let segments: [LoadedSegment]
var rawInteractions: [TSInteraction] {
segments.flatMap(\.rawInteractions)
}
var displayableInteractions: [TSInteraction] {
segments.flatMap(\.displayableInteractions)
}
var rawInteractionCount: Int {
segments.lazy.map(\.rawInteractions.count).reduce(0, +)
}
func trimmingDisplayableInteractions(
trimOlder: Bool,
) -> LoadedPage {
let segments = trimOlder ? self.segments.reversed() : self.segments
var trimmedSegments: [LoadedSegment] = []
var displayableCount = 0
for segment in segments {
let segmentDisplayableCount = segment.displayableInteractions.count
displayableCount += segmentDisplayableCount
guard displayableCount <= Constants.maxDisplayableInteractionCount else {
break
}
trimmedSegments.append(segment)
}
if trimOlder {
trimmedSegments.reverse()
}
return LoadedPage(segments: trimmedSegments)
}
}
func loadMessagePage(
aroundInteractionId interactionUniqueId: String,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -162,7 +101,6 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -170,7 +108,6 @@ class MessageLoader {
func loadNewerMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -178,7 +115,6 @@ class MessageLoader {
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -186,7 +122,6 @@ class MessageLoader {
func loadOlderMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -194,7 +129,6 @@ class MessageLoader {
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -202,7 +136,6 @@ class MessageLoader {
func loadNewestMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -210,7 +143,6 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -219,7 +151,6 @@ class MessageLoader {
focusMessageId: String?,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
if let focusMessageId {
@ -228,14 +159,12 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
} else {
try loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -244,15 +173,13 @@ class MessageLoader {
func loadSameLocation(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
.sameLocation,
count: max(initialLoadCount, loadedDisplayableInteractions.count),
count: max(initialLoadCount, loadedInteractions.count),
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -268,122 +195,21 @@ class MessageLoader {
count: Int,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext?,
tx: DBReadTransaction,
) throws {
owsAssertDebug(count > 0)
let maxRawInteractionFetchCount = Constants.maxDisplayableInteractionCount * Constants.maxCollapseSetSize
let count = count.clamp(1, maxRawInteractionFetchCount)
let loadedDisplayableCount = loadedDisplayableInteractions.count
let desiredDisplayableInteractionCount: Int = switch direction {
case .older, .newer:
loadedDisplayableCount + count
case .sameLocation:
max(initialLoadCount, loadedDisplayableCount)
case .around, .newest:
count
}
var loadBatch = try buildLoadBatch(
let count = count.clamp(1, Constants.maxInteractionCount)
let loadBatch = try buildLoadBatch(
direction,
count: count,
deletedInteractionIds: deletedInteractionIds,
tx: tx,
)
var loadedPage = buildLoadedPage(
for: loadBatch,
loadedInteractions = fetchInteractions(
uniqueIds: loadBatch.uniqueIds,
reusableInteractions: reusableInteractions,
preprocessingContext: preprocessingContext,
tx: tx,
)
func loadMoreIfNeeded(context: MessageLoaderPreprocessingContext) throws -> Bool {
let loadedDisplayableInteractionCount = loadedPage.displayableInteractions.count
guard loadedDisplayableInteractionCount < desiredDisplayableInteractionCount else {
return false
}
// Heuristically adjust fetch size based on the proportion of
// messages so far that are collapsed.
let remainingCount = desiredDisplayableInteractionCount - loadedDisplayableInteractionCount
let estimatedRawInteractionsPerDisplayableInteraction = min(
Constants.maxCollapseSetSize,
max(
1,
Int(ceil(Double(loadedPage.rawInteractionCount) / Double(max(loadedDisplayableInteractionCount, 1)))),
),
)
let fetchCount = min(
maxRawInteractionFetchCount,
max(count, remainingCount * estimatedRawInteractionsPerDisplayableInteraction),
)
guard fetchCount > 0 else {
return false
}
func fetchOlder() throws -> Bool {
guard
loadBatch.canLoadOlder,
let firstInteraction = loadedPage.segments.first?.rawInteractions.first,
let rowId = firstInteraction.sqliteRowId
else {
return false
}
return try self.fetchOlder(before: rowId, count: fetchCount, batch: &loadBatch, tx: tx) > 0
}
func fetchNewer() throws -> Bool {
guard
loadBatch.canLoadNewer,
let lastInteraction = loadedPage.segments.last?.rawInteractions.last,
let rowId = lastInteraction.sqliteRowId
else {
return false
}
return try self.fetchNewer(after: rowId, count: fetchCount, batch: &loadBatch, tx: tx) > 0
}
let didLoadMore: Bool
switch direction {
case .older, .newest:
didLoadMore = try fetchOlder()
case .newer:
didLoadMore = try fetchNewer()
case .sameLocation, .around:
if try fetchOlder() {
didLoadMore = true
} else {
didLoadMore = try fetchNewer()
}
}
guard didLoadMore else {
return false
}
loadedPage = buildLoadedPage(
for: loadBatch,
reusableInteractions: reusableInteractions,
preprocessingContext: context,
tx: tx,
)
return true
}
if let preprocessingContext {
while try loadMoreIfNeeded(context: preprocessingContext) {
// Loading more messages...
}
}
trimLoadedPageIfNeeded(
&loadBatch,
loadedPage: &loadedPage,
loadDirection: direction,
)
loadedInteractions = loadedPage.rawInteractions
loadedDisplayableInteractions = loadedPage.displayableInteractions
canLoadNewer = loadBatch.canLoadNewer
canLoadOlder = loadBatch.canLoadOlder
}
@ -402,6 +228,24 @@ class MessageLoader {
)
}
/// Expands `batch` with `count` messages preceding `rowId`.
@discardableResult
func fetchOlder(before rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .before(rowId), limit: count)
batch.insertOlder(uniqueIds: uniqueIds, didReachOldest: uniqueIds.count < count)
batch.trimNewer()
return uniqueIds.count
}
/// Expands `batch` with `count` messages succeeding `rowId`.
@discardableResult
func fetchNewer(after rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .after(rowId), limit: count)
batch.insertNewer(uniqueIds: uniqueIds, didReachNewest: uniqueIds.count < count)
batch.trimOlder()
return uniqueIds.count
}
/// Fetches uniqueIds in the range of provided rowIds.
func fetchRange(_ rowIds: ClosedRange<Int64>) throws -> [String] {
return try fetch(filter: .range(rowIds), limit: rowIds.count)
@ -421,8 +265,8 @@ class MessageLoader {
return try loadNewest()
}
var batch = MessageLoaderBatch(canLoadNewer: true, canLoadOlder: true, uniqueIds: [uniqueId])
let olderCount = try fetchOlder(before: rowId, count: count / 2, batch: &batch, tx: tx)
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch, tx: tx)
let olderCount = try fetchOlder(before: rowId, count: count / 2, batch: &batch)
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch)
return batch
}
@ -467,7 +311,7 @@ class MessageLoader {
return batch
case .older:
var batch = priorLoad.batch
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch, tx: tx)
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch)
return batch
case .sameLocation where !priorLoad.batch.canLoadNewer:
// If we're loading at the same location and are already at the end of the
@ -475,13 +319,13 @@ class MessageLoader {
fallthrough
case .newer:
var batch = priorLoad.batch
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch, tx: tx)
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch)
return batch
case .sameLocation:
var batch = priorLoad.batch
if batch.uniqueIds.count < initialLoadCount {
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch, tx: tx)
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch, tx: tx)
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch)
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch)
}
return batch
case .around(interactionUniqueId: let uniqueId):
@ -499,32 +343,6 @@ class MessageLoader {
}
}
/// Expands `batch` with `count` messages preceding `rowId`.
@discardableResult
private func fetchOlder(
before rowId: Int64,
count: Int,
batch: inout MessageLoaderBatch,
tx: DBReadTransaction,
) throws -> Int {
let uniqueIds = try batchFetcher.fetchUniqueIds(filter: .before(rowId), limit: count, tx: tx)
batch.insertOlder(uniqueIds: uniqueIds, didReachOldest: uniqueIds.count < count)
return uniqueIds.count
}
/// Expands `batch` with `count` messages succeeding `rowId`.
@discardableResult
private func fetchNewer(
after rowId: Int64,
count: Int,
batch: inout MessageLoaderBatch,
tx: DBReadTransaction,
) throws -> Int {
let uniqueIds = try batchFetcher.fetchUniqueIds(filter: .after(rowId), limit: count, tx: tx)
batch.insertNewer(uniqueIds: uniqueIds, didReachNewest: uniqueIds.count < count)
return uniqueIds.count
}
private func fetchInteractions(
uniqueIds interactionIds: [String],
reusableInteractions: [String: TSInteraction] = [:],
@ -542,268 +360,6 @@ class MessageLoader {
}
return refinery.values.compacted()
}
private func buildLoadedPage(
for batch: MessageLoaderBatch,
reusableInteractions: [String: TSInteraction],
preprocessingContext: MessageLoaderPreprocessingContext?,
tx: DBReadTransaction,
) -> LoadedPage {
let rawInteractions = fetchInteractions(
uniqueIds: batch.uniqueIds,
reusableInteractions: reusableInteractions,
tx: tx,
)
return LoadedPage(
segments: Self.preprocessInteractions(
rawInteractions,
preprocessingContext: preprocessingContext,
),
)
}
private func trimLoadedPageIfNeeded(
_ loadBatch: inout MessageLoaderBatch,
loadedPage: inout LoadedPage,
loadDirection: LoadWindowDirection,
) {
guard loadedPage.displayableInteractions.count > Constants.maxDisplayableInteractionCount else {
return
}
let trimOlder: Bool = switch loadDirection {
case .newer, .around, .newest, .sameLocation:
true
case .older:
false
}
loadedPage = loadedPage.trimmingDisplayableInteractions(trimOlder: trimOlder)
loadBatch.uniqueIds = loadedPage.rawInteractions.map(\.uniqueId)
if trimOlder {
loadBatch.canLoadOlder = true
} else {
loadBatch.canLoadNewer = true
}
}
/// Converts interactions into page segments. When a preprocessing context
/// is provided, this also inserts dynamic items (date headers and unread
/// indicators) and collapse sets.
private static func preprocessInteractions(
_ interactions: [TSInteraction],
preprocessingContext: MessageLoaderPreprocessingContext?,
) -> [LoadedSegment] {
guard let preprocessingContext else {
return interactions.map { interaction in
LoadedSegment(rawInteractions: [interaction], displayableInteractions: [interaction])
}
}
let thread = preprocessingContext.thread
let isGroupThread = thread.isGroupThread
let oldestUnreadSortId = preprocessingContext.oldestUnreadSortId
let todayDate = Date()
var result = [LoadedSegment]()
var pendingDisplayableInteractions = [TSInteraction]()
var currentRun = [TSInteraction]()
var currentRunType: CollapseSetInteraction.MessagesType?
var pastUnreadIndicator = false
var shouldShowDateOnNextViewItem = true
var previousDaysBeforeToday: Int?
func appendItem(_ interaction: TSInteraction) {
result.append(LoadedSegment(
rawInteractions: [interaction],
displayableInteractions: pendingDisplayableInteractions + [interaction],
))
pendingDisplayableInteractions.removeAll()
}
func finalizeSet() {
defer {
currentRun.removeAll()
currentRunType = nil
}
guard !currentRun.isEmpty else {
return
}
guard currentRun.count >= 2, let runType = currentRunType else {
for interaction in currentRun {
appendItem(interaction)
}
return
}
let collapseSetInteraction = CollapseSetInteraction(
thread: thread,
collapsedInteractions: currentRun,
collapseSetType: runType,
)
result.append(LoadedSegment(
rawInteractions: currentRun,
displayableInteractions: pendingDisplayableInteractions + [collapseSetInteraction],
))
pendingDisplayableInteractions.removeAll()
}
for interaction in interactions {
let timestamp = interaction.timestamp
let daysBeforeToday = DateUtil.daysFrom(
firstDate: Date(millisecondsSince1970: timestamp),
toSecondDate: todayDate,
)
if let previousDaysBeforeToday {
if daysBeforeToday != previousDaysBeforeToday {
shouldShowDateOnNextViewItem = true
}
} else {
// Only show for the first item if the date is not today
shouldShowDateOnNextViewItem = daysBeforeToday != 0
}
if
shouldShowDateOnNextViewItem,
canShowDateHeader(before: interaction)
{
// Collapse sets shouldn't cross date boundaries
finalizeSet()
pendingDisplayableInteractions.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
shouldShowDateOnNextViewItem = false
}
previousDaysBeforeToday = daysBeforeToday
// Only insert one unread indicator and don't collapse unread events
if pastUnreadIndicator {
appendItem(interaction)
continue
}
if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
finalizeSet()
let unreadIndicatorInteraction = UnreadIndicatorInteraction(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: interaction.receivedAtTimestamp,
)
pendingDisplayableInteractions.append(unreadIndicatorInteraction)
pastUnreadIndicator = true
appendItem(interaction)
continue
}
guard BuildFlags.collapsingChatEvents else {
appendItem(interaction)
continue
}
let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
if let collapseType {
let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
let exceededCurrentRunLimit = currentRun.count >= Constants.maxCollapseSetSize
if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
finalizeSet()
}
currentRun.append(interaction)
currentRunType = collapseType
} else {
finalizeSet()
appendItem(interaction)
}
}
finalizeSet()
return result
}
private static func canShowDateHeader(before interaction: TSInteraction) -> Bool {
switch interaction.interactionType {
case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet:
return false
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("Invalid interaction.")
return false
}
// Only show the date for non-synced thread messages;
return infoMessage.messageType != .syncedThread
case .unreadIndicator, .incomingMessage, .outgoingMessage, .error, .call:
return true
}
}
private static func collapseSetType(
for interaction: TSInteraction,
isGroupThread: Bool,
) -> CollapseSetInteraction.MessagesType? {
switch interaction.interactionType {
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("info interaction is not TSInfoMessage")
return nil
}
switch infoMessage.messageType {
case .typeDisappearingMessagesUpdate:
return .timerChanges
case .typeGroupUpdate:
if
let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems]
as? TSInfoMessage.PersistableGroupUpdateItemsWrapper
{
for event in wrapper.updateItems {
switch event {
case
.groupTerminatedByLocalUser,
.groupTerminatedByOtherUser,
.groupTerminatedByUnknownUser:
return nil
case
.disappearingMessagesEnabledByLocalUser,
.disappearingMessagesEnabledByOtherUser,
.disappearingMessagesEnabledByUnknownUser,
.disappearingMessagesDisabledByLocalUser,
.disappearingMessagesDisabledByOtherUser,
.disappearingMessagesDisabledByUnknownUser:
return .timerChanges
default:
break
}
}
}
return isGroupThread ? .groupUpdates : .chatUpdates
case .verificationStateChange,
.profileUpdate,
.phoneNumberChange,
.typeEndPoll,
.typePinnedMessage:
return isGroupThread ? .groupUpdates : .chatUpdates
default:
return nil
}
case .error:
guard let errorMessage = interaction as? TSErrorMessage else {
owsFailDebug("error interaction is not TSErrorMessage")
return nil
}
if errorMessage.errorType == .nonBlockingIdentityChange {
return isGroupThread ? .groupUpdates : .chatUpdates
}
return nil
case .call:
// Don't collapse an active group call.
if
let groupCallMessage = interaction as? OWSGroupCallMessage,
!groupCallMessage.hasEnded
{
return nil
}
return .callEvents
default:
return nil
}
}
}
// MARK: -
@ -891,6 +447,8 @@ struct MessageLoaderBatch {
}
uniqueIds = otherUniqueIds.dropLast(overlappingCount) + uniqueIds
mergeCanLoad(otherLoadBatch)
// Make sure we keep all of `self`, so trim entries we just added if needed.
trimOlder()
case (let firstIndex?, nil):
let overlappingCount = uniqueIds.endIndex - firstIndex
guard uniqueIds.suffix(overlappingCount) == otherUniqueIds.prefix(overlappingCount) else {
@ -900,6 +458,8 @@ struct MessageLoaderBatch {
}
uniqueIds += otherUniqueIds.dropFirst(overlappingCount)
mergeCanLoad(otherLoadBatch)
// Make sure we keep all of `self`, so trim entries we just added if needed.
trimNewer()
case (let firstIndex?, let lastIndex?):
guard uniqueIds[firstIndex...lastIndex] == otherUniqueIds[...] else {
// If this breaks, it probably means `deletedInteractionIds` is broken (or
@ -934,4 +494,24 @@ struct MessageLoaderBatch {
canLoadNewer = false
}
}
mutating func trimOlder() {
guard uniqueIds.count > Constants.maxInteractionCount else {
return
}
uniqueIds = Array(uniqueIds.suffix(Constants.maxInteractionCount))
// We trimmed from the beginning. If the oldest had been marked as loaded,
// it's no longer loaded.
canLoadOlder = true
}
mutating func trimNewer() {
guard uniqueIds.count > Constants.maxInteractionCount else {
return
}
uniqueIds = Array(uniqueIds.prefix(Constants.maxInteractionCount))
// We trimmed from the end. If the newest had already been marked as
// loaded, it's no longer loaded.
canLoadNewer = true
}
}

View File

@ -473,33 +473,13 @@ class MessageRequestView: ConversationBottomPanelView {
// MARK: -
private func buttonConfiguration(title: String) -> UIButton.Configuration {
var configuration: UIButton.Configuration
if #available(iOS 26, *) {
configuration = .prominentGlass()
configuration.baseForegroundColor = .Signal.label
} else {
configuration = .plain()
configuration.baseForegroundColor = .Signal.accent
}
configuration.titleAlignment = .center
configuration.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
configuration.baseBackgroundColor = .clear
if #available(iOS 26, *) {
configuration.cornerStyle = .capsule
}
configuration.title = title
configuration.contentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 8)
return configuration
}
private func prepareButton(
title: String,
destructive: Bool = false,
actionBlock: @escaping () -> Void,
) -> UIButton {
let button = UIButton(
configuration: buttonConfiguration(title: title),
configuration: .mediumSecondary(title: title),
primaryAction: UIAction { _ in
actionBlock()
},

View File

@ -47,12 +47,11 @@ enum ContactSupportActionSheet {
let submitWithLogAction = ActionSheetAction(title: submitWithLogTitle, style: .default) { [weak fromViewController] _ in
guard let fromViewController else { return }
let logs = DebugLogs(dumper: logDumper)
let emailRequest = SupportEmailModel(
userDescription: nil,
emojiMood: nil,
supportFilter: emailFilter.asString,
debugLogPolicy: .requireUpload(logs),
debugLogPolicy: .requireUpload(logDumper),
hasRecentChallenge: logDumper.challengeReceivedRecently(),
)

View File

@ -8,7 +8,7 @@ import SignalServiceKit
import SignalUI
import zlib
struct DebugLogDumper {
public struct DebugLogDumper {
fileprivate var accountManager: (any TSAccountManager)?
fileprivate var appVersion: any AppVersion
fileprivate var db: (any DB)?
@ -25,7 +25,7 @@ struct DebugLogDumper {
)
}
func challengeReceivedRecently() -> Bool {
public func challengeReceivedRecently() -> Bool {
guard let db else {
return false
}
@ -57,134 +57,34 @@ struct DebugLogDumper {
}
}
final class DebugLogs {
private let dumper: DebugLogDumper
private var logsDirPath: String?
enum DebugLogs {
init(dumper: DebugLogDumper) {
self.dumper = dumper
self.logsDirPath = DebugLogs.collectAndFlushLogs(dumper: dumper)
}
deinit {
if let logsDirPath {
OWSFileSystem.deleteFile(logsDirPath)
}
}
func showPreview(
from viewController: UIViewController,
onSubmit: (() -> Void)? = nil,
onCancel: (() -> Void)? = nil,
) {
guard let logsDirPath else {
Logger.error("No logs path found for preview")
handleError(error: .noLogs, viewController: viewController)
onCancel?()
return
}
let logFilePaths = ((try? FileManager.default.contentsOfDirectory(atPath: logsDirPath)) ?? []).map {
URL(fileURLWithPath: logsDirPath).appendingPathComponent($0).path
}
let previewVC = DebugLogPreviewViewController(logFilePaths: logFilePaths, onSubmit: onSubmit, onCancel: onCancel)
let nav = OWSNavigationController(rootViewController: previewVC)
viewController.present(nav, animated: true)
}
/// Presents a log preview with an option to submit. Completion is only
/// called if the user submits, after the submission is completed.
@MainActor
func promptToSubmitLogs(
from viewController: UIViewController,
supportTag: String? = nil,
completion: (() -> Void)? = nil,
) {
showPreview(from: viewController, onSubmit: {
Task {
await viewController.awaitableDismiss(animated: true)
await self.submitLogs(supportTag: supportTag)
if let completion {
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
static func submitLogs(supportTag: String? = nil, dumper: DebugLogDumper, completion: (() -> Void)? = nil) {
let submitLogsCompletion = {
if let completion {
// Wait a moment. If the user opens a URL, it needs a moment to complete.
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
completion()
}
}
})
}
@MainActor
func promptToSubmitLogs(
from viewController: UIViewController,
supportTag: String? = nil,
) async {
let didSubmit = await withCheckedContinuation { continuation in
showPreview(
from: viewController,
onSubmit: {
continuation.resume(returning: true)
},
onCancel: {
continuation.resume(returning: false)
},
)
}
if didSubmit {
await viewController.awaitableDismiss(animated: true)
await submitLogs(supportTag: supportTag)
}
}
enum DebugLogsError: LocalizedError {
case noLogs
case couldNotPackageLogs
case uploadError(zipFilePath: String)
var errorDescription: String? { localizedErrorMessage }
var localizedErrorMessage: String {
switch self {
case .noLogs:
OWSLocalizedString(
"DEBUG_LOG_ALERT_NO_LOGS",
comment: "Error indicating that no debug logs could be found.",
)
case .couldNotPackageLogs:
OWSLocalizedString(
"DEBUG_LOG_ALERT_COULD_NOT_PACKAGE_LOGS",
comment: "Error indicating that the debug logs could not be packaged.",
)
case .uploadError:
OWSLocalizedString(
"DEBUG_LOG_ALERT_ERROR_UPLOADING_LOG",
comment: "Error indicating that a debug log could not be uploaded.",
)
}
}
}
@MainActor
private func submitLogs(supportTag: String?) async {
var supportFilter = "Signal - iOS Debug Log"
if let supportTag {
supportFilter += " - \(supportTag)"
}
guard let frontmostViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts else {
submitLogsCompletion()
return
}
uploadLogsUsingViewController(frontmostViewController, dumper: dumper) { url in
guard let presentingViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts else {
submitLogsCompletion()
return
}
let url: URL?
do {
url = try await uploadLogsWithUI(from: frontmostViewController)
} catch {
self.handleError(error: error, viewController: frontmostViewController)
return
}
guard let url else { return }
guard let presentingViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts else {
return
}
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
let alert = ActionSheetController(
title: NSLocalizedString("DEBUG_LOG_ALERT_TITLE", comment: "Title of the debug log alert."),
message: NSLocalizedString("DEBUG_LOG_ALERT_MESSAGE", comment: "Message of the debug log alert."),
@ -202,10 +102,10 @@ final class DebugLogs {
await ComposeSupportEmailOperation.sendEmailWithDefaultErrorHandling(
supportFilter: supportFilter,
logUrl: url,
hasRecentChallenge: self.dumper.challengeReceivedRecently(),
hasRecentChallenge: dumper.challengeReceivedRecently(),
)
}
continuation.resume()
submitLogsCompletion()
},
))
}
@ -218,7 +118,7 @@ final class DebugLogs {
handler: { _ in
UIPasteboard.general.string = url.absoluteString
presentingViewController.presentToast(text: CommonStrings.copiedToClipboardToast, image: .copy)
continuation.resume()
submitLogsCompletion()
},
))
alert.addAction(ActionSheetAction(
@ -231,39 +131,67 @@ final class DebugLogs {
AttachmentSharing.showShareUI(
for: url.absoluteString,
sender: nil,
completion: { continuation.resume() },
completion: submitLogsCompletion,
)
},
))
alert.addAction(ActionSheetAction(
title: CommonStrings.cancelButton,
style: .cancel,
handler: { _ in continuation.resume() },
handler: { _ in submitLogsCompletion() },
))
presentingViewController.presentActionSheet(alert)
}
}
@MainActor
private func uploadLogsWithUI(from viewController: UIViewController) async throws(DebugLogsError) -> URL? {
return try await ModalActivityIndicatorViewController.presentAndPropagateResult(
from: viewController,
private static func uploadLogsUsingViewController(_ viewController: UIViewController, dumper: DebugLogDumper, completion: @escaping (URL) -> Void) {
AssertIsOnMainThread()
ModalActivityIndicatorViewController.present(
fromViewController: viewController,
canCancel: true,
) { () throws(DebugLogsError) -> URL? in
do throws(DebugLogsError) {
return try await self.uploadLogs()
} catch {
if Task.isCancelled {
return nil
asyncBlock: { await _uploadLogs(dumper: dumper, modalActivityIndicator: $0, completion: completion) },
)
}
@MainActor
private static func _uploadLogs(dumper: DebugLogDumper, modalActivityIndicator: ModalActivityIndicatorViewController, completion: @escaping (URL) -> Void) async {
do {
let url = try await uploadLogs(dumper: dumper)
guard !modalActivityIndicator.wasCancelled else { return }
modalActivityIndicator.dismiss {
completion(url)
}
} catch {
guard !modalActivityIndicator.wasCancelled else {
if let logArchiveOrDirectoryPath = error.logArchiveOrDirectoryPath {
OWSFileSystem.deleteFile(logArchiveOrDirectoryPath)
}
throw error
return
}
modalActivityIndicator.dismiss {
DebugLogs.showFailureAlert(
with: error.localizedErrorMessage,
logArchiveOrDirectoryPath: error.logArchiveOrDirectoryPath,
)
}
}
}
// MARK: - Collecting & uploading
private static func collectLogs() -> String? {
private struct NoLogsError: Error {
var errorString: String {
OWSLocalizedString(
"DEBUG_LOG_ALERT_NO_LOGS",
comment: "Error indicating that no debug logs could be found.",
)
}
}
private static func collectLogs() -> Result<String, NoLogsError> {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy.MM.dd hh.mm.ss"
let dateString = dateFormatter.string(from: Date())
@ -275,7 +203,7 @@ final class DebugLogs {
let logFilePaths = DebugLogger.shared.allLogFilePaths
if logFilePaths.isEmpty {
return nil
return .failure(NoLogsError())
}
for logFilePath in logFilePaths {
@ -291,44 +219,50 @@ final class DebugLogs {
OWSFileSystem.protectFileOrFolder(atPath: copyFilePath)
}
return zipDirPath
return .success(zipDirPath)
}
func exportLogs(viewController: UIViewController) {
static func exportLogs() {
AssertIsOnMainThread()
guard let logsDirPath else {
return handleError(
error: .noLogs,
viewController: viewController,
)
}
AttachmentSharing.showShareUI(for: URL(fileURLWithPath: logsDirPath), sender: nil) {
OWSFileSystem.deleteFile(logsDirPath)
switch collectLogs() {
case let .success(logsDirPath):
AttachmentSharing.showShareUI(for: URL(fileURLWithPath: logsDirPath), sender: nil) {
OWSFileSystem.deleteFile(logsDirPath)
}
case let .failure(error):
Self.showFailureAlert(with: error.errorString, logArchiveOrDirectoryPath: nil)
return
}
}
private static func collectAndFlushLogs(
dumper: DebugLogDumper,
) -> String? {
// Dump any additional details that are relevant.
struct UploadDebugLogError: Error {
var localizedErrorMessage: String
var logArchiveOrDirectoryPath: String?
}
/// - Note: Various dependencies might not be initialized yet when this
/// method is called from the database recovery flow. Notably, the database
/// isn't available in that flow.
static func uploadLogs(dumper: DebugLogDumper) async throws(UploadDebugLogError) -> URL {
// Phase 1: Dump any additional details that are relevant.
dumper.dump()
Logger.info("About to zip debug logs")
// Flush pending logs to disk.
// Phase 2: Flush pending logs to disk.
Logger.flush()
// Make a local copy of all of the log files.
return collectLogs()
}
func uploadLogs() async throws(DebugLogsError) -> URL {
guard let logsDirPath else {
throw DebugLogsError.noLogs
// Phase 3: Make a local copy of all of the log files.
let zipDirPath: String
switch collectLogs() {
case let .success(logsDirPath):
zipDirPath = logsDirPath
case let .failure(error):
throw UploadDebugLogError(localizedErrorMessage: error.errorString)
}
// Zip up the log files.
let zipDirUrl = URL(fileURLWithPath: logsDirPath)
let zipFileUrl = URL(fileURLWithPath: (logsDirPath as NSString).appendingPathExtension("zip")!)
// Phase 4: Zip up the log files.
let zipDirUrl = URL(fileURLWithPath: zipDirPath)
let zipFileUrl = URL(fileURLWithPath: (zipDirPath as NSString).appendingPathExtension("zip")!)
let fileCoordinator = NSFileCoordinator()
var zipError: NSError?
fileCoordinator.coordinate(readingItemAt: zipDirUrl, options: [.forUploading], error: &zipError) { temporaryFileUrl in
@ -339,44 +273,38 @@ final class DebugLogs {
}
}
if zipError != nil || !OWSFileSystem.fileOrFolderExists(url: zipFileUrl) {
throw DebugLogsError.couldNotPackageLogs
let errorMessage = OWSLocalizedString(
"DEBUG_LOG_ALERT_COULD_NOT_PACKAGE_LOGS",
comment: "Error indicating that the debug logs could not be packaged.",
)
throw UploadDebugLogError(localizedErrorMessage: errorMessage, logArchiveOrDirectoryPath: zipDirPath)
}
OWSFileSystem.protectFileOrFolder(atPath: zipFileUrl.path)
OWSFileSystem.deleteFile(zipDirPath)
// Upload the log files.
// Phase 5: Upload the log files.
do {
let url = try await DebugLogUploader.uploadFile(fileUrl: zipFileUrl, mimeType: MimeType.applicationZip.rawValue)
try OWSFileSystem.deleteFile(url: zipFileUrl)
return url
} catch {
throw DebugLogsError.uploadError(zipFilePath: zipFileUrl.path)
let errorMessage = OWSLocalizedString(
"DEBUG_LOG_ALERT_ERROR_UPLOADING_LOG",
comment: "Error indicating that a debug log could not be uploaded.",
)
throw UploadDebugLogError(localizedErrorMessage: errorMessage, logArchiveOrDirectoryPath: zipFileUrl.path)
}
}
private func handleError(
error: DebugLogsError,
viewController: UIViewController,
) {
let logsPath: String?
let completion: (() -> Void)?
switch error {
case .noLogs:
logsPath = nil
completion = nil
case .couldNotPackageLogs:
logsPath = self.logsDirPath
completion = nil
case .uploadError(let zipFilePath):
logsPath = zipFilePath
completion = {
OWSFileSystem.deleteFile(zipFilePath)
}
private static func showFailureAlert(with message: String, logArchiveOrDirectoryPath: String?) {
let deleteArchive: (String) -> Void = { filePath in
OWSFileSystem.deleteFile(filePath)
}
let alert = ActionSheetController(message: error.localizedErrorMessage)
let alert = ActionSheetController(title: nil, message: message)
if let logsPath {
if let logArchiveOrDirectoryPath {
alert.addAction(.init(
title: OWSLocalizedString(
"DEBUG_LOG_ALERT_OPTION_EXPORT_LOG_ARCHIVE",
@ -384,18 +312,23 @@ final class DebugLogs {
),
) { _ in
AttachmentSharing.showShareUI(
for: URL(fileURLWithPath: logsPath),
for: URL(fileURLWithPath: logArchiveOrDirectoryPath),
sender: nil,
completion: completion,
completion: {
deleteArchive(logArchiveOrDirectoryPath)
},
)
})
}
alert.addAction(.init(title: CommonStrings.okButton) { _ in
completion?()
if let logArchiveOrDirectoryPath {
deleteArchive(logArchiveOrDirectoryPath)
}
})
viewController.presentActionSheet(alert)
let presentingViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts
presentingViewController?.presentActionSheet(alert)
}
}

View File

@ -32,6 +32,9 @@ extension DeviceTransferService {
let wal: DeviceTransferProtoFile = try {
let file = SSKEnvironment.shared.databaseStorageRef.grdbStorage.databaseWALFilePath
let size = try OWSFileSystem.fileSize(ofPath: file)
guard size > 0 else {
throw OWSAssertionError("database wal is empty")
}
estimatedTotalSize += size
let fileBuilder = DeviceTransferProtoFile.builder(
identifier: DeviceTransferService.databaseWALIdentifier,

View File

@ -5,7 +5,6 @@
import CryptoKit
import Foundation
import GRDB
import MultipeerConnectivity
import SignalServiceKit
@ -367,15 +366,7 @@ class DeviceTransferService: NSObject, DeviceTransferServiceProtocol {
taskGroup.addTask {
// Make a copy of the database files within a write transaction so we can be confident
// they aren't mutated during the copy. We then transfer these copies.
let dbCopy = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
// The MultipeerConnectivity framework stalls if we try to send an empty
// file. The receiver requires a non-empty file. We can't send garbage
// (because that would corrupt the database), so mutate the database, force
// it to be written to the WAL file, and then send that result to our peer.
let store = NewKeyValueStore(collection: "DeviceTransferWAL")
store.writeValue(Randomness.generateRandomBytes(32), forKey: "MustBeNonEmpty", tx: tx)
store.removeValue(forKey: "MustBeNonEmpty", tx: tx)
sqlite3_db_cacheflush(tx.database.sqliteConnection!)
let dbCopy = try await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { _ in
do {
let dbCopy = try Self.makeLocalCopy(databaseFile: database.database)
let walCopy = try Self.makeLocalCopy(databaseFile: database.wal)

View File

@ -113,7 +113,7 @@ extension EmojiReactionPickerConfigViewController: MessageReactionPickerDelegate
present(picker, animated: true)
}
func didSelectShowFullEmojiPicker() {
func didSelectAnyEmoji() {
// No-op for configuration
}
}

View File

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "change-number-error.pdf",
"filename" : "change-number-dark-40.pdf",
"idiom" : "universal"
}
],

View File

@ -0,0 +1,176 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.231373 0.231373 0.231373 scn
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
f
n
Q
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
W*
n
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.368627 0.368627 0.368627 scn
8.000000 77.000000 m
32.000000 77.000000 l
32.000000 83.000000 l
8.000000 83.000000 l
8.000000 77.000000 l
h
37.000000 72.000000 m
37.000000 8.000000 l
43.000000 8.000000 l
43.000000 72.000000 l
37.000000 72.000000 l
h
32.000000 3.000000 m
8.000000 3.000000 l
8.000000 -3.000000 l
32.000000 -3.000000 l
32.000000 3.000000 l
h
3.000000 8.000000 m
3.000000 72.000000 l
-3.000000 72.000000 l
-3.000000 8.000000 l
3.000000 8.000000 l
h
8.000000 3.000000 m
5.238576 3.000000 3.000000 5.238579 3.000000 8.000000 c
-3.000000 8.000000 l
-3.000000 1.924866 1.924867 -3.000000 8.000000 -3.000000 c
8.000000 3.000000 l
h
37.000000 8.000000 m
37.000000 5.238579 34.761421 3.000000 32.000000 3.000000 c
32.000000 -3.000000 l
38.075134 -3.000000 43.000000 1.924873 43.000000 8.000000 c
37.000000 8.000000 l
h
32.000000 77.000000 m
34.761425 77.000000 37.000000 74.761421 37.000000 72.000000 c
43.000000 72.000000 l
43.000000 78.075134 38.075134 83.000000 32.000000 83.000000 c
32.000000 77.000000 l
h
8.000000 83.000000 m
1.924867 83.000000 -3.000000 78.075127 -3.000000 72.000000 c
3.000000 72.000000 l
3.000000 74.761421 5.238577 77.000000 8.000000 77.000000 c
8.000000 83.000000 l
h
f
n
Q
Q
q
1.000000 0.000000 -0.000000 1.000000 9.502441 28.866180 cm
0.380392 0.568627 0.952941 scn
0.807579 19.713818 m
0.217579 18.883818 -0.502421 17.333817 0.497579 14.303818 c
1.647666 11.308455 3.414371 8.588205 5.683169 6.319407 c
7.951967 4.050610 10.672216 2.283905 13.667579 1.133818 c
16.667580 0.063818 18.247580 0.833818 19.077579 1.423819 c
19.806948 1.924915 20.384636 2.616756 20.747580 3.423819 c
20.993914 3.896008 21.059738 4.441780 20.932735 4.958998 c
20.805733 5.476215 20.494604 5.929427 20.057579 6.233817 c
16.627579 8.633817 l
16.198772 8.939078 15.673998 9.078884 15.150155 9.027419 c
14.626312 8.975954 14.138780 8.736692 13.777579 8.353816 c
13.387579 7.943816 13.137579 7.663816 12.777579 7.353816 c
12.599248 7.137367 12.346200 6.995701 12.068459 6.956817 c
11.790717 6.917933 11.508494 6.984663 11.277578 7.143817 c
10.357920 7.826672 9.488976 8.575300 8.677579 9.383817 c
7.890476 10.190874 7.161960 11.053063 6.497579 11.963817 c
6.338425 12.194733 6.271694 12.476955 6.310578 12.754697 c
6.349462 13.032438 6.491130 13.285485 6.707579 13.463817 c
7.057579 13.773817 7.337579 14.023817 7.707579 14.413816 c
8.090455 14.775017 8.329715 15.262550 8.381180 15.786394 c
8.432645 16.310236 8.292840 16.835011 7.987579 17.263817 c
5.617579 20.693817 l
5.313189 21.130842 4.859977 21.441969 4.342760 21.568972 c
3.825542 21.695976 3.279768 21.630152 2.807579 21.383818 c
2.000517 21.020874 1.308676 20.443186 0.807579 19.713818 c
0.807579 19.713818 l
h
f
n
Q
endstream
endobj
3 0 obj
3567
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 40.000000 80.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000003657 00000 n
0000003680 00000 n
0000003853 00000 n
0000003927 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
3986
%%EOF

View File

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "safetytip_48_lock.pdf",
"filename" : "change-number-light-40.pdf",
"idiom" : "universal"
}
],

View File

@ -0,0 +1,176 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
1.000000 1.000000 1.000000 scn
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
f
n
Q
0.000000 72.000000 m
0.000000 76.418274 3.581722 80.000000 8.000000 80.000000 c
32.000000 80.000000 l
36.418278 80.000000 40.000000 76.418274 40.000000 72.000000 c
40.000000 8.000000 l
40.000000 3.581726 36.418278 0.000000 32.000000 0.000000 c
8.000000 0.000000 l
3.581722 0.000000 0.000000 3.581718 0.000000 8.000000 c
0.000000 72.000000 l
h
W*
n
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.725490 0.725490 0.725490 scn
8.000000 77.000000 m
32.000000 77.000000 l
32.000000 83.000000 l
8.000000 83.000000 l
8.000000 77.000000 l
h
37.000000 72.000000 m
37.000000 8.000000 l
43.000000 8.000000 l
43.000000 72.000000 l
37.000000 72.000000 l
h
32.000000 3.000000 m
8.000000 3.000000 l
8.000000 -3.000000 l
32.000000 -3.000000 l
32.000000 3.000000 l
h
3.000000 8.000000 m
3.000000 72.000000 l
-3.000000 72.000000 l
-3.000000 8.000000 l
3.000000 8.000000 l
h
8.000000 3.000000 m
5.238576 3.000000 3.000000 5.238579 3.000000 8.000000 c
-3.000000 8.000000 l
-3.000000 1.924866 1.924867 -3.000000 8.000000 -3.000000 c
8.000000 3.000000 l
h
37.000000 8.000000 m
37.000000 5.238579 34.761421 3.000000 32.000000 3.000000 c
32.000000 -3.000000 l
38.075134 -3.000000 43.000000 1.924873 43.000000 8.000000 c
37.000000 8.000000 l
h
32.000000 77.000000 m
34.761425 77.000000 37.000000 74.761421 37.000000 72.000000 c
43.000000 72.000000 l
43.000000 78.075134 38.075134 83.000000 32.000000 83.000000 c
32.000000 77.000000 l
h
8.000000 83.000000 m
1.924867 83.000000 -3.000000 78.075127 -3.000000 72.000000 c
3.000000 72.000000 l
3.000000 74.761421 5.238577 77.000000 8.000000 77.000000 c
8.000000 83.000000 l
h
f
n
Q
Q
q
1.000000 0.000000 -0.000000 1.000000 9.502441 28.866180 cm
0.172549 0.419608 0.929412 scn
0.807579 19.713818 m
0.217579 18.883818 -0.502421 17.333817 0.497579 14.303818 c
1.647666 11.308455 3.414371 8.588205 5.683169 6.319407 c
7.951967 4.050610 10.672216 2.283905 13.667579 1.133818 c
16.667580 0.063818 18.247580 0.833818 19.077579 1.423819 c
19.806948 1.924915 20.384636 2.616756 20.747580 3.423819 c
20.993914 3.896008 21.059738 4.441780 20.932735 4.958998 c
20.805733 5.476215 20.494604 5.929427 20.057579 6.233817 c
16.627579 8.633817 l
16.198772 8.939078 15.673998 9.078884 15.150155 9.027419 c
14.626312 8.975954 14.138780 8.736692 13.777579 8.353816 c
13.387579 7.943816 13.137579 7.663816 12.777579 7.353816 c
12.599248 7.137367 12.346200 6.995701 12.068459 6.956817 c
11.790717 6.917933 11.508494 6.984663 11.277578 7.143817 c
10.357920 7.826672 9.488976 8.575300 8.677579 9.383817 c
7.890476 10.190874 7.161960 11.053063 6.497579 11.963817 c
6.338425 12.194733 6.271694 12.476955 6.310578 12.754697 c
6.349462 13.032438 6.491130 13.285485 6.707579 13.463817 c
7.057579 13.773817 7.337579 14.023817 7.707579 14.413816 c
8.090455 14.775017 8.329715 15.262550 8.381180 15.786394 c
8.432645 16.310236 8.292840 16.835011 7.987579 17.263817 c
5.617579 20.693817 l
5.313189 21.130842 4.859977 21.441969 4.342760 21.568972 c
3.825542 21.695976 3.279768 21.630152 2.807579 21.383818 c
2.000517 21.020874 1.308676 20.443186 0.807579 19.713818 c
0.807579 19.713818 l
h
f
n
Q
endstream
endobj
3 0 obj
3567
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 40.000000 80.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Type /Catalog
/Pages 5 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000003657 00000 n
0000003680 00000 n
0000003853 00000 n
0000003927 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
3986
%%EOF

View File

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "change-number.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ios-rick-roll-dark@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "ios-rick-roll-dark@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "ios-rick-roll-dark@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

View File

@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "official-wallpaper.pdf",
"filename" : "official_wallpaper_reduced.pdf",
"idiom" : "universal"
}
],

View File

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "safetytip_48_pin.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "verificationcode_alert_96.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More