Compare commits
15 Commits
main
...
8.12.1.161
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ead22b6f3 | ||
|
|
5b4ee2044a | ||
|
|
0c15ff347f | ||
|
|
4c9b2b0a23 | ||
|
|
08f6b2cfdc | ||
|
|
0a04105c36 | ||
|
|
0a4fe237fb | ||
|
|
baa8fbc68f | ||
|
|
5412e40756 | ||
|
|
53e63f63a4 | ||
|
|
246ce3f0ed | ||
|
|
0a0ff25925 | ||
|
|
1197943ddb | ||
|
|
dd9bf89bb4 | ||
|
|
999978dc1c |
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/precommit.yml
vendored
2
.github/workflows/precommit.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/protobuf-check.yml
vendored
2
.github/workflows/protobuf-check.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/translation-check.yml
vendored
2
.github/workflows/translation-check.yml
vendored
@ -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:
|
||||
|
||||
2
.github/workflows/translation-tool.yml
vendored
2
.github/workflows/translation-tool.yml
vendored
@ -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:
|
||||
|
||||
2
.github/workflows/translation-validator.yml
vendored
2
.github/workflows/translation-validator.yml
vendored
@ -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
3
.gitignore
vendored
@ -36,9 +36,6 @@ Index/
|
||||
*.sdsjson
|
||||
Scripts/sds_codegen/sds-includes/*
|
||||
|
||||
# Logs
|
||||
debuglogs/
|
||||
|
||||
/.idea
|
||||
/.vscode
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
3.4.9
|
||||
3.2.2
|
||||
|
||||
@ -1 +1 @@
|
||||
Xcode 26.5
|
||||
Xcode 26.4.1
|
||||
|
||||
@ -310,4 +310,4 @@ DEPENDENCIES
|
||||
xcode-install
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.9
|
||||
2.5.6
|
||||
|
||||
8
Podfile
8
Podfile
@ -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'
|
||||
|
||||
30
Podfile.lock
30
Podfile.lock
@ -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
2
Pods
@ -1 +1 @@
|
||||
Subproject commit 5e81462d833ad24e8091d7b6ab675c2cdc94af54
|
||||
Subproject commit e80eecf2f339915be6ae35aa9908051f6211c86b
|
||||
@ -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
|
||||
}
|
||||
@ -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
@ -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()
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -225,6 +225,7 @@ final class BackupDisablingManager {
|
||||
|
||||
accountEntropyPoolManager.setAccountEntropyPool(
|
||||
newAccountEntropyPool: try! AccountEntropyPool(key: aepBeingRotatedString),
|
||||
disablePIN: false,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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]],
|
||||
|
||||
@ -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),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -71,7 +71,6 @@ struct BackupOnboardingIntroView: View {
|
||||
|
||||
Image(.backupsLogo)
|
||||
.frame(width: 80, height: 80)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Spacer().frame(height: 16)
|
||||
HStack {
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,7 +240,7 @@ extension CallControlsOverflowView: MessageReactionPickerDelegate {
|
||||
self.react(with: reaction)
|
||||
}
|
||||
|
||||
func didSelectShowFullEmojiPicker() {
|
||||
func didSelectAnyEmoji() {
|
||||
let sheet = EmojiPickerSheet(
|
||||
message: nil,
|
||||
reactionPickerConfigurationListener: self,
|
||||
|
||||
@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -150,7 +150,6 @@ private class CVQuotedMessageViewAdapter: CVQuotedMessageViewDelegate {
|
||||
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
|
||||
message,
|
||||
priority: .userInitiated,
|
||||
useThumbnails: false,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -59,7 +59,7 @@ extension TSInfoMessage.PersistableGroupUpdateItem {
|
||||
)
|
||||
{
|
||||
owsAssertDebug(
|
||||
!isTail,
|
||||
isTail.negated,
|
||||
"Collapsed item with a following request shouldn't be a tail!",
|
||||
)
|
||||
return nextItemAction
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: -
|
||||
|
||||
@ -31,8 +31,6 @@ extension ConversationViewController {
|
||||
} else {
|
||||
headerView.titleLabel.text = title
|
||||
}
|
||||
|
||||
headerView.updateTitleSpinning()
|
||||
}
|
||||
|
||||
public func createHeaderViews() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: [],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
},
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -113,7 +113,7 @@ extension EmojiReactionPickerConfigViewController: MessageReactionPickerDelegate
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
func didSelectShowFullEmojiPicker() {
|
||||
func didSelectAnyEmoji() {
|
||||
// No-op for configuration
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "change-number-error.pdf",
|
||||
"filename" : "change-number-dark-40.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
176
Signal/Images.xcassets/change-number-dark-40.imageset/change-number-dark-40.pdf
vendored
Normal file
176
Signal/Images.xcassets/change-number-dark-40.imageset/change-number-dark-40.pdf
vendored
Normal 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
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "safetytip_48_lock.pdf",
|
||||
"filename" : "change-number-light-40.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
176
Signal/Images.xcassets/change-number-light-40.imageset/change-number-light-40.pdf
vendored
Normal file
176
Signal/Images.xcassets/change-number-light-40.imageset/change-number-light-40.pdf
vendored
Normal 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
|
||||
@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "change-number.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
23
Signal/Images.xcassets/introducing-link-previews-dark.imageset/Contents.json
vendored
Normal file
23
Signal/Images.xcassets/introducing-link-previews-dark.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
Signal/Images.xcassets/introducing-link-previews-dark.imageset/ios-rick-roll-dark@1x.png
vendored
Normal file
BIN
Signal/Images.xcassets/introducing-link-previews-dark.imageset/ios-rick-roll-dark@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
BIN
Signal/Images.xcassets/introducing-link-previews-dark.imageset/ios-rick-roll-dark@2x.png
vendored
Normal file
BIN
Signal/Images.xcassets/introducing-link-previews-dark.imageset/ios-rick-roll-dark@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
BIN
Signal/Images.xcassets/introducing-link-previews-dark.imageset/ios-rick-roll-dark@3x.png
vendored
Normal file
BIN
Signal/Images.xcassets/introducing-link-previews-dark.imageset/ios-rick-roll-dark@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 596 KiB |
Binary file not shown.
@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "official-wallpaper.pdf",
|
||||
"filename" : "official_wallpaper_reduced.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
BIN
Signal/Images.xcassets/official_wallpaper_reduced.imageset/official_wallpaper_reduced.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/official_wallpaper_reduced.imageset/official_wallpaper_reduced.pdf
vendored
Normal file
Binary file not shown.
@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "safetytip_48_pin.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "verificationcode_alert_96.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
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
Loading…
Reference in New Issue
Block a user