Compare commits
277 Commits
8.12.1.161
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972078533b | ||
|
|
1eb8b48bb4 | ||
|
|
221043a998 | ||
|
|
3914c811be | ||
|
|
c4b6b61da7 | ||
|
|
aa353b3f59 | ||
|
|
ee158d4c4c | ||
|
|
459643fd14 | ||
|
|
3b1636e179 | ||
|
|
cad4022e68 | ||
|
|
d12641a3a2 | ||
|
|
f995e51b28 | ||
|
|
213cbcd9ad | ||
|
|
f5892e0aad | ||
|
|
b139b7c7b9 | ||
|
|
3e6dc35321 | ||
|
|
30431a6354 | ||
|
|
6be8862bdb | ||
|
|
2182e5952a | ||
|
|
800a5cc0bc | ||
|
|
82ce1ead86 | ||
|
|
d10259eae1 | ||
|
|
1a9a0dbdd9 | ||
|
|
1b8e0a0c93 | ||
|
|
cef72e5a5a | ||
|
|
eb533a72a2 | ||
|
|
08a3e32943 | ||
|
|
b957357516 | ||
|
|
c38b1309dd | ||
|
|
926432d03a | ||
|
|
6628b9f6fc | ||
|
|
1954342a36 | ||
|
|
f40bc944ae | ||
|
|
feeb1303e5 | ||
|
|
a6e8eda73c | ||
|
|
a661667b24 | ||
|
|
a978b4cc8b | ||
|
|
15ada8dcf0 | ||
|
|
15f9d3dc96 | ||
|
|
fc5102cd54 | ||
|
|
16c179115e | ||
|
|
d28e29fa21 | ||
|
|
265757716a | ||
|
|
0f0c3e6fc6 | ||
|
|
8a53464a41 | ||
|
|
79bbd556a4 | ||
|
|
cfb22a38b3 | ||
|
|
808f3218db | ||
|
|
280fc1f244 | ||
|
|
78130adac7 | ||
|
|
feba86dbfb | ||
|
|
202d8a1f07 | ||
|
|
c6492caae7 | ||
|
|
a82216e06c | ||
|
|
a173d4599a | ||
|
|
6f5bc03b96 | ||
|
|
ba15734132 | ||
|
|
5a57831b26 | ||
|
|
31867c8d06 | ||
|
|
08ae6b3e07 | ||
|
|
28e9247793 | ||
|
|
8b1379149c | ||
|
|
0cc18a5285 | ||
|
|
f64e718ba2 | ||
|
|
185035784c | ||
|
|
dcf02125a0 | ||
|
|
44e6c6cb43 | ||
|
|
dc3a819024 | ||
|
|
c0cedd0026 | ||
|
|
27439824e7 | ||
|
|
c7005df406 | ||
|
|
4caec2f2d3 | ||
|
|
7dded9229a | ||
|
|
aa7bced824 | ||
|
|
8663b50018 | ||
|
|
2259a151d9 | ||
|
|
08bf2bb9e5 | ||
|
|
0206e8c487 | ||
|
|
39780d4bc7 | ||
|
|
49311ef328 | ||
|
|
08371f4c50 | ||
|
|
0d76c69ec1 | ||
|
|
e80b3d8bdb | ||
|
|
79122a2301 | ||
|
|
8f60728454 | ||
|
|
65f577efed | ||
|
|
8f0c315ad7 | ||
|
|
7fd03d6bd2 | ||
|
|
102b164f89 | ||
|
|
c57f731c67 | ||
|
|
8b613f8bc1 | ||
|
|
a178545e1e | ||
|
|
7dde1505d7 | ||
|
|
6d78ec66e1 | ||
|
|
832f2b06eb | ||
|
|
2260eb9b8f | ||
|
|
d535e8bfe2 | ||
|
|
5ba733f275 | ||
|
|
3fafbe67bc | ||
|
|
52f5b28db3 | ||
|
|
0bc63e4b57 | ||
|
|
344081c1b6 | ||
|
|
9e4e2976c6 | ||
|
|
892b51221a | ||
|
|
fa6876eefa | ||
|
|
79fc5037a3 | ||
|
|
0088304b35 | ||
|
|
e4b9550f31 | ||
|
|
7856a0f0e1 | ||
|
|
e0b88ecf86 | ||
|
|
507959b305 | ||
|
|
aa059ff975 | ||
|
|
3ac71942a7 | ||
|
|
28b9200637 | ||
|
|
1c5dfb2e71 | ||
|
|
2cd69719c7 | ||
|
|
cdb96e1029 | ||
|
|
961936b0ca | ||
|
|
46445edfe7 | ||
|
|
ab454da687 | ||
|
|
025a9ff9be | ||
|
|
c91c15ec7f | ||
|
|
975834e1f6 | ||
|
|
77bc1008ad | ||
|
|
ed9f3615ba | ||
|
|
aad90b9f5b | ||
|
|
dc3827ed5f | ||
|
|
f5806db594 | ||
|
|
18871a45bd | ||
|
|
ec69b9425f | ||
|
|
732d375c82 | ||
|
|
ce442092f3 | ||
|
|
2faeff7589 | ||
|
|
8384fab6a1 | ||
|
|
3a3ffde3dd | ||
|
|
6e45f851f2 | ||
|
|
a6476a9e79 | ||
|
|
276d778e22 | ||
|
|
92b54a1ceb | ||
|
|
ea190d9ae0 | ||
|
|
507d23b760 | ||
|
|
e39fb58e06 | ||
|
|
d655b7b7a4 | ||
|
|
29b863abf8 | ||
|
|
7beb7330a0 | ||
|
|
e14f223e79 | ||
|
|
334f6b9888 | ||
|
|
5f7cbb5f66 | ||
|
|
0ca83a1b50 | ||
|
|
665fda1f2a | ||
|
|
ab097068a8 | ||
|
|
ebd1292f61 | ||
|
|
118e6289ab | ||
|
|
73e5108c1e | ||
|
|
fa65a9e9b5 | ||
|
|
1ea6b1b1d9 | ||
|
|
b8a90daaf0 | ||
|
|
e7a0d760ca | ||
|
|
e541922e02 | ||
|
|
7166c115af | ||
|
|
5897252015 | ||
|
|
7771cf159d | ||
|
|
7f55a610d8 | ||
|
|
c859d83b1d | ||
|
|
c23f22445a | ||
|
|
3cbd9ece13 | ||
|
|
140a572d85 | ||
|
|
a6387b9bfd | ||
|
|
d3b8a06e00 | ||
|
|
48fb1065c6 | ||
|
|
87eb0382b3 | ||
|
|
30c949e930 | ||
|
|
60554470e6 | ||
|
|
4ab7d1d12d | ||
|
|
a7eb78f46c | ||
|
|
153efb2d45 | ||
|
|
ba2b662d37 | ||
|
|
543085bd26 | ||
|
|
a65ec79c04 | ||
|
|
2ea672abb3 | ||
|
|
e7d209ee66 | ||
|
|
df2e8557e9 | ||
|
|
46b3f825a2 | ||
|
|
7b7727287c | ||
|
|
cf47211efe | ||
|
|
3cb8044133 | ||
|
|
8ddef58df8 | ||
|
|
7f240db8a4 | ||
|
|
d5747a432a | ||
|
|
c1c292b23a | ||
|
|
ce9b44ec82 | ||
|
|
608bea72f6 | ||
|
|
1aadffb78a | ||
|
|
25f73ea745 | ||
|
|
3cac16ff20 | ||
|
|
ab5c593d00 | ||
|
|
8b77f19f16 | ||
|
|
0f62aa13f2 | ||
|
|
6c2037b2f2 | ||
|
|
92efa6a1fb | ||
|
|
d427041444 | ||
|
|
71444c4eca | ||
|
|
abc2e213c7 | ||
|
|
7635902bb8 | ||
|
|
ddb0f79fc1 | ||
|
|
43889edbca | ||
|
|
85dd27caf8 | ||
|
|
b5d530fb14 | ||
|
|
743c59f545 | ||
|
|
dda98e5f6c | ||
|
|
f5d8b785db | ||
|
|
7c3a73d1a7 | ||
|
|
7a30fc750e | ||
|
|
1105ac39a3 | ||
|
|
ee6cd21fb0 | ||
|
|
ba6e02810d | ||
|
|
6560f22eac | ||
|
|
f1335b65d3 | ||
|
|
43256239f2 | ||
|
|
7c3430d9aa | ||
|
|
da245447f3 | ||
|
|
dfbae2e781 | ||
|
|
6015f70ce0 | ||
|
|
863d3e62f4 | ||
|
|
d0585fd780 | ||
|
|
057dc81197 | ||
|
|
5817347d32 | ||
|
|
3915313e5e | ||
|
|
88ed60064b | ||
|
|
e67abb1376 | ||
|
|
77e65a98e3 | ||
|
|
a49428670a | ||
|
|
a9e3580fb4 | ||
|
|
e72061106f | ||
|
|
979d64d10a | ||
|
|
3260d67215 | ||
|
|
0cf7d2f1e0 | ||
|
|
3e9c06e504 | ||
|
|
a9c49fabcc | ||
|
|
32e804824e | ||
|
|
fa6f8e6489 | ||
|
|
2af2d50a84 | ||
|
|
5aa3493580 | ||
|
|
296aa8cc46 | ||
|
|
b5838a1afc | ||
|
|
868ed6bb4b | ||
|
|
ff39563167 | ||
|
|
ffc8a07b36 | ||
|
|
99a5252b59 | ||
|
|
b789a19ba4 | ||
|
|
7984addb13 | ||
|
|
c2d2af31b8 | ||
|
|
957e1c1b1d | ||
|
|
02ebaf8e14 | ||
|
|
988dc123f1 | ||
|
|
1f4b88f6d2 | ||
|
|
5121d5a125 | ||
|
|
6e7e8154fb | ||
|
|
2707897017 | ||
|
|
8b0d0c27e2 | ||
|
|
c836d27490 | ||
|
|
f1ec358d7f | ||
|
|
68d6736df4 | ||
|
|
b339e224d0 | ||
|
|
27e471e195 | ||
|
|
23daba5750 | ||
|
|
20002eec14 | ||
|
|
0ff64b6073 | ||
|
|
8ca0d7ada9 | ||
|
|
a9338e9da7 | ||
|
|
710c225f9f | ||
|
|
33ec84806a | ||
|
|
aed08142cd | ||
|
|
a7fd1a2506 | ||
|
|
714a789cc5 | ||
|
|
8a0e1a9a2c | ||
|
|
4d8b53474f |
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.4.app
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.5.app
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
@ -31,7 +31,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
# Add additional Xcode versions here if necessary.
|
||||
xcode: ["Xcode_26.4"]
|
||||
xcode: ["Xcode_26.5"]
|
||||
|
||||
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.4.app
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.5.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.4.app
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.5.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.4.app
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.5.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.4.app
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.5.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.4.app
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.5.app
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -36,6 +36,9 @@ Index/
|
||||
*.sdsjson
|
||||
Scripts/sds_codegen/sds-includes/*
|
||||
|
||||
# Logs
|
||||
debuglogs/
|
||||
|
||||
/.idea
|
||||
/.vscode
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
3.2.2
|
||||
3.4.9
|
||||
|
||||
@ -1 +1 @@
|
||||
Xcode 26.4.1
|
||||
Xcode 26.5
|
||||
|
||||
166
Gemfile.lock
166
Gemfile.lock
@ -1,10 +1,8 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
activesupport (7.1.3.2)
|
||||
base64
|
||||
bigdecimal
|
||||
@ -15,32 +13,36 @@ GEM
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
algoliasearch (1.27.5)
|
||||
httpclient (~> 2.8, >= 2.8.3)
|
||||
json (>= 1.5.1)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1001.0)
|
||||
aws-sdk-core (3.211.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1249.0)
|
||||
aws-sdk-core (3.247.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.95.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
logger
|
||||
aws-sdk-kms (1.125.0)
|
||||
aws-sdk-core (~> 3, >= 3.247.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.169.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-s3 (1.222.0)
|
||||
aws-sdk-core (~> 3, >= 3.247.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.1)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
bigdecimal (3.1.6)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.2)
|
||||
claide (1.1.0)
|
||||
cocoapods (1.15.2)
|
||||
addressable (~> 2.8)
|
||||
@ -85,8 +87,9 @@ GEM
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.2.3)
|
||||
connection_pool (2.4.1)
|
||||
csv (3.3.5)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.5)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
@ -97,7 +100,7 @@ GEM
|
||||
ethon (0.16.0)
|
||||
ffi (>= 1.15.0)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@ -109,32 +112,36 @@ GEM
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday-retry (1.0.4)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.3.1)
|
||||
fastlane (2.225.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.234.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 1.17.3, < 5.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
@ -142,20 +149,24 @@ GEM
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-env (>= 1.6.0, <= 2.1.1)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
@ -166,97 +177,100 @@ GEM
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
ffi (1.17.3)
|
||||
fourflusher (2.3.1)
|
||||
fuzzy_match (2.0.4)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
google-apis-androidpublisher_v3 (0.100.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (0.18.0)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
googleauth (~> 1.9)
|
||||
httpclient (>= 2.8.3, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
mutex_m
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.7.1)
|
||||
google-apis-iamcredentials_v1 (0.27.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.17.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.62.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.4.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
google-cloud-env (2.1.1)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-storage (1.60.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
googleauth (1.11.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.1)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.7)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
i18n (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.8.1)
|
||||
jwt (2.9.3)
|
||||
json (2.19.5)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.22.2)
|
||||
molinillo (0.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_json (1.21.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.2.0)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
nap (1.1.0)
|
||||
naturally (2.2.1)
|
||||
naturally (2.3.0)
|
||||
netrc (0.11.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.5.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
plist (3.7.1)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (4.0.7)
|
||||
rake (13.2.1)
|
||||
rake (13.4.2)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.3.9)
|
||||
rouge (2.0.7)
|
||||
retriable (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby-macho (2.5.1)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.19.0)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
@ -282,8 +296,8 @@ GEM
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
@ -296,4 +310,4 @@ DEPENDENCIES
|
||||
xcode-install
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.6
|
||||
2.6.9
|
||||
|
||||
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'] = '2b781ed29e11848acf7127457e24dedd5f6dce2189ba3c8f6773ee85fc255b3b'
|
||||
pod 'LibSignalClient', git: 'https://github.com/signalapp/libsignal.git', tag: 'v0.94.0', testspecs: ["Tests"]
|
||||
ENV['LIBSIGNAL_FFI_PREBUILD_CHECKSUM'] = '79f53932ff82f792b70e30bad3b38801da0b882137adaf65ad54d907a94f3d29'
|
||||
pod 'LibSignalClient', git: 'https://github.com/signalapp/libsignal.git', tag: 'v0.95.0', testspecs: ["Tests"]
|
||||
# pod 'LibSignalClient', path: '../libsignal', testspecs: ["Tests"]
|
||||
|
||||
ENV['RINGRTC_PREBUILD_CHECKSUM'] = '64743212da1c13ab7092ac4ba905c4b629d2ab1935bf3b3b8db7341cc4b5864e'
|
||||
ENV['RINGRTC_PREBUILD_CHECKSUM'] = 'c19c813ab5255aa3cd7c2af36374100f7cc69c2fd794cae23baebd6ec9dae90c'
|
||||
# ENV['RINGRTC_USE_FILE_BASED_CAMERA'] = '1'
|
||||
pod 'SignalRingRTC', git: 'https://github.com/signalapp/ringrtc', tag: 'v2.68.1', inhibit_warnings: true
|
||||
pod 'SignalRingRTC', git: 'https://github.com/signalapp/ringrtc', tag: 'v2.69.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.94.0)
|
||||
- LibSignalClient/Tests (0.94.0)
|
||||
- LibSignalClient (0.95.0)
|
||||
- LibSignalClient/Tests (0.95.0)
|
||||
- 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.68.1):
|
||||
- SignalRingRTC/WebRTC (= 2.68.1)
|
||||
- SignalRingRTC/WebRTC (2.68.1)
|
||||
- SignalRingRTC (2.69.1):
|
||||
- SignalRingRTC/WebRTC (= 2.69.1)
|
||||
- SignalRingRTC/WebRTC (2.69.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.94.0`)
|
||||
- LibSignalClient/Tests (from `https://github.com/signalapp/libsignal.git`, tag `v0.94.0`)
|
||||
- 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`)
|
||||
- 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.68.1`)
|
||||
- SignalRingRTC (from `https://github.com/signalapp/ringrtc`, tag `v2.69.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.94.0
|
||||
:tag: v0.95.0
|
||||
MobileCoin:
|
||||
:git: https://github.com/mobilecoinofficial/MobileCoin-Swift
|
||||
:tag: v6.0.3
|
||||
SignalRingRTC:
|
||||
:git: https://github.com/signalapp/ringrtc
|
||||
:tag: v2.68.1
|
||||
:tag: v2.69.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.94.0
|
||||
:tag: v0.95.0
|
||||
MobileCoin:
|
||||
:git: https://github.com/mobilecoinofficial/MobileCoin-Swift
|
||||
:tag: v6.0.3
|
||||
SignalRingRTC:
|
||||
:git: https://github.com/signalapp/ringrtc
|
||||
:tag: v2.68.1
|
||||
:tag: v2.69.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: 8023facf81b9909ad817f75e8df296cf2d8846f1
|
||||
LibSignalClient: a98db1d538243e43ecac040005204bd274cbd8c7
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
Logging: beeb016c9c80cf77042d62e83495816847ef108b
|
||||
lottie-ios: fcb5e73e17ba4c983140b7d21095c834b3087418
|
||||
@ -139,10 +139,10 @@ SPEC CHECKSUMS:
|
||||
PureLayout: f08c01b8dec00bb14a1fefa3de4c7d9c265df85e
|
||||
SDWebImage: 9f177d83116802728e122410fb25ad88f5c7608a
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
SignalRingRTC: 0d98294e8b0c95ddb94ab294a59789e280dd72e0
|
||||
SignalRingRTC: b907e1c8ef7743926c031810e9655366d7aa3eeb
|
||||
SQLCipher: ff2f045b20d675a73a70f7329395ddd4a2580063
|
||||
SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38
|
||||
|
||||
PODFILE CHECKSUM: 7328d74a7af4adf8f9cbef39102cf1b41d5201a6
|
||||
PODFILE CHECKSUM: ee98007764e1569e9dbe4f25053510725b19fc88
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
|
||||
2
Pods
2
Pods
@ -1 +1 @@
|
||||
Subproject commit cf2c3580a41c5b30e319cf3c2c691e276b437d71
|
||||
Subproject commit 5e81462d833ad24e8091d7b6ab675c2cdc94af54
|
||||
@ -1,76 +1,27 @@
|
||||
{
|
||||
"#comment": "NOTE: This file is generated by /Scripts/sds_codegen/sds_generate.py. Do not manually edit it, instead run `sds_codegen.sh`.",
|
||||
"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,
|
||||
"#max": 80,
|
||||
"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,
|
||||
"TSPaymentModel": 67,
|
||||
"TSPaymentRequestModel": 66,
|
||||
"TSPrivateStoryThread": 72,
|
||||
"TSRecipientReadReceipt": 12,
|
||||
"TSThread": 2,
|
||||
"TSUnreadIndicatorInteraction": 4,
|
||||
"TestModel": 59
|
||||
"TSUnreadIndicatorInteraction": 4
|
||||
}
|
||||
@ -2440,31 +2440,23 @@ 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):
|
||||
record_type_map_filepath = 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)
|
||||
|
||||
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)
|
||||
max_record_type = old_record_types.get("#max", 0)
|
||||
|
||||
for clazz in global_class_map.values():
|
||||
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
|
||||
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
|
||||
|
||||
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__),)
|
||||
@ -2472,7 +2464,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_map_filepath, json_string)
|
||||
sds_common.write_text_file_if_changed(record_type_json_path, json_string)
|
||||
|
||||
# TODO: We'll need to import SignalServiceKit for non-SSK classes.
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import Foundation
|
||||
private let languageMap: [String: [String]] = [
|
||||
// These languages are returned from Smartling and need to be moved to their correct final destination.
|
||||
"ar": ["ar-SA"],
|
||||
"bn-BD": ["bn-BD"],
|
||||
"ca": ["ca"],
|
||||
"cs": ["cs"],
|
||||
"da": ["da"],
|
||||
@ -17,6 +18,7 @@ private let languageMap: [String: [String]] = [
|
||||
"es": ["es-ES", "es-MX"],
|
||||
"fi": ["fi"],
|
||||
"fr": ["fr-CA", "fr-FR"],
|
||||
"gu-IN": ["gu-IN"],
|
||||
"he": ["he"],
|
||||
"hi-IN": ["hi"],
|
||||
"hr-HR": ["hr"],
|
||||
@ -25,6 +27,7 @@ private let languageMap: [String: [String]] = [
|
||||
"it": ["it"],
|
||||
"ja": ["ja"],
|
||||
"ko": ["ko"],
|
||||
"mr-IN": ["mr-IN"],
|
||||
"ms": ["ms"],
|
||||
"nb": ["no"],
|
||||
"nl": ["nl-NL"],
|
||||
@ -38,6 +41,7 @@ private let languageMap: [String: [String]] = [
|
||||
"th": ["th"],
|
||||
"tr": ["tr"],
|
||||
"uk-UA": ["uk"],
|
||||
"ur": ["ur-PK"],
|
||||
"vi": ["vi"],
|
||||
"zh-CN": ["zh-Hans"],
|
||||
"zh-HK": ["zh-Hant"],
|
||||
@ -45,15 +49,11 @@ private let languageMap: [String: [String]] = [
|
||||
// These don't exist in App Store Connect, so there's no need to fetch them from Smartling.
|
||||
// "be-BY": [],
|
||||
// "bg-BG": [],
|
||||
// "bn-BD": [],
|
||||
// "fa-IR": [],
|
||||
// "ga-IE": [],
|
||||
// "gu-IN": [],
|
||||
// "lt-LT": [],
|
||||
// "mr-IN": [],
|
||||
// "sr-YR": [],
|
||||
// "ug": [],
|
||||
// "ur": [],
|
||||
// "yue": [],
|
||||
// "zh-TW": [],
|
||||
]
|
||||
|
||||
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, shouldRunCron: false)
|
||||
self.refreshConnection(isAppActive: false)
|
||||
}
|
||||
|
||||
clearAppropriateNotificationsAndRestoreBadgeCount()
|
||||
@ -148,9 +148,6 @@ 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.") }
|
||||
@ -372,13 +369,14 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var screenLockUI = ScreenLockUI(appReadiness: appReadiness)
|
||||
|
||||
private func configureGlobalUI(in window: UIWindow) {
|
||||
let screenLockUI = AppEnvironment.shared.screenLockUI
|
||||
let windowManager = AppEnvironment.shared.windowManagerRef
|
||||
|
||||
Theme.setupSignalAppearance()
|
||||
|
||||
screenLockUI.setupWithRootWindow(window)
|
||||
AppEnvironment.shared.windowManagerRef.setupWithRootWindow(window, screenBlockingWindow: screenLockUI.screenBlockingWindow)
|
||||
windowManager.setupWithRootWindow(window, screenBlockingWindow: screenLockUI.screenBlockingWindow)
|
||||
screenLockUI.startObserving()
|
||||
}
|
||||
|
||||
@ -400,7 +398,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
let dataMigrationContinuation = globalsContinuation.initGlobals(
|
||||
appContext: launchContext.appContext,
|
||||
appReadiness: appReadiness,
|
||||
backupArchiveErrorPresenterFactory: BackupArchiveErrorPresenterFactoryInternal(),
|
||||
deviceBatteryLevelManager: DeviceBatteryLevelManagerImpl(),
|
||||
deviceSleepManager: launchContext.deviceSleepManager,
|
||||
paymentsEvents: PaymentsEventsMainApp(),
|
||||
@ -646,16 +643,16 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
let remoteMegaphoneFetcher = RemoteMegaphoneFetcher(
|
||||
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
|
||||
signalService: SSKEnvironment.shared.signalServiceRef,
|
||||
let remoteReleaseNotesFetchingManager = RemoteReleaseNotesFetchingManager(
|
||||
db: DependenciesBridge.shared.db,
|
||||
remoteReleaseNotesService: DependenciesBridge.shared.remoteReleaseNotesService,
|
||||
)
|
||||
cron.schedulePeriodically(
|
||||
uniqueKey: .fetchMegaphones,
|
||||
approximateInterval: 3 * .day,
|
||||
mustBeRegistered: false,
|
||||
mustBeConnected: true,
|
||||
operation: { try await remoteMegaphoneFetcher.syncRemoteMegaphones() },
|
||||
operation: { try await remoteReleaseNotesFetchingManager.syncRemoteReleaseNotes() },
|
||||
)
|
||||
|
||||
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
|
||||
@ -720,6 +717,7 @@ 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()
|
||||
|
||||
@ -781,6 +779,27 @@ 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()
|
||||
@ -790,7 +809,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, shouldRunCron: false)
|
||||
self.refreshConnection(isAppActive: false)
|
||||
}
|
||||
|
||||
if registeredState != nil {
|
||||
@ -1233,14 +1252,20 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
switch action {
|
||||
case .submitDebugLogsAndCrash:
|
||||
addSubmitDebugLogsAction {
|
||||
DebugLogs.submitLogs(supportTag: supportTag, dumper: logDumper) {
|
||||
DebugLogs(dumper: logDumper).promptToSubmitLogs(
|
||||
from: viewController,
|
||||
supportTag: supportTag,
|
||||
) {
|
||||
owsFail("Exiting after submitting debug logs")
|
||||
}
|
||||
}
|
||||
|
||||
case .submitDebugLogsAndLaunchApp(let window, let launchContext):
|
||||
addSubmitDebugLogsAction { [unowned window] in
|
||||
DebugLogs.submitLogs(supportTag: supportTag, dumper: logDumper) {
|
||||
DebugLogs(dumper: logDumper).promptToSubmitLogs(
|
||||
from: viewController,
|
||||
supportTag: supportTag,
|
||||
) {
|
||||
ignoreErrorAndLaunchApp(in: window, launchContext: launchContext)
|
||||
}
|
||||
}
|
||||
@ -1367,7 +1392,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
refreshConnection(isAppActive: true, shouldRunCron: true)
|
||||
refreshConnection(isAppActive: true)
|
||||
|
||||
// Every time we become active...
|
||||
if registeredState != nil {
|
||||
@ -1435,7 +1460,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
/// is in the background.
|
||||
private var backgroundFetchHandle: BackgroundTaskHandle?
|
||||
|
||||
private func refreshConnection(isAppActive: Bool, shouldRunCron: Bool) {
|
||||
private func refreshConnection(isAppActive: Bool) {
|
||||
let chatConnectionManager = DependenciesBridge.shared.chatConnectionManager
|
||||
|
||||
let oldActiveConnectionTokens = self.activeConnectionTokens
|
||||
@ -1443,9 +1468,10 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// If we're active, open a connection.
|
||||
self.activeConnectionTokens = chatConnectionManager.requestConnections()
|
||||
oldActiveConnectionTokens.forEach { $0.releaseConnection() }
|
||||
if shouldRunCron {
|
||||
self.startCronTask()
|
||||
}
|
||||
|
||||
// Start a new Cron task on activate.
|
||||
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()
|
||||
@ -1462,17 +1488,14 @@ 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 {
|
||||
@ -1745,8 +1768,24 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
return false
|
||||
}
|
||||
let isVideo = isVideoCall(intent)
|
||||
appReadiness.runNowOrWhenAppDidBecomeReadySync {
|
||||
|
||||
Task { @MainActor [appReadiness] in
|
||||
do {
|
||||
try await appReadiness.waitForAppReady()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let callService = AppEnvironment.shared.callService!
|
||||
let screenLockUI = AppEnvironment.shared.screenLockUI
|
||||
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
|
||||
@ -1764,7 +1803,6 @@ 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")
|
||||
@ -1776,6 +1814,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
callService.initiateCall(to: callTarget, isVideo: isVideo)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1790,17 +1829,12 @@ 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.
|
||||
@ -1908,9 +1942,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||
Task { @MainActor [appReadiness] () -> Void in
|
||||
defer { completionHandler() }
|
||||
|
||||
try await self.appReadiness.waitForAppReady()
|
||||
do {
|
||||
try await self.appReadiness.waitForAppReady()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
@ -1919,7 +1959,11 @@ 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)
|
||||
try await NotificationActionHandler.handleNotificationResponse(
|
||||
response,
|
||||
appReadiness: appReadiness,
|
||||
screenLockUI: screenLockUI,
|
||||
)
|
||||
|
||||
// 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 cvAudioPlayerRef = CVAudioPlayer()
|
||||
let speechManagerRef = SpeechManager()
|
||||
let windowManagerRef = WindowManager()
|
||||
let screenLockUI: ScreenLockUI
|
||||
let speechManagerRef: SpeechManager
|
||||
let windowManagerRef: WindowManager
|
||||
|
||||
private(set) var appIconBadgeUpdater: AppIconBadgeUpdater!
|
||||
private(set) var avatarHistoryManager: AvatarHistoryManager!
|
||||
@ -44,8 +44,12 @@ 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()
|
||||
|
||||
@ -193,6 +197,51 @@ 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()
|
||||
@ -203,14 +252,11 @@ 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
|
||||
@ -241,11 +287,7 @@ public class AppEnvironment: NSObject {
|
||||
// Things that should run on either the primary or linked devices.
|
||||
if let registeredState, registeredState.isPrimary {
|
||||
Task {
|
||||
do {
|
||||
try await avatarDefaultColorStorageServiceMigrator.performMigrationIfNecessary()
|
||||
} catch {
|
||||
Logger.warn("Couldn't perform avatar default color migration: \(error)")
|
||||
}
|
||||
await avatarDefaultColorStorageServiceMigrator.performMigrationIfNecessary()
|
||||
}
|
||||
|
||||
Task {
|
||||
@ -286,12 +328,6 @@ 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)
|
||||
@ -305,31 +341,6 @@ 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,6 +128,7 @@ public class SignalApp {
|
||||
owsFailDebug("Missing conversationSplitViewController.")
|
||||
return
|
||||
}
|
||||
|
||||
conversationSplitViewController.showAppSettingsWithMode(mode, completion: completion)
|
||||
}
|
||||
|
||||
@ -223,11 +224,6 @@ 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 throws {
|
||||
try await db.awaitableWrite { tx in
|
||||
func performMigrationIfNecessary() async {
|
||||
await db.awaitableWrite { tx in
|
||||
if kvStore.hasValue(StoreKeys.hasEnqueuedMigrationKey, transaction: tx) {
|
||||
return
|
||||
}
|
||||
@ -46,15 +46,14 @@ struct AvatarDefaultColorStorageServiceMigrator {
|
||||
}
|
||||
|
||||
var groupV2MasterKeys = [GroupMasterKey]()
|
||||
try threadStore.enumerateGroupThreads(tx: tx) { groupThread in
|
||||
guard
|
||||
threadStore.enumerateGroupThreads(tx: tx) { groupThread in
|
||||
if
|
||||
let groupModelV2 = groupThread.groupModel as? TSGroupModelV2,
|
||||
let groupMasterKey = try? groupModelV2.masterKey()
|
||||
else {
|
||||
return true
|
||||
{
|
||||
groupV2MasterKeys.append(groupMasterKey)
|
||||
}
|
||||
|
||||
groupV2MasterKeys.append(groupMasterKey)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@ -225,7 +225,6 @@ final class BackupDisablingManager {
|
||||
|
||||
accountEntropyPoolManager.setAccountEntropyPool(
|
||||
newAccountEntropyPool: try! AccountEntropyPool(key: aepBeingRotatedString),
|
||||
disablePIN: false,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
@ -263,7 +263,10 @@ final class BackupEnablingManager {
|
||||
|
||||
private func enablePaidPlanWithoutStoreKit() async throws(SheetDisplayableError) {
|
||||
do {
|
||||
try await backupTestFlightEntitlementManager.acquireEntitlement()
|
||||
await db.awaitableWrite { tx in
|
||||
backupTestFlightEntitlementManager.setRenewEntitlementIsNecessary(tx: tx)
|
||||
}
|
||||
try await backupTestFlightEntitlementManager.renewEntitlementIfNecessary()
|
||||
} catch where error.isNetworkFailureOrTimeout {
|
||||
throw .networkError
|
||||
} catch {
|
||||
|
||||
45
Signal/Backups/BackupNeverShareRecoveryKeySheet.swift
Normal file
45
Signal/Backups/BackupNeverShareRecoveryKeySheet.swift
Normal file
@ -0,0 +1,45 @@
|
||||
//
|
||||
// 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
105
Signal/Backups/BackupPlanOptionView.swift
Normal file
105
Signal/Backups/BackupPlanOptionView.swift
Normal file
@ -0,0 +1,105 @@
|
||||
//
|
||||
// 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,6 +77,9 @@ 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 {
|
||||
@ -115,7 +118,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?.copyToClipboard()
|
||||
self?.copyToClipboardWithConfirmation()
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -175,6 +178,26 @@ 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,6 +16,7 @@ class BackupSettingsViewController:
|
||||
enum OnAppearAction {
|
||||
case presentWelcomeToBackupsSheet
|
||||
case automaticallyStartBackup(completion: ((UIViewController) -> Void)?)
|
||||
case disableOptimizeLocalStorage
|
||||
}
|
||||
|
||||
private let accountEntropyPoolManager: AccountEntropyPoolManager
|
||||
@ -120,7 +121,7 @@ class BackupSettingsViewController:
|
||||
|
||||
self.onAppearAction = onAppearAction
|
||||
switch onAppearAction {
|
||||
case .presentWelcomeToBackupsSheet, nil:
|
||||
case nil, .presentWelcomeToBackupsSheet, .disableOptimizeLocalStorage:
|
||||
break
|
||||
case .automaticallyStartBackup(let completion):
|
||||
self.onBackupComplete = completion
|
||||
@ -179,6 +180,8 @@ class BackupSettingsViewController:
|
||||
presentWelcomeToBackupsSheet()
|
||||
case .automaticallyStartBackup:
|
||||
performManualBackup()
|
||||
case .disableOptimizeLocalStorage:
|
||||
setOptimizeLocalStorage(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -618,28 +621,87 @@ class BackupSettingsViewController:
|
||||
final class WelcomeToBackupsSheet: HeroSheetViewController {
|
||||
override var canBeDismissed: Bool { false }
|
||||
|
||||
init(onConfirm: @escaping () -> Void) {
|
||||
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
|
||||
}
|
||||
|
||||
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: 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() },
|
||||
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,
|
||||
),
|
||||
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 welcomeToBackupsSheet = WelcomeToBackupsSheet { [self] in
|
||||
viewModel.performManualBackup()
|
||||
dismiss(animated: true)
|
||||
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()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
present(welcomeToBackupsSheet, animated: true)
|
||||
@ -972,6 +1034,15 @@ 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(
|
||||
@ -1009,35 +1080,38 @@ class BackupSettingsViewController:
|
||||
// MARK: -
|
||||
|
||||
fileprivate func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool) {
|
||||
let isPaidPlanTester: Bool = db.write { tx in
|
||||
let hasMadeAtLeastOneBackup: Bool? = db.write { tx in
|
||||
let currentBackupPlan = backupPlanManager.backupPlan(tx: tx)
|
||||
let newBackupPlan: BackupPlan
|
||||
let isPaidPlanTester: Bool
|
||||
let lastBackupDetails = backupSettingsStore.lastBackupDetails(tx: tx)
|
||||
|
||||
let newBackupPlan: BackupPlan
|
||||
switch currentBackupPlan {
|
||||
case .disabled, .disabling, .free:
|
||||
owsFailDebug("Shouldn't be setting Optimize Local Storage: \(currentBackupPlan)")
|
||||
return false
|
||||
case .disabled,
|
||||
.disabling,
|
||||
.free,
|
||||
.paid(optimizeLocalStorage: newOptimizeLocalStorage),
|
||||
.paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage),
|
||||
.paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage):
|
||||
return nil
|
||||
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 isPaidPlanTester
|
||||
return lastBackupDetails != nil
|
||||
}
|
||||
|
||||
// If disabling Optimize Local Storage, offer to start downloads now.
|
||||
if !newOptimizeLocalStorage {
|
||||
if
|
||||
hasMadeAtLeastOneBackup == true,
|
||||
!newOptimizeLocalStorage
|
||||
{
|
||||
// If disabling Optimize Local Storage with media potentially
|
||||
// offloaded, offer to start downloads now.
|
||||
showDownloadOffloadedMediaSheet()
|
||||
} else if isPaidPlanTester {
|
||||
showOffloadedMediaForTestersWarningSheet(onAcknowledge: {})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1076,54 +1150,41 @@ 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, .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:
|
||||
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 {
|
||||
let warningSheet = ActionSheetController(
|
||||
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.",
|
||||
),
|
||||
title: warningTitle,
|
||||
message: warningMessage,
|
||||
)
|
||||
warningSheet.addAction(ActionSheetAction(
|
||||
title: OWSLocalizedString(
|
||||
@ -1132,9 +1193,31 @@ class BackupSettingsViewController:
|
||||
),
|
||||
style: .destructive,
|
||||
handler: { [self] _ in
|
||||
db.write { tx in
|
||||
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
|
||||
}
|
||||
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)
|
||||
},
|
||||
))
|
||||
warningSheet.addAction(ActionSheetAction(
|
||||
@ -1149,6 +1232,10 @@ class BackupSettingsViewController:
|
||||
warningSheet.addAction(.cancel)
|
||||
|
||||
presentActionSheet(warningSheet)
|
||||
} else {
|
||||
db.write { tx in
|
||||
backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
db.write { tx in
|
||||
@ -1352,10 +1439,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.",
|
||||
@ -1381,7 +1468,6 @@ class BackupSettingsViewController:
|
||||
|
||||
accountEntropyPoolManager.setAccountEntropyPool(
|
||||
newAccountEntropyPool: newCandidateAEP,
|
||||
disablePIN: false,
|
||||
tx: tx,
|
||||
)
|
||||
case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester:
|
||||
@ -1624,16 +1710,21 @@ private class BackupSettingsViewModel: ObservableObject {
|
||||
|
||||
// MARK: -
|
||||
|
||||
var optimizeLocalStorageAvailable: Bool {
|
||||
/// Whether the "Optimze Storage" feature is available, per the current
|
||||
/// `BackupPlan`.
|
||||
var isOptimizeLocalStorageAvailable: Bool {
|
||||
switch backupPlan {
|
||||
case .disabled, .disabling, .free:
|
||||
false
|
||||
case .paid, .paidExpiringSoon, .paidAsTester:
|
||||
case .paid, .paidAsTester:
|
||||
true
|
||||
case .paidExpiringSoon(let optimizeLocalStorage):
|
||||
// Only allow disabling Optimize Storage if expiring soon, not enabling.
|
||||
optimizeLocalStorage
|
||||
}
|
||||
}
|
||||
|
||||
var optimizeLocalStorage: Bool {
|
||||
var isOptimizeLocalStorageEnabled: Bool {
|
||||
switch backupPlan {
|
||||
case .disabled, .disabling, .free:
|
||||
false
|
||||
@ -1906,44 +1997,32 @@ struct BackupSettingsView: View {
|
||||
viewModel: viewModel,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
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: {
|
||||
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)
|
||||
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.",
|
||||
)
|
||||
}
|
||||
|
||||
Text(footerText)
|
||||
.foregroundStyle(Color.Signal.secondaryLabel)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
SignalSection {
|
||||
@ -3200,7 +3279,8 @@ private extension BackupSettingsViewModel {
|
||||
backupSubscriptionLoadingState: .loaded(.paidButExpiring(
|
||||
expirationDate: Date().addingTimeInterval(.week),
|
||||
)),
|
||||
backupPlan: .paidExpiringSoon(optimizeLocalStorage: false),
|
||||
backupPlan: .paidExpiringSoon(optimizeLocalStorage: true),
|
||||
latestBackupAttachmentDownloadUpdateState: .suspended,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,10 @@ import SignalUI
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
|
||||
class ChooseBackupPlanViewController: HostingController<ChooseBackupPlanView> {
|
||||
class ChooseBackupPlanViewController:
|
||||
HostingController<ChooseBackupPlanView>,
|
||||
ChooseBackupPlanViewModel.ActionsDelegate
|
||||
{
|
||||
typealias OnConfirmPlanSelectionBlock = (ChooseBackupPlanViewController, PlanSelection) -> Void
|
||||
|
||||
enum StoreKitAvailability {
|
||||
@ -118,11 +121,9 @@ class ChooseBackupPlanViewController: HostingController<ChooseBackupPlanView> {
|
||||
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):
|
||||
@ -233,7 +234,7 @@ struct ChooseBackupPlanView: View {
|
||||
|
||||
Spacer().frame(height: 20)
|
||||
|
||||
PlanOptionView(
|
||||
BackupPlanOptionView(
|
||||
title: OWSLocalizedString(
|
||||
"CHOOSE_BACKUP_PLAN_FREE_PLAN_TITLE",
|
||||
comment: "Title for the free plan option, when choosing a Backup plan.",
|
||||
@ -247,11 +248,11 @@ struct ChooseBackupPlanView: View {
|
||||
viewModel.freeMediaTierDays,
|
||||
),
|
||||
bullets: [
|
||||
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
|
||||
BackupPlanOptionView.BulletPoint(icon: .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.",
|
||||
)),
|
||||
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: String.localizedStringWithFormat(
|
||||
BackupPlanOptionView.BulletPoint(icon: .albumTilt, text: String.localizedStringWithFormat(
|
||||
OWSLocalizedString(
|
||||
"CHOOSE_BACKUP_PLAN_BULLET_RECENT_MEDIA_BACKUP_%d",
|
||||
tableName: "PluralAware",
|
||||
@ -269,7 +270,7 @@ struct ChooseBackupPlanView: View {
|
||||
|
||||
Spacer().frame(height: 16)
|
||||
|
||||
PlanOptionView(
|
||||
BackupPlanOptionView(
|
||||
title: {
|
||||
switch viewModel.storeKitAvailability {
|
||||
case .available(let paidPlanDisplayPrice):
|
||||
@ -292,15 +293,15 @@ struct ChooseBackupPlanView: View {
|
||||
comment: "Subtitle for the paid plan option, when choosing a Backup plan.",
|
||||
),
|
||||
bullets: [
|
||||
PlanOptionView.BulletPoint(iconKey: "thread", text: OWSLocalizedString(
|
||||
BackupPlanOptionView.BulletPoint(icon: .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.",
|
||||
)),
|
||||
PlanOptionView.BulletPoint(iconKey: "album-tilt", text: OWSLocalizedString(
|
||||
BackupPlanOptionView.BulletPoint(icon: .albumTilt, 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.",
|
||||
)),
|
||||
PlanOptionView.BulletPoint(iconKey: "data", text: String.nonPluralLocalizedStringWithFormat(
|
||||
BackupPlanOptionView.BulletPoint(icon: .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' }}.",
|
||||
@ -383,106 +384,6 @@ 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,9 +31,8 @@ struct DisplayableAccountEntropyPool {
|
||||
.uppercased()
|
||||
.map { char in
|
||||
switch char {
|
||||
// TODO: Reenable this once support is available for all platforms
|
||||
// case "0": "="
|
||||
// case "O", "o": "#"
|
||||
case "0": "="
|
||||
case "O", "o": "#"
|
||||
default: char
|
||||
}
|
||||
},
|
||||
|
||||
@ -51,6 +51,9 @@ 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,6 +71,7 @@ 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: any CallLinkRecordStore
|
||||
private let callLinkStore: CallLinkRecordStore
|
||||
private let db: any DB
|
||||
private let messageSenderJobQueue: MessageSenderJobQueue
|
||||
|
||||
@ -32,7 +32,7 @@ final class AdHocCallStateObserver {
|
||||
init(
|
||||
callLinkCall: CallLinkCall,
|
||||
adHocCallRecordManager: any AdHocCallRecordManager,
|
||||
callLinkStore: any CallLinkRecordStore,
|
||||
callLinkStore: CallLinkRecordStore,
|
||||
messageSenderJobQueue: MessageSenderJobQueue,
|
||||
db: any DB,
|
||||
) {
|
||||
@ -62,33 +62,29 @@ final class AdHocCallStateObserver {
|
||||
}
|
||||
self.furthestJoinLevel = joinLevel
|
||||
db.write { tx in
|
||||
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)")
|
||||
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)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,15 +101,11 @@ final class AdHocCallStateObserver {
|
||||
}
|
||||
self.activeEraId = .some(peekInfo.eraId)
|
||||
db.write { tx in
|
||||
do {
|
||||
try adHocCallRecordManager.handlePeekResult(
|
||||
eraId: peekInfo.eraId,
|
||||
rootKey: self.callLinkCall.callLink.rootKey,
|
||||
tx: tx,
|
||||
)
|
||||
} catch {
|
||||
owsFailDebug("\(error)")
|
||||
}
|
||||
adHocCallRecordManager.handlePeekResult(
|
||||
eraId: peekInfo.eraId,
|
||||
rootKey: self.callLinkCall.callLink.rootKey,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,12 +8,12 @@ import SignalServiceKit
|
||||
|
||||
/// Refreshes call links that need to be updated.
|
||||
actor CallLinkFetchJobRunner: DatabaseChangeDelegate {
|
||||
private let callLinkStore: any CallLinkRecordStore
|
||||
private let callLinkStore: CallLinkRecordStore
|
||||
private let callLinkStateUpdater: CallLinkStateUpdater
|
||||
private let db: any DB
|
||||
|
||||
init(
|
||||
callLinkStore: any CallLinkRecordStore,
|
||||
callLinkStore: CallLinkRecordStore,
|
||||
callLinkStateUpdater: CallLinkStateUpdater,
|
||||
db: any DB,
|
||||
) {
|
||||
@ -52,13 +52,8 @@ actor CallLinkFetchJobRunner: DatabaseChangeDelegate {
|
||||
|
||||
var sequentialFailureCount = 0
|
||||
while true {
|
||||
let callLinkToFetch: CallLinkRecord?
|
||||
do {
|
||||
callLinkToFetch = try db.read(block: callLinkStore.fetchAnyPendingRecord(tx:))
|
||||
} catch {
|
||||
owsFailDebug("Can't fetch pending record: \(error)")
|
||||
mightHavePendingFetch = false
|
||||
return
|
||||
let callLinkToFetch = db.read { tx in
|
||||
callLinkStore.fetchAnyPendingRecord(tx: tx)
|
||||
}
|
||||
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: any CallLinkRecordStore
|
||||
private let callLinkStore: 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: any CallLinkRecordStore,
|
||||
callLinkStore: CallLinkRecordStore,
|
||||
callRecordDeleteManager: any CallRecordDeleteManager,
|
||||
callRecordStore: any CallRecordStore,
|
||||
db: any DB,
|
||||
@ -90,8 +90,8 @@ actor CallLinkStateUpdater {
|
||||
}
|
||||
|
||||
let registeredState = try tsAccountManager.registeredStateWithMaybeSneakyTransaction()
|
||||
let oldRecord = try db.read { tx -> CallLinkRecord? in
|
||||
return try callLinkStore.fetch(roomId: roomId, tx: tx)
|
||||
let oldRecord = db.read { tx -> CallLinkRecord? in
|
||||
return 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
|
||||
}
|
||||
|
||||
try await db.awaitableWrite { tx in
|
||||
if var newRecord = try self.callLinkStore.fetch(roomId: roomId, tx: tx) {
|
||||
await db.awaitableWrite { tx in
|
||||
if var newRecord = 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())
|
||||
try self.callRecordDeleteManager.deleteCallRecords(
|
||||
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()
|
||||
}
|
||||
try self.callLinkStore.update(newRecord, tx: tx)
|
||||
self.callLinkStore.update(newRecord, tx: tx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -192,13 +192,16 @@ class CallQualitySurveyManager {
|
||||
return proto
|
||||
}
|
||||
|
||||
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
|
||||
func submit(
|
||||
rating: CallQualitySurvey.Rating,
|
||||
logsToSubmit logs: DebugLogs?,
|
||||
) {
|
||||
var proto = buildProto(rating: rating)
|
||||
|
||||
Task {
|
||||
if shouldSubmitDebugLogs {
|
||||
if let logs {
|
||||
do {
|
||||
let debugLogURL = try await DebugLogs.uploadLogs(dumper: .fromGlobals())
|
||||
let debugLogURL = try await logs.uploadLogs()
|
||||
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: any CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
|
||||
private var callLinkStore: 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: any CallLinkRecordStore,
|
||||
callLinkStore: 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) = try databaseStorage.read { tx -> (Data?, Bool) in
|
||||
let callLinkRecord = try callLinkStore.fetch(roomId: callLink.rootKey.deriveRoomId(), tx: tx)
|
||||
let (adminPasskey, isDeleted) = databaseStorage.read { tx -> (Data?, Bool) in
|
||||
let callLinkRecord = callLinkStore.fetch(roomId: callLink.rootKey.deriveRoomId(), tx: tx)
|
||||
return (callLinkRecord?.adminPasskey, callLinkRecord?.isDeleted == true)
|
||||
}
|
||||
let serverPublicParams = CallService.serverPublicParams()
|
||||
|
||||
@ -282,20 +282,16 @@ private extension GroupCallRecordManager {
|
||||
}
|
||||
|
||||
logger.info("Creating or updating record for group call join.")
|
||||
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)")
|
||||
}
|
||||
createOrUpdateCallRecord(
|
||||
callId: callId,
|
||||
groupThread: groupThread,
|
||||
groupThreadRowId: groupThreadRowId,
|
||||
callDirection: callDirection,
|
||||
groupCallStatus: groupCallStatus,
|
||||
callEventTimestamp: joinTimestamp,
|
||||
shouldSendSyncMessage: true,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create or update a call record in response to the local declining a ring
|
||||
@ -314,19 +310,15 @@ private extension GroupCallRecordManager {
|
||||
}
|
||||
|
||||
logger.info("Creating or updating record for group ring decline.")
|
||||
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)")
|
||||
}
|
||||
createOrUpdateCallRecord(
|
||||
callId: callIdFromRingId(ringId),
|
||||
groupThread: groupThread,
|
||||
groupThreadRowId: groupThreadRowId,
|
||||
callDirection: .incoming,
|
||||
groupCallStatus: .ringingDeclined,
|
||||
callEventTimestamp: Date().ows_millisecondsSince1970,
|
||||
shouldSendSyncMessage: true,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,7 +240,7 @@ extension CallControlsOverflowView: MessageReactionPickerDelegate {
|
||||
self.react(with: reaction)
|
||||
}
|
||||
|
||||
func didSelectAnyEmoji() {
|
||||
func didSelectShowFullEmojiPicker() {
|
||||
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.negated,
|
||||
isVideoMuted: !self.individualCall.isRemoteVideoEnabled,
|
||||
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: any CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
|
||||
private var callLinkStore: CallLinkRecordStore { DependenciesBridge.shared.callLinkStore }
|
||||
|
||||
private let callLink: CallLink
|
||||
|
||||
@ -259,14 +259,10 @@ final class CallLinkViewController: OWSTableViewController2 {
|
||||
private func createCallLinkRecord() -> Int64 {
|
||||
let rowId = SSKEnvironment.shared.databaseStorageRef.write { tx in
|
||||
var callLinkRecord: CallLinkRecord
|
||||
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)")
|
||||
}
|
||||
(callLinkRecord, _) = callLinkStore.fetchOrInsert(rootKey: callLink.rootKey, tx: tx)
|
||||
callLinkRecord.adminPasskey = adminPasskey!
|
||||
callLinkRecord.updateState(callLinkState!)
|
||||
callLinkStore.update(callLinkRecord, tx: tx)
|
||||
|
||||
CallLinkUpdateMessageSender(
|
||||
messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef,
|
||||
@ -333,19 +329,14 @@ final class CallLinkViewController: OWSTableViewController2 {
|
||||
extension CallLinkViewController: DatabaseChangeDelegate {
|
||||
private func loadStateAndReloadViewIfNeeded(callLinkRowId: Int64) {
|
||||
let didChangeVisibleProperty: Bool
|
||||
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
|
||||
}
|
||||
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
|
||||
if didChangeVisibleProperty, self.isViewLoaded {
|
||||
updateContents(shouldReload: true)
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ extension CallsListViewController {
|
||||
case newer
|
||||
}
|
||||
|
||||
private let callLinkStore: any CallLinkRecordStore
|
||||
private let callLinkStore: 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: any CallLinkRecordStore,
|
||||
callLinkStore: CallLinkRecordStore,
|
||||
callRecordLoader: CallRecordLoader,
|
||||
callViewModelForCallRecords: @escaping CallViewModelForCallRecords,
|
||||
callViewModelForUpcomingCallLink: @escaping CallViewModelForUpcomingCallLink,
|
||||
@ -233,13 +233,7 @@ extension CallsListViewController {
|
||||
guard shouldFetchUpcomingCallLinks else {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
let upcomingCallLinks = callLinkStore.fetchUpcoming(earlierThan: nil, limit: 2048, tx: tx)
|
||||
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: any CallLinkRecordStore
|
||||
let callLinkStore: 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 = (try? self.deps.callLinkStore.fetchAll(tx: tx).compactMap {
|
||||
callLinksToDelete = 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 try? deps.callLinkStore.fetch(roomId: call.callLink.rootKey.deriveRoomId(), tx: tx)?.id }
|
||||
let rowId = deps.db.read { tx in 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,13 +1015,8 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
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)")
|
||||
}
|
||||
return deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
|
||||
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
|
||||
}()
|
||||
|
||||
if let callLinkRecord {
|
||||
@ -1236,8 +1231,8 @@ class CallsListViewController: OWSViewController, HomeTabViewController, CallSer
|
||||
} catch CallLinkManagerImpl.PeekError.expired, CallLinkManagerImpl.PeekError.invalid {
|
||||
eraId = nil
|
||||
}
|
||||
try await deps.db.awaitableWrite { tx in
|
||||
try deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx)
|
||||
await deps.db.awaitableWrite { tx in
|
||||
deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2051,15 +2046,9 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
|
||||
guard let callLinkRowId else {
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
let callLinkRecord = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
|
||||
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
|
||||
return callLinkRecord.adminPasskey != nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2069,18 +2058,17 @@ 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 = try await deps.databaseStorage.awaitableWrite { tx in
|
||||
callLinksToDelete = 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 = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
|
||||
throw OWSAssertionError("Couldn't fetch CallLink that must exist.")
|
||||
}()
|
||||
let callLinkRecord = self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx)
|
||||
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
|
||||
if let adminPasskey = callLinkRecord.adminPasskey {
|
||||
callLinksToDelete.append((callLinkRecord.rootKey, adminPasskey))
|
||||
} else {
|
||||
try self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx)
|
||||
self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx)
|
||||
}
|
||||
} else {
|
||||
callRecordIdsWithInteractions.append(contentsOf: modelReferences.callRecordRowIds)
|
||||
@ -2107,8 +2095,8 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
|
||||
try await deleteCallLinks(callLinksToDelete: callLinksToDelete)
|
||||
}
|
||||
|
||||
private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) throws {
|
||||
let callRecords = try deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx)
|
||||
private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) {
|
||||
let callRecords = deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx)
|
||||
deps.callRecordDeleteManager.deleteCallRecords(callRecords, sendSyncMessageOnDelete: true, tx: tx)
|
||||
}
|
||||
|
||||
@ -2202,13 +2190,8 @@ extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelega
|
||||
|
||||
private func showCallInfo(forRootKey rootKey: CallLinkRootKey, callRecords: [CallRecord]) {
|
||||
let callLinkRecord = deps.db.read { tx -> CallLinkRecord in
|
||||
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)")
|
||||
}
|
||||
return deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx)
|
||||
.owsFailUnwrap("FOREIGN KEYs mean this must exist.")
|
||||
}
|
||||
showCallInfo(viewController: CallLinkViewController.forExisting(callLinkRecord: callLinkRecord, callRecords: callRecords))
|
||||
}
|
||||
@ -2319,23 +2302,14 @@ 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: config,
|
||||
configuration: .roundGray(image: Theme.iconImage(icon)),
|
||||
primaryAction: UIAction { [weak self] _ in
|
||||
self?.detailsTapped(viewModel: viewModel)
|
||||
},
|
||||
|
||||
@ -125,6 +125,7 @@ extension NewCallViewController: RecipientContextMenuHelperDelegate {
|
||||
// MARK: - RecipientPickerDelegate
|
||||
|
||||
extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelegate {
|
||||
|
||||
func recipientPicker(
|
||||
_ recipientPickerViewController: RecipientPickerViewController,
|
||||
selectionStyleForRecipient recipient: PickedRecipient,
|
||||
@ -133,7 +134,10 @@ 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)
|
||||
@ -143,7 +147,12 @@ extension NewCallViewController: RecipientPickerDelegate, UsernameLinkScanDelega
|
||||
}
|
||||
}
|
||||
|
||||
func recipientPicker(_ recipientPickerViewController: RecipientPickerViewController, accessoryViewForRecipient recipient: PickedRecipient, transaction: DBReadTransaction) -> ContactCellAccessoryView? {
|
||||
func recipientPicker(
|
||||
_ recipientPickerViewController: RecipientPickerViewController,
|
||||
contactCellAccessoryForRecipient recipient: PickedRecipient,
|
||||
transaction: DBReadTransaction,
|
||||
) -> ContactCellView.Accessory? {
|
||||
|
||||
let stackView = UIStackView()
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = 20
|
||||
|
||||
@ -16,10 +16,12 @@ 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)
|
||||
}
|
||||
@ -129,7 +131,8 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
|
||||
let container = UIView()
|
||||
|
||||
let textView = LinkingTextView { [weak self] in
|
||||
self?.showDebugLogPreview()
|
||||
guard let self else { return }
|
||||
self.logs.showPreview(from: self)
|
||||
}
|
||||
textView.attributedText = .composed(of: [
|
||||
OWSLocalizedString(
|
||||
@ -193,12 +196,6 @@ 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
|
||||
@ -209,7 +206,7 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
|
||||
private func submit() {
|
||||
sheetNav?.submit(
|
||||
rating: self.rating,
|
||||
shouldSubmitDebugLogs: self.shouldSubmitDebugLogs,
|
||||
logsToSubmit: shouldSubmitDebugLogs ? logs : nil,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,10 +82,13 @@ final class CallQualitySurveyNavigationController: UINavigationController {
|
||||
pushViewController(vc, animated: false)
|
||||
}
|
||||
|
||||
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
|
||||
func submit(
|
||||
rating: CallQualitySurvey.Rating,
|
||||
logsToSubmit: DebugLogs?,
|
||||
) {
|
||||
callQualitySurveyManager.submit(
|
||||
rating: rating,
|
||||
shouldSubmitDebugLogs: shouldSubmitDebugLogs,
|
||||
logsToSubmit: logsToSubmit,
|
||||
)
|
||||
let host = presentingViewController
|
||||
dismiss(animated: true) {
|
||||
|
||||
@ -159,11 +159,10 @@ class MessageUserSubsetSheet: OWSTableSheetViewController {
|
||||
|
||||
cell.selectionStyle = .none
|
||||
|
||||
let configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .asLocalUser)
|
||||
var configuration = ContactCellView.Configuration(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,6 +149,8 @@ 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,9 +114,7 @@ class CVAttachmentProgressView: ManualLayoutView {
|
||||
|
||||
addLayoutBlock { view in
|
||||
guard let view = view as? CVAttachmentProgressView else { return }
|
||||
DispatchQueue.main.async {
|
||||
view.loadInitialStateIfNeeded()
|
||||
}
|
||||
view.loadInitialStateIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,14 +192,19 @@ 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,6 +363,22 @@ 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: UIImage(named: Theme.iconName(.ellipse)))
|
||||
let checkMark = UIImageView(image: UIImage(named: Theme.iconName(.checkmark)))
|
||||
let spinningEllipse = UIImageView(image: Theme.iconImage(.ellipse))
|
||||
let checkMark = UIImageView(image: Theme.iconImage(.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: UIImage(named: Theme.iconName(.circle)))
|
||||
let circle = UIImageView(image: Theme.iconImage(.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: UIImage(named: Theme.iconName(.checkCircleFill)))
|
||||
let checkMarkCircle = UIImageView(image: Theme.iconImage(.checkCircleFill))
|
||||
checkboxContainer.addSubview(checkMarkCircle, withLayoutBlock: { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let subviewFrame = CGRect(
|
||||
|
||||
@ -6,63 +6,41 @@
|
||||
import SignalServiceKit
|
||||
import SignalUI
|
||||
|
||||
/// ManualLayoutView wrapper around SelectionIndicatorView.
|
||||
class MessageSelectionView: ManualLayoutView {
|
||||
|
||||
var isSelected: Bool = false {
|
||||
didSet {
|
||||
selectedView.isHidden = !isSelected
|
||||
unselectedView.isHidden = isSelected
|
||||
private let selectionIndicatorView = SelectionIndicatorView(style: .list)
|
||||
|
||||
var isSelected: Bool {
|
||||
get {
|
||||
selectionIndicatorView.isSelected
|
||||
}
|
||||
set {
|
||||
selectionIndicatorView.isSelected = newValue
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(name: "MessageSelectionView")
|
||||
|
||||
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
|
||||
addSubviewToFillSuperviewEdges(selectionIndicatorView)
|
||||
}
|
||||
|
||||
static var preferredSize: CGSize {
|
||||
CGSize(square: ConversationStyle.selectionViewWidth)
|
||||
CGSize(square: SelectionIndicatorView.preferredSize)
|
||||
}
|
||||
|
||||
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) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
selectedView.backgroundColor = conversationStyle.chatColorValue.asChatUIElementTintColor()
|
||||
unselectedView.tintColor = UIColor.Signal.tertiaryLabel
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
let hasWallpaper = conversationStyle.hasWallpaper
|
||||
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
|
||||
let isReusing = componentView.rootView.superview != nil
|
||||
&& componentView.label.superview != nil
|
||||
&& componentView.innerStack.superview != nil
|
||||
&& !wallpaperModeHasChanged
|
||||
|
||||
if !isReusing {
|
||||
@ -75,19 +75,32 @@ 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.label],
|
||||
subviews: [componentView.innerStack],
|
||||
)
|
||||
|
||||
let bubbleView: UIView
|
||||
@ -110,13 +123,20 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
}
|
||||
componentView.outerStack.addSubview(bubbleView)
|
||||
componentView.outerStack.sendSubviewToBack(bubbleView)
|
||||
// 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.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)
|
||||
}
|
||||
}
|
||||
|
||||
componentView.isShowingExpanded = collapseSet.isExpanded
|
||||
componentView.chevronLabel.transform = collapseSet.isExpanded
|
||||
? CGAffineTransform(rotationAngle: -.pi)
|
||||
: .identity
|
||||
|
||||
if
|
||||
hasWallpaper,
|
||||
let wallpaperBlurView = componentView.wallpaperBlurView
|
||||
@ -128,15 +148,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
componentView.outerStack.isAccessibilityElement = true
|
||||
componentView.outerStack.accessibilityLabel = titleString
|
||||
componentView.outerStack.accessibilityTraits = .button
|
||||
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.",
|
||||
)
|
||||
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: collapseSet.isExpanded)
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
@ -147,6 +159,35 @@ 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
|
||||
}
|
||||
@ -154,6 +195,7 @@ 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)
|
||||
@ -161,18 +203,38 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
0,
|
||||
maxWidth - outerStackConfig.layoutMargins.totalWidth,
|
||||
)
|
||||
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: availableWidth)
|
||||
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 outerMeasurement = ManualStackView.measure(
|
||||
config: outerStackConfig,
|
||||
measurementBuilder: measurementBuilder,
|
||||
measurementKey: Self.measurementKey_outerStack,
|
||||
subviewInfos: [labelSize.asManualSubviewInfo(hasFixedWidth: true)],
|
||||
subviewInfos: [innerMeasurement.measuredSize.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,
|
||||
@ -233,7 +295,6 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
)
|
||||
|
||||
let nbsp = SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue
|
||||
let chevron: SignalSymbol = collapseSet.isExpanded ? .chevronUp : .chevronDown
|
||||
|
||||
let result = NSMutableAttributedString()
|
||||
result.append(leadingIcon.attributedString(
|
||||
@ -242,18 +303,33 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
attributes: [.foregroundColor: UIColor.Signal.label],
|
||||
))
|
||||
result.append(NSAttributedString(
|
||||
string: "\(nbsp)\(labelText)\(nbsp)",
|
||||
string: "\(nbsp)\(labelText)",
|
||||
attributes: [
|
||||
.font: labelFont,
|
||||
.foregroundColor: UIColor.Signal.label,
|
||||
],
|
||||
))
|
||||
result.append(chevron.attributedString(
|
||||
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(
|
||||
for: .footnote,
|
||||
clamped: false,
|
||||
attributes: [.foregroundColor: UIColor.Signal.label],
|
||||
))
|
||||
return result
|
||||
)
|
||||
}
|
||||
|
||||
private var titleString: String {
|
||||
@ -264,6 +340,18 @@ 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,
|
||||
@ -322,9 +410,14 @@ 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 {
|
||||
@ -341,14 +434,24 @@ 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,6 +150,7 @@ private class CVQuotedMessageViewAdapter: CVQuotedMessageViewDelegate {
|
||||
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
|
||||
message,
|
||||
priority: .userInitiated,
|
||||
useThumbnails: false,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
@ -493,6 +493,9 @@ 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
|
||||
@ -525,7 +528,6 @@ 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
|
||||
}
|
||||
}
|
||||
@ -1194,7 +1196,7 @@ private extension CVComponentState.Builder {
|
||||
self.collapseSet = CVComponentState.CollapseSet(
|
||||
collapsedInteractions: collapseSetInteraction.collapsedInteractions,
|
||||
collapseSetType: collapseSetInteraction.collapseSetType,
|
||||
isExpanded: collapseSetInteraction.isExpanded,
|
||||
isExpanded: viewStateSnapshot.expandedCollapseSetIds.contains(collapseSetInteraction.uniqueId),
|
||||
finalTimerDescription: collapseSetInteraction.finalTimerDescription,
|
||||
)
|
||||
return build()
|
||||
@ -1361,7 +1363,33 @@ private extension CVComponentState.Builder {
|
||||
case .failed:
|
||||
mediaAlbumHasFailedAttachment = true
|
||||
case .none:
|
||||
mediaAlbumHasSkippedAttachment = true
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1397,6 +1425,28 @@ 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)
|
||||
|
||||
@ -1631,7 +1681,6 @@ 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
|
||||
@ -1714,10 +1763,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.negated
|
||||
).fullTextValue.isEmpty
|
||||
} ?? false
|
||||
|
||||
switch cvAttachment {
|
||||
@ -2006,7 +2055,7 @@ private extension CVComponentState.Builder {
|
||||
self.giftBadge = GiftBadge(
|
||||
messageUniqueId: messageUniqueId,
|
||||
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
|
||||
cachedBadge: DonationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
|
||||
cachedBadge: DependenciesBridge.shared.donationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
|
||||
expirationDate: expirationDate,
|
||||
redemptionState: giftBadge.redemptionState,
|
||||
)
|
||||
|
||||
@ -195,16 +195,22 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
if safetySection.shouldShowProfileNamesEducation {
|
||||
innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel))
|
||||
|
||||
let nameNotVerifiedButton = componentView.profileNamesEducationButton
|
||||
var buttonConfiguration = headerButtonConfigurationBase()
|
||||
buttonConfiguration.baseBackgroundColor = .Signal.warningLabel.withAlphaComponent(0.2)
|
||||
buttonConfiguration.contentInsets = notVerifierButtonContentInsets
|
||||
|
||||
let nameNotVerifiedButtonLabelConfig = nameNotVerifiedConfig()
|
||||
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)
|
||||
}
|
||||
nameNotVerifiedButtonLabelConfig.applyForRendering(buttonConfiguration: &buttonConfiguration)
|
||||
|
||||
let nameNotVerifiedButton = UIButton(
|
||||
configuration: buttonConfiguration,
|
||||
primaryAction: UIAction { _ in
|
||||
componentDelegate.didTapNameEducation(type: safetySection.threadType)
|
||||
},
|
||||
)
|
||||
innerViews.append(nameNotVerifiedButton)
|
||||
|
||||
componentView.profileNamesEducationButton = nameNotVerifiedButton
|
||||
} else if safetySection.isOfficialChat {
|
||||
innerViews.append(UIView.spacer(withHeight: vSpacingNotVerifiedLabel))
|
||||
|
||||
@ -249,23 +255,30 @@ 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 {
|
||||
let showTipsButton = componentView.showTipsButton
|
||||
safetyTipsButtonLabelConfig.applyForRendering(buttonConfiguration: &showTipsButton.configuration!)
|
||||
showTipsButton.configuration?.baseBackgroundColor =
|
||||
var buttonConfiguration = headerButtonConfigurationBase()
|
||||
buttonConfiguration.contentInsets = safetyButtonContentInsets
|
||||
buttonConfiguration.baseBackgroundColor =
|
||||
conversationStyle.hasWallpaper ? .Signal.MaterialBase.button : .Signal.secondaryFill
|
||||
showTipsButton.addAction(
|
||||
UIAction { _ in
|
||||
|
||||
let safetyTipsButtonLabelConfig = safetyTipsButtonLabelConfig()
|
||||
safetyTipsButtonLabelConfig.applyForRendering(buttonConfiguration: &buttonConfiguration)
|
||||
|
||||
let showTipsButton = UIButton(
|
||||
configuration: buttonConfiguration,
|
||||
primaryAction: UIAction { _ in
|
||||
componentDelegate.didTapSafetyTips()
|
||||
},
|
||||
for: .primaryActionTriggered,
|
||||
)
|
||||
|
||||
innerViews.append(UIView.spacer(withHeight: vSpacingSafetyButton))
|
||||
innerViews.append(showTipsButton)
|
||||
|
||||
componentView.showTipsButton = showTipsButton
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,7 +439,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
}
|
||||
|
||||
private func officialLabelConfig() -> CVLabelConfig {
|
||||
let symbol = SignalSymbol.checkCircle.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize)
|
||||
let symbol = SignalSymbol.officialBadge.attributedString(dynamicTypeBaseSize: UIFont.dynamicTypeCalloutClamped.pointSize)
|
||||
let notVerifiedString = NSAttributedString.composed(
|
||||
of: [
|
||||
symbol,
|
||||
@ -448,7 +461,7 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
)
|
||||
}
|
||||
|
||||
private var safetyTipsButtonLabelConfig: CVLabelConfig {
|
||||
private func safetyTipsButtonLabelConfig() -> CVLabelConfig {
|
||||
CVLabelConfig.unstyledText(
|
||||
OWSLocalizedString(
|
||||
"SAFETY_TIPS_BUTTON_ACTION_TITLE",
|
||||
@ -642,10 +655,14 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
private let vSpacingSafetySectionDefault: CGFloat = 8
|
||||
|
||||
private let safetyButtonContentInsets = NSDirectionalEdgeInsets(hMargin: 12, vMargin: 5)
|
||||
private let hPaddingGroupDetails: CGFloat = 25
|
||||
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 vPaddingNotVerifiedButton: CGFloat = 2
|
||||
private let hPaddingNotVerifiedButton: CGFloat = 12
|
||||
private let hPaddingGroupDetails: CGFloat = 25
|
||||
|
||||
private let vOffsetThreadDetailsOutline: CGFloat = 16
|
||||
|
||||
@ -688,20 +705,18 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
if let safetySection = threadDetails.safetySection {
|
||||
if safetySection.shouldShowProfileNamesEducation {
|
||||
innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo)
|
||||
let notVerifiedSize = CVText.measureLabel(
|
||||
let buttonSize = CVText.measureLabel(
|
||||
config: nameNotVerifiedConfig(),
|
||||
maxWidth: maxContentWidth,
|
||||
)
|
||||
let notVerifiedSizeWithPadding = CGSize(width: notVerifiedSize.width + hPaddingNotVerifiedButton * 2, height: notVerifiedSize.height + vPaddingNotVerifiedButton * 2)
|
||||
innerSubviewInfos.append(notVerifiedSizeWithPadding.asManualSubviewInfo)
|
||||
) + notVerifierButtonContentInsets.asSize
|
||||
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
|
||||
} else if safetySection.isOfficialChat {
|
||||
innerSubviewInfos.append(CGSize(square: vSpacingNotVerifiedLabel).asManualSubviewInfo)
|
||||
let officialLabelSize = CVText.measureLabel(
|
||||
let buttonSize = CVText.measureLabel(
|
||||
config: officialLabelConfig(),
|
||||
maxWidth: maxContentWidth,
|
||||
)
|
||||
let officialLabelSizeWithPadding = CGSize(width: officialLabelSize.width + hPaddingNotVerifiedButton * 2, height: officialLabelSize.height + vPaddingNotVerifiedButton * 2)
|
||||
innerSubviewInfos.append(officialLabelSizeWithPadding.asManualSubviewInfo)
|
||||
) + notVerifierButtonContentInsets.asSize
|
||||
innerSubviewInfos.append(buttonSize.asManualSubviewInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@ -737,7 +752,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)
|
||||
@ -786,7 +801,8 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
if let safetySection = threadDetails.safetySection {
|
||||
if
|
||||
safetySection.shouldShowSafetyTipsButton,
|
||||
componentView.showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
|
||||
let showTipsButton = componentView.showTipsButton,
|
||||
showTipsButton.bounds.contains(sender.location(in: componentView.showTipsButton))
|
||||
{
|
||||
componentDelegate.didTapSafetyTips()
|
||||
return true
|
||||
@ -803,7 +819,8 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
|
||||
if
|
||||
safetySection.shouldShowProfileNamesEducation,
|
||||
componentView.profileNamesEducationButton.bounds.contains(sender.location(in: componentView.profileNamesEducationButton))
|
||||
let profileNamesEducationButton = componentView.profileNamesEducationButton,
|
||||
profileNamesEducationButton.bounds.contains(sender.location(in: componentView.profileNamesEducationButton))
|
||||
{
|
||||
componentDelegate.didTapNameEducation(type: safetySection.threadType)
|
||||
return true
|
||||
@ -838,17 +855,13 @@ public class CVComponentThreadDetails: CVComponentBase, CVRootComponent {
|
||||
fileprivate let titleButton = CVButton()
|
||||
fileprivate let bioLabel = CVLabel()
|
||||
|
||||
fileprivate let profileNamesEducationButton = OWSRoundedButton()
|
||||
fileprivate var profileNamesEducationButton: UIButton?
|
||||
fileprivate let officialLabel = CVLabel()
|
||||
|
||||
fileprivate let reviewCarefullyLabel = CVLabel()
|
||||
fileprivate let detailsButton = CVButton()
|
||||
fileprivate let mutualGroupsLabel = CVLabel()
|
||||
fileprivate let showTipsButton: UIButton = {
|
||||
let button = UIButton(configuration: .gray())
|
||||
button.configuration?.contentInsets = NSDirectionalEdgeInsets(hMargin: 10, vMargin: 5)
|
||||
return button
|
||||
}()
|
||||
fileprivate var showTipsButton: UIButton?
|
||||
|
||||
fileprivate let groupDescriptionPreviewView = GroupDescriptionPreviewView(
|
||||
shouldDeactivateConstraints: true,
|
||||
@ -906,6 +919,7 @@ 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,
|
||||
@ -1038,6 +1052,7 @@ extension CVComponentThreadDetails {
|
||||
shouldShowProfileNamesEducation: shouldShowUnknownThreadWarning,
|
||||
detailsText: membersAttributedText,
|
||||
mutualGroupsText: nil,
|
||||
mutualGroupsAccessibilityText: nil,
|
||||
threadType: .group,
|
||||
shouldShowSafetyTipsButton: shouldShowUnknownThreadWarning && groupThread.hasPendingMessageRequest(transaction: tx),
|
||||
isOfficialChat: false,
|
||||
@ -1070,6 +1085,7 @@ extension CVComponentThreadDetails {
|
||||
with: .font(.dynamicTypeSubheadline),
|
||||
.color(UIColor.Signal.label),
|
||||
),
|
||||
mutualGroupsAccessibilityText: nil,
|
||||
threadType: .contact,
|
||||
shouldShowSafetyTipsButton: false,
|
||||
isOfficialChat: false,
|
||||
@ -1174,6 +1190,7 @@ extension CVComponentThreadDetails {
|
||||
" ",
|
||||
formattedString,
|
||||
]),
|
||||
mutualGroupsAccessibilityText: formattedString,
|
||||
threadType: .contact,
|
||||
shouldShowSafetyTipsButton: isMessageRequest,
|
||||
isOfficialChat: false,
|
||||
|
||||
@ -59,7 +59,7 @@ extension TSInfoMessage.PersistableGroupUpdateItem {
|
||||
)
|
||||
{
|
||||
owsAssertDebug(
|
||||
isTail.negated,
|
||||
!isTail,
|
||||
"Collapsed item with a following request shouldn't be a tail!",
|
||||
)
|
||||
return nextItemAction
|
||||
|
||||
@ -22,9 +22,12 @@ class ConversationBottomPanelView: UIView {
|
||||
let contentLayoutGuide = UILayoutGuide()
|
||||
|
||||
private var backgroundViewEffect: UIVisualEffect {
|
||||
guard #available(iOS 26, *), useGlassPanel else {
|
||||
if UIAccessibility.isReduceTransparencyEnabled {
|
||||
return UIBlurEffect(style: .systemThinMaterial)
|
||||
}
|
||||
guard #available(iOS 26, *), useGlassPanel else {
|
||||
return Theme.barBlurEffect
|
||||
}
|
||||
// Same as in ConversationInputToolbar.
|
||||
let glassEffect = UIGlassEffect(style: .regular)
|
||||
glassEffect.tintColor = .Signal.glassBackgroundTint
|
||||
@ -123,6 +126,19 @@ 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,6 +179,23 @@ 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,6 +9,7 @@ public import UIKit
|
||||
|
||||
public protocol ConversationInputTextViewDelegate: AnyObject {
|
||||
func didAttemptAttachmentPaste()
|
||||
func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void)
|
||||
func inputTextViewSendMessagePressed()
|
||||
func textViewDidChange(_ textView: UITextView)
|
||||
}
|
||||
@ -199,9 +200,50 @@ 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,6 +249,7 @@ 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),
|
||||
@ -345,6 +346,7 @@ 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),
|
||||
@ -378,6 +380,7 @@ 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),
|
||||
@ -403,6 +406,7 @@ 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),
|
||||
@ -707,9 +711,10 @@ 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: 20)
|
||||
glassEffectView.cornerConfiguration = .uniformCorners(radius: .fixed(cornerRadius))
|
||||
glassEffectView.contentView.addSubview(messageComponentsView)
|
||||
backgroundView = glassEffectView
|
||||
|
||||
@ -717,7 +722,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
|
||||
} else {
|
||||
backgroundView = UIView()
|
||||
backgroundView.backgroundColor = UIColor.Signal.tertiaryFill
|
||||
backgroundView.layer.cornerRadius = 20
|
||||
backgroundView.layer.cornerRadius = cornerRadius
|
||||
|
||||
messageContentView.addSubview(backgroundView)
|
||||
messageContentView.addSubview(messageComponentsView)
|
||||
@ -1214,7 +1219,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
|
||||
lazy var sendButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.accessibilityLabel = MessageStrings.sendButton
|
||||
button.ows_adjustsImageWhenDisabled = true
|
||||
button.isPointerInteractionEnabled = 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)
|
||||
@ -1224,6 +1229,7 @@ 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.",
|
||||
@ -1241,6 +1247,7 @@ 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",
|
||||
@ -1366,6 +1373,8 @@ 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(_ renderState: CVRenderState) -> CVUpdateToken {
|
||||
func willUpdateWithNewRenderState(_ update: CVUpdate) -> CVUpdateToken {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
// HACK to work around radar #28167779
|
||||
@ -171,7 +171,9 @@ 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()
|
||||
let scrollContinuityToken = layout.buildScrollContinuityToken(
|
||||
preferredAnchorInteractionId: update.loadRequest.preferredScrollContinuityAnchorInteractionId,
|
||||
)
|
||||
|
||||
// CVC will often use this state to ensure scroll continuity
|
||||
// when landing loads, so ensure the value is updated before
|
||||
@ -543,6 +545,14 @@ 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,7 +32,11 @@ extension ConversationViewController: CVComponentDelegate {
|
||||
} else {
|
||||
viewState.expandedCollapseSets.insert(collapseSetId)
|
||||
}
|
||||
loadCoordinator.enqueueReload()
|
||||
loadCoordinator.enqueueReload(
|
||||
updatedInteractionIds: [collapseSetId],
|
||||
deletedInteractionIds: [],
|
||||
preferredScrollContinuityAnchorInteractionId: collapseSetId,
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Double-Tap
|
||||
@ -179,7 +183,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 {
|
||||
Task.detached {
|
||||
let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
|
||||
let attachmentStore = DependenciesBridge.shared.attachmentStore
|
||||
let backupAttachmentDownloadStore = DependenciesBridge.shared.backupAttachmentDownloadStore
|
||||
@ -190,17 +194,22 @@ extension ConversationViewController: CVComponentDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
let messageHasAnyEnqueuedBackupDownloads = db.read { tx in
|
||||
enum DownloadTypeToEnqueue {
|
||||
case thumbnail
|
||||
case fullsize
|
||||
}
|
||||
|
||||
let messageTypeToDownload: DownloadTypeToEnqueue? = db.read { tx in
|
||||
let referencedAttachments = attachmentStore.fetchReferencedAttachmentsOwnedByMessage(
|
||||
messageRowId: messageRowId,
|
||||
tx: tx,
|
||||
)
|
||||
|
||||
return referencedAttachments.contains { referencedAttachment in
|
||||
let downloadTypes: [DownloadTypeToEnqueue] = referencedAttachments.compactMap { 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 false
|
||||
return nil
|
||||
}
|
||||
// Otherwise use presence in the backup download queue to indicate
|
||||
// downloadability; this just functionally bumps the priority so the
|
||||
@ -211,22 +220,60 @@ 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 false
|
||||
case .ineligible, .ready:
|
||||
return true
|
||||
return nil
|
||||
case .ready:
|
||||
return .fullsize
|
||||
}
|
||||
}
|
||||
|
||||
if downloadTypes.contains(.fullsize) {
|
||||
return .fullsize
|
||||
} else if downloadTypes.contains(.thumbnail) {
|
||||
return .thumbnail
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if messageHasAnyEnqueuedBackupDownloads {
|
||||
switch messageTypeToDownload {
|
||||
case .fullsize:
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -240,6 +287,7 @@ extension ConversationViewController: CVComponentDelegate {
|
||||
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
|
||||
message,
|
||||
priority: .userInitiated,
|
||||
useThumbnails: false,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -1406,17 +1454,16 @@ extension ConversationViewController: CVComponentDelegate {
|
||||
}
|
||||
|
||||
public func didTapSafetyTips() {
|
||||
let viewController = SafetyTipsViewController()
|
||||
viewController.delegate = self
|
||||
present(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SafetyTipsViewControllerDelegate
|
||||
|
||||
extension ConversationViewController: SafetyTipsViewControllerDelegate {
|
||||
public func didTapViewMoreSafetyTips() {
|
||||
let viewController = MoreSafetyTipsViewController()
|
||||
let viewController = SafetyTipsViewController(
|
||||
mode: .messageRequest,
|
||||
primaryButton: SafetyTipsViewController.Button(
|
||||
title: CommonStrings.viewMoreButton,
|
||||
action: { [weak self] in
|
||||
let viewController = MoreSafetyTipsViewController()
|
||||
self?.present(viewController, animated: true)
|
||||
},
|
||||
),
|
||||
)
|
||||
present(viewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,6 +295,58 @@ 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 DonationSubscriptionManager.probablyHasCurrentSubscription(tx: tx)
|
||||
return DependenciesBridge.shared.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,6 +78,14 @@ 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)
|
||||
}
|
||||
|
||||
@ -203,6 +211,28 @@ 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,6 +31,8 @@ extension ConversationViewController {
|
||||
} else {
|
||||
headerView.titleLabel.text = title
|
||||
}
|
||||
|
||||
headerView.updateTitleSpinning()
|
||||
}
|
||||
|
||||
public func createHeaderViews() {
|
||||
|
||||
@ -893,6 +893,21 @@ 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
|
||||
@ -967,7 +982,7 @@ public class ConversationViewLayout: UICollectionViewLayout {
|
||||
delegateScrollContinuityMode = .disabled
|
||||
}
|
||||
|
||||
public func buildScrollContinuityToken() -> CVScrollContinuityToken {
|
||||
public func buildScrollContinuityToken(preferredAnchorInteractionId: String? = nil) -> CVScrollContinuityToken {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let layoutInfo = ensureCurrentLayoutInfo()
|
||||
@ -992,6 +1007,7 @@ public class ConversationViewLayout: UICollectionViewLayout {
|
||||
layoutInfo: layoutInfo,
|
||||
contentOffset: contentOffset,
|
||||
visibleUniqueIds: visibleUniqueIds,
|
||||
anchorInteractionId: preferredAnchorInteractionId,
|
||||
)
|
||||
}
|
||||
|
||||
@ -1095,15 +1111,18 @@ 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
|
||||
|
||||
class CollapseSetInteraction: TSInteraction {
|
||||
final class CollapseSetInteraction: TSInteraction {
|
||||
|
||||
enum MessagesType: Equatable {
|
||||
case groupUpdates
|
||||
@ -18,8 +18,6 @@ class CollapseSetInteraction: TSInteraction {
|
||||
|
||||
let collapseSetType: MessagesType
|
||||
|
||||
let isExpanded: Bool
|
||||
|
||||
let finalTimerDescription: String?
|
||||
|
||||
override var isDynamicInteraction: Bool { true }
|
||||
@ -32,12 +30,10 @@ 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,
|
||||
@ -45,13 +41,17 @@ class CollapseSetInteraction: TSInteraction {
|
||||
|
||||
let firstInteraction = collapsedInteractions[0]
|
||||
super.init(
|
||||
customUniqueId: "CollapseSet_\(firstInteraction.timestamp)",
|
||||
customUniqueId: Self.id(firstInteraction: firstInteraction),
|
||||
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,7 +423,6 @@ 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(_ renderState: CVRenderState) -> CVUpdateToken
|
||||
func willUpdateWithNewRenderState(_ update: CVUpdate) -> CVUpdateToken
|
||||
|
||||
func updateWithNewRenderState(
|
||||
update: CVUpdate,
|
||||
@ -385,6 +385,13 @@ 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()
|
||||
@ -420,6 +427,23 @@ 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()
|
||||
|
||||
@ -616,7 +640,7 @@ public class CVLoadCoordinator: NSObject {
|
||||
}
|
||||
|
||||
let renderState = update.renderState
|
||||
let updateToken = delegate.willUpdateWithNewRenderState(renderState)
|
||||
let updateToken = delegate.willUpdateWithNewRenderState(update)
|
||||
|
||||
self.renderState = renderState
|
||||
|
||||
|
||||
@ -87,6 +87,7 @@ struct CVLoadRequest {
|
||||
let canReuseInteractionModels: Bool
|
||||
let canReuseComponentStates: Bool
|
||||
let didReset: Bool
|
||||
let preferredScrollContinuityAnchorInteractionId: String?
|
||||
|
||||
var isInitialLoad: Bool {
|
||||
switch loadType {
|
||||
@ -147,6 +148,7 @@ struct CVLoadRequest {
|
||||
private var canReuseInteractionModels = true
|
||||
private var canReuseComponentStates = true
|
||||
private var didReset = false
|
||||
private var preferredScrollContinuityAnchorInteractionId: String?
|
||||
|
||||
mutating func reload(
|
||||
updatedInteractionIds: Set<String>,
|
||||
@ -232,6 +234,13 @@ struct CVLoadRequest {
|
||||
shouldLoad = true
|
||||
}
|
||||
|
||||
mutating func reload(preferredScrollContinuityAnchorInteractionId: String) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
self.preferredScrollContinuityAnchorInteractionId = preferredScrollContinuityAnchorInteractionId
|
||||
reload()
|
||||
}
|
||||
|
||||
mutating func reloadWithoutCaches() {
|
||||
reload(canReuseInteractionModels: false, canReuseComponentStates: false, didReset: true)
|
||||
}
|
||||
@ -265,6 +274,7 @@ struct CVLoadRequest {
|
||||
canReuseInteractionModels: canReuseInteractionModels,
|
||||
canReuseComponentStates: canReuseComponentStates,
|
||||
didReset: didReset,
|
||||
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,6 +80,10 @@ 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
|
||||
@ -132,30 +136,35 @@ 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, _):
|
||||
@ -163,6 +172,7 @@ public class CVLoader: NSObject {
|
||||
aroundInteractionId: interactionId,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: transaction,
|
||||
)
|
||||
}
|
||||
@ -171,36 +181,18 @@ public class CVLoader: NSObject {
|
||||
throw error
|
||||
}
|
||||
|
||||
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
|
||||
let expandedInteractions = messageLoader.loadedDisplayableInteractions.flatMap { interaction in
|
||||
if
|
||||
let collapseSet = interaction as? CollapseSetInteraction,
|
||||
viewStateSnapshot.expandedCollapseSetIds.contains(collapseSet.uniqueId)
|
||||
{
|
||||
try messageLoader.loadOlderMessagePage(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
tx: transaction,
|
||||
)
|
||||
processedInteractions = Self.preprocessInteractions(
|
||||
messageLoader.loadedInteractions,
|
||||
loadContext: loadContext,
|
||||
)
|
||||
extraLoads += 1
|
||||
return [collapseSet] + collapseSet.collapsedInteractions
|
||||
}
|
||||
return [interaction]
|
||||
}
|
||||
|
||||
let itemModels = self.buildItemModels(
|
||||
interactions: processedInteractions,
|
||||
interactions: expandedInteractions,
|
||||
loadContext: loadContext,
|
||||
updatedInteractionIds: updatedInteractionIds,
|
||||
localAci: localAci,
|
||||
@ -272,214 +264,6 @@ 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 expandedCollapseSets: Set<String>
|
||||
let expandedCollapseSetIds: 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,
|
||||
expandedCollapseSets: viewState.expandedCollapseSets,
|
||||
expandedCollapseSetIds: viewState.expandedCollapseSets,
|
||||
)
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ struct CVViewStateSnapshot {
|
||||
oldestUnreadMessageSortId: nil,
|
||||
hasActiveCall: false,
|
||||
currentGroupThreadCallGroupId: nil,
|
||||
expandedCollapseSets: [],
|
||||
expandedCollapseSetIds: [],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,11 +7,13 @@ import Foundation
|
||||
import SignalServiceKit
|
||||
|
||||
private enum Constants {
|
||||
/// The maximum number of interactions to keep in memory. We start dropping
|
||||
/// interactions (in an LRU fashion) once we've exceeded this value.
|
||||
/// 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.
|
||||
///
|
||||
/// TODO: Should we reduce this value?
|
||||
static let maxInteractionCount = 500
|
||||
static let maxDisplayableInteractionCount = 500
|
||||
|
||||
static let maxCollapseSetSize = 50
|
||||
}
|
||||
|
||||
protocol MessageLoaderBatchFetcher {
|
||||
@ -28,11 +30,19 @@ 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.
|
||||
@ -90,10 +100,61 @@ 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(
|
||||
@ -101,6 +162,7 @@ class MessageLoader {
|
||||
count: initialLoadCount,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -108,6 +170,7 @@ class MessageLoader {
|
||||
func loadNewerMessagePage(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
@ -115,6 +178,7 @@ class MessageLoader {
|
||||
count: initialLoadCount * 2,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -122,6 +186,7 @@ class MessageLoader {
|
||||
func loadOlderMessagePage(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
@ -129,6 +194,7 @@ class MessageLoader {
|
||||
count: initialLoadCount * 2,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -136,6 +202,7 @@ class MessageLoader {
|
||||
func loadNewestMessagePage(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
@ -143,6 +210,7 @@ class MessageLoader {
|
||||
count: initialLoadCount,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -151,6 +219,7 @@ class MessageLoader {
|
||||
focusMessageId: String?,
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
if let focusMessageId {
|
||||
@ -159,12 +228,14 @@ class MessageLoader {
|
||||
count: initialLoadCount,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
} else {
|
||||
try loadNewestMessagePage(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -173,13 +244,15 @@ class MessageLoader {
|
||||
func loadSameLocation(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
.sameLocation,
|
||||
count: max(initialLoadCount, loadedInteractions.count),
|
||||
count: max(initialLoadCount, loadedDisplayableInteractions.count),
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -195,21 +268,122 @@ class MessageLoader {
|
||||
count: Int,
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext?,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
owsAssertDebug(count > 0)
|
||||
let count = count.clamp(1, Constants.maxInteractionCount)
|
||||
let loadBatch = try buildLoadBatch(
|
||||
|
||||
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(
|
||||
direction,
|
||||
count: count,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
tx: tx,
|
||||
)
|
||||
loadedInteractions = fetchInteractions(
|
||||
uniqueIds: loadBatch.uniqueIds,
|
||||
|
||||
var loadedPage = buildLoadedPage(
|
||||
for: loadBatch,
|
||||
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
|
||||
}
|
||||
@ -228,24 +402,6 @@ 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)
|
||||
@ -265,8 +421,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)
|
||||
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch)
|
||||
let olderCount = try fetchOlder(before: rowId, count: count / 2, batch: &batch, tx: tx)
|
||||
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch, tx: tx)
|
||||
return batch
|
||||
}
|
||||
|
||||
@ -311,7 +467,7 @@ class MessageLoader {
|
||||
return batch
|
||||
case .older:
|
||||
var batch = priorLoad.batch
|
||||
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch)
|
||||
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch, tx: tx)
|
||||
return batch
|
||||
case .sameLocation where !priorLoad.batch.canLoadNewer:
|
||||
// If we're loading at the same location and are already at the end of the
|
||||
@ -319,13 +475,13 @@ class MessageLoader {
|
||||
fallthrough
|
||||
case .newer:
|
||||
var batch = priorLoad.batch
|
||||
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch)
|
||||
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch, tx: tx)
|
||||
return batch
|
||||
case .sameLocation:
|
||||
var batch = priorLoad.batch
|
||||
if batch.uniqueIds.count < initialLoadCount {
|
||||
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch)
|
||||
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch)
|
||||
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch, tx: tx)
|
||||
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch, tx: tx)
|
||||
}
|
||||
return batch
|
||||
case .around(interactionUniqueId: let uniqueId):
|
||||
@ -343,6 +499,32 @@ 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] = [:],
|
||||
@ -360,6 +542,268 @@ 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: -
|
||||
@ -447,8 +891,6 @@ 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 {
|
||||
@ -458,8 +900,6 @@ 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
|
||||
@ -494,24 +934,4 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,6 +101,12 @@ class MessageRequestView: ConversationBottomPanelView {
|
||||
|
||||
weak var delegate: MessageRequestDelegate?
|
||||
|
||||
// MARK: - ConversationBottomPanelView
|
||||
|
||||
override var useGlassPanel: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
init(threadViewModel: ThreadViewModel) {
|
||||
let thread = threadViewModel.threadRecord
|
||||
self.thread = thread
|
||||
@ -467,13 +473,33 @@ 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: .mediumSecondary(title: title),
|
||||
configuration: buttonConfiguration(title: title),
|
||||
primaryAction: UIAction { _ in
|
||||
actionBlock()
|
||||
},
|
||||
|
||||
@ -47,11 +47,12 @@ 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(logDumper),
|
||||
debugLogPolicy: .requireUpload(logs),
|
||||
hasRecentChallenge: logDumper.challengeReceivedRecently(),
|
||||
)
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import SignalServiceKit
|
||||
import SignalUI
|
||||
import zlib
|
||||
|
||||
public struct DebugLogDumper {
|
||||
struct DebugLogDumper {
|
||||
fileprivate var accountManager: (any TSAccountManager)?
|
||||
fileprivate var appVersion: any AppVersion
|
||||
fileprivate var db: (any DB)?
|
||||
@ -25,7 +25,7 @@ public struct DebugLogDumper {
|
||||
)
|
||||
}
|
||||
|
||||
public func challengeReceivedRecently() -> Bool {
|
||||
func challengeReceivedRecently() -> Bool {
|
||||
guard let db else {
|
||||
return false
|
||||
}
|
||||
@ -57,34 +57,134 @@ public struct DebugLogDumper {
|
||||
}
|
||||
}
|
||||
|
||||
enum DebugLogs {
|
||||
final class DebugLogs {
|
||||
private let dumper: DebugLogDumper
|
||||
private var logsDirPath: String?
|
||||
|
||||
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
|
||||
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) {
|
||||
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)
|
||||
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."),
|
||||
@ -102,10 +202,10 @@ enum DebugLogs {
|
||||
await ComposeSupportEmailOperation.sendEmailWithDefaultErrorHandling(
|
||||
supportFilter: supportFilter,
|
||||
logUrl: url,
|
||||
hasRecentChallenge: dumper.challengeReceivedRecently(),
|
||||
hasRecentChallenge: self.dumper.challengeReceivedRecently(),
|
||||
)
|
||||
}
|
||||
submitLogsCompletion()
|
||||
continuation.resume()
|
||||
},
|
||||
))
|
||||
}
|
||||
@ -118,7 +218,7 @@ enum DebugLogs {
|
||||
handler: { _ in
|
||||
UIPasteboard.general.string = url.absoluteString
|
||||
presentingViewController.presentToast(text: CommonStrings.copiedToClipboardToast, image: .copy)
|
||||
submitLogsCompletion()
|
||||
continuation.resume()
|
||||
},
|
||||
))
|
||||
alert.addAction(ActionSheetAction(
|
||||
@ -131,67 +231,39 @@ enum DebugLogs {
|
||||
AttachmentSharing.showShareUI(
|
||||
for: url.absoluteString,
|
||||
sender: nil,
|
||||
completion: submitLogsCompletion,
|
||||
completion: { continuation.resume() },
|
||||
)
|
||||
},
|
||||
))
|
||||
alert.addAction(ActionSheetAction(
|
||||
title: CommonStrings.cancelButton,
|
||||
style: .cancel,
|
||||
handler: { _ in submitLogsCompletion() },
|
||||
handler: { _ in continuation.resume() },
|
||||
))
|
||||
presentingViewController.presentActionSheet(alert)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func uploadLogsUsingViewController(_ viewController: UIViewController, dumper: DebugLogDumper, completion: @escaping (URL) -> Void) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
ModalActivityIndicatorViewController.present(
|
||||
fromViewController: viewController,
|
||||
private func uploadLogsWithUI(from viewController: UIViewController) async throws(DebugLogsError) -> URL? {
|
||||
return try await ModalActivityIndicatorViewController.presentAndPropagateResult(
|
||||
from: viewController,
|
||||
canCancel: true,
|
||||
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)
|
||||
) { () throws(DebugLogsError) -> URL? in
|
||||
do throws(DebugLogsError) {
|
||||
return try await self.uploadLogs()
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
return nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
modalActivityIndicator.dismiss {
|
||||
DebugLogs.showFailureAlert(
|
||||
with: error.localizedErrorMessage,
|
||||
logArchiveOrDirectoryPath: error.logArchiveOrDirectoryPath,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Collecting & uploading
|
||||
|
||||
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> {
|
||||
private static func collectLogs() -> String? {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy.MM.dd hh.mm.ss"
|
||||
let dateString = dateFormatter.string(from: Date())
|
||||
@ -203,7 +275,7 @@ enum DebugLogs {
|
||||
|
||||
let logFilePaths = DebugLogger.shared.allLogFilePaths
|
||||
if logFilePaths.isEmpty {
|
||||
return .failure(NoLogsError())
|
||||
return nil
|
||||
}
|
||||
|
||||
for logFilePath in logFilePaths {
|
||||
@ -219,50 +291,44 @@ enum DebugLogs {
|
||||
OWSFileSystem.protectFileOrFolder(atPath: copyFilePath)
|
||||
}
|
||||
|
||||
return .success(zipDirPath)
|
||||
return zipDirPath
|
||||
}
|
||||
|
||||
static func exportLogs() {
|
||||
func exportLogs(viewController: UIViewController) {
|
||||
AssertIsOnMainThread()
|
||||
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
|
||||
guard let logsDirPath else {
|
||||
return handleError(
|
||||
error: .noLogs,
|
||||
viewController: viewController,
|
||||
)
|
||||
}
|
||||
AttachmentSharing.showShareUI(for: URL(fileURLWithPath: logsDirPath), sender: nil) {
|
||||
OWSFileSystem.deleteFile(logsDirPath)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
private static func collectAndFlushLogs(
|
||||
dumper: DebugLogDumper,
|
||||
) -> String? {
|
||||
// Dump any additional details that are relevant.
|
||||
dumper.dump()
|
||||
Logger.info("About to zip debug logs")
|
||||
|
||||
// Phase 2: Flush pending logs to disk.
|
||||
// Flush pending logs to disk.
|
||||
Logger.flush()
|
||||
|
||||
// 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)
|
||||
// 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 4: Zip up the log files.
|
||||
let zipDirUrl = URL(fileURLWithPath: zipDirPath)
|
||||
let zipFileUrl = URL(fileURLWithPath: (zipDirPath as NSString).appendingPathExtension("zip")!)
|
||||
// Zip up the log files.
|
||||
let zipDirUrl = URL(fileURLWithPath: logsDirPath)
|
||||
let zipFileUrl = URL(fileURLWithPath: (logsDirPath as NSString).appendingPathExtension("zip")!)
|
||||
let fileCoordinator = NSFileCoordinator()
|
||||
var zipError: NSError?
|
||||
fileCoordinator.coordinate(readingItemAt: zipDirUrl, options: [.forUploading], error: &zipError) { temporaryFileUrl in
|
||||
@ -273,38 +339,44 @@ enum DebugLogs {
|
||||
}
|
||||
}
|
||||
if zipError != nil || !OWSFileSystem.fileOrFolderExists(url: zipFileUrl) {
|
||||
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)
|
||||
throw DebugLogsError.couldNotPackageLogs
|
||||
}
|
||||
|
||||
OWSFileSystem.protectFileOrFolder(atPath: zipFileUrl.path)
|
||||
OWSFileSystem.deleteFile(zipDirPath)
|
||||
|
||||
// Phase 5: Upload the log files.
|
||||
// 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 {
|
||||
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)
|
||||
throw DebugLogsError.uploadError(zipFilePath: zipFileUrl.path)
|
||||
}
|
||||
}
|
||||
|
||||
private static func showFailureAlert(with message: String, logArchiveOrDirectoryPath: String?) {
|
||||
let deleteArchive: (String) -> Void = { filePath in
|
||||
OWSFileSystem.deleteFile(filePath)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
let alert = ActionSheetController(title: nil, message: message)
|
||||
let alert = ActionSheetController(message: error.localizedErrorMessage)
|
||||
|
||||
if let logArchiveOrDirectoryPath {
|
||||
if let logsPath {
|
||||
alert.addAction(.init(
|
||||
title: OWSLocalizedString(
|
||||
"DEBUG_LOG_ALERT_OPTION_EXPORT_LOG_ARCHIVE",
|
||||
@ -312,23 +384,18 @@ enum DebugLogs {
|
||||
),
|
||||
) { _ in
|
||||
AttachmentSharing.showShareUI(
|
||||
for: URL(fileURLWithPath: logArchiveOrDirectoryPath),
|
||||
for: URL(fileURLWithPath: logsPath),
|
||||
sender: nil,
|
||||
completion: {
|
||||
deleteArchive(logArchiveOrDirectoryPath)
|
||||
},
|
||||
completion: completion,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
alert.addAction(.init(title: CommonStrings.okButton) { _ in
|
||||
if let logArchiveOrDirectoryPath {
|
||||
deleteArchive(logArchiveOrDirectoryPath)
|
||||
}
|
||||
completion?()
|
||||
})
|
||||
|
||||
let presentingViewController = UIApplication.shared.frontmostViewControllerIgnoringAlerts
|
||||
presentingViewController?.presentActionSheet(alert)
|
||||
viewController.presentActionSheet(alert)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -32,9 +32,6 @@ 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,6 +5,7 @@
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import GRDB
|
||||
import MultipeerConnectivity
|
||||
import SignalServiceKit
|
||||
|
||||
@ -366,7 +367,15 @@ 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 { _ in
|
||||
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!)
|
||||
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 didSelectAnyEmoji() {
|
||||
func didSelectShowFullEmojiPicker() {
|
||||
// No-op for configuration
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,176 +0,0 @@
|
||||
%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,176 +0,0 @@
|
||||
%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,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "change-number-dark-40.pdf",
|
||||
"filename" : "change-number.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
BIN
Signal/Images.xcassets/change_number.imageset/change-number.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/change_number.imageset/change-number.pdf
vendored
Normal file
Binary file not shown.
@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "change-number-light-40.pdf",
|
||||
"filename" : "change-number-error.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
BIN
Signal/Images.xcassets/change_number_error.imageset/change-number-error.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/change_number_error.imageset/change-number-error.pdf
vendored
Normal file
Binary file not shown.
@ -1,23 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ios-rick-roll-dark@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ios-rick-roll-dark@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ios-rick-roll-dark@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 301 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 596 KiB |
@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "official_wallpaper_reduced.pdf",
|
||||
"filename" : "official-wallpaper.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
BIN
Signal/Images.xcassets/official-wallpaper.imageset/official-wallpaper.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/official-wallpaper.imageset/official-wallpaper.pdf
vendored
Normal file
Binary file not shown.
Binary file not shown.
12
Signal/Images.xcassets/safety-tips/safetytip_48_04.imageset/Contents.json
vendored
Normal file
12
Signal/Images.xcassets/safety-tips/safetytip_48_04.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "safetytip_48_pin.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Signal/Images.xcassets/safety-tips/safetytip_48_04.imageset/safetytip_48_pin.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/safety-tips/safetytip_48_04.imageset/safetytip_48_pin.pdf
vendored
Normal file
Binary file not shown.
12
Signal/Images.xcassets/safety-tips/safetytip_48_05.imageset/Contents.json
vendored
Normal file
12
Signal/Images.xcassets/safety-tips/safetytip_48_05.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "safetytip_48_lock.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Signal/Images.xcassets/safety-tips/safetytip_48_05.imageset/safetytip_48_lock.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/safety-tips/safetytip_48_05.imageset/safetytip_48_lock.pdf
vendored
Normal file
Binary file not shown.
12
Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/Contents.json
vendored
Normal file
12
Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "verificationcode_alert_96.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/verificationcode_alert_96.pdf
vendored
Normal file
BIN
Signal/Images.xcassets/safety-tips/verificationcode_alert_96.imageset/verificationcode_alert_96.pdf
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user