mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-18 08:49:08 +00:00
Compare commits
339 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46638a1948 | ||
|
|
5cee85fcdc | ||
|
|
f97d7e3dfd | ||
|
|
6da0ecf827 | ||
|
|
9803550bba | ||
|
|
15284da4c5 | ||
|
|
351c3219e4 | ||
|
|
ab95dbbc77 | ||
|
|
cc6cba45c6 | ||
|
|
ce37660df2 | ||
|
|
ca14ed9b2c | ||
|
|
ba4cdea75d | ||
|
|
83c34dd4cc | ||
|
|
b6db3802d3 | ||
|
|
a9a19d3ae0 | ||
|
|
52fb873b1b | ||
|
|
9a0bb243cd | ||
|
|
78bbab37fb | ||
|
|
9af73b1409 | ||
|
|
9c5bb4aa17 | ||
|
|
49ba83dda8 | ||
|
|
de3b0d4ca2 | ||
|
|
b2efc42357 | ||
|
|
a71faf674d | ||
|
|
34faa9003f | ||
|
|
bc527a2bc1 | ||
|
|
0a3f96935a | ||
|
|
35232a3928 | ||
|
|
70d74e0bb1 | ||
|
|
36c91a95e2 | ||
|
|
4600e38a2a | ||
|
|
55abd88a03 | ||
|
|
cd880b0879 | ||
|
|
bbae6d876f | ||
|
|
48a0c5a5a9 | ||
|
|
c261df41b0 | ||
|
|
cc98eced27 | ||
|
|
452d5960e4 | ||
|
|
c95b180728 | ||
|
|
3c380d35fd | ||
|
|
41935120e5 | ||
|
|
03d8f72c41 | ||
|
|
ab9ecff4d4 | ||
|
|
e351a0b235 | ||
|
|
4a08de370a | ||
|
|
6d657b449c | ||
|
|
adef572abb | ||
|
|
d6f2039bd1 | ||
|
|
1223c3c768 | ||
|
|
333fa22c96 | ||
|
|
76c04d8d6d | ||
|
|
c3070f2913 | ||
|
|
234b3967ed | ||
|
|
89d420cda8 | ||
|
|
ced4ece5b8 | ||
|
|
8c81e47737 | ||
|
|
5d15eef61d | ||
|
|
8f3e62245f | ||
|
|
e4ab795c62 | ||
|
|
e4d6f9240f | ||
|
|
cfaf40e605 | ||
|
|
bdcf2431e7 | ||
|
|
7241283be2 | ||
|
|
dde2a8b63a | ||
|
|
f7763a5b82 | ||
|
|
c6f4a01001 | ||
|
|
95a6835988 | ||
|
|
f9a8f447d2 | ||
|
|
d20f588802 | ||
|
|
f23476a4e9 | ||
|
|
fd4864b3b1 | ||
|
|
c5c0c432c4 | ||
|
|
69c40a6835 | ||
|
|
7ef7aa65e6 | ||
|
|
97c08f0d52 | ||
|
|
18e6c57e75 | ||
|
|
ffc1463cda | ||
|
|
84e654efb2 | ||
|
|
d983265e08 | ||
|
|
e60b32202e | ||
|
|
95fbd7a31c | ||
|
|
00a91e32fc | ||
|
|
fa32b7a883 | ||
|
|
63e6f955ed | ||
|
|
7dcb8a425a | ||
|
|
f35ce068f9 | ||
|
|
881d231a93 | ||
|
|
293634c758 | ||
|
|
4134df3f35 | ||
|
|
f78a019c70 | ||
|
|
d561a1385c | ||
|
|
9b5387e221 | ||
|
|
25b1a814fe | ||
|
|
b043b6e458 | ||
|
|
8a972d93e9 | ||
|
|
8fe66a14c5 | ||
|
|
f82bd64c10 | ||
|
|
4bcab49539 | ||
|
|
0f4618ab11 | ||
|
|
475ca50fab | ||
|
|
a64a02fa0c | ||
|
|
f3669a5865 | ||
|
|
34dbd11db0 | ||
|
|
2e7279c72f | ||
|
|
6ad72f00af | ||
|
|
b771a21518 | ||
|
|
04fb459acd | ||
|
|
690a68f0d0 | ||
|
|
f34ae8d118 | ||
|
|
da43ff1e95 | ||
|
|
f053ebbd51 | ||
|
|
87606af29c | ||
|
|
c811bdcffa | ||
|
|
0536628da3 | ||
|
|
1fa53cfcb8 | ||
|
|
a9ea3854d2 | ||
|
|
dc35261e00 | ||
|
|
716bc1f5e7 | ||
|
|
db27204084 | ||
|
|
42aeceffe2 | ||
|
|
03845eabaf | ||
|
|
62af9dad50 | ||
|
|
ee58d47926 | ||
|
|
d74260b536 | ||
|
|
15d8a698c5 | ||
|
|
62cf3feeaa | ||
|
|
947ab7d48b | ||
|
|
a82b9ee25f | ||
|
|
1e4d96b7c4 | ||
|
|
735a8e680c | ||
|
|
d9e9fe1d6a | ||
|
|
4bcd1df4f8 | ||
|
|
9762899272 | ||
|
|
ce1b73970c | ||
|
|
58282e589b | ||
|
|
75bd113545 | ||
|
|
7a6bd0e1f2 | ||
|
|
f673c4eb83 | ||
|
|
cbb04e8f0c | ||
|
|
cd03da54d5 | ||
|
|
5f31f5966c | ||
|
|
d8bbfe2678 | ||
|
|
7a2d408ca2 | ||
|
|
5e4dfcc65f | ||
|
|
7811e51b41 | ||
|
|
9703a868e5 | ||
|
|
1b7784b01f | ||
|
|
a83abaca1d | ||
|
|
29b3f09d8a | ||
|
|
d36b2a23f5 | ||
|
|
8f1722c718 | ||
|
|
5416c3b8aa | ||
|
|
89eeae36c4 | ||
|
|
eec2685e67 | ||
|
|
318b59a6b2 | ||
|
|
a2e0468cd9 | ||
|
|
689eacd618 | ||
|
|
8617a074ad | ||
|
|
046b8da880 | ||
|
|
34a36ddfea | ||
|
|
9330448198 | ||
|
|
b3336b4d84 | ||
|
|
9553c94097 | ||
|
|
c1845ae1c4 | ||
|
|
b6cc3852b0 | ||
|
|
eefc86f27e | ||
|
|
09404157aa | ||
|
|
abfd9f8f41 | ||
|
|
e04381fd75 | ||
|
|
30cc3ff9fc | ||
|
|
6f5f299035 | ||
|
|
02eed02cb8 | ||
|
|
c1d29b5c39 | ||
|
|
db4442939d | ||
|
|
6ece776382 | ||
|
|
0eda714755 | ||
|
|
831d099503 | ||
|
|
fa23e4ca70 | ||
|
|
982f602178 | ||
|
|
713298109a | ||
|
|
8793981804 | ||
|
|
9bd4e9524c | ||
|
|
791dc2724f | ||
|
|
ba3473c61a | ||
|
|
3ea194255d | ||
|
|
ea081e981f | ||
|
|
2ce6ea9a2a | ||
|
|
295c9310e9 | ||
|
|
7447ed2eac | ||
|
|
d5bf16b91a | ||
|
|
76665c1f0d | ||
|
|
dd28523b05 | ||
|
|
16588c401e | ||
|
|
dbf8a7ca87 | ||
|
|
e92c76434e | ||
|
|
7adb581271 | ||
|
|
869476a41b | ||
|
|
8daf1bca20 | ||
|
|
d044b3c931 | ||
|
|
0fcb19e1cc | ||
|
|
2a6977da75 | ||
|
|
26bd435bf6 | ||
|
|
91f8d6075c | ||
|
|
9ed9a330f4 | ||
|
|
8bbf6b790f | ||
|
|
a277e9b307 | ||
|
|
f8e6bcf290 | ||
|
|
3ba2b46bb0 | ||
|
|
b50eab230d | ||
|
|
3f91824325 | ||
|
|
879e05148b | ||
|
|
78e36b85d4 | ||
|
|
544cc06f13 | ||
|
|
133b7ef3f1 | ||
|
|
08a407dc23 | ||
|
|
6c697fad8b | ||
|
|
c904a7aa97 | ||
|
|
ad131d7c65 | ||
|
|
e12d2d1e98 | ||
|
|
f01e044662 | ||
|
|
03d3ae7043 | ||
|
|
6b60a22879 | ||
|
|
bbded8caa8 | ||
|
|
3a6352d2a3 | ||
|
|
8293d6bc4c | ||
|
|
56bdb28c2f | ||
|
|
b081fb1e13 | ||
|
|
58c1f64dfe | ||
|
|
92b7147dcd | ||
|
|
fa3a85c948 | ||
|
|
9da4513694 | ||
|
|
de520036a9 | ||
|
|
97ca15a1c0 | ||
|
|
713a34a5e7 | ||
|
|
d688280a30 | ||
|
|
ebbf8fad4b | ||
|
|
5891c6fb2d | ||
|
|
7c96319fb6 | ||
|
|
0d652ccfd6 | ||
|
|
d3718aa7ef | ||
|
|
fcdcb9fd33 | ||
|
|
a8f925def0 | ||
|
|
53cb125712 | ||
|
|
2a5793d96e | ||
|
|
d460fa7ed4 | ||
|
|
5272b13c41 | ||
|
|
a66ac42038 | ||
|
|
0014a2cba7 | ||
|
|
7a9c01e6e5 | ||
|
|
16402e43a5 | ||
|
|
b1944da58d | ||
|
|
9081d3c826 | ||
|
|
d4ae0ca4cb | ||
|
|
88f6ab915e | ||
|
|
939024faff | ||
|
|
4c6e7991df | ||
|
|
036d91c039 | ||
|
|
869c922532 | ||
|
|
217d15a853 | ||
|
|
931ffd0ba3 | ||
|
|
fecac297fa | ||
|
|
b0ea8d7df5 | ||
|
|
f126df2120 | ||
|
|
42450024fc | ||
|
|
101db6e164 | ||
|
|
13bef94bf7 | ||
|
|
02792c5a6f | ||
|
|
303929090b | ||
|
|
7a24554b68 | ||
|
|
5b10aa6fa7 | ||
|
|
e6eefac609 | ||
|
|
5f5a80dcbe | ||
|
|
7802448b24 | ||
|
|
16d231f718 | ||
|
|
62ca6cdd2f | ||
|
|
7d81ed1150 | ||
|
|
27812bb1ec | ||
|
|
6854f7eb2a | ||
|
|
de86c5622d | ||
|
|
6bf1a4295f | ||
|
|
7de2f0f460 | ||
|
|
50149a3803 | ||
|
|
d7ee9639fd | ||
|
|
7d5627b17b | ||
|
|
e24c951d83 | ||
|
|
e6a11c1ccf | ||
|
|
3f66981359 | ||
|
|
874f808d56 | ||
|
|
450dc2f368 | ||
|
|
7a69df42a7 | ||
|
|
1ce1e30d32 | ||
|
|
011f1d592e | ||
|
|
1d29b0166d | ||
|
|
6df1a68213 | ||
|
|
b7ee6bfcb3 | ||
|
|
c0cb2b5e12 | ||
|
|
b38865bdc7 | ||
|
|
6f46331772 | ||
|
|
989bd662c6 | ||
|
|
359e593481 | ||
|
|
b7e0fe22db | ||
|
|
61cfbd6852 | ||
|
|
02c0d3ed6e | ||
|
|
e4d6c3aeb2 | ||
|
|
0c6761fcfd | ||
|
|
8f884fdd5c | ||
|
|
07cea1818e | ||
|
|
132bc15373 | ||
|
|
d993748753 | ||
|
|
3372565a39 | ||
|
|
134ac2b2fd | ||
|
|
0e0e91b4fe | ||
|
|
25b50bdb8f | ||
|
|
1988085171 | ||
|
|
f892e9baff | ||
|
|
4828d84caf | ||
|
|
aeae6ac292 | ||
|
|
0544c1f249 | ||
|
|
5027159ed8 | ||
|
|
ce778be895 | ||
|
|
9e349d2b30 | ||
|
|
72f19758db | ||
|
|
55bce1fa12 | ||
|
|
5e1ebaa5d4 | ||
|
|
742c348998 | ||
|
|
9d46b52786 | ||
|
|
ef374952ab | ||
|
|
f8ef4d5985 | ||
|
|
85929809f0 | ||
|
|
068540120e | ||
|
|
471c4fc200 | ||
|
|
398c67362d | ||
|
|
4ceeda5f02 | ||
|
|
2bf6b993fe | ||
|
|
68363c5b82 | ||
|
|
9f47a41017 | ||
|
|
ba70101efd | ||
|
|
3aa54c9982 | ||
|
|
825ca0d737 |
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -2,7 +2,7 @@
|
||||
### First time contributor checklist
|
||||
<!-- replace the empty checkboxes [ ] below with checked ones [x] accordingly -->
|
||||
- [ ] I have read [how to contribute](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md) to this project
|
||||
- [ ] I have signed the [Contributor License Agreement](https://whispersystems.org/cla/)
|
||||
- [ ] I have signed the [Contributor License Agreement](https://signal.org/cla/)
|
||||
|
||||
### Contributor checklist
|
||||
<!-- replace the empty checkboxes [ ] below with checked ones [x] accordingly -->
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "libwebp"]
|
||||
path = libwebp
|
||||
url = https://github.com/webmproject/libwebp.git
|
||||
2
apntool/.gitignore
vendored
2
apntool/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*.db
|
||||
*.db.gz
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
import sqlite3
|
||||
import gzip
|
||||
from progressbar import ProgressBar, Counter, Timer
|
||||
from lxml import etree
|
||||
|
||||
parser = argparse.ArgumentParser(prog='apntool', description="""Process Android's apn xml files and drop them into an
|
||||
easily queryable SQLite db. Tested up to version 9 of
|
||||
their APN file.""")
|
||||
parser.add_argument('-v', '--version', action='version', version='%(prog)s v1.1')
|
||||
parser.add_argument('-i', '--input', help='the xml file to parse', default='apns.xml', required=False)
|
||||
parser.add_argument('-o', '--output', help='the sqlite db output file', default='apns.db', required=False)
|
||||
parser.add_argument('--quiet', help='do not show progress or verbose instructions', action='store_true', required=False)
|
||||
parser.add_argument('--no-gzip', help="do not gzip after creation", action='store_true', required=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def normalized(target):
|
||||
o2_typo = re.compile(r"02\.co\.uk")
|
||||
port_typo = re.compile(r"(\d+\.\d+\.\d+\.\d+)\.(\d+)")
|
||||
leading_zeros = re.compile(r"(/|\.|^)0+(\d+)")
|
||||
subbed = o2_typo.sub(r'o2.co.uk', target)
|
||||
subbed = port_typo.sub(r'\1:\2', subbed)
|
||||
subbed = leading_zeros.sub(r'\1\2', subbed)
|
||||
return subbed
|
||||
|
||||
try:
|
||||
connection = sqlite3.connect(args.output)
|
||||
cursor = connection.cursor()
|
||||
cursor.execute('SELECT SQLITE_VERSION()')
|
||||
version = cursor.fetchone()
|
||||
if not args.quiet:
|
||||
print("SQLite version: %s" % version)
|
||||
print("Opening %s" % args.input)
|
||||
|
||||
cursor.execute("PRAGMA legacy_file_format=ON")
|
||||
cursor.execute("PRAGMA journal_mode=DELETE")
|
||||
cursor.execute("PRAGMA page_size=32768")
|
||||
cursor.execute("VACUUM")
|
||||
cursor.execute("DROP TABLE IF EXISTS apns")
|
||||
cursor.execute("""CREATE TABLE apns(_id INTEGER PRIMARY KEY, mccmnc TEXT, mcc TEXT, mnc TEXT, carrier TEXT,
|
||||
apn TEXT, mmsc TEXT, port INTEGER, type TEXT, protocol TEXT, bearer TEXT, roaming_protocol TEXT,
|
||||
carrier_enabled INTEGER, mmsproxy TEXT, mmsport INTEGER, proxy TEXT, mvno_match_data TEXT,
|
||||
mvno_type TEXT, authtype INTEGER, user TEXT, password TEXT, server TEXT)""")
|
||||
|
||||
apns = etree.parse(args.input)
|
||||
root = apns.getroot()
|
||||
pbar = None
|
||||
if not args.quiet:
|
||||
pbar = ProgressBar(widgets=['Processed: ', Counter(), ' apns (', Timer(), ')'], maxval=len(list(root))).start()
|
||||
|
||||
count = 0
|
||||
for apn in root.iter("apn"):
|
||||
if apn.get("mmsc") is None:
|
||||
continue
|
||||
sqlvars = ["?" for x in apn.attrib.keys()] + ["?"]
|
||||
mccmnc = "%s%s" % (apn.get("mcc"), apn.get("mnc"))
|
||||
normalized_mmsc = normalized(apn.get("mmsc"))
|
||||
if normalized_mmsc != apn.get("mmsc"):
|
||||
print("normalize MMSC: %s => %s" % (apn.get("mmsc"), normalized_mmsc))
|
||||
apn.set("mmsc", normalized_mmsc)
|
||||
|
||||
if not apn.get("mmsproxy") is None:
|
||||
normalized_mmsproxy = normalized(apn.get("mmsproxy"))
|
||||
if normalized_mmsproxy != apn.get("mmsproxy"):
|
||||
print("normalize proxy: %s => %s" % (apn.get("mmsproxy"), normalized_mmsproxy))
|
||||
apn.set("mmsproxy", normalized_mmsproxy)
|
||||
|
||||
values = [apn.get(attrib) for attrib in apn.attrib.keys()] + [mccmnc]
|
||||
keys = apn.attrib.keys() + ["mccmnc"]
|
||||
|
||||
cursor.execute("SELECT 1 FROM apns WHERE mccmnc = ? AND apn = ?", [mccmnc, apn.get("apn")])
|
||||
if cursor.fetchone() is None:
|
||||
statement = "INSERT INTO apns (%s) VALUES (%s)" % (", ".join(keys), ", ".join(sqlvars))
|
||||
cursor.execute(statement, values)
|
||||
|
||||
count += 1
|
||||
if not args.quiet:
|
||||
pbar.update(count)
|
||||
|
||||
if not args.quiet:
|
||||
pbar.finish()
|
||||
connection.commit()
|
||||
print("Successfully written to %s" % args.output)
|
||||
|
||||
if not args.no_gzip:
|
||||
gzipped_file = "%s.gz" % (args.output,)
|
||||
with open(args.output, 'rb') as orig:
|
||||
with gzip.open(gzipped_file, 'wb') as gzipped:
|
||||
gzipped.writelines(orig)
|
||||
print("Successfully gzipped to %s" % gzipped_file)
|
||||
|
||||
if not args.quiet:
|
||||
print("\nTo include this in the distribution, copy it to the project's assets/databases/ directory.")
|
||||
print("If you support API 10 or lower, you must use the gzipped version to avoid corruption.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
if connection:
|
||||
connection.rollback()
|
||||
print("Error: %s" % e.args[0])
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if connection:
|
||||
connection.close()
|
||||
@@ -1,3 +0,0 @@
|
||||
argparse>=1.2.1
|
||||
lxml>=3.3.3
|
||||
progressbar-latest>=2.4
|
||||
@@ -21,8 +21,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1399
|
||||
val canonicalVersionName = "7.1.2"
|
||||
val canonicalVersionCode = 1419
|
||||
val canonicalVersionName = "7.7.2"
|
||||
|
||||
val postFixSize = 100
|
||||
val abiPostFix: Map<String, Int> = mapOf(
|
||||
@@ -178,7 +178,6 @@ android {
|
||||
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\"")
|
||||
@@ -199,7 +198,7 @@ android {
|
||||
buildConfigField("String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"a6622ad4656e1abcd0bc0ff17c229477747d2ded0495c4ebee7ed35c1789fa97\"")
|
||||
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\"")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0I=\"")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0LUlT9vALgh/f2DPVOOmR0RW6bgRvc7DSF20V/omg+YBw==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AJwNSU55fsFCbgaxGRD11wO1juAs8Yr5GF8FPlGzzvdJJIKH5/4CC7ZJSOe3yL2vturVaRU2Cx0n751Vt8wkj1bozK3CBV1UokxV09GWf+hdVImLGjXGYLLhnI1J2TWEe7iWHyb553EEnRb5oxr9n3lUbNAJuRmFM7hrr0Al0F0wrDD4S8lo2mGaXe0MJCOM166F8oYRQqpFeEHfiLnxA1O8ZLh7vMdv4g9jI5phpRBTsJ5IjiJrWeP0zdIGHEssUeprDZ9OUJ14m0v61eYJMKsf59Bn+mAT2a7YfB+Don9O\"")
|
||||
buildConfigField("String[]", "LANGUAGES", "new String[]{ ${languageList().map { "\"$it\"" }.joinToString(separator = ", ")} }")
|
||||
@@ -216,6 +215,7 @@ android {
|
||||
buildConfigField("String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "false")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "false")
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -376,11 +376,10 @@ android {
|
||||
buildConfigField("String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"acb1973aa0bbbd14b3b4e06f145497d948fd4a98efc500fcce363b3b743ec482\"")
|
||||
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCM=\"")
|
||||
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCNWI+wwkyFCqNDXz/qxl1gAntuCJtSfq9OC3NkdhQlgYQ==\"")
|
||||
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
|
||||
buildConfigField("String", "BACKUP_SERVER_PUBLIC_PARAMS", "\"AHYrGb9IfugAAJiPKp+mdXUx+OL9zBolPYHYQz6GI1gWjpEu5me3zVNSvmYY4zWboZHif+HG1sDHSuvwFd0QszSwuSF4X4kRP3fJREdTZ5MCR0n55zUppTwfHRW2S4sdQ0JGz7YDQIJCufYSKh0pGNEHL6hv79Agrdnr4momr3oXdnkpVBIp3HWAQ6IbXQVSG18X36GaicI1vdT0UFmTwU2KTneluC2eyL9c5ff8PcmiS+YcLzh0OKYQXB5ZfQ06d6DiINvDQLy75zcfUOniLAj0lGJiHxGczin/RXisKSR8\"")
|
||||
buildConfigField("String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\"")
|
||||
@@ -390,6 +389,7 @@ android {
|
||||
|
||||
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
|
||||
buildConfigField("boolean", "MESSAGE_BACKUP_RESTORE_ENABLED", "true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +499,7 @@ dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.extensions)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.concurrent.futures)
|
||||
@@ -537,10 +538,6 @@ dependencies {
|
||||
implementation(libs.android.tooltips) {
|
||||
exclude(group = "com.android.support", module = "appcompat-v7")
|
||||
}
|
||||
implementation(libs.android.smsmms) {
|
||||
exclude(group = "com.squareup.okhttp", module = "okhttp")
|
||||
exclude(group = "com.squareup.okhttp", module = "okhttp-urlconnection")
|
||||
}
|
||||
implementation(libs.stream)
|
||||
implementation(libs.lottie)
|
||||
implementation(libs.signal.android.database.sqlcipher)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep class org.whispersystems.** { *; }
|
||||
-keep class org.signal.libsignal.net.** { *; }
|
||||
-keep class org.signal.libsignal.protocol.** { *; }
|
||||
-keep class org.signal.libsignal.usernames.** { *; }
|
||||
-keep class org.thoughtcrime.securesms.** { *; }
|
||||
-keep class org.signal.donations.json.** { *; }
|
||||
-keepclassmembers class ** {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
@@ -15,7 +16,6 @@ import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
@@ -137,7 +137,7 @@ class ConversationItemPreviewer {
|
||||
|
||||
private fun attachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
ReleaseChannel.CDN_NUMBER,
|
||||
Cdn.CDN_3.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
"image/webp",
|
||||
null,
|
||||
|
||||
@@ -328,5 +328,7 @@ class V2ConversationItemShapeTest {
|
||||
override fun onReportSpamLearnMoreClicked() = Unit
|
||||
|
||||
override fun onMessageRequestAcceptOptionsClicked() = Unit
|
||||
|
||||
override fun onItemDoubleClick(item: MultiselectPart) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,18 +51,16 @@ class AttachmentTableTest {
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment,
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4, 5)),
|
||||
false
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4, 5))
|
||||
)
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment2,
|
||||
createMediaStream(byteArrayOf(1, 2, 3)),
|
||||
false
|
||||
createMediaStream(byteArrayOf(1, 2, 3))
|
||||
)
|
||||
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE)
|
||||
val attachment1Info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId)
|
||||
val attachment2Info = SignalDatabase.attachments.getDataFileInfo(attachment2.attachmentId)
|
||||
|
||||
assertNotEquals(attachment1Info, attachment2Info)
|
||||
}
|
||||
@@ -79,18 +77,16 @@ class AttachmentTableTest {
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment,
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4, 5)),
|
||||
true
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4, 5))
|
||||
)
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment2,
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4)),
|
||||
true
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4))
|
||||
)
|
||||
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA_FILE)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA_FILE)
|
||||
val attachment1Info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId)
|
||||
val attachment2Info = SignalDatabase.attachments.getDataFileInfo(attachment2.attachmentId)
|
||||
|
||||
assertNotEquals(attachment1Info, attachment2Info)
|
||||
}
|
||||
@@ -121,15 +117,14 @@ class AttachmentTableTest {
|
||||
val highDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityPreUpload)
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.attachments.updateAttachmentData(standardDatabaseAttachment, createMediaStream(compressedData), false)
|
||||
SignalDatabase.attachments.updateAttachmentData(standardDatabaseAttachment, createMediaStream(compressedData))
|
||||
|
||||
// THEN
|
||||
val previousInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(previousDatabaseAttachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val previousInfo = SignalDatabase.attachments.getDataFileInfo(previousDatabaseAttachmentId)!!
|
||||
val standardInfo = SignalDatabase.attachments.getDataFileInfo(standardDatabaseAttachment.attachmentId)!!
|
||||
val highInfo = SignalDatabase.attachments.getDataFileInfo(highDatabaseAttachment.attachmentId)!!
|
||||
|
||||
assertNotEquals(standardInfo, highInfo)
|
||||
standardInfo.file assertIs previousInfo.file
|
||||
highInfo.file assertIsNot standardInfo.file
|
||||
highInfo.file.exists() assertIs true
|
||||
}
|
||||
@@ -158,9 +153,9 @@ class AttachmentTableTest {
|
||||
val secondHighDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondHighQualityPreUpload)
|
||||
|
||||
// THEN
|
||||
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val secondHighInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(secondHighDatabaseAttachment.attachmentId, AttachmentTable.DATA_FILE)!!
|
||||
val standardInfo = SignalDatabase.attachments.getDataFileInfo(standardDatabaseAttachment.attachmentId)!!
|
||||
val highInfo = SignalDatabase.attachments.getDataFileInfo(highDatabaseAttachment.attachmentId)!!
|
||||
val secondHighInfo = SignalDatabase.attachments.getDataFileInfo(secondHighDatabaseAttachment.attachmentId)!!
|
||||
|
||||
highInfo.file assertIsNot standardInfo.file
|
||||
secondHighInfo.file assertIs highInfo.file
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.MediaStream
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Collection of [AttachmentTable] tests focused around deduping logic.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AttachmentTableTest_deduping {
|
||||
|
||||
companion object {
|
||||
val DATA_A = byteArrayOf(1, 2, 3)
|
||||
val DATA_A_COMPRESSED = byteArrayOf(4, 5, 6)
|
||||
val DATA_A_HASH = byteArrayOf(1, 1, 1)
|
||||
|
||||
val DATA_B = byteArrayOf(7, 8, 9)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalStore.account().setAci(ServiceId.ACI.from(UUID.randomUUID()))
|
||||
SignalStore.account().setPni(ServiceId.PNI.from(UUID.randomUUID()))
|
||||
SignalStore.account().setE164("+15558675309")
|
||||
|
||||
SignalDatabase.attachments.deleteAllAttachments()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates two different files with different data. Should not dedupe.
|
||||
*/
|
||||
@Test
|
||||
fun differentFiles() {
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertWithData(DATA_B)
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts files with identical data but with transform properties that make them incompatible. Should not dedupe.
|
||||
*/
|
||||
@Test
|
||||
fun identicalFiles_incompatibleTransforms() {
|
||||
// Non-matching qualities
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
|
||||
// Non-matching video trim flag
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties())
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true))
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
|
||||
// Non-matching video trim start time
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 2))
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
|
||||
// Non-matching video trim end time
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 1))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 0, videoTrimEndTimeUs = 2))
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
|
||||
// Non-matching mp4 fast start
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(mp4FastStart = true))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(mp4FastStart = false))
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts files with identical data and compatible transform properties. Should dedupe.
|
||||
*/
|
||||
@Test
|
||||
fun identicalFiles_compatibleTransforms() {
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertWithData(DATA_A)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertSkipTransform(id1, false)
|
||||
assertSkipTransform(id2, false)
|
||||
}
|
||||
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertSkipTransform(id1, false)
|
||||
assertSkipTransform(id2, false)
|
||||
}
|
||||
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertSkipTransform(id1, false)
|
||||
assertSkipTransform(id2, false)
|
||||
}
|
||||
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
|
||||
val id2 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertSkipTransform(id1, false)
|
||||
assertSkipTransform(id2, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks through various scenarios where files are compressed and uploaded.
|
||||
*/
|
||||
@Test
|
||||
fun compressionAndUploads() {
|
||||
// Matches after the first is compressed, skip transform properly set
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id2, true)
|
||||
}
|
||||
|
||||
// Matches after the first is uploaded, skip transform and ending hash properly set
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1)
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id2, true)
|
||||
}
|
||||
|
||||
// Mimics sending two files at once. Ensures all fields are kept in sync as we compress and upload.
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertWithData(DATA_A)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertSkipTransform(id1, false)
|
||||
assertSkipTransform(id2, false)
|
||||
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id2, true)
|
||||
|
||||
upload(id1)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
|
||||
// Re-use the upload when uploaded recently
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1, uploadTimestamp = System.currentTimeMillis())
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id2, true)
|
||||
}
|
||||
|
||||
// Do not re-use old uploads
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1, uploadTimestamp = System.currentTimeMillis() - 100.days.inWholeMilliseconds)
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id2, true)
|
||||
|
||||
assertDoesNotHaveRemoteFields(id2)
|
||||
}
|
||||
|
||||
// This isn't so much "desirable behavior" as it is documenting how things work.
|
||||
// If an attachment is compressed but not uploaded yet, it will have a DATA_HASH_START that doesn't match the actual file content.
|
||||
// This means that if we insert a new attachment with data that matches the compressed data, we won't find a match.
|
||||
// This is ok because we don't allow forwarding unsent messages, so the chances of the user somehow sending a file that matches data we compressed are very low.
|
||||
// What *is* more common is that the user may send DATA_A again, and in this case we will still catch the dedupe (which is already tested above).
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
|
||||
val id2 = insertWithData(DATA_A_COMPRESSED)
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
}
|
||||
|
||||
// This represents what would happen if you forward an already-send compressed attachment. We should match, skip transform, and skip upload.
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1, uploadTimestamp = System.currentTimeMillis())
|
||||
|
||||
val id2 = insertWithData(DATA_A_COMPRESSED)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id1, true)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
|
||||
// This represents what would happen if you edited a video, sent it, then forwarded it. We should match, skip transform, and skip upload.
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1, uploadTimestamp = System.currentTimeMillis())
|
||||
|
||||
val id2 = insertWithData(DATA_A_COMPRESSED)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id1, true)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
|
||||
// This represents what would happen if you edited a video, sent it, then forwarded it, but *edited the forwarded video*. We should not dedupe.
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1, uploadTimestamp = System.currentTimeMillis())
|
||||
|
||||
val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(videoTrim = true, videoTrimStartTimeUs = 1, videoTrimEndTimeUs = 2))
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id2, false)
|
||||
assertDoesNotHaveRemoteFields(id2)
|
||||
}
|
||||
|
||||
// This represents what would happen if you sent an image using standard quality, then forwarded it using high quality.
|
||||
// Since you're forwarding, it doesn't matter if the new thing has a higher quality, we should still match and skip transform.
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1, uploadTimestamp = System.currentTimeMillis())
|
||||
|
||||
val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id1, true)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
|
||||
// This represents what would happen if you sent an image using high quality, then forwarded it using standard quality.
|
||||
// Since you're forwarding, it doesn't matter if the new thing has a lower quality, we should still match and skip transform.
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, TransformProperties(sentMediaQuality = SentMediaQuality.HIGH.code))
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1, uploadTimestamp = System.currentTimeMillis())
|
||||
|
||||
val id2 = insertWithData(DATA_A_COMPRESSED, TransformProperties(sentMediaQuality = SentMediaQuality.STANDARD.code))
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertSkipTransform(id1, true)
|
||||
assertSkipTransform(id1, true)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
|
||||
// Make sure that files marked as unhashable are all updated together
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertWithData(DATA_A)
|
||||
upload(id1)
|
||||
upload(id2)
|
||||
clearHashes(id1)
|
||||
clearHashes(id2)
|
||||
|
||||
val file = dataFile(id1)
|
||||
SignalDatabase.attachments.markDataFileAsUnhashable(file)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
|
||||
val dataFileInfo = SignalDatabase.attachments.getDataFileInfo(id1)!!
|
||||
assertTrue(dataFileInfo.hashEnd!!.startsWith("UNHASHABLE-"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Various deletion scenarios to ensure that duped files don't deleted while there's still references.
|
||||
*/
|
||||
@Test
|
||||
fun deletions() {
|
||||
// Delete original then dupe
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertWithData(DATA_A)
|
||||
val dataFile = dataFile(id1)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
|
||||
delete(id1)
|
||||
|
||||
assertDeleted(id1)
|
||||
assertRowAndFileExists(id2)
|
||||
assertTrue(dataFile.exists())
|
||||
|
||||
delete(id2)
|
||||
|
||||
assertDeleted(id2)
|
||||
assertFalse(dataFile.exists())
|
||||
}
|
||||
|
||||
// Delete dupe then original
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertWithData(DATA_A)
|
||||
val dataFile = dataFile(id1)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
|
||||
delete(id2)
|
||||
assertDeleted(id2)
|
||||
assertRowAndFileExists(id1)
|
||||
assertTrue(dataFile.exists())
|
||||
|
||||
delete(id1)
|
||||
assertDeleted(id1)
|
||||
assertFalse(dataFile.exists())
|
||||
}
|
||||
|
||||
// Delete original after it was compressed
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
|
||||
delete(id1)
|
||||
|
||||
assertDeleted(id1)
|
||||
assertRowAndFileExists(id2)
|
||||
assertSkipTransform(id2, true)
|
||||
}
|
||||
|
||||
// Quotes are weak references and should not prevent us from deleting the file
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertQuote(id1)
|
||||
|
||||
val dataFile = dataFile(id1)
|
||||
|
||||
delete(id1)
|
||||
assertDeleted(id1)
|
||||
assertRowExists(id2)
|
||||
assertFalse(dataFile.exists())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quotes() {
|
||||
// Basic quote deduping
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertQuote(id1)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
}
|
||||
|
||||
// Making sure remote fields carry
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertQuote(id1)
|
||||
upload(id1)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashStartMatches(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
|
||||
// Making sure things work for quotes of videos, which have trickier transform properties
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A, transformProperties = TransformProperties.forVideoTrim(1, 2))
|
||||
compress(id1, DATA_A_COMPRESSED)
|
||||
upload(id1)
|
||||
|
||||
val id2 = insertQuote(id1)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertRemoteFieldsMatch(id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suite of tests around the migration where we hash all of the attachments and potentially dedupe them.
|
||||
*/
|
||||
@Test
|
||||
fun migration() {
|
||||
// Verifying that getUnhashedDataFile only returns if there's actually missing hashes
|
||||
test {
|
||||
val id = insertWithData(DATA_A)
|
||||
upload(id)
|
||||
assertNull(SignalDatabase.attachments.getUnhashedDataFile())
|
||||
}
|
||||
|
||||
// Verifying that getUnhashedDataFile finds the missing hash
|
||||
test {
|
||||
val id = insertWithData(DATA_A)
|
||||
upload(id)
|
||||
clearHashes(id)
|
||||
assertNotNull(SignalDatabase.attachments.getUnhashedDataFile())
|
||||
}
|
||||
|
||||
// Verifying that getUnhashedDataFile doesn't return if the file isn't done downloading
|
||||
test {
|
||||
val id = insertWithData(DATA_A)
|
||||
upload(id)
|
||||
setTransferState(id, AttachmentTable.TRANSFER_PROGRESS_PENDING)
|
||||
clearHashes(id)
|
||||
assertNull(SignalDatabase.attachments.getUnhashedDataFile())
|
||||
}
|
||||
|
||||
// If two attachments share the same file, when we backfill the hash, make sure both get their hashes set
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
val id2 = insertWithData(DATA_A)
|
||||
upload(id1)
|
||||
upload(id2)
|
||||
|
||||
clearHashes(id1)
|
||||
clearHashes(id2)
|
||||
|
||||
val file = dataFile(id1)
|
||||
SignalDatabase.attachments.setHashForDataFile(file, DATA_A_HASH)
|
||||
|
||||
assertDataHashEnd(id1, DATA_A_HASH)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
}
|
||||
|
||||
// Creates a situation where two different attachments have the same data but wrote to different files, and verifies the migration dedupes it
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
upload(id1)
|
||||
clearHashes(id1)
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
upload(id2)
|
||||
clearHashes(id2)
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
|
||||
val file1 = dataFile(id1)
|
||||
SignalDatabase.attachments.setHashForDataFile(file1, DATA_A_HASH)
|
||||
|
||||
assertDataHashEnd(id1, DATA_A_HASH)
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
|
||||
val file2 = dataFile(id2)
|
||||
SignalDatabase.attachments.setHashForDataFile(file2, DATA_A_HASH)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertFalse(file2.exists())
|
||||
}
|
||||
|
||||
// We've got three files now with the same data, with two of them sharing a file. We want to make sure *both* entries that share the same file get deduped.
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
upload(id1)
|
||||
clearHashes(id1)
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
val id3 = insertWithData(DATA_A)
|
||||
upload(id2)
|
||||
upload(id3)
|
||||
clearHashes(id2)
|
||||
clearHashes(id3)
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertDataFilesAreTheSame(id2, id3)
|
||||
|
||||
val file1 = dataFile(id1)
|
||||
SignalDatabase.attachments.setHashForDataFile(file1, DATA_A_HASH)
|
||||
assertDataHashEnd(id1, DATA_A_HASH)
|
||||
|
||||
val file2 = dataFile(id2)
|
||||
SignalDatabase.attachments.setHashForDataFile(file2, DATA_A_HASH)
|
||||
|
||||
assertDataFilesAreTheSame(id1, id2)
|
||||
assertDataHashEndMatches(id1, id2)
|
||||
assertDataHashEndMatches(id2, id3)
|
||||
assertFalse(file2.exists())
|
||||
}
|
||||
|
||||
// We don't want to mess with files that are still downloading, so this makes sure that even if data matches, we don't dedupe and don't delete the file
|
||||
test {
|
||||
val id1 = insertWithData(DATA_A)
|
||||
upload(id1)
|
||||
clearHashes(id1)
|
||||
|
||||
val id2 = insertWithData(DATA_A)
|
||||
// *not* uploaded
|
||||
clearHashes(id2)
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
|
||||
val file1 = dataFile(id1)
|
||||
SignalDatabase.attachments.setHashForDataFile(file1, DATA_A_HASH)
|
||||
assertDataHashEnd(id1, DATA_A_HASH)
|
||||
|
||||
val file2 = dataFile(id2)
|
||||
SignalDatabase.attachments.setHashForDataFile(file2, DATA_A_HASH)
|
||||
|
||||
assertDataFilesAreDifferent(id1, id2)
|
||||
assertTrue(file2.exists())
|
||||
}
|
||||
}
|
||||
|
||||
private class TestContext {
|
||||
fun insertWithData(data: ByteArray, transformProperties: TransformProperties = TransformProperties.empty()): AttachmentId {
|
||||
val uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
|
||||
|
||||
val attachment = UriAttachmentBuilder.build(
|
||||
id = Random.nextLong(),
|
||||
uri = uri,
|
||||
contentType = MediaUtil.IMAGE_JPEG,
|
||||
transformProperties = transformProperties
|
||||
)
|
||||
|
||||
return SignalDatabase.attachments.insertAttachmentForPreUpload(attachment).attachmentId
|
||||
}
|
||||
|
||||
fun insertQuote(attachmentId: AttachmentId): AttachmentId {
|
||||
val originalAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.self())
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(
|
||||
message = OutgoingMessage(
|
||||
threadRecipient = Recipient.self(),
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
body = "some text",
|
||||
outgoingQuote = QuoteModel(
|
||||
id = 123,
|
||||
author = Recipient.self().id,
|
||||
text = "Some quote text",
|
||||
isOriginalMissing = false,
|
||||
attachments = listOf(originalAttachment),
|
||||
mentions = emptyList(),
|
||||
type = QuoteModel.Type.NORMAL,
|
||||
bodyRanges = null
|
||||
)
|
||||
),
|
||||
threadId = threadId,
|
||||
forceSms = false,
|
||||
insertListener = null
|
||||
)
|
||||
|
||||
val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
|
||||
return attachments[0].attachmentId
|
||||
}
|
||||
|
||||
fun compress(attachmentId: AttachmentId, newData: ByteArray, mp4FastStart: Boolean = false) {
|
||||
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
SignalDatabase.attachments.updateAttachmentData(databaseAttachment, newData.asMediaStream())
|
||||
SignalDatabase.attachments.markAttachmentAsTransformed(attachmentId, withFastStart = mp4FastStart)
|
||||
}
|
||||
|
||||
fun upload(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()) {
|
||||
SignalDatabase.attachments.finalizeAttachmentAfterUpload(attachmentId, createPointerAttachment(attachmentId, uploadTimestamp), uploadTimestamp)
|
||||
}
|
||||
|
||||
fun delete(attachmentId: AttachmentId) {
|
||||
SignalDatabase.attachments.deleteAttachment(attachmentId)
|
||||
}
|
||||
|
||||
fun dataFile(attachmentId: AttachmentId): File {
|
||||
return SignalDatabase.attachments.getDataFileInfo(attachmentId)!!.file
|
||||
}
|
||||
|
||||
fun setTransferState(attachmentId: AttachmentId, transferState: Int) {
|
||||
// messageId doesn't actually matter -- that's for notifying listeners
|
||||
SignalDatabase.attachments.setTransferState(messageId = -1, attachmentId = attachmentId, transferState = transferState)
|
||||
}
|
||||
|
||||
fun clearHashes(id: AttachmentId) {
|
||||
SignalDatabase.attachments.writableDatabase
|
||||
.update(AttachmentTable.TABLE_NAME)
|
||||
.values(
|
||||
AttachmentTable.DATA_HASH_START to null,
|
||||
AttachmentTable.DATA_HASH_END to null
|
||||
)
|
||||
.where("${AttachmentTable.ID} = ?", id)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun assertDeleted(attachmentId: AttachmentId) {
|
||||
assertNull("$attachmentId exists, but it shouldn't!", SignalDatabase.attachments.getAttachment(attachmentId))
|
||||
}
|
||||
|
||||
fun assertRowAndFileExists(attachmentId: AttachmentId) {
|
||||
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)
|
||||
assertNotNull("$attachmentId does not exist!", databaseAttachment)
|
||||
|
||||
val dataFileInfo = SignalDatabase.attachments.getDataFileInfo(attachmentId)
|
||||
assertTrue("The file for $attachmentId does not exist!", dataFileInfo!!.file.exists())
|
||||
}
|
||||
|
||||
fun assertRowExists(attachmentId: AttachmentId) {
|
||||
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)
|
||||
assertNotNull("$attachmentId does not exist!", databaseAttachment)
|
||||
}
|
||||
|
||||
fun assertDataFilesAreTheSame(lhs: AttachmentId, rhs: AttachmentId) {
|
||||
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
|
||||
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
|
||||
|
||||
assert(lhsInfo.file.exists())
|
||||
assert(rhsInfo.file.exists())
|
||||
|
||||
assertEquals(lhsInfo.file, rhsInfo.file)
|
||||
assertEquals(lhsInfo.length, rhsInfo.length)
|
||||
assertArrayEquals(lhsInfo.random, rhsInfo.random)
|
||||
}
|
||||
|
||||
fun assertDataFilesAreDifferent(lhs: AttachmentId, rhs: AttachmentId) {
|
||||
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
|
||||
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
|
||||
|
||||
assert(lhsInfo.file.exists())
|
||||
assert(rhsInfo.file.exists())
|
||||
|
||||
assertNotEquals(lhsInfo.file, rhsInfo.file)
|
||||
}
|
||||
|
||||
fun assertDataHashStartMatches(lhs: AttachmentId, rhs: AttachmentId) {
|
||||
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
|
||||
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
|
||||
|
||||
assertNotNull(lhsInfo.hashStart)
|
||||
assertEquals("DATA_HASH_START's did not match!", lhsInfo.hashStart, rhsInfo.hashStart)
|
||||
}
|
||||
|
||||
fun assertDataHashEndMatches(lhs: AttachmentId, rhs: AttachmentId) {
|
||||
val lhsInfo = SignalDatabase.attachments.getDataFileInfo(lhs)!!
|
||||
val rhsInfo = SignalDatabase.attachments.getDataFileInfo(rhs)!!
|
||||
|
||||
assertNotNull(lhsInfo.hashEnd)
|
||||
assertEquals("DATA_HASH_END's did not match!", lhsInfo.hashEnd, rhsInfo.hashEnd)
|
||||
}
|
||||
|
||||
fun assertDataHashEnd(id: AttachmentId, byteArray: ByteArray) {
|
||||
val dataFileInfo = SignalDatabase.attachments.getDataFileInfo(id)!!
|
||||
assertArrayEquals(byteArray, Base64.decode(dataFileInfo.hashEnd!!))
|
||||
}
|
||||
|
||||
fun assertRemoteFieldsMatch(lhs: AttachmentId, rhs: AttachmentId) {
|
||||
val lhsAttachment = SignalDatabase.attachments.getAttachment(lhs)!!
|
||||
val rhsAttachment = SignalDatabase.attachments.getAttachment(rhs)!!
|
||||
|
||||
assertEquals(lhsAttachment.remoteLocation, rhsAttachment.remoteLocation)
|
||||
assertEquals(lhsAttachment.remoteKey, rhsAttachment.remoteKey)
|
||||
assertArrayEquals(lhsAttachment.remoteDigest, rhsAttachment.remoteDigest)
|
||||
assertArrayEquals(lhsAttachment.incrementalDigest, rhsAttachment.incrementalDigest)
|
||||
assertEquals(lhsAttachment.incrementalMacChunkSize, rhsAttachment.incrementalMacChunkSize)
|
||||
assertEquals(lhsAttachment.cdn.cdnNumber, rhsAttachment.cdn.cdnNumber)
|
||||
}
|
||||
|
||||
fun assertDoesNotHaveRemoteFields(attachmentId: AttachmentId) {
|
||||
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
assertEquals(0, databaseAttachment.uploadTimestamp)
|
||||
assertNull(databaseAttachment.remoteLocation)
|
||||
assertNull(databaseAttachment.remoteDigest)
|
||||
assertNull(databaseAttachment.remoteKey)
|
||||
assertEquals(0, databaseAttachment.cdn.cdnNumber)
|
||||
}
|
||||
|
||||
fun assertSkipTransform(attachmentId: AttachmentId, state: Boolean) {
|
||||
val transformProperties = SignalDatabase.attachments.getTransformProperties(attachmentId)!!
|
||||
assertEquals("Incorrect skipTransform!", transformProperties.skipTransform, state)
|
||||
}
|
||||
|
||||
private fun ByteArray.asMediaStream(): MediaStream {
|
||||
return MediaStream(this.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2)
|
||||
}
|
||||
|
||||
private fun createPointerAttachment(attachmentId: AttachmentId, uploadTimestamp: Long = System.currentTimeMillis()): PointerAttachment {
|
||||
val location = "somewhere-${Random.nextLong()}"
|
||||
val key = "somekey-${Random.nextLong()}"
|
||||
val digest = Random.nextBytes(32)
|
||||
val incrementalDigest = Random.nextBytes(16)
|
||||
|
||||
val databaseAttachment = SignalDatabase.attachments.getAttachment(attachmentId)!!
|
||||
|
||||
return PointerAttachment(
|
||||
"image/jpeg",
|
||||
AttachmentTable.TRANSFER_PROGRESS_DONE,
|
||||
databaseAttachment.size, // size
|
||||
null,
|
||||
Cdn.CDN_3, // cdnNumber
|
||||
location,
|
||||
key,
|
||||
digest,
|
||||
incrementalDigest,
|
||||
5, // incrementalMacChunkSize
|
||||
null,
|
||||
databaseAttachment.voiceNote,
|
||||
databaseAttachment.borderless,
|
||||
databaseAttachment.videoGif,
|
||||
databaseAttachment.width,
|
||||
databaseAttachment.height,
|
||||
uploadTimestamp,
|
||||
databaseAttachment.caption,
|
||||
databaseAttachment.stickerLocator,
|
||||
databaseAttachment.blurHash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun test(content: TestContext.() -> Unit) {
|
||||
SignalDatabase.attachments.deleteAllAttachments()
|
||||
val context = TestContext()
|
||||
context.content()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
@@ -75,21 +74,6 @@ class GroupTableTest {
|
||||
assertEquals(2, groups.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenGroups_whenIQueryGroupsByMembership_thenIExpectBothGroups() {
|
||||
insertPushGroup()
|
||||
insertMmsGroup(members = listOf(harness.others[1]))
|
||||
|
||||
val groups = groupTable.queryGroupsByMembership(
|
||||
setOf(harness.self.id, harness.others[1]),
|
||||
includeInactive = false,
|
||||
excludeV1 = false,
|
||||
excludeMms = false
|
||||
)
|
||||
|
||||
assertEquals(2, groups.cursor?.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenGroups_whenIGetGroups_thenIExpectBothGroups() {
|
||||
insertPushGroup()
|
||||
@@ -181,68 +165,6 @@ class GroupTableTest {
|
||||
assertFalse(actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroup_whenIUpdateMembers_thenIExpectUpdatedMembers() {
|
||||
val v2Group = insertPushGroup()
|
||||
groupTable.updateMembers(v2Group, listOf(harness.self.id, harness.others[1]))
|
||||
val groupRecord = groupTable.getGroup(v2Group)
|
||||
|
||||
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.get().members.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnMmsGroup_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val members: List<RecipientId> = listOf(harness.self.id, harness.others[0])
|
||||
val other = insertMmsGroup(members + listOf(harness.others[1]))
|
||||
val mmsGroup = insertMmsGroup(members)
|
||||
val actual = groupTable.getOrCreateMmsGroupForMembers(members.toSet())
|
||||
|
||||
assertNotEquals(other, actual)
|
||||
assertEquals(mmsGroup, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleMmsGroups_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1])
|
||||
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2])
|
||||
|
||||
val group1: GroupId = insertMmsGroup(group1Members)
|
||||
val group2: GroupId = insertMmsGroup(group2Members)
|
||||
|
||||
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.toSet())
|
||||
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.toSet())
|
||||
|
||||
assertEquals(group1, group1Result)
|
||||
assertEquals(group2, group2Result)
|
||||
assertNotEquals(group1Result, group2Result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleMmsGroupsWithDifferentMemberOrders_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1], harness.others[2]).shuffled()
|
||||
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2], harness.others[3]).shuffled()
|
||||
|
||||
val group1: GroupId = insertMmsGroup(group1Members)
|
||||
val group2: GroupId = insertMmsGroup(group2Members)
|
||||
|
||||
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.shuffled().toSet())
|
||||
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.shuffled().toSet())
|
||||
|
||||
assertEquals(group1, group1Result)
|
||||
assertEquals(group2, group2Result)
|
||||
assertNotEquals(group1Result, group2Result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMmsGroupWithOneMember_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
|
||||
val groupMembers: List<RecipientId> = listOf(harness.self.id)
|
||||
val group: GroupId = insertMmsGroup(groupMembers)
|
||||
|
||||
val groupResult: GroupId = groupTable.getOrCreateMmsGroupForMembers(groupMembers.toSet())
|
||||
|
||||
assertEquals(group, groupResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
|
||||
val g1 = insertPushGroup(listOf())
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIsSize
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NameCollisionTablesTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
private lateinit var alice: RecipientId
|
||||
private lateinit var bob: RecipientId
|
||||
private lateinit var charlie: RecipientId
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
alice = setUpRecipient(harness.others[0])
|
||||
bob = setUpRecipient(harness.others[1])
|
||||
charlie = setUpRecipient(harness.others[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAUserWithAThreadIdButNoConflicts_whenIGetCollisionsForThreadRecipient_thenIExpectNoCollisions() {
|
||||
val threadRecipientId = alice
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(threadRecipientId))
|
||||
val actual = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(threadRecipientId)
|
||||
|
||||
actual assertIsSize 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoUsers_whenOneChangesTheirProfileNameToMatchTheOther_thenIExpectANameCollision() {
|
||||
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
|
||||
|
||||
actualAlice assertIsSize 2
|
||||
actualBob assertIsSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoUsersWithANameCollisions_whenOneChangesToADifferentName_thenIExpectNoNameCollisions() {
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
|
||||
|
||||
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
|
||||
|
||||
actualAlice assertIsSize 0
|
||||
actualBob assertIsSize 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenThreeUsersWithANameCollisions_whenOneChangesToADifferentName_thenIExpectTwoNameCollisions() {
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(charlie, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
|
||||
|
||||
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
|
||||
val actualCharlie = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(charlie)
|
||||
|
||||
actualAlice assertIsSize 0
|
||||
actualBob assertIsSize 2
|
||||
actualCharlie assertIsSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoUsersWithADismissedNameCollision_whenOneChangesToADifferentNameAndBack_thenIExpectANameCollision() {
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
|
||||
|
||||
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
|
||||
actualAlice assertIsSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADismissedNameCollisionForAlice_whenIGetNameCollisionsForAlice_thenIExpectNoNameCollisions() {
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
|
||||
|
||||
val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
|
||||
actualCollisions assertIsSize 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADismissedNameCollisionForAliceThatIUpdate_whenIGetNameCollisionsForAlice_thenIExpectNoNameCollisions() {
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice))
|
||||
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
|
||||
|
||||
actualCollisions assertIsSize 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenADismissedNameCollisionForAlice_whenIGetNameCollisionsForBob_thenIExpectANameCollisionWithTwoEntries() {
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice))
|
||||
|
||||
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
|
||||
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
|
||||
|
||||
val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
|
||||
|
||||
actualCollisions assertIsSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupWithAliceAndBob_whenIInsertNameChangeMessageForAlice_thenIExpectAGroupNameCollision() {
|
||||
val alice = Recipient.resolved(alice)
|
||||
val bob = Recipient.resolved(bob)
|
||||
val info = createGroup()
|
||||
|
||||
setProfileName(alice.id, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob.id, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId))
|
||||
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android")
|
||||
|
||||
val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId)
|
||||
|
||||
collisions assertIsSize 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupWithAliceAndBobWithDismissedCollision_whenIInsertNameChangeMessageForAlice_thenIExpectAGroupNameCollision() {
|
||||
val alice = Recipient.resolved(alice)
|
||||
val bob = Recipient.resolved(bob)
|
||||
val info = createGroup()
|
||||
|
||||
setProfileName(alice.id, ProfileName.fromParts("Bob", "Android"))
|
||||
setProfileName(bob.id, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId))
|
||||
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android")
|
||||
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(info.recipientId)
|
||||
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android")
|
||||
|
||||
val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId)
|
||||
|
||||
collisions assertIsSize 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupWithAliceAndBob_whenIInsertNameChangeMessageForAliceWithMismatch_thenIExpectNoGroupNameCollision() {
|
||||
val alice = Recipient.resolved(alice)
|
||||
val bob = Recipient.resolved(bob)
|
||||
val info = createGroup()
|
||||
|
||||
setProfileName(alice.id, ProfileName.fromParts("Alice", "Android"))
|
||||
setProfileName(bob.id, ProfileName.fromParts("Bob", "Android"))
|
||||
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId))
|
||||
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Alice Android", "Bob Android")
|
||||
|
||||
val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId)
|
||||
|
||||
collisions assertIsSize 0
|
||||
}
|
||||
|
||||
private fun setUpRecipient(recipientId: RecipientId): RecipientId {
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, false)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipientId, false)
|
||||
|
||||
MmsHelper.insert(
|
||||
threadId = threadId,
|
||||
message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = recipientId,
|
||||
groupId = null,
|
||||
body = "hi",
|
||||
sentTimeMillis = 100L,
|
||||
receivedTimeMillis = 200L,
|
||||
serverTimeMillis = 100L,
|
||||
isUnidentified = true
|
||||
)
|
||||
)
|
||||
|
||||
return recipientId
|
||||
}
|
||||
|
||||
private fun setProfileName(recipientId: RecipientId, name: ProfileName) {
|
||||
SignalDatabase.recipients.setProfileName(recipientId, name)
|
||||
SignalDatabase.nameCollisions.handleIndividualNameCollision(recipientId)
|
||||
}
|
||||
|
||||
private fun createGroup(): GroupTestingUtils.TestGroupInfo {
|
||||
return GroupTestingUtils.insertGroup(
|
||||
revision = 0,
|
||||
DecryptedMember(
|
||||
aciBytes = harness.self.requireAci().toByteString(),
|
||||
role = Member.Role.ADMINISTRATOR
|
||||
),
|
||||
DecryptedMember(
|
||||
aciBytes = Recipient.resolved(alice).requireAci().toByteString(),
|
||||
role = Member.Role.ADMINISTRATOR
|
||||
),
|
||||
DecryptedMember(
|
||||
aciBytes = Recipient.resolved(bob).requireAci().toByteString(),
|
||||
role = Member.Role.ADMINISTRATOR
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class MessageContentProcessor__recipientStatusTest {
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
|
||||
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0])),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId = groupId),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ class MessageContentProcessor__recipientStatusTest {
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
|
||||
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0], harness.others[1]), recipientUpdate = true),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId = groupId),
|
||||
serverDeliveredTimestamp = MessageContentFuzzer.fuzzServerDeliveredTimestamp(envelopeTimestamp)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.jobs.ThreadUpdateJob
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncMessageProcessorTest_readSyncs {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
private lateinit var alice: RecipientId
|
||||
private lateinit var bob: RecipientId
|
||||
private lateinit var group: GroupTestingUtils.TestGroupInfo
|
||||
private lateinit var processor: MessageContentProcessor
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
alice = harness.others[0]
|
||||
bob = harness.others[1]
|
||||
group = harness.group!!
|
||||
|
||||
processor = MessageContentProcessor(harness.context)
|
||||
|
||||
val threadIdSlot = slot<Long>()
|
||||
mockkStatic(ThreadUpdateJob::class)
|
||||
every { ThreadUpdateJob.enqueue(capture(threadIdSlot)) } answers {
|
||||
SignalDatabase.threads.update(threadIdSlot.captured, false)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(ThreadUpdateJob::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSynchronizeReadMessage() {
|
||||
val messageHelper = MessageHelper()
|
||||
|
||||
val message1Timestamp = messageHelper.incomingText().timestamp
|
||||
val message2Timestamp = messageHelper.incomingText().timestamp
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(alice)!!
|
||||
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 2
|
||||
|
||||
messageHelper.syncReadMessage(alice to message1Timestamp, alice to message2Timestamp)
|
||||
|
||||
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSynchronizeReadMessageMissingTimestamp() {
|
||||
val messageHelper = MessageHelper()
|
||||
|
||||
messageHelper.incomingText().timestamp
|
||||
val message2Timestamp = messageHelper.incomingText().timestamp
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(alice)!!
|
||||
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 2
|
||||
|
||||
messageHelper.syncReadMessage(alice to message2Timestamp)
|
||||
|
||||
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSynchronizeReadWithEdits() {
|
||||
val messageHelper = MessageHelper()
|
||||
|
||||
val message1Timestamp = messageHelper.incomingText().timestamp
|
||||
messageHelper.syncReadMessage(alice to message1Timestamp)
|
||||
|
||||
val editMessage1Timestamp1 = messageHelper.incomingEditText(message1Timestamp).timestamp
|
||||
val editMessage1Timestamp2 = messageHelper.incomingEditText(editMessage1Timestamp1).timestamp
|
||||
|
||||
val message2Timestamp = messageHelper.incomingMedia().timestamp
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(alice)!!
|
||||
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 2
|
||||
|
||||
messageHelper.syncReadMessage(alice to message2Timestamp, alice to editMessage1Timestamp1, alice to editMessage1Timestamp2)
|
||||
|
||||
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 0
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSynchronizeReadWithEditsInGroup() {
|
||||
val messageHelper = MessageHelper()
|
||||
|
||||
val message1Timestamp = messageHelper.incomingText(sender = alice, destination = group.recipientId).timestamp
|
||||
|
||||
messageHelper.syncReadMessage(alice to message1Timestamp)
|
||||
|
||||
val editMessage1Timestamp1 = messageHelper.incomingEditText(targetTimestamp = message1Timestamp, sender = alice, destination = group.recipientId).timestamp
|
||||
val editMessage1Timestamp2 = messageHelper.incomingEditText(targetTimestamp = editMessage1Timestamp1, sender = alice, destination = group.recipientId).timestamp
|
||||
|
||||
val message2Timestamp = messageHelper.incomingMedia(sender = bob, destination = group.recipientId).timestamp
|
||||
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId)!!
|
||||
var threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 2
|
||||
|
||||
messageHelper.syncReadMessage(bob to message2Timestamp, alice to editMessage1Timestamp1, alice to editMessage1Timestamp2)
|
||||
|
||||
threadRecord = SignalDatabase.threads.getThreadRecord(threadId)!!
|
||||
threadRecord.unreadCount assertIs 0
|
||||
}
|
||||
|
||||
private inner class MessageHelper(var startTime: Long = System.currentTimeMillis()) {
|
||||
|
||||
fun incomingText(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData {
|
||||
startTime += 1000
|
||||
|
||||
val messageData = MessageData(timestamp = startTime)
|
||||
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
|
||||
content = MessageContentFuzzer.fuzzTextMessage(
|
||||
sentTimestamp = messageData.timestamp,
|
||||
groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null
|
||||
),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(
|
||||
source = sender,
|
||||
destination = harness.self.id,
|
||||
groupId = if (destination == group.recipientId) group.groupId else null
|
||||
),
|
||||
serverDeliveredTimestamp = messageData.timestamp + 10
|
||||
)
|
||||
|
||||
return messageData
|
||||
}
|
||||
|
||||
fun incomingMedia(sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData {
|
||||
startTime += 1000
|
||||
|
||||
val messageData = MessageData(timestamp = startTime)
|
||||
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
|
||||
content = MessageContentFuzzer.fuzzStickerMediaMessage(
|
||||
sentTimestamp = messageData.timestamp,
|
||||
groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null
|
||||
),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(
|
||||
source = sender,
|
||||
destination = harness.self.id,
|
||||
groupId = if (destination == group.recipientId) group.groupId else null
|
||||
),
|
||||
serverDeliveredTimestamp = messageData.timestamp + 10
|
||||
)
|
||||
|
||||
return messageData
|
||||
}
|
||||
|
||||
fun incomingEditText(targetTimestamp: Long = System.currentTimeMillis(), sender: RecipientId = alice, destination: RecipientId = harness.self.id): MessageData {
|
||||
startTime += 1000
|
||||
|
||||
val messageData = MessageData(timestamp = startTime)
|
||||
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
|
||||
content = MessageContentFuzzer.editTextMessage(
|
||||
targetTimestamp = targetTimestamp,
|
||||
editedDataMessage = MessageContentFuzzer.fuzzTextMessage(
|
||||
sentTimestamp = messageData.timestamp,
|
||||
groupContextV2 = if (destination == group.recipientId) group.groupV2Context else null
|
||||
).dataMessage!!
|
||||
),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(
|
||||
source = sender,
|
||||
destination = harness.self.id,
|
||||
groupId = if (destination == group.recipientId) group.groupId else null
|
||||
),
|
||||
serverDeliveredTimestamp = messageData.timestamp + 10
|
||||
)
|
||||
|
||||
return messageData
|
||||
}
|
||||
|
||||
fun syncReadMessage(vararg reads: Pair<RecipientId, Long>): MessageData {
|
||||
startTime += 1000
|
||||
val messageData = MessageData(timestamp = startTime)
|
||||
|
||||
processor.process(
|
||||
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
|
||||
content = MessageContentFuzzer.syncReadsMessage(reads.toList()),
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2),
|
||||
serverDeliveredTimestamp = messageData.timestamp + 10
|
||||
)
|
||||
|
||||
return messageData
|
||||
}
|
||||
}
|
||||
|
||||
private data class MessageData(val serverGuid: UUID = UUID.randomUUID(), val timestamp: Long)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
@@ -9,6 +10,7 @@ import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@@ -46,5 +48,8 @@ object GroupTestingUtils {
|
||||
return member(aci = requireAci())
|
||||
}
|
||||
|
||||
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId)
|
||||
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId) {
|
||||
val groupV2Context: GroupContextV2
|
||||
get() = GroupContextV2(masterKey = masterKey.serialize().toByteString(), revision = 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.whispersystems.signalservice.internal.push.AttachmentPointer
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.EditMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
@@ -33,22 +34,22 @@ object MessageContentFuzzer {
|
||||
/**
|
||||
* Create an [Envelope].
|
||||
*/
|
||||
fun envelope(timestamp: Long): Envelope {
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
|
||||
return Envelope.Builder()
|
||||
.timestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuid(UUID.randomUUID().toString())
|
||||
.serverGuid(serverGuid.toString())
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create metadata to match an [Envelope].
|
||||
*/
|
||||
fun envelopeMetadata(source: RecipientId, destination: RecipientId, groupId: GroupId.V2? = null): EnvelopeMetadata {
|
||||
fun envelopeMetadata(source: RecipientId, destination: RecipientId, sourceDeviceId: Int = 1, groupId: GroupId.V2? = null): EnvelopeMetadata {
|
||||
return EnvelopeMetadata(
|
||||
sourceServiceId = Recipient.resolved(source).requireServiceId(),
|
||||
sourceE164 = null,
|
||||
sourceDeviceId = 1,
|
||||
sourceDeviceId = sourceDeviceId,
|
||||
sealedSender = true,
|
||||
groupId = groupId?.decodedId,
|
||||
destinationServiceId = Recipient.resolved(destination).requireServiceId()
|
||||
@@ -60,10 +61,11 @@ object MessageContentFuzzer {
|
||||
* - An expire timer value
|
||||
* - Bold style body ranges
|
||||
*/
|
||||
fun fuzzTextMessage(groupContextV2: GroupContextV2? = null): Content {
|
||||
fun fuzzTextMessage(sentTimestamp: Long? = null, groupContextV2: GroupContextV2? = null): Content {
|
||||
return Content.Builder()
|
||||
.dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
timestamp = sentTimestamp
|
||||
body = string()
|
||||
if (random.nextBoolean()) {
|
||||
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
|
||||
@@ -87,6 +89,20 @@ object MessageContentFuzzer {
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an edit message.
|
||||
*/
|
||||
fun editTextMessage(targetTimestamp: Long, editedDataMessage: DataMessage): Content {
|
||||
return Content.Builder()
|
||||
.editMessage(
|
||||
EditMessage.Builder().buildWith {
|
||||
targetSentTimestamp = targetTimestamp
|
||||
dataMessage = editedDataMessage
|
||||
}
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sync sent text message for the given [DataMessage].
|
||||
*/
|
||||
@@ -116,6 +132,24 @@ object MessageContentFuzzer {
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sync reads message for the given [RecipientId] and message timestamp pairings.
|
||||
*/
|
||||
fun syncReadsMessage(timestamps: List<Pair<RecipientId, Long>>): Content {
|
||||
return Content
|
||||
.Builder()
|
||||
.syncMessage(
|
||||
SyncMessage.Builder().buildWith {
|
||||
read = timestamps.map { (senderId, timestamp) ->
|
||||
SyncMessage.Read.Builder().buildWith {
|
||||
this.senderAci = Recipient.resolved(senderId).requireAci().toString()
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random media message that may be:
|
||||
* - A text body
|
||||
@@ -184,22 +218,21 @@ object MessageContentFuzzer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a random media message that can never contain a text body. It may be:
|
||||
* - A sticker
|
||||
* Create a random media message that contains a sticker.
|
||||
*/
|
||||
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
|
||||
fun fuzzStickerMediaMessage(sentTimestamp: Long? = null, groupContextV2: GroupContextV2? = null): Content {
|
||||
return Content.Builder()
|
||||
.dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
if (random.nextFloat() < 0.9) {
|
||||
sticker = DataMessage.Sticker.Builder().buildWith {
|
||||
packId = byteString(length = 24)
|
||||
packKey = byteString(length = 128)
|
||||
stickerId = random.nextInt()
|
||||
data_ = attachmentPointer()
|
||||
emoji = emojis.random(random)
|
||||
}
|
||||
timestamp = sentTimestamp
|
||||
sticker = DataMessage.Sticker.Builder().buildWith {
|
||||
packId = byteString(length = 24)
|
||||
packKey = byteString(length = 128)
|
||||
stickerId = random.nextInt()
|
||||
data_ = attachmentPointer()
|
||||
emoji = emojis.random(random)
|
||||
}
|
||||
groupV2 = groupContextV2
|
||||
}
|
||||
).build()
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
@@ -9,7 +10,6 @@ import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
@@ -144,7 +144,7 @@ object TestMessages {
|
||||
}
|
||||
private fun imageAttachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
ReleaseChannel.CDN_NUMBER,
|
||||
Cdn.S3.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
"image/webp",
|
||||
null,
|
||||
@@ -167,7 +167,7 @@ object TestMessages {
|
||||
|
||||
private fun voiceAttachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
ReleaseChannel.CDN_NUMBER,
|
||||
Cdn.S3.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
"audio/aac",
|
||||
null,
|
||||
|
||||
@@ -100,7 +100,7 @@ object TestUsers {
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()
|
||||
|
||||
@@ -67,7 +67,8 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
hasWallpaper = springboardViewModel.hasWallpaper.value,
|
||||
colorizer = Colorizer(),
|
||||
startExpirationTimeout = {},
|
||||
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) }
|
||||
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) },
|
||||
displayDialogFragment = {}
|
||||
)
|
||||
|
||||
if (springboardViewModel.hasWallpaper.value) {
|
||||
@@ -299,6 +300,10 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onItemDoubleClick(item: MultiselectPart) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onShowSafetyTips(forGroup: Boolean) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:usesCleartextTraffic"
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
||||
<uses-permission android:name="org.thoughtcrime.securesms.ACCESS_SECRETS"/>
|
||||
<uses-permission android:name="android.permission.READ_PROFILE"/>
|
||||
<uses-permission android:name="android.permission.BROADCAST_WAP_PUSH"
|
||||
tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
@@ -45,16 +43,10 @@
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_STATE"/>
|
||||
|
||||
<!-- For sending/receiving events -->
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
|
||||
|
||||
<!-- Normal -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
@@ -71,17 +63,14 @@
|
||||
<uses-permission android:name="android.permission.INSTALL_SHORTCUT"/>
|
||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||
|
||||
<!-- For fixing MMS -->
|
||||
<!-- For device transfer -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||
|
||||
<!-- Set image as wallpaper -->
|
||||
<uses-permission android:name="android.permission.SET_WALLPAPER"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BROADCAST_STICKY" />
|
||||
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
|
||||
<uses-permission android:name="android.permission.RAISED_THREAD_PRIORITY" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
@@ -174,10 +163,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".preferences.MmsPreferencesActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false" />
|
||||
|
||||
<activity android:name=".sharing.interstitial.ShareInterstitialActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -719,6 +704,7 @@
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:exported="false"/>
|
||||
|
||||
@@ -763,7 +749,7 @@
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".backup.v2.ui.MessageBackupsFlowActivity"
|
||||
android:name=".backup.v2.ui.subscription.MessageBackupsFlowActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
@@ -851,6 +837,20 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".registration.v2.ui.RegistrationV2Activity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".restore.RestoreActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".revealable.ViewOnceMessageActivity"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/TextSecure.FullScreenMedia"
|
||||
@@ -940,12 +940,12 @@
|
||||
</activity>
|
||||
|
||||
<activity android:name="org.thoughtcrime.securesms.webrtc.VoiceCallShare"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:permission="android.permission.CALL_PHONE"
|
||||
android:theme="@style/NoAnimation.Theme.BlackScreen"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:permission="android.permission.CALL_PHONE"
|
||||
android:theme="@style/NoAnimation.Theme.BlackScreen"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -976,11 +976,20 @@
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".backup.v2.ui.subscription.MessageBackupsTestRestoreActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".profiles.manage.EditProfileActivity"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".nicknames.NicknameActivity"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".payments.preferences.PaymentsActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
@@ -1150,19 +1159,6 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service android:name=".service.QuickResponseService"
|
||||
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
|
||||
android:exported="true" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.RESPOND_VIA_MESSAGE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="sms" />
|
||||
<data android:scheme="smsto" />
|
||||
<data android:scheme="mms" />
|
||||
<data android:scheme="mmsto" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service android:name=".service.AccountAuthenticatorService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
@@ -1186,6 +1182,10 @@
|
||||
android:name=".service.AttachmentProgressService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".service.BackupProgressService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".gcm.FcmFetchBackgroundService"
|
||||
android:exported="false"/>
|
||||
@@ -1200,13 +1200,6 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name=".service.SmsDeliveryListener"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="org.thoughtcrime.securesms.services.MESSAGE_SENT"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".notifications.MarkReadReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
@@ -1266,11 +1259,6 @@
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
|
||||
<provider android:name=".providers.MmsBodyProvider"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false"
|
||||
android:authorities="${applicationId}.mms" />
|
||||
|
||||
<provider android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
@@ -1318,6 +1306,12 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.AnalyzeDatabaseAlarmListener" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name="org.thoughtcrime.securesms.jobs.ForegroundServiceUtil$Receiver" android:exported="false" />
|
||||
|
||||
<receiver android:name=".service.PersistentConnectionBootListener" android:exported="false">
|
||||
@@ -1382,7 +1376,11 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService" android:exported="false" />
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="camera|microphone" />
|
||||
|
||||
<receiver android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallServiceReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallAction.DENY"/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||
@@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
@@ -82,12 +84,14 @@ import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil;
|
||||
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
|
||||
import org.thoughtcrime.securesms.service.AnalyzeDatabaseAlarmListener;
|
||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.service.MessageBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
@@ -198,7 +202,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> ApplicationDependencies.getDeletedCallEventManager().scheduleIfNecessary())
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
.addPostRender(this::initializeTrimThreadsByDateManager)
|
||||
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
@@ -216,6 +219,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
|
||||
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupRingCleanupJob::enqueue)
|
||||
.addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary)
|
||||
.addPostRender(() -> ActiveCallManager.clearNotifications(this))
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -274,7 +279,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
public void checkBuildExpiration() {
|
||||
if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
|
||||
Log.w(TAG, "Build expired!");
|
||||
SignalStore.misc().markClientDeprecated();
|
||||
SignalStore.misc().setClientDeprecated(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +421,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
RotateSignedPreKeyListener.schedule(this);
|
||||
DirectoryRefreshListener.schedule(this);
|
||||
LocalBackupListener.schedule(this);
|
||||
MessageBackupListener.schedule(this);
|
||||
RotateSenderCertificateListener.schedule(this);
|
||||
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
|
||||
AnalyzeDatabaseAlarmListener.schedule(this);
|
||||
|
||||
if (BuildConfig.MANAGES_APP_UPDATES) {
|
||||
ApkUpdateRefreshListener.schedule(this);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@@ -58,6 +59,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
|
||||
void setEventListener(@Nullable EventListener listener);
|
||||
|
||||
default void setGestureDetector(@Nullable GestureDetector gestureDetector) {
|
||||
// Intentionally Blank.
|
||||
}
|
||||
|
||||
default void setParentScrolling(boolean isParentScrolling) {
|
||||
// Intentionally Blank.
|
||||
}
|
||||
@@ -126,5 +131,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onShowSafetyTips(boolean forGroup);
|
||||
void onReportSpamLearnMoreClicked();
|
||||
void onMessageRequestAcceptOptionsClicked();
|
||||
void onItemDoubleClick(MultiselectPart multiselectPart);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +73,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
@Override
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
boolean includeSms = Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
|
||||
int displayMode = includeSms ? ContactSelectionDisplayMode.FLAG_ALL : ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
|
||||
int displayMode = ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.qr.kitkat.ScanListener;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.signal.core.util.Base64;
|
||||
@@ -231,6 +232,8 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
||||
protected void onPostExecute(Integer result) {
|
||||
super.onPostExecute(result);
|
||||
|
||||
LinkedDeviceInactiveCheckJob.enqueue();
|
||||
|
||||
Context context = DeviceActivity.this;
|
||||
|
||||
switch (result) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.devicelist.Device;
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
@@ -166,6 +167,7 @@ public class DeviceListFragment extends ListFragment
|
||||
super.onPostExecute(result);
|
||||
if (result) {
|
||||
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
|
||||
LinkedDeviceInactiveCheckJob.enqueue();
|
||||
} else {
|
||||
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
@@ -119,14 +119,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
smsSendButton.setOnClickListener(new SmsSendClickListener());
|
||||
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
|
||||
|
||||
if (Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().isSmsSupported()) {
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
smsButton.setOnClickListener(new SmsClickListener());
|
||||
} else {
|
||||
smsButton.setVisibility(View.GONE);
|
||||
shareText.setText(R.string.InviteActivity_share);
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
}
|
||||
smsButton.setVisibility(View.GONE);
|
||||
shareText.setText(R.string.InviteActivity_share);
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
}
|
||||
|
||||
private Animation loadAnimation(@AnimRes int animResId) {
|
||||
@@ -200,13 +195,6 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
}
|
||||
|
||||
private class SmsClickListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
ViewUtil.animateIn(smsSendFrame, slideInAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
private class SmsCancelClickListener implements OnClickListener {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
|
||||
@@ -152,7 +152,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device)
|
||||
.setPositiveButton(R.string.OldDeviceTransferLockedDialog__done, (d, w) -> OldDeviceExitActivity.exit(this))
|
||||
.setNegativeButton(R.string.OldDeviceTransferLockedDialog__cancel_and_activate_this_device, (d, w) -> {
|
||||
SignalStore.misc().clearOldDeviceTransferLocked();
|
||||
SignalStore.misc().setOldDeviceTransferLocked(false);
|
||||
DeviceTransferBlockingInterceptor.getInstance().unblockNetwork();
|
||||
})
|
||||
.setCancelable(false)
|
||||
|
||||
@@ -121,8 +121,6 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(boolean isFromUnknownSearchKey, @NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
boolean smsSupported = SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
|
||||
|
||||
if (recipientId.isPresent()) {
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
@@ -138,7 +136,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
|
||||
if (result instanceof RecipientRepository.LookupResult.Success) {
|
||||
Recipient resolved = Recipient.resolved(((RecipientRepository.LookupResult.Success) result).getRecipientId());
|
||||
if (smsSupported || resolved.isRegistered() && resolved.hasServiceId()) {
|
||||
if (resolved.isRegistered() && resolved.getHasServiceId()) {
|
||||
launch(resolved);
|
||||
}
|
||||
} else if (result instanceof RecipientRepository.LookupResult.NotFound || result instanceof RecipientRepository.LookupResult.InvalidEntry) {
|
||||
@@ -153,8 +151,6 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
.show();
|
||||
}
|
||||
});
|
||||
} else if (smsSupported) {
|
||||
launch(Recipient.external(this, number));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +300,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recipient.isRegistered() || (SignalStore.misc().getSmsExportPhase().allowSmsFeatures())) {
|
||||
if (recipient.isRegistered()) {
|
||||
return new ActionItem(
|
||||
R.drawable.ic_phone_right_24,
|
||||
getString(R.string.NewConversationActivity__audio_call),
|
||||
@@ -338,13 +334,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
|
||||
getString(R.string.NewConversationActivity__remove),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> {
|
||||
if (recipient.isSystemContact()) {
|
||||
displayIsInSystemContactsDialog(recipient);
|
||||
} else {
|
||||
displayRemovalDialog(recipient);
|
||||
}
|
||||
}
|
||||
() -> displayRemovalDialog(recipient)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
||||
import org.thoughtcrime.securesms.keyvalue.InternalValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
|
||||
@@ -28,8 +29,11 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
|
||||
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2Activity;
|
||||
import org.thoughtcrime.securesms.restore.RestoreActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -51,6 +55,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
private static final int STATE_TRANSFER_ONGOING = 8;
|
||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||
private static final int STATE_RESTORE_BACKUP = 11;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -125,8 +130,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private void routeApplicationState(boolean locked) {
|
||||
Intent intent = getIntentForState(getApplicationState(locked));
|
||||
final int applicationState = getApplicationState(locked);
|
||||
Intent intent = getIntentForState(applicationState);
|
||||
if (intent != null) {
|
||||
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
@@ -146,6 +153,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
|
||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
case STATE_RESTORE_BACKUP: return getRestoreIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -159,6 +167,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_UI_BLOCKING_UPGRADE;
|
||||
} else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
|
||||
return STATE_WELCOME_PUSH_SCREEN;
|
||||
} else if (SignalStore.internalValues().enterRestoreV2Flow()) {
|
||||
return STATE_RESTORE_BACKUP;
|
||||
} else if (SignalStore.storageService().needsAccountRestore()) {
|
||||
return STATE_ENTER_SIGNAL_PIN;
|
||||
} else if (userHasSkippedOrForgottenPin()) {
|
||||
@@ -208,7 +218,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getPushRegistrationIntent() {
|
||||
return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
if (FeatureFlags.registrationV2()) {
|
||||
return RegistrationV2Activity.newIntentForNewRegistration(this, getIntent());
|
||||
} else {
|
||||
return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
|
||||
}
|
||||
}
|
||||
|
||||
private Intent getEnterSignalPinIntent() {
|
||||
@@ -227,6 +241,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return getRoutedIntent(CreateSvrPinActivity.class, intent);
|
||||
}
|
||||
|
||||
private Intent getRestoreIntent() {
|
||||
Intent intent = RestoreActivity.getIntentForRestore(this);
|
||||
return getRoutedIntent(intent, getIntent());
|
||||
}
|
||||
|
||||
private Intent getCreateProfileNameIntent() {
|
||||
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
|
||||
return getRoutedIntent(intent, getIntent());
|
||||
|
||||
@@ -35,6 +35,7 @@ import android.view.WindowManager;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.LiveDataReactiveStreams;
|
||||
@@ -61,6 +62,7 @@ import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
|
||||
@@ -137,6 +139,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
|
||||
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
|
||||
public static final String EXTRA_STARTED_FROM_CALL_LINK = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
|
||||
public static final String EXTRA_LAUNCH_IN_PIP = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
|
||||
|
||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
|
||||
@@ -159,6 +162,9 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
private long lastCallLinkDisconnectDialogShowTime;
|
||||
private ControlsAndInfoController controlsAndInfo;
|
||||
private boolean enterPipOnResume;
|
||||
private long lastProcessedIntentTimestamp;
|
||||
private WebRtcViewModel previousEvent = null;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
@@ -219,6 +225,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
processIntent(getIntent());
|
||||
|
||||
registerSystemPipChangeListeners();
|
||||
|
||||
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
|
||||
|
||||
windowInfoTrackerCallbackAdapter = new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
|
||||
@@ -231,6 +239,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface));
|
||||
}
|
||||
|
||||
private void registerSystemPipChangeListeners() {
|
||||
addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfo -> {
|
||||
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
|
||||
CallReactionScrubber.dismissCustomEmojiBottomSheet(getSupportFragmentManager());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
@@ -264,6 +279,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}, TimeUnit.SECONDS.toMillis(1));
|
||||
}
|
||||
|
||||
if (enterPipOnResume) {
|
||||
enterPipOnResume = false;
|
||||
enterPipModeIfPossible();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -299,10 +319,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
requestNewSizesThrottle.clear();
|
||||
}
|
||||
|
||||
ApplicationDependencies.getSignalCallManager().setEnableVideo(false);
|
||||
|
||||
if (!viewModel.isCallStarting()) {
|
||||
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
|
||||
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
|
||||
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
|
||||
if (state != null) {
|
||||
if (state.getCallState().isPreJoinOrNetworkUnavailable()) {
|
||||
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
|
||||
} else if (state.getCallState().getInOngoingCall() && isInPipMode()) {
|
||||
ApplicationDependencies.getSignalCallManager().relaunchPipOnForeground();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,8 +368,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
return false;
|
||||
}
|
||||
|
||||
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
|
||||
|
||||
return true;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
@@ -361,6 +385,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
Log.d(TAG, "Intent: Action: " + intent.getAction());
|
||||
Log.d(TAG, "Intent: EXTRA_STARTED_FROM_FULLSCREEN: " + intent.getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false));
|
||||
Log.d(TAG, "Intent: EXTRA_ENABLE_VIDEO_IF_AVAILABLE: " + intent.getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false));
|
||||
Log.d(TAG, "Intent: EXTRA_LAUNCH_IN_PIP: " + intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false));
|
||||
}
|
||||
|
||||
private void processIntent(@NonNull Intent intent) {
|
||||
@@ -373,6 +398,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
} else if (END_CALL_ACTION.equals(intent.getAction())) {
|
||||
handleEndCall();
|
||||
}
|
||||
|
||||
if (System.currentTimeMillis() - lastProcessedIntentTimestamp > TimeUnit.SECONDS.toMillis(1)) {
|
||||
enterPipOnResume = intent.getBooleanExtra(EXTRA_LAUNCH_IN_PIP, false);
|
||||
}
|
||||
|
||||
lastProcessedIntentTimestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
private void initializePendingParticipantFragmentListener() {
|
||||
@@ -851,8 +882,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
private boolean isSystemPipEnabledAndAvailable() {
|
||||
return Build.VERSION.SDK_INT >= 26 &&
|
||||
getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
|
||||
return Build.VERSION.SDK_INT >= 26 && getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
|
||||
}
|
||||
|
||||
private void delayedFinish() {
|
||||
@@ -865,7 +895,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventMainThread(@NonNull WebRtcViewModel event) {
|
||||
Log.i(TAG, "Got message from service: " + event);
|
||||
Log.i(TAG, "Got message from service: " + event.describeDifference(previousEvent));
|
||||
previousEvent = event;
|
||||
|
||||
viewModel.setRecipient(event.getRecipient());
|
||||
callScreen.setRecipient(event.getRecipient());
|
||||
|
||||
@@ -54,6 +54,7 @@ class SignalBackupAgent : BackupAgent() {
|
||||
items.find { dataInput.key == it.getKey() }?.restoreData(buffer)
|
||||
}
|
||||
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(cumulativeHashCode()) }
|
||||
Log.i(TAG, "Android Backup Service complete.")
|
||||
}
|
||||
|
||||
private fun cumulativeHashCode(): Int {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import org.signal.core.util.Base64
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
|
||||
class ArchivedAttachment : Attachment {
|
||||
|
||||
@JvmField
|
||||
val archiveCdn: Int
|
||||
|
||||
@JvmField
|
||||
val archiveMediaName: String
|
||||
|
||||
@JvmField
|
||||
val archiveMediaId: String
|
||||
|
||||
constructor(
|
||||
contentType: String?,
|
||||
size: Long,
|
||||
cdn: Int,
|
||||
key: ByteArray,
|
||||
cdnKey: String?,
|
||||
archiveCdn: Int?,
|
||||
archiveMediaName: String,
|
||||
archiveMediaId: String,
|
||||
digest: ByteArray,
|
||||
incrementalMac: ByteArray?,
|
||||
incrementalMacChunkSize: Int?,
|
||||
width: Int?,
|
||||
height: Int?,
|
||||
caption: String?,
|
||||
blurHash: String?,
|
||||
voiceNote: Boolean,
|
||||
borderless: Boolean,
|
||||
gif: Boolean,
|
||||
quote: Boolean
|
||||
) : super(
|
||||
contentType = contentType ?: "",
|
||||
quote = quote,
|
||||
transferState = AttachmentTable.TRANSFER_NEEDS_RESTORE,
|
||||
size = size,
|
||||
fileName = null,
|
||||
cdn = Cdn.fromCdnNumber(cdn),
|
||||
remoteLocation = cdnKey,
|
||||
remoteKey = Base64.encodeWithoutPadding(key),
|
||||
remoteDigest = digest,
|
||||
incrementalDigest = incrementalMac,
|
||||
fastPreflightId = null,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
videoGif = gif,
|
||||
width = width ?: 0,
|
||||
height = height ?: 0,
|
||||
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
|
||||
uploadTimestamp = 0,
|
||||
caption = caption,
|
||||
stickerLocator = null,
|
||||
blurHash = BlurHash.parseOrNull(blurHash),
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
) {
|
||||
this.archiveCdn = archiveCdn ?: Cdn.CDN_3.cdnNumber
|
||||
this.archiveMediaName = archiveMediaName
|
||||
this.archiveMediaId = archiveMediaId
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel) {
|
||||
archiveCdn = parcel.readInt()
|
||||
archiveMediaName = parcel.readString()!!
|
||||
archiveMediaId = parcel.readString()!!
|
||||
}
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
super.writeToParcel(dest, flags)
|
||||
dest.writeInt(archiveCdn)
|
||||
dest.writeString(archiveMediaName)
|
||||
dest.writeString(archiveMediaId)
|
||||
}
|
||||
|
||||
override val uri: Uri? = null
|
||||
override val publicUri: Uri? = null
|
||||
}
|
||||
@@ -29,7 +29,7 @@ abstract class Attachment(
|
||||
@JvmField
|
||||
val fileName: String?,
|
||||
@JvmField
|
||||
val cdnNumber: Int,
|
||||
val cdn: Cdn,
|
||||
@JvmField
|
||||
val remoteLocation: String?,
|
||||
@JvmField
|
||||
@@ -76,7 +76,7 @@ abstract class Attachment(
|
||||
transferState = parcel.readInt(),
|
||||
size = parcel.readLong(),
|
||||
fileName = parcel.readString(),
|
||||
cdnNumber = parcel.readInt(),
|
||||
cdn = Cdn.deserialize(parcel.readInt()),
|
||||
remoteLocation = parcel.readString(),
|
||||
remoteKey = parcel.readString(),
|
||||
remoteDigest = ParcelUtil.readByteArray(parcel),
|
||||
@@ -103,7 +103,7 @@ abstract class Attachment(
|
||||
dest.writeInt(transferState)
|
||||
dest.writeLong(size)
|
||||
dest.writeString(fileName)
|
||||
dest.writeInt(cdnNumber)
|
||||
dest.writeInt(cdn.serialize())
|
||||
dest.writeString(remoteLocation)
|
||||
dest.writeString(remoteKey)
|
||||
ParcelUtil.writeByteArray(dest, remoteDigest)
|
||||
|
||||
@@ -17,7 +17,8 @@ object AttachmentCreator : Parcelable.Creator<Attachment> {
|
||||
DATABASE(DatabaseAttachment::class.java, "database"),
|
||||
POINTER(PointerAttachment::class.java, "pointer"),
|
||||
TOMBSTONE(TombstoneAttachment::class.java, "tombstone"),
|
||||
URI(UriAttachment::class.java, "uri")
|
||||
URI(UriAttachment::class.java, "uri"),
|
||||
ARCHIVED(ArchivedAttachment::class.java, "archived")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@@ -34,6 +35,7 @@ object AttachmentCreator : Parcelable.Creator<Attachment> {
|
||||
Subclass.POINTER -> PointerAttachment(source)
|
||||
Subclass.TOMBSTONE -> TombstoneAttachment(source)
|
||||
Subclass.URI -> UriAttachment(source)
|
||||
Subclass.ARCHIVED -> ArchivedAttachment(source)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ package org.thoughtcrime.securesms.attachments
|
||||
import android.os.Parcelable
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.DatabaseId
|
||||
|
||||
@Parcelize
|
||||
data class AttachmentId(
|
||||
@JsonProperty("rowId")
|
||||
@JvmField
|
||||
val id: Long
|
||||
) : Parcelable {
|
||||
) : Parcelable, DatabaseId {
|
||||
|
||||
val isValid: Boolean
|
||||
get() = id >= 0
|
||||
@@ -17,4 +18,8 @@ data class AttachmentId(
|
||||
override fun toString(): String {
|
||||
return "AttachmentId::$id"
|
||||
}
|
||||
|
||||
override fun serialize(): String {
|
||||
return id.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Build
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHashEncoder
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import java.io.IOException
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* A place collect common attachment upload operations to allow for code reuse.
|
||||
*/
|
||||
object AttachmentUploadUtil {
|
||||
|
||||
private val TAG = Log.tag(AttachmentUploadUtil::class.java)
|
||||
|
||||
/**
|
||||
* Builds a [SignalServiceAttachmentStream] from the provided data, which can then be provided to various upload methods.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun buildSignalServiceAttachmentStream(
|
||||
context: Context,
|
||||
attachment: Attachment,
|
||||
uploadSpec: ResumableUpload,
|
||||
cancellationSignal: (() -> Boolean)? = null,
|
||||
progressListener: ProgressListener? = null
|
||||
): SignalServiceAttachmentStream {
|
||||
val inputStream = PartAuthority.getAttachmentStream(context, attachment.uri!!)
|
||||
val builder = SignalServiceAttachment.newStreamBuilder()
|
||||
.withStream(inputStream)
|
||||
.withContentType(attachment.contentType)
|
||||
.withLength(attachment.size)
|
||||
.withFileName(attachment.fileName)
|
||||
.withVoiceNote(attachment.voiceNote)
|
||||
.withBorderless(attachment.borderless)
|
||||
.withGif(attachment.videoGif)
|
||||
.withFaststart(attachment.transformProperties?.mp4FastStart ?: false)
|
||||
.withWidth(attachment.width)
|
||||
.withHeight(attachment.height)
|
||||
.withUploadTimestamp(System.currentTimeMillis())
|
||||
.withCaption(attachment.caption)
|
||||
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
|
||||
.withCancelationSignal(cancellationSignal)
|
||||
.withListener(progressListener)
|
||||
|
||||
if (MediaUtil.isImageType(attachment.contentType)) {
|
||||
builder.withBlurHash(getImageBlurHash(context, attachment))
|
||||
} else if (MediaUtil.isVideoType(attachment.contentType)) {
|
||||
builder.withBlurHash(getVideoBlurHash(context, attachment))
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun getImageBlurHash(context: Context, attachment: Attachment): String? {
|
||||
if (attachment.blurHash != null) {
|
||||
return attachment.blurHash!!.hash
|
||||
}
|
||||
|
||||
if (attachment.uri == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return PartAuthority.getAttachmentStream(context, attachment.uri!!).use { inputStream ->
|
||||
BlurHashEncoder.encode(inputStream)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun getVideoBlurHash(context: Context, attachment: Attachment): String? {
|
||||
if (attachment.blurHash != null) {
|
||||
return attachment.blurHash.hash
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 23) {
|
||||
Log.w(TAG, "Video thumbnails not supported...")
|
||||
return null
|
||||
}
|
||||
|
||||
return MediaUtil.getVideoThumbnail(context, Objects.requireNonNull(attachment.uri), 1000)?.let { bitmap ->
|
||||
val thumb = Bitmap.createScaledBitmap(bitmap, 100, 100, false)
|
||||
bitmap.recycle()
|
||||
|
||||
Log.i(TAG, "Generated video thumbnail...")
|
||||
val hash = BlurHashEncoder.encode(thumb)
|
||||
thumb.recycle()
|
||||
|
||||
hash
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import org.signal.core.util.IntSerializer
|
||||
|
||||
/**
|
||||
* Attachments/media can come from and go to multiple CDN locations depending on when and where
|
||||
* they were uploaded. This class represents the CDNs where attachments/media can live.
|
||||
*/
|
||||
enum class Cdn(private val value: Int) {
|
||||
S3(-1),
|
||||
CDN_0(0),
|
||||
CDN_2(2),
|
||||
CDN_3(3);
|
||||
|
||||
val cdnNumber: Int
|
||||
get() {
|
||||
return when (this) {
|
||||
S3 -> -1
|
||||
CDN_0 -> 0
|
||||
CDN_2 -> 2
|
||||
CDN_3 -> 3
|
||||
}
|
||||
}
|
||||
|
||||
fun serialize(): Int {
|
||||
return Serializer.serialize(this)
|
||||
}
|
||||
|
||||
companion object Serializer : IntSerializer<Cdn> {
|
||||
override fun serialize(data: Cdn): Int {
|
||||
return data.value
|
||||
}
|
||||
|
||||
override fun deserialize(data: Int): Cdn {
|
||||
return values().first { it.value == data }
|
||||
}
|
||||
|
||||
fun fromCdnNumber(cdnNumber: Int): Cdn {
|
||||
return when (cdnNumber) {
|
||||
-1 -> S3
|
||||
0 -> CDN_0
|
||||
2 -> CDN_2
|
||||
3 -> CDN_3
|
||||
else -> throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,15 @@ class DatabaseAttachment : Attachment {
|
||||
@JvmField
|
||||
val dataHash: String?
|
||||
|
||||
@JvmField
|
||||
val archiveCdn: Int
|
||||
|
||||
@JvmField
|
||||
val archiveMediaName: String?
|
||||
|
||||
@JvmField
|
||||
val archiveMediaId: String?
|
||||
|
||||
private val hasThumbnail: Boolean
|
||||
val displayOrder: Int
|
||||
|
||||
@@ -37,7 +46,7 @@ class DatabaseAttachment : Attachment {
|
||||
transferProgress: Int,
|
||||
size: Long,
|
||||
fileName: String?,
|
||||
cdnNumber: Int,
|
||||
cdn: Cdn,
|
||||
location: String?,
|
||||
key: String?,
|
||||
digest: ByteArray?,
|
||||
@@ -57,13 +66,16 @@ class DatabaseAttachment : Attachment {
|
||||
transformProperties: TransformProperties?,
|
||||
displayOrder: Int,
|
||||
uploadTimestamp: Long,
|
||||
dataHash: String?
|
||||
dataHash: String?,
|
||||
archiveCdn: Int,
|
||||
archiveMediaName: String?,
|
||||
archiveMediaId: String?
|
||||
) : super(
|
||||
contentType = contentType!!,
|
||||
transferState = transferProgress,
|
||||
size = size,
|
||||
fileName = fileName,
|
||||
cdnNumber = cdnNumber,
|
||||
cdn = cdn,
|
||||
remoteLocation = location,
|
||||
remoteKey = key,
|
||||
remoteDigest = digest,
|
||||
@@ -88,6 +100,9 @@ class DatabaseAttachment : Attachment {
|
||||
this.dataHash = dataHash
|
||||
this.hasThumbnail = hasThumbnail
|
||||
this.displayOrder = displayOrder
|
||||
this.archiveCdn = archiveCdn
|
||||
this.archiveMediaName = archiveMediaName
|
||||
this.archiveMediaId = archiveMediaId
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel) {
|
||||
@@ -97,6 +112,9 @@ class DatabaseAttachment : Attachment {
|
||||
hasThumbnail = ParcelUtil.readBoolean(parcel)
|
||||
mmsId = parcel.readLong()
|
||||
displayOrder = parcel.readInt()
|
||||
archiveCdn = parcel.readInt()
|
||||
archiveMediaName = parcel.readString()
|
||||
archiveMediaId = parcel.readString()
|
||||
}
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
@@ -107,6 +125,9 @@ class DatabaseAttachment : Attachment {
|
||||
ParcelUtil.writeBoolean(dest, hasThumbnail)
|
||||
dest.writeLong(mmsId)
|
||||
dest.writeInt(displayOrder)
|
||||
dest.writeInt(archiveCdn)
|
||||
dest.writeString(archiveMediaName)
|
||||
dest.writeString(archiveMediaId)
|
||||
}
|
||||
|
||||
override val uri: Uri?
|
||||
|
||||
@@ -2,24 +2,25 @@ package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.signal.core.util.Base64.encodeWithPadding
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
|
||||
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.util.Optional
|
||||
|
||||
class PointerAttachment : Attachment {
|
||||
private constructor(
|
||||
@VisibleForTesting
|
||||
constructor(
|
||||
contentType: String,
|
||||
transferState: Int,
|
||||
size: Long,
|
||||
fileName: String?,
|
||||
cdnNumber: Int,
|
||||
cdn: Cdn,
|
||||
location: String,
|
||||
key: String?,
|
||||
digest: ByteArray?,
|
||||
@@ -40,7 +41,7 @@ class PointerAttachment : Attachment {
|
||||
transferState = transferState,
|
||||
size = size,
|
||||
fileName = fileName,
|
||||
cdnNumber = cdnNumber,
|
||||
cdn = cdn,
|
||||
remoteLocation = location,
|
||||
remoteKey = key,
|
||||
remoteDigest = digest,
|
||||
@@ -81,7 +82,7 @@ class PointerAttachment : Attachment {
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun forPointer(pointer: Optional<SignalServiceAttachment>, stickerLocator: StickerLocator? = null, fastPreflightId: String? = null): Optional<Attachment> {
|
||||
fun forPointer(pointer: Optional<SignalServiceAttachment>, stickerLocator: StickerLocator? = null, fastPreflightId: String? = null, transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING): Optional<Attachment> {
|
||||
if (!pointer.isPresent || !pointer.get().isPointer) {
|
||||
return Optional.empty()
|
||||
}
|
||||
@@ -95,10 +96,10 @@ class PointerAttachment : Attachment {
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
contentType = pointer.get().contentType,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
transferState = transferState,
|
||||
size = pointer.get().asPointer().size.orElse(0).toLong(),
|
||||
fileName = pointer.get().asPointer().fileName.orElse(null),
|
||||
cdnNumber = pointer.get().asPointer().cdnNumber,
|
||||
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
|
||||
location = pointer.get().asPointer().remoteId.toString(),
|
||||
key = encodedKey,
|
||||
digest = pointer.get().asPointer().digest.orElse(null),
|
||||
@@ -118,35 +119,6 @@ class PointerAttachment : Attachment {
|
||||
)
|
||||
}
|
||||
|
||||
fun forPointer(pointer: SignalServiceDataMessage.Quote.QuotedAttachment): Optional<Attachment> {
|
||||
val thumbnail = pointer.thumbnail
|
||||
|
||||
return Optional.of(
|
||||
PointerAttachment(
|
||||
contentType = pointer.contentType,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
|
||||
fileName = pointer.fileName,
|
||||
cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0,
|
||||
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
|
||||
key = if (thumbnail != null && thumbnail.asPointer().key != null) encodeWithPadding(thumbnail.asPointer().key) else null,
|
||||
digest = thumbnail?.asPointer()?.digest?.orElse(null),
|
||||
incrementalDigest = thumbnail?.asPointer()?.incrementalDigest?.orElse(null),
|
||||
incrementalMacChunkSize = thumbnail?.asPointer()?.incrementalMacChunkSize ?: 0,
|
||||
fastPreflightId = null,
|
||||
voiceNote = false,
|
||||
borderless = false,
|
||||
videoGif = false,
|
||||
width = thumbnail?.asPointer()?.width ?: 0,
|
||||
height = thumbnail?.asPointer()?.height ?: 0,
|
||||
uploadTimestamp = thumbnail?.asPointer()?.uploadTimestamp ?: 0,
|
||||
caption = thumbnail?.asPointer()?.caption?.orElse(null),
|
||||
stickerLocator = null,
|
||||
blurHash = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun forPointer(quotedAttachment: DataMessage.Quote.QuotedAttachment): Optional<Attachment> {
|
||||
val thumbnail: SignalServiceAttachment? = try {
|
||||
if (quotedAttachment.thumbnail != null) {
|
||||
@@ -164,7 +136,7 @@ class PointerAttachment : Attachment {
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
|
||||
fileName = quotedAttachment.fileName,
|
||||
cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0,
|
||||
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
|
||||
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
|
||||
key = if (thumbnail != null && thumbnail.asPointer().key != null) encodeWithPadding(thumbnail.asPointer().key) else null,
|
||||
digest = thumbnail?.asPointer()?.digest?.orElse(null),
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
|
||||
/**
|
||||
@@ -17,7 +18,7 @@ class TombstoneAttachment : Attachment {
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
|
||||
size = 0,
|
||||
fileName = null,
|
||||
cdnNumber = 0,
|
||||
cdn = Cdn.CDN_0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteDigest = null,
|
||||
@@ -37,6 +38,44 @@ class TombstoneAttachment : Attachment {
|
||||
transformProperties = null
|
||||
)
|
||||
|
||||
constructor(
|
||||
contentType: String?,
|
||||
incrementalMac: ByteArray?,
|
||||
incrementalMacChunkSize: Int?,
|
||||
width: Int?,
|
||||
height: Int?,
|
||||
caption: String?,
|
||||
blurHash: String?,
|
||||
voiceNote: Boolean = false,
|
||||
borderless: Boolean = false,
|
||||
gif: Boolean = false,
|
||||
quote: Boolean
|
||||
) : super(
|
||||
contentType = contentType ?: "",
|
||||
quote = quote,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE,
|
||||
size = 0,
|
||||
fileName = null,
|
||||
cdn = Cdn.CDN_0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteDigest = null,
|
||||
incrementalDigest = incrementalMac,
|
||||
fastPreflightId = null,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
videoGif = gif,
|
||||
width = width ?: 0,
|
||||
height = height ?: 0,
|
||||
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
|
||||
uploadTimestamp = 0,
|
||||
caption = caption,
|
||||
stickerLocator = null,
|
||||
blurHash = BlurHash.parseOrNull(blurHash),
|
||||
audioHash = null,
|
||||
transformProperties = null
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : super(parcel)
|
||||
|
||||
override val uri: Uri? = null
|
||||
|
||||
@@ -69,7 +69,7 @@ class UriAttachment : Attachment {
|
||||
transferState = transferState,
|
||||
size = size,
|
||||
fileName = fileName,
|
||||
cdnNumber = 0,
|
||||
cdn = Cdn.CDN_0,
|
||||
remoteLocation = null,
|
||||
remoteKey = null,
|
||||
remoteDigest = null,
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable;
|
||||
import org.thoughtcrime.securesms.database.PendingRetryReceiptTable;
|
||||
import org.thoughtcrime.securesms.database.ReactionTable;
|
||||
import org.thoughtcrime.securesms.database.RemappedRecordTables;
|
||||
import org.thoughtcrime.securesms.database.SearchTable;
|
||||
import org.thoughtcrime.securesms.database.SenderKeyTable;
|
||||
import org.thoughtcrime.securesms.database.SenderKeySharedTable;
|
||||
@@ -92,7 +93,9 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
SenderKeyTable.TABLE_NAME,
|
||||
SenderKeySharedTable.TABLE_NAME,
|
||||
PendingRetryReceiptTable.TABLE_NAME,
|
||||
AvatarPickerDatabase.TABLE_NAME
|
||||
AvatarPickerDatabase.TABLE_NAME,
|
||||
RemappedRecordTables.Recipients.TABLE_NAME,
|
||||
RemappedRecordTables.Threads.TABLE_NAME
|
||||
);
|
||||
|
||||
public static BackupEvent export(@NonNull Context context,
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -194,7 +195,7 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
|
||||
throws IOException
|
||||
{
|
||||
File dataFile = AttachmentTable.newFile(context);
|
||||
File dataFile = AttachmentTable.newDataFile(context);
|
||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
||||
boolean isLegacyTable = SqlUtil.tableExists(db, "part");
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import org.signal.core.util.LongSerializer
|
||||
|
||||
enum class RestoreState(val id: Int, val inProgress: Boolean) {
|
||||
FAILED(-1, false),
|
||||
NONE(0, false),
|
||||
PENDING(1, true),
|
||||
RESTORING_DB(2, true),
|
||||
RESTORING_MEDIA(3, true);
|
||||
|
||||
companion object {
|
||||
val serializer: LongSerializer<RestoreState> = Serializer()
|
||||
}
|
||||
|
||||
class Serializer : LongSerializer<RestoreState> {
|
||||
override fun serialize(data: RestoreState): Long {
|
||||
return data.id.toLong()
|
||||
}
|
||||
|
||||
override fun deserialize(data: Long): RestoreState {
|
||||
return when (data.toInt()) {
|
||||
FAILED.id -> FAILED
|
||||
PENDING.id -> PENDING
|
||||
RESTORING_DB.id -> RESTORING_DB
|
||||
RESTORING_MEDIA.id -> RESTORING_MEDIA
|
||||
else -> NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.signal.core.util.LongSerializer
|
||||
|
||||
/**
|
||||
* Describes how often a users messages are backed up.
|
||||
*/
|
||||
enum class BackupFrequency(val id: Int) {
|
||||
DAILY(0),
|
||||
WEEKLY(1),
|
||||
MONTHLY(2),
|
||||
MANUAL(-1);
|
||||
|
||||
companion object Serializer : LongSerializer<BackupFrequency> {
|
||||
override fun serialize(data: BackupFrequency): Long {
|
||||
return data.id.toLong()
|
||||
}
|
||||
|
||||
override fun deserialize(data: Long): BackupFrequency {
|
||||
return when (data.toInt()) {
|
||||
MANUAL.id -> MANUAL
|
||||
DAILY.id -> DAILY
|
||||
WEEKLY.id -> WEEKLY
|
||||
MONTHLY.id -> MONTHLY
|
||||
else -> MANUAL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -14,11 +14,14 @@ import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult
|
||||
import org.signal.libsignal.messagebackup.MessageBackupKey
|
||||
import org.signal.libsignal.protocol.ServiceId.Aci
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.CallLogBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor
|
||||
@@ -35,19 +38,25 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.StatusCodeErrorAction
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object BackupRepository {
|
||||
@@ -55,10 +64,15 @@ object BackupRepository {
|
||||
private val TAG = Log.tag(BackupRepository::class.java)
|
||||
private const val VERSION = 1L
|
||||
|
||||
fun export(plaintext: Boolean = false): ByteArray {
|
||||
val eventTimer = EventTimer()
|
||||
private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error ->
|
||||
if (error.code == 401) {
|
||||
Log.i(TAG, "Resetting initialized state due to 401.")
|
||||
SignalStore.backup().backupsInitialized = false
|
||||
}
|
||||
}
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
fun export(outputStream: OutputStream, append: (ByteArray) -> Unit, plaintext: Boolean = false) {
|
||||
val eventTimer = EventTimer()
|
||||
val writer: BackupExportWriter = if (plaintext) {
|
||||
PlainTextBackupWriter(outputStream)
|
||||
} else {
|
||||
@@ -66,17 +80,17 @@ object BackupRepository {
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
aci = SignalStore.account().aci!!,
|
||||
outputStream = outputStream,
|
||||
append = { mac -> outputStream.write(mac) }
|
||||
append = append
|
||||
)
|
||||
}
|
||||
|
||||
val exportState = ExportState()
|
||||
val exportState = ExportState(backupTime = System.currentTimeMillis(), allowMediaBackup = true)
|
||||
|
||||
writer.use {
|
||||
writer.write(
|
||||
BackupInfo(
|
||||
version = VERSION,
|
||||
backupTimeMs = System.currentTimeMillis()
|
||||
backupTimeMs = exportState.backupTime
|
||||
)
|
||||
)
|
||||
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
|
||||
@@ -97,7 +111,7 @@ object BackupRepository {
|
||||
eventTimer.emit("thread")
|
||||
}
|
||||
|
||||
CallLogBackupProcessor.export { frame ->
|
||||
AdHocCallBackupProcessor.export { frame ->
|
||||
writer.write(frame)
|
||||
eventTimer.emit("call")
|
||||
}
|
||||
@@ -110,7 +124,11 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
Log.d(TAG, "export() ${eventTimer.stop().summary}")
|
||||
}
|
||||
|
||||
fun export(plaintext: Boolean = false): ByteArray {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
export(outputStream = outputStream, append = { mac -> outputStream.write(mac) }, plaintext = plaintext)
|
||||
return outputStream.toByteArray()
|
||||
}
|
||||
|
||||
@@ -118,17 +136,19 @@ object BackupRepository {
|
||||
val masterKey = SignalStore.svr().getOrCreateMasterKey()
|
||||
val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray()))
|
||||
|
||||
return MessageBackup.validate(key, inputStreamFactory, length)
|
||||
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, inputStreamFactory, length)
|
||||
}
|
||||
|
||||
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
|
||||
val eventTimer = EventTimer()
|
||||
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val frameReader = if (plaintext) {
|
||||
PlainTextBackupReader(inputStreamFactory())
|
||||
} else {
|
||||
EncryptedBackupReader(
|
||||
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
|
||||
key = backupKey,
|
||||
aci = selfData.aci,
|
||||
streamLength = length,
|
||||
dataStream = inputStreamFactory
|
||||
@@ -160,7 +180,7 @@ object BackupRepository {
|
||||
SignalDatabase.recipients.setProfileSharing(selfId, true)
|
||||
|
||||
eventTimer.emit("setup")
|
||||
val backupState = BackupState()
|
||||
val backupState = BackupState(backupKey)
|
||||
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
|
||||
|
||||
for (frame in frameReader) {
|
||||
@@ -180,8 +200,8 @@ object BackupRepository {
|
||||
eventTimer.emit("chat")
|
||||
}
|
||||
|
||||
frame.call != null -> {
|
||||
CallLogBackupProcessor.import(frame.call, backupState)
|
||||
frame.adHocCall != null -> {
|
||||
AdHocCallBackupProcessor.import(frame.adHocCall, backupState)
|
||||
eventTimer.emit("call")
|
||||
}
|
||||
|
||||
@@ -215,6 +235,27 @@ object BackupRepository {
|
||||
Log.d(TAG, "import() ${eventTimer.stop().summary}")
|
||||
}
|
||||
|
||||
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getArchiveMediaItemsPage(backupKey, credential, limit, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
fun getRemoteBackupUsedSpace(): NetworkResult<Long?> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, credential)
|
||||
.map { it.usedSpace }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with details about the remote backup state.
|
||||
*/
|
||||
@@ -222,14 +263,7 @@ object BackupRepository {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential ->
|
||||
api.setPublicKey(backupKey, credential)
|
||||
.also { Log.i(TAG, "PublicKeyResult: $it") }
|
||||
.map { credential }
|
||||
}
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, credential)
|
||||
.map { it to credential }
|
||||
@@ -256,14 +290,7 @@ object BackupRepository {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential ->
|
||||
api.setPublicKey(backupKey, credential)
|
||||
.also { Log.i(TAG, "PublicKeyResult: $it") }
|
||||
.map { credential }
|
||||
}
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getMessageBackupUploadForm(backupKey, credential)
|
||||
.also { Log.i(TAG, "UploadFormResult: $it") }
|
||||
@@ -281,6 +308,22 @@ object BackupRepository {
|
||||
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success
|
||||
}
|
||||
|
||||
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): Boolean {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, credential)
|
||||
}
|
||||
.then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
|
||||
.map { pair ->
|
||||
val (cdnCredentials, info) = pair
|
||||
val messageReceiver = ApplicationDependencies.getSignalServiceMessageReceiver()
|
||||
messageReceiver.retrieveBackup(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}", destination, listener)
|
||||
} is NetworkResult.Success
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with details about the remote backup state.
|
||||
*/
|
||||
@@ -288,60 +331,122 @@ object BackupRepository {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.debugGetUploadedMediaItemMetadata(backupKey, credential)
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
/**
|
||||
* Retrieves an upload spec that can be used to upload attachment media.
|
||||
*/
|
||||
fun getMediaUploadSpec(): NetworkResult<ResumableUploadSpec> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.archiveAttachmentMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
item = attachment.toArchiveMediaRequest(backupKey)
|
||||
)
|
||||
api.getMediaUploadForm(backupKey, credential)
|
||||
}
|
||||
.then { form ->
|
||||
api.getResumableUploadSpec(form)
|
||||
}
|
||||
.also { Log.i(TAG, "backupMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun archiveMedia(attachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResponse> {
|
||||
fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.archiveAttachmentMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
items = attachments.map { it.toArchiveMediaRequest(backupKey) }
|
||||
item = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
|
||||
)
|
||||
}
|
||||
.also { Log.i(TAG, "backupMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<Unit> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
val mediaName = attachment.getMediaName()
|
||||
val request = attachment.toArchiveMediaRequest(mediaName, backupKey)
|
||||
api
|
||||
.archiveAttachmentMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
item = request
|
||||
)
|
||||
.map { Triple(mediaName, request.mediaId, it) }
|
||||
}
|
||||
.map { (mediaName, mediaId, response) ->
|
||||
SignalDatabase.attachments.setArchiveData(attachmentId = attachment.attachmentId, archiveCdn = response.cdn, archiveMediaName = mediaName.name, archiveMediaId = mediaId)
|
||||
}
|
||||
.also { Log.i(TAG, "archiveMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun archiveMedia(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
val requests = mutableListOf<ArchiveMediaRequest>()
|
||||
val mediaIdToAttachmentId = mutableMapOf<String, AttachmentId>()
|
||||
val attachmentIdToMediaName = mutableMapOf<AttachmentId, String>()
|
||||
|
||||
databaseAttachments.forEach {
|
||||
val mediaName = it.getMediaName()
|
||||
val request = it.toArchiveMediaRequest(mediaName, backupKey)
|
||||
requests += request
|
||||
mediaIdToAttachmentId[request.mediaId] = it.attachmentId
|
||||
attachmentIdToMediaName[it.attachmentId] = mediaName.name
|
||||
}
|
||||
|
||||
api
|
||||
.archiveAttachmentMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
items = requests
|
||||
)
|
||||
.map { BatchArchiveMediaResult(it, mediaIdToAttachmentId, attachmentIdToMediaName) }
|
||||
}
|
||||
.map { result ->
|
||||
result
|
||||
.successfulResponses
|
||||
.forEach {
|
||||
val attachmentId = result.mediaIdToAttachmentId(it.mediaId)
|
||||
val mediaName = result.attachmentIdToMediaName(attachmentId)
|
||||
SignalDatabase.attachments.setArchiveData(attachmentId = attachmentId, archiveCdn = it.cdn!!, archiveMediaName = mediaName, archiveMediaId = it.mediaId)
|
||||
}
|
||||
result
|
||||
}
|
||||
.also { Log.i(TAG, "archiveMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val mediaToDelete = attachments.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = 3, // TODO [cody] store and reuse backup cdn returned from copy/move call
|
||||
mediaId = backupKey.deriveMediaId(Base64.decode(it.dataHash!!)).toString()
|
||||
)
|
||||
val mediaToDelete = attachments
|
||||
.filter { it.archiveMediaId != null }
|
||||
.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = it.archiveCdn,
|
||||
mediaId = it.archiveMediaId!!
|
||||
)
|
||||
}
|
||||
|
||||
if (mediaToDelete.isEmpty()) {
|
||||
Log.i(TAG, "No media to delete, quick success")
|
||||
return NetworkResult.Success(Unit)
|
||||
}
|
||||
|
||||
return getAuthCredential()
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.deleteArchivedMedia(
|
||||
backupKey = backupKey,
|
||||
@@ -349,7 +454,155 @@ object BackupRepository {
|
||||
mediaToDelete = mediaToDelete
|
||||
)
|
||||
}
|
||||
.also { Log.i(TAG, "deleteBackupMediaResult: $it") }
|
||||
.map {
|
||||
SignalDatabase.attachments.clearArchiveData(attachments.map { it.attachmentId })
|
||||
}
|
||||
.also { Log.i(TAG, "deleteArchivedMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun deleteAbandonedMediaObjects(mediaObjects: Collection<ArchivedMediaObject>): NetworkResult<Unit> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val mediaToDelete = mediaObjects
|
||||
.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = it.cdn,
|
||||
mediaId = it.mediaId
|
||||
)
|
||||
}
|
||||
|
||||
if (mediaToDelete.isEmpty()) {
|
||||
Log.i(TAG, "No media to delete, quick success")
|
||||
return NetworkResult.Success(Unit)
|
||||
}
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.deleteArchivedMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
mediaToDelete = mediaToDelete
|
||||
)
|
||||
}
|
||||
.also { Log.i(TAG, "deleteAbandonedMediaObjectsResult: $it") }
|
||||
}
|
||||
|
||||
fun debugDeleteAllArchivedMedia(): NetworkResult<Unit> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return debugGetArchivedMediaState()
|
||||
.then { archivedMedia ->
|
||||
val mediaToDelete = archivedMedia
|
||||
.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = it.cdn,
|
||||
mediaId = it.mediaId
|
||||
)
|
||||
}
|
||||
|
||||
if (mediaToDelete.isEmpty()) {
|
||||
Log.i(TAG, "No media to delete, quick success")
|
||||
NetworkResult.Success(Unit)
|
||||
} else {
|
||||
getAuthCredential()
|
||||
.then { credential ->
|
||||
api.deleteArchivedMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
mediaToDelete = mediaToDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.map {
|
||||
SignalDatabase.attachments.clearAllArchiveData()
|
||||
}
|
||||
.also { Log.i(TAG, "debugDeleteAllArchivedMediaResult: $it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve credentials for reading from the backup cdn.
|
||||
*/
|
||||
fun getCdnReadCredentials(cdnNumber: Int): NetworkResult<GetArchiveCdnCredentialsResponse> {
|
||||
val cached = SignalStore.backup().cdnReadCredentials
|
||||
if (cached != null) {
|
||||
return NetworkResult.Success(cached)
|
||||
}
|
||||
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getCdnReadCredentials(
|
||||
cdnNumber = cdnNumber,
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential
|
||||
)
|
||||
}
|
||||
.also {
|
||||
if (it is NetworkResult.Success) {
|
||||
SignalStore.backup().cdnReadCredentials = it.result
|
||||
}
|
||||
}
|
||||
.also { Log.i(TAG, "getCdnReadCredentialsResult: $it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves backupDir and mediaDir, preferring cached value if available.
|
||||
*
|
||||
* These will only ever change if the backup expires.
|
||||
*/
|
||||
fun getCdnBackupDirectories(): NetworkResult<BackupDirectories> {
|
||||
val cachedBackupDirectory = SignalStore.backup().cachedBackupDirectory
|
||||
val cachedBackupMediaDirectory = SignalStore.backup().cachedBackupMediaDirectory
|
||||
|
||||
if (cachedBackupDirectory != null && cachedBackupMediaDirectory != null) {
|
||||
return NetworkResult.Success(
|
||||
BackupDirectories(
|
||||
backupDir = cachedBackupDirectory,
|
||||
mediaDir = cachedBackupMediaDirectory
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return initBackupAndFetchAuth(backupKey)
|
||||
.then { credential ->
|
||||
api.getBackupInfo(backupKey, credential).map {
|
||||
SignalStore.backup().usedBackupMediaSpace = it.usedSpace ?: 0L
|
||||
BackupDirectories(it.backupDir!!, it.mediaDir!!)
|
||||
}
|
||||
}
|
||||
.also {
|
||||
if (it is NetworkResult.Success) {
|
||||
SignalStore.backup().cachedBackupDirectory = it.result.backupDir
|
||||
SignalStore.backup().cachedBackupMediaDirectory = it.result.mediaDir
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the backupId has been reserved and that your public key has been set, while also returning an auth credential.
|
||||
* Should be the basis of all backup operations.
|
||||
*/
|
||||
private fun initBackupAndFetchAuth(backupKey: BackupKey): NetworkResult<ArchiveServiceCredential> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
|
||||
return if (SignalStore.backup().backupsInitialized) {
|
||||
getAuthCredential().runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
} else {
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential -> api.setPublicKey(backupKey, credential).map { credential } }
|
||||
.runIfSuccessful { SignalStore.backup().backupsInitialized = true }
|
||||
.runOnStatusCodeError(resetInitializedStateErrorAction)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,15 +633,24 @@ object BackupRepository {
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
|
||||
private fun DatabaseAttachment.toArchiveMediaRequest(backupKey: BackupKey): ArchiveMediaRequest {
|
||||
val mediaSecrets = backupKey.deriveMediaSecrets(Base64.decode(dataHash!!))
|
||||
fun DatabaseAttachment.getMediaName(): MediaName {
|
||||
return MediaName.fromDigest(remoteDigest!!)
|
||||
}
|
||||
|
||||
fun DatabaseAttachment.getThumbnailMediaName(): MediaName {
|
||||
return MediaName.fromDigestForThumbnail(remoteDigest!!)
|
||||
}
|
||||
|
||||
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, backupKey: BackupKey): ArchiveMediaRequest {
|
||||
val mediaSecrets = backupKey.deriveMediaSecrets(mediaName)
|
||||
|
||||
return ArchiveMediaRequest(
|
||||
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
|
||||
cdn = cdnNumber,
|
||||
cdn = cdn.cdnNumber,
|
||||
key = remoteLocation!!
|
||||
),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
|
||||
mediaId = mediaSecrets.id.toString(),
|
||||
mediaId = mediaSecrets.id.encode(),
|
||||
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
|
||||
encryptionKey = Base64.encodeWithPadding(mediaSecrets.cipherKey),
|
||||
iv = Base64.encodeWithPadding(mediaSecrets.iv)
|
||||
@@ -396,20 +658,28 @@ object BackupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
class ExportState {
|
||||
data class ArchivedMediaObject(val mediaId: String, val cdn: Int)
|
||||
|
||||
data class BackupDirectories(val backupDir: String, val mediaDir: String)
|
||||
|
||||
class ExportState(val backupTime: Long, val allowMediaBackup: Boolean) {
|
||||
val recipientIds = HashSet<Long>()
|
||||
val threadIds = HashSet<Long>()
|
||||
}
|
||||
|
||||
class BackupState {
|
||||
class BackupState(val backupKey: BackupKey) {
|
||||
val backupToLocalRecipientId = HashMap<Long, RecipientId>()
|
||||
val chatIdToLocalThreadId = HashMap<Long, Long>()
|
||||
val chatIdToLocalRecipientId = HashMap<Long, RecipientId>()
|
||||
val chatIdToBackupRecipientId = HashMap<Long, Long>()
|
||||
val callIdToType = HashMap<Long, Long>()
|
||||
}
|
||||
|
||||
class BackupMetadata(
|
||||
val usedSpace: Long,
|
||||
val mediaCount: Long
|
||||
)
|
||||
|
||||
enum class MessageBackupTier {
|
||||
FREE,
|
||||
PAID
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
|
||||
|
||||
/**
|
||||
* Responsible for managing logic around restore prioritization
|
||||
*/
|
||||
object BackupRestoreManager {
|
||||
|
||||
private val reprioritizedAttachments: HashSet<AttachmentId> = HashSet()
|
||||
|
||||
/**
|
||||
* Raise priority of all attachments for the included message records.
|
||||
*
|
||||
* This is so we can make certain attachments get downloaded more quickly
|
||||
*/
|
||||
fun prioritizeAttachmentsIfNeeded(messageRecords: List<MessageRecord>) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
synchronized(this) {
|
||||
val restoringAttachments: List<AttachmentId> = messageRecords
|
||||
.mapNotNull { (it as? MmsMessageRecord?)?.slideDeck?.slides }
|
||||
.flatten()
|
||||
.mapNotNull { it.asAttachment() as? DatabaseAttachment }
|
||||
.filter { it.transferState == AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS && !reprioritizedAttachments.contains(it.attachmentId) }
|
||||
.map { it.attachmentId }
|
||||
|
||||
reprioritizedAttachments += restoringAttachments
|
||||
|
||||
if (restoringAttachments.isNotEmpty()) {
|
||||
RestoreAttachmentJob.modifyPriorities(restoringAttachments.toSet(), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
class BackupV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
|
||||
enum class Type {
|
||||
PROGRESS_MESSAGES, PROGRESS_ATTACHMENTS, FINISHED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
|
||||
|
||||
/**
|
||||
* Result of attempting to batch copy multiple attachments at once with helpers for
|
||||
* processing the collection of mini-responses.
|
||||
*/
|
||||
data class BatchArchiveMediaResult(
|
||||
private val response: BatchArchiveMediaResponse,
|
||||
private val mediaIdToAttachmentId: Map<String, AttachmentId>,
|
||||
private val attachmentIdToMediaName: Map<AttachmentId, String>
|
||||
) {
|
||||
val successfulResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
|
||||
get() = response
|
||||
.responses
|
||||
.asSequence()
|
||||
.filter { it.status == 200 }
|
||||
|
||||
val sourceNotFoundResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
|
||||
get() = response
|
||||
.responses
|
||||
.asSequence()
|
||||
.filter { it.status == 410 }
|
||||
|
||||
fun mediaIdToAttachmentId(mediaId: String): AttachmentId {
|
||||
return mediaIdToAttachmentId[mediaId]!!
|
||||
}
|
||||
|
||||
fun attachmentIdToMediaName(attachmentId: AttachmentId): String {
|
||||
return attachmentIdToMediaName[attachmentId]!!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.ringrtc.CallLinkRootKey
|
||||
import org.signal.ringrtc.CallLinkState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.CallLink
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
|
||||
import java.io.Closeable
|
||||
import java.time.Instant
|
||||
|
||||
fun CallLinkTable.getCallLinksForBackup(): BackupCallLinkIterator {
|
||||
val cursor = readableDatabase
|
||||
.select()
|
||||
.from(CallLinkTable.TABLE_NAME)
|
||||
.run()
|
||||
|
||||
return BackupCallLinkIterator(cursor)
|
||||
}
|
||||
|
||||
fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId {
|
||||
return SignalDatabase.callLinks.insertCallLink(
|
||||
CallLinkTable.CallLink(
|
||||
recipientId = RecipientId.UNKNOWN,
|
||||
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(callLink.rootKey.toByteArray())),
|
||||
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
|
||||
state = SignalCallLinkState(
|
||||
name = callLink.name,
|
||||
restrictions = callLink.restrictions.toLocal(),
|
||||
expiration = Instant.ofEpochMilli(callLink.expirationMs)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class BackupCallLinkIterator(private val cursor: Cursor) : Iterator<BackupRecipient>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupRecipient {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val callLink = CallLinkTable.CallLinkDeserializer.deserialize(cursor)
|
||||
return BackupRecipient(
|
||||
id = callLink.recipientId.toLong(),
|
||||
callLink = CallLink(
|
||||
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
|
||||
adminKey = callLink.credentials.adminPassBytes?.toByteString(),
|
||||
name = callLink.state.name,
|
||||
expirationMs = callLink.state.expiration.toEpochMilli(),
|
||||
restrictions = callLink.state.restrictions.toBackup()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallLinkState.Restrictions.toBackup(): CallLink.Restrictions {
|
||||
return when (this) {
|
||||
CallLinkState.Restrictions.ADMIN_APPROVAL -> CallLink.Restrictions.ADMIN_APPROVAL
|
||||
CallLinkState.Restrictions.NONE -> CallLink.Restrictions.NONE
|
||||
CallLinkState.Restrictions.UNKNOWN -> CallLink.Restrictions.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun CallLink.Restrictions.toLocal(): CallLinkState.Restrictions {
|
||||
return when (this) {
|
||||
CallLink.Restrictions.ADMIN_APPROVAL -> CallLinkState.Restrictions.ADMIN_APPROVAL
|
||||
CallLink.Restrictions.NONE -> CallLinkState.Restrictions.NONE
|
||||
CallLink.Restrictions.UNKNOWN -> CallLinkState.Restrictions.UNKNOWN
|
||||
}
|
||||
}
|
||||
@@ -8,57 +8,37 @@ package org.thoughtcrime.securesms.backup.v2.database
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.isNull
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Call
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import java.io.Closeable
|
||||
|
||||
typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call
|
||||
|
||||
fun CallTable.getCallsForBackup(): CallLogIterator {
|
||||
fun CallTable.getAdhocCallsForBackup(): CallLogIterator {
|
||||
return CallLogIterator(
|
||||
readableDatabase
|
||||
.select()
|
||||
.from(CallTable.TABLE_NAME)
|
||||
.where("${CallTable.EVENT} != ${CallTable.Event.serialize(CallTable.Event.DELETE)}")
|
||||
.where("${CallTable.TYPE}=?", CallTable.Type.AD_HOC_CALL)
|
||||
.run()
|
||||
)
|
||||
}
|
||||
|
||||
fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupState) {
|
||||
val type = when (call.type) {
|
||||
Call.Type.VIDEO_CALL -> CallTable.Type.VIDEO_CALL
|
||||
Call.Type.AUDIO_CALL -> CallTable.Type.AUDIO_CALL
|
||||
Call.Type.AD_HOC_CALL -> CallTable.Type.AD_HOC_CALL
|
||||
Call.Type.GROUP_CALL -> CallTable.Type.GROUP_CALL
|
||||
Call.Type.UNKNOWN_TYPE -> return
|
||||
}
|
||||
|
||||
fun CallTable.restoreCallLogFromBackup(call: AdHocCall, backupState: BackupState) {
|
||||
val event = when (call.state) {
|
||||
Call.State.MISSED -> CallTable.Event.MISSED
|
||||
Call.State.COMPLETED -> CallTable.Event.ACCEPTED
|
||||
Call.State.DECLINED_BY_USER -> CallTable.Event.DECLINED
|
||||
Call.State.DECLINED_BY_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE
|
||||
Call.State.UNKNOWN_EVENT -> return
|
||||
AdHocCall.State.GENERIC -> CallTable.Event.GENERIC_GROUP_CALL
|
||||
AdHocCall.State.UNKNOWN_STATE -> CallTable.Event.GENERIC_GROUP_CALL
|
||||
}
|
||||
|
||||
val direction = if (call.outgoing) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING
|
||||
|
||||
backupState.callIdToType[call.callId] = CallTable.Call.getMessageType(type, direction, event)
|
||||
|
||||
val values = contentValuesOf(
|
||||
CallTable.CALL_ID to call.callId,
|
||||
CallTable.PEER to backupState.backupToLocalRecipientId[call.conversationRecipientId]!!.serialize(),
|
||||
CallTable.TYPE to CallTable.Type.serialize(type),
|
||||
CallTable.DIRECTION to CallTable.Direction.serialize(direction),
|
||||
CallTable.PEER to backupState.backupToLocalRecipientId[call.recipientId]!!.serialize(),
|
||||
CallTable.TYPE to CallTable.Type.serialize(CallTable.Type.AD_HOC_CALL),
|
||||
CallTable.DIRECTION to CallTable.Direction.serialize(CallTable.Direction.OUTGOING),
|
||||
CallTable.EVENT to CallTable.Event.serialize(event),
|
||||
CallTable.TIMESTAMP to call.timestamp,
|
||||
CallTable.RINGER to if (call.ringerRecipientId != null) backupState.backupToLocalRecipientId[call.ringerRecipientId]?.toLong() else null
|
||||
CallTable.TIMESTAMP to call.startedCallTimestamp
|
||||
)
|
||||
|
||||
writableDatabase.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
@@ -68,49 +48,23 @@ fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupStat
|
||||
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
|
||||
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
|
||||
*/
|
||||
class CallLogIterator(private val cursor: Cursor) : Iterator<BackupCall?>, Closeable {
|
||||
class CallLogIterator(private val cursor: Cursor) : Iterator<AdHocCall?>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return cursor.count > 0 && !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): BackupCall? {
|
||||
override fun next(): AdHocCall? {
|
||||
if (!cursor.moveToNext()) {
|
||||
throw NoSuchElementException()
|
||||
}
|
||||
|
||||
val callId = cursor.requireLong(CallTable.CALL_ID)
|
||||
val type = CallTable.Type.deserialize(cursor.requireInt(CallTable.TYPE))
|
||||
val direction = CallTable.Direction.deserialize(cursor.requireInt(CallTable.DIRECTION))
|
||||
val event = CallTable.Event.deserialize(cursor.requireInt(CallTable.EVENT))
|
||||
|
||||
return BackupCall(
|
||||
return AdHocCall(
|
||||
callId = callId,
|
||||
conversationRecipientId = cursor.requireLong(CallTable.PEER),
|
||||
type = when (type) {
|
||||
CallTable.Type.AUDIO_CALL -> Call.Type.AUDIO_CALL
|
||||
CallTable.Type.VIDEO_CALL -> Call.Type.VIDEO_CALL
|
||||
CallTable.Type.AD_HOC_CALL -> Call.Type.AD_HOC_CALL
|
||||
CallTable.Type.GROUP_CALL -> Call.Type.GROUP_CALL
|
||||
},
|
||||
outgoing = when (direction) {
|
||||
CallTable.Direction.OUTGOING -> true
|
||||
else -> false
|
||||
},
|
||||
timestamp = cursor.requireLong(CallTable.TIMESTAMP),
|
||||
ringerRecipientId = if (cursor.isNull(CallTable.RINGER)) null else cursor.requireLong(CallTable.RINGER),
|
||||
state = when (event) {
|
||||
CallTable.Event.ONGOING -> Call.State.COMPLETED
|
||||
CallTable.Event.OUTGOING_RING -> Call.State.COMPLETED
|
||||
CallTable.Event.ACCEPTED -> Call.State.COMPLETED
|
||||
CallTable.Event.DECLINED -> Call.State.DECLINED_BY_USER
|
||||
CallTable.Event.GENERIC_GROUP_CALL -> Call.State.COMPLETED
|
||||
CallTable.Event.JOINED -> Call.State.COMPLETED
|
||||
CallTable.Event.MISSED -> Call.State.MISSED
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> Call.State.DECLINED_BY_NOTIFICATION_PROFILE
|
||||
CallTable.Event.DELETE -> Call.State.COMPLETED
|
||||
CallTable.Event.RINGING -> Call.State.MISSED
|
||||
CallTable.Event.NOT_ACCEPTED -> Call.State.MISSED
|
||||
}
|
||||
recipientId = cursor.requireLong(CallTable.PEER),
|
||||
state = AdHocCall.State.GENERIC,
|
||||
startedCallTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import android.database.Cursor
|
||||
import com.annimon.stream.Stream
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Base64.decode
|
||||
@@ -17,12 +16,16 @@ import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.CallChatUpdate
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
|
||||
@@ -33,6 +36,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Text
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
@@ -60,7 +65,6 @@ import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.util.LinkedList
|
||||
import java.util.Queue
|
||||
import java.util.UUID
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
|
||||
|
||||
/**
|
||||
@@ -70,7 +74,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
|
||||
*
|
||||
* All of this complexity is hidden from the user -- they just get a normal iterator interface.
|
||||
*/
|
||||
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int) : Iterator<ChatItem>, Closeable {
|
||||
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int, private val archiveMedia: Boolean) : Iterator<ChatItem>, Closeable {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ChatItemExportIterator::class.java)
|
||||
@@ -106,6 +110,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
|
||||
val reactionsById: Map<Long, List<ReactionRecord>> = SignalDatabase.reactions.getReactionsForMessages(records.keys)
|
||||
val mentionsById: Map<Long, List<Mention>> = SignalDatabase.mentions.getMentionsForMessages(records.keys)
|
||||
val attachmentsById: Map<Long, List<DatabaseAttachment>> = SignalDatabase.attachments.getAttachmentsForMessages(records.keys)
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys)
|
||||
|
||||
for ((id, record) in records) {
|
||||
@@ -117,15 +122,25 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
MessageTypes.isIdentityUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_UPDATE))
|
||||
MessageTypes.isIdentityVerified(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_VERIFIED))
|
||||
MessageTypes.isIdentityDefault(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_DEFAULT))
|
||||
MessageTypes.isChangeNumber(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
|
||||
MessageTypes.isBoostRequest(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
|
||||
MessageTypes.isChangeNumber(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
|
||||
builder.sms = false
|
||||
}
|
||||
MessageTypes.isBoostRequest(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
|
||||
builder.sms = false
|
||||
}
|
||||
MessageTypes.isEndSessionType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.END_SESSION))
|
||||
MessageTypes.isChatSessionRefresh(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHAT_SESSION_REFRESH))
|
||||
MessageTypes.isBadDecryptType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BAD_DECRYPT))
|
||||
MessageTypes.isPaymentsActivated(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENTS_ACTIVATED))
|
||||
MessageTypes.isPaymentsRequestToActivate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST))
|
||||
MessageTypes.isExpirationTimerUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate((record.expiresIn / 1000).toInt()))
|
||||
MessageTypes.isExpirationTimerUpdate(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn.toInt()))
|
||||
builder.expiresInMs = 0
|
||||
}
|
||||
MessageTypes.isProfileChange(record.type) -> {
|
||||
if (record.body == null) continue
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
profileChange = try {
|
||||
val decoded: ByteArray = Base64.decode(record.body!!)
|
||||
@@ -140,6 +155,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
ProfileChangeChatUpdate()
|
||||
}
|
||||
)
|
||||
builder.sms = false
|
||||
}
|
||||
MessageTypes.isSessionSwitchoverType(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
@@ -182,45 +198,115 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
}
|
||||
MessageTypes.isCallLog(record.type) -> {
|
||||
builder.sms = false
|
||||
val call = calls.getCallByMessageId(record.id)
|
||||
if (call != null) {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callId = call.callId))
|
||||
if (call.type == CallTable.Type.GROUP_CALL) {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
groupCall = GroupCall(
|
||||
callId = record.id,
|
||||
state = when (call.event) {
|
||||
CallTable.Event.MISSED -> GroupCall.State.MISSED
|
||||
CallTable.Event.ONGOING -> GroupCall.State.GENERIC
|
||||
CallTable.Event.ACCEPTED -> GroupCall.State.ACCEPTED
|
||||
CallTable.Event.NOT_ACCEPTED -> GroupCall.State.GENERIC
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> GroupCall.State.MISSED_NOTIFICATION_PROFILE
|
||||
CallTable.Event.DELETE -> continue
|
||||
CallTable.Event.GENERIC_GROUP_CALL -> GroupCall.State.GENERIC
|
||||
CallTable.Event.JOINED -> GroupCall.State.JOINED
|
||||
CallTable.Event.RINGING -> GroupCall.State.RINGING
|
||||
CallTable.Event.DECLINED -> GroupCall.State.DECLINED
|
||||
CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING
|
||||
},
|
||||
ringerRecipientId = call.ringerRecipient?.toLong(),
|
||||
startedCallAci = if (call.ringerRecipient != null) SignalDatabase.recipients.getRecord(call.ringerRecipient).aci?.toByteString() else null,
|
||||
startedCallTimestamp = call.timestamp
|
||||
)
|
||||
)
|
||||
} else if (call.type != CallTable.Type.AD_HOC_CALL) {
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
callId = call.callId,
|
||||
type = if (call.type == CallTable.Type.VIDEO_CALL) IndividualCall.Type.VIDEO_CALL else IndividualCall.Type.AUDIO_CALL,
|
||||
direction = if (call.direction == CallTable.Direction.INCOMING) IndividualCall.Direction.INCOMING else IndividualCall.Direction.OUTGOING,
|
||||
state = when (call.event) {
|
||||
CallTable.Event.MISSED -> IndividualCall.State.MISSED
|
||||
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> IndividualCall.State.MISSED_NOTIFICATION_PROFILE
|
||||
CallTable.Event.ACCEPTED -> IndividualCall.State.ACCEPTED
|
||||
CallTable.Event.NOT_ACCEPTED -> IndividualCall.State.NOT_ACCEPTED
|
||||
else -> IndividualCall.State.UNKNOWN_STATE
|
||||
},
|
||||
startedCallTimestamp = call.timestamp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
MessageTypes.isMissedAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL)))
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.MISSED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isMissedVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL)))
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.MISSED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isIncomingAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL)))
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isIncomingVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL)))
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.INCOMING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isOutgoingAudioCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL)))
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.AUDIO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.OUTGOING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isOutgoingVideoCall(record.type) -> {
|
||||
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL)))
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
individualCall = IndividualCall(
|
||||
type = IndividualCall.Type.VIDEO_CALL,
|
||||
state = IndividualCall.State.ACCEPTED,
|
||||
direction = IndividualCall.Direction.OUTGOING
|
||||
)
|
||||
)
|
||||
}
|
||||
MessageTypes.isGroupCall(record.type) -> {
|
||||
try {
|
||||
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body)
|
||||
|
||||
val joinedMembers = Stream.of(groupCallUpdateDetails.inCallUuids)
|
||||
.map { uuid: String? -> UuidUtil.parseOrNull(uuid) }
|
||||
.withoutNulls()
|
||||
.map { obj: UUID? -> ACI.from(obj!!).toByteString() }
|
||||
.toList()
|
||||
builder.updateMessage = ChatUpdateMessage(
|
||||
callingMessage = CallChatUpdate(
|
||||
groupCall = GroupCallChatUpdate(
|
||||
startedCallAci = ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid)).toByteString(),
|
||||
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
|
||||
inCallAcis = joinedMembers
|
||||
)
|
||||
groupCall = GroupCall(
|
||||
state = GroupCall.State.GENERIC,
|
||||
startedCallAci = ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid)).toByteString(),
|
||||
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
|
||||
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp
|
||||
)
|
||||
)
|
||||
} catch (exception: java.lang.Exception) {
|
||||
@@ -230,11 +316,11 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
}
|
||||
}
|
||||
record.body == null -> {
|
||||
Log.w(TAG, "Record missing a body, skipping")
|
||||
record.body == null && !attachmentsById.containsKey(record.id) -> {
|
||||
Log.w(TAG, "Record missing a body and doesnt have attachments, skipping")
|
||||
continue
|
||||
}
|
||||
else -> builder.standardMessage = record.toTextMessage(reactionsById[id], mentions = mentionsById[id])
|
||||
else -> builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id])
|
||||
}
|
||||
|
||||
buffer += builder.build()
|
||||
@@ -268,13 +354,13 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
chatId = record.threadId
|
||||
authorId = record.fromRecipientId
|
||||
dateSent = record.dateSent
|
||||
sealedSender = record.sealedSender
|
||||
expireStartDate = if (record.expireStarted > 0) record.expireStarted else null
|
||||
expiresInMs = if (record.expiresIn > 0) record.expiresIn else null
|
||||
expireStartDate = if (record.expireStarted > 0) record.expireStarted else 0
|
||||
expiresInMs = if (record.expiresIn > 0) record.expiresIn else 0
|
||||
revisions = emptyList()
|
||||
sms = !MessageTypes.isSecureType(record.type)
|
||||
|
||||
if (MessageTypes.isOutgoingMessageType(record.type)) {
|
||||
if (MessageTypes.isCallLog(record.type)) {
|
||||
directionless = ChatItem.DirectionlessMessageDetails()
|
||||
} else if (MessageTypes.isOutgoingMessageType(record.type)) {
|
||||
outgoing = ChatItem.OutgoingMessageDetails(
|
||||
sendStatus = record.toBackupSendStatus(groupReceipts)
|
||||
)
|
||||
@@ -282,19 +368,28 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
incoming = ChatItem.IncomingMessageDetails(
|
||||
dateServerSent = record.dateServer,
|
||||
dateReceived = record.dateReceived,
|
||||
read = record.read
|
||||
read = record.read,
|
||||
sealedSender = record.sealedSender
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toTextMessage(reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?): StandardMessage {
|
||||
return StandardMessage(
|
||||
quote = this.toQuote(),
|
||||
text = Text(
|
||||
body = this.body!!,
|
||||
private fun BackupMessageRecord.toStandardMessage(reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
|
||||
val text = if (body == null) {
|
||||
null
|
||||
} else {
|
||||
Text(
|
||||
body = this.body,
|
||||
bodyRanges = (this.bodyRanges?.toBackupBodyRanges() ?: emptyList()) + (mentions?.toBackupBodyRanges() ?: emptyList())
|
||||
),
|
||||
)
|
||||
}
|
||||
val quotedAttachments = attachments?.filter { it.quote } ?: emptyList()
|
||||
val messageAttachments = attachments?.filter { !it.quote } ?: emptyList()
|
||||
return StandardMessage(
|
||||
quote = this.toQuote(quotedAttachments),
|
||||
text = text,
|
||||
attachments = messageAttachments.toBackupAttachments(),
|
||||
// TODO Link previews!
|
||||
linkPreview = emptyList(),
|
||||
longText = null,
|
||||
@@ -302,14 +397,14 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toQuote(): Quote? {
|
||||
private fun BackupMessageRecord.toQuote(attachments: List<DatabaseAttachment>? = null): Quote? {
|
||||
return if (this.quoteTargetSentTimestamp != MessageTable.QUOTE_NOT_PRESENT_ID && this.quoteAuthor > 0) {
|
||||
// TODO Attachments!
|
||||
val type = QuoteModel.Type.fromCode(this.quoteType)
|
||||
Quote(
|
||||
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID },
|
||||
authorId = this.quoteAuthor,
|
||||
text = this.quoteBody,
|
||||
attachments = attachments?.toBackupQuoteAttachments() ?: emptyList(),
|
||||
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(),
|
||||
type = when (type) {
|
||||
QuoteModel.Type.NORMAL -> Quote.Type.NORMAL
|
||||
@@ -321,6 +416,66 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<DatabaseAttachment>.toBackupQuoteAttachments(): List<Quote.QuotedAttachment> {
|
||||
return this.map { attachment ->
|
||||
Quote.QuotedAttachment(
|
||||
contentType = attachment.contentType,
|
||||
fileName = attachment.fileName,
|
||||
thumbnail = attachment.toBackupAttachment()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DatabaseAttachment.toBackupAttachment(): MessageAttachment {
|
||||
val builder = FilePointer.Builder()
|
||||
builder.contentType = contentType
|
||||
builder.incrementalMac = incrementalDigest?.toByteString()
|
||||
builder.incrementalMacChunkSize = incrementalMacChunkSize
|
||||
builder.fileName = fileName
|
||||
builder.width = width
|
||||
builder.height = height
|
||||
builder.caption = caption
|
||||
builder.blurHash = blurHash?.hash
|
||||
|
||||
if (remoteKey.isNullOrBlank() || remoteDigest == null || size == 0L) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
} else {
|
||||
if (archiveMedia) {
|
||||
builder.backupLocator = FilePointer.BackupLocator(
|
||||
mediaName = archiveMediaName ?: this.getMediaName().toString(),
|
||||
cdnNumber = if (archiveMediaName != null) archiveCdn else Cdn.CDN_3.cdnNumber, // TODO (clark): Update when new proto with optional cdn is landed
|
||||
key = decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = remoteDigest.toByteString()
|
||||
)
|
||||
} else {
|
||||
if (remoteLocation.isNullOrBlank()) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
} else {
|
||||
builder.attachmentLocator = FilePointer.AttachmentLocator(
|
||||
cdnKey = this.remoteLocation,
|
||||
cdnNumber = this.cdn.cdnNumber,
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
key = decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = remoteDigest.toByteString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return MessageAttachment(
|
||||
pointer = builder.build(),
|
||||
wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE,
|
||||
flag = if (voiceNote) MessageAttachment.Flag.VOICE_MESSAGE else if (videoGif) MessageAttachment.Flag.GIF else if (borderless) MessageAttachment.Flag.BORDERLESS else MessageAttachment.Flag.NONE
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<DatabaseAttachment>.toBackupAttachments(): List<MessageAttachment> {
|
||||
return this.map { attachment ->
|
||||
attachment.toBackupAttachment()
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<Mention>.toBackupBodyRanges(): List<BackupBodyRange> {
|
||||
return this.map {
|
||||
BackupBodyRange(
|
||||
|
||||
@@ -10,18 +10,27 @@ import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.toInt
|
||||
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Quote
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
@@ -33,6 +42,7 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
|
||||
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
|
||||
@@ -44,8 +54,13 @@ import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them
|
||||
@@ -88,7 +103,8 @@ class ChatItemImportInserter(
|
||||
MessageTable.SHARED_CONTACTS,
|
||||
MessageTable.LINK_PREVIEWS,
|
||||
MessageTable.MESSAGE_RANGES,
|
||||
MessageTable.VIEW_ONCE
|
||||
MessageTable.VIEW_ONCE,
|
||||
MessageTable.MESSAGE_EXTRAS
|
||||
)
|
||||
|
||||
private val REACTION_COLUMNS = arrayOf(
|
||||
@@ -200,11 +216,53 @@ class ChatItemImportInserter(
|
||||
|
||||
var followUp: ((Long) -> Unit)? = null
|
||||
if (this.updateMessage != null) {
|
||||
if (this.updateMessage.callingMessage != null && this.updateMessage.callingMessage.callId != null) {
|
||||
if (this.updateMessage.individualCall != null && this.updateMessage.individualCall.callId != null) {
|
||||
followUp = { messageRowId ->
|
||||
val callContentValues = ContentValues()
|
||||
callContentValues.put(CallTable.MESSAGE_ID, messageRowId)
|
||||
db.update(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, callContentValues, "${CallTable.CALL_ID} = ?", SqlUtil.buildArgs(this.updateMessage.callingMessage.callId))
|
||||
val values = contentValuesOf(
|
||||
CallTable.CALL_ID to updateMessage.individualCall.callId,
|
||||
CallTable.MESSAGE_ID to messageRowId,
|
||||
CallTable.PEER to chatRecipientId.serialize(),
|
||||
CallTable.TYPE to CallTable.Type.serialize(if (updateMessage.individualCall.type == IndividualCall.Type.VIDEO_CALL) CallTable.Type.VIDEO_CALL else CallTable.Type.AUDIO_CALL),
|
||||
CallTable.DIRECTION to CallTable.Direction.serialize(if (updateMessage.individualCall.direction == IndividualCall.Direction.OUTGOING) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING),
|
||||
CallTable.EVENT to CallTable.Event.serialize(
|
||||
when (updateMessage.individualCall.state) {
|
||||
IndividualCall.State.MISSED -> CallTable.Event.MISSED
|
||||
IndividualCall.State.MISSED_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE
|
||||
IndividualCall.State.ACCEPTED -> CallTable.Event.ACCEPTED
|
||||
IndividualCall.State.NOT_ACCEPTED -> CallTable.Event.NOT_ACCEPTED
|
||||
else -> CallTable.Event.MISSED
|
||||
}
|
||||
),
|
||||
CallTable.TIMESTAMP to updateMessage.individualCall.startedCallTimestamp,
|
||||
CallTable.READ to CallTable.ReadState.serialize(CallTable.ReadState.UNREAD)
|
||||
)
|
||||
db.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
}
|
||||
} else if (this.updateMessage.groupCall != null && this.updateMessage.groupCall.callId != null) {
|
||||
followUp = { messageRowId ->
|
||||
val values = contentValuesOf(
|
||||
CallTable.CALL_ID to updateMessage.groupCall.callId,
|
||||
CallTable.MESSAGE_ID to messageRowId,
|
||||
CallTable.PEER to chatRecipientId.serialize(),
|
||||
CallTable.TYPE to CallTable.Type.serialize(CallTable.Type.GROUP_CALL),
|
||||
CallTable.DIRECTION to CallTable.Direction.serialize(if (backupState.backupToLocalRecipientId[updateMessage.groupCall.ringerRecipientId] == selfId) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING),
|
||||
CallTable.EVENT to CallTable.Event.serialize(
|
||||
when (updateMessage.groupCall.state) {
|
||||
GroupCall.State.ACCEPTED -> CallTable.Event.ACCEPTED
|
||||
GroupCall.State.MISSED -> CallTable.Event.MISSED
|
||||
GroupCall.State.MISSED_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE
|
||||
GroupCall.State.GENERIC -> CallTable.Event.GENERIC_GROUP_CALL
|
||||
GroupCall.State.JOINED -> CallTable.Event.JOINED
|
||||
GroupCall.State.RINGING -> CallTable.Event.RINGING
|
||||
GroupCall.State.OUTGOING_RING -> CallTable.Event.OUTGOING_RING
|
||||
GroupCall.State.DECLINED -> CallTable.Event.DECLINED
|
||||
else -> CallTable.Event.GENERIC_GROUP_CALL
|
||||
}
|
||||
),
|
||||
CallTable.TIMESTAMP to updateMessage.groupCall.startedCallTimestamp,
|
||||
CallTable.READ to CallTable.ReadState.serialize(CallTable.ReadState.UNREAD)
|
||||
)
|
||||
db.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,6 +286,17 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
}
|
||||
val attachments = this.standardMessage.attachments.mapNotNull { attachment ->
|
||||
attachment.toLocalAttachment()
|
||||
}
|
||||
val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull {
|
||||
it.toLocalAttachment()
|
||||
} ?: emptyList()
|
||||
if (attachments.isNotEmpty()) {
|
||||
followUp = { messageRowId ->
|
||||
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments, quoteAttachments)
|
||||
}
|
||||
}
|
||||
}
|
||||
return MessageInsert(contentValues, followUp)
|
||||
}
|
||||
@@ -244,7 +313,7 @@ class ChatItemImportInserter(
|
||||
contentValues.put(MessageTable.TO_RECIPIENT_ID, (if (this.outgoing != null) chatRecipientId else selfId).serialize())
|
||||
contentValues.put(MessageTable.THREAD_ID, threadId)
|
||||
contentValues.put(MessageTable.DATE_RECEIVED, this.incoming?.dateReceived ?: this.dateSent)
|
||||
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOf { it.lastStatusUpdateTimestamp } ?: 0)
|
||||
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOfOrNull { it.lastStatusUpdateTimestamp } ?: 0)
|
||||
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
|
||||
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
|
||||
contentValues.put(MessageTable.REVISION_NUMBER, 0)
|
||||
@@ -268,7 +337,7 @@ class ChatItemImportInserter(
|
||||
contentValues.put(MessageTable.VIEWED_COLUMN, 0)
|
||||
contentValues.put(MessageTable.HAS_READ_RECEIPT, 0)
|
||||
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, 0)
|
||||
contentValues.put(MessageTable.UNIDENTIFIED, this.sealedSender?.toInt())
|
||||
contentValues.put(MessageTable.UNIDENTIFIED, this.incoming?.sealedSender?.toInt() ?: 0)
|
||||
contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0)
|
||||
contentValues.put(MessageTable.NOTIFIED, 1)
|
||||
}
|
||||
@@ -382,23 +451,24 @@ class ChatItemImportInserter(
|
||||
var typeFlags: Long = 0
|
||||
when {
|
||||
updateMessage.simpleUpdate != null -> {
|
||||
val typeWithoutBase = (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
typeFlags = when (updateMessage.simpleUpdate.type) {
|
||||
SimpleChatUpdate.Type.UNKNOWN -> 0
|
||||
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE
|
||||
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT
|
||||
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT
|
||||
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT
|
||||
SimpleChatUpdate.Type.UNKNOWN -> typeWithoutBase
|
||||
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE or typeWithoutBase
|
||||
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
|
||||
SimpleChatUpdate.Type.BOOST_REQUEST -> MessageTypes.BOOST_REQUEST_TYPE
|
||||
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT
|
||||
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT
|
||||
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE
|
||||
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
|
||||
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST
|
||||
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT or typeWithoutBase
|
||||
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE or typeWithoutBase
|
||||
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED or typeWithoutBase
|
||||
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
|
||||
}
|
||||
}
|
||||
updateMessage.expirationTimerChange != null -> {
|
||||
typeFlags = MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
|
||||
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
|
||||
put(MessageTable.EXPIRES_IN, updateMessage.expirationTimerChange.expiresInMs.toLong())
|
||||
}
|
||||
updateMessage.profileChange != null -> {
|
||||
@@ -408,35 +478,31 @@ class ChatItemImportInserter(
|
||||
put(MessageTable.BODY, Base64.encodeWithPadding(profileChangeDetails))
|
||||
}
|
||||
updateMessage.sessionSwitchover != null -> {
|
||||
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE
|
||||
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
val sessionSwitchoverDetails = SessionSwitchoverEvent(e164 = updateMessage.sessionSwitchover.e164.toString()).encode()
|
||||
put(MessageTable.BODY, Base64.encodeWithPadding(sessionSwitchoverDetails))
|
||||
}
|
||||
updateMessage.threadMerge != null -> {
|
||||
typeFlags = MessageTypes.THREAD_MERGE_TYPE
|
||||
typeFlags = MessageTypes.THREAD_MERGE_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
|
||||
val threadMergeDetails = ThreadMergeEvent(previousE164 = updateMessage.threadMerge.previousE164.toString()).encode()
|
||||
put(MessageTable.BODY, Base64.encodeWithPadding(threadMergeDetails))
|
||||
}
|
||||
updateMessage.callingMessage != null -> {
|
||||
when {
|
||||
updateMessage.callingMessage.callId != null -> {
|
||||
typeFlags = backupState.callIdToType[updateMessage.callingMessage.callId]!!
|
||||
}
|
||||
updateMessage.callingMessage.callMessage != null -> {
|
||||
typeFlags = when (updateMessage.callingMessage.callMessage.type) {
|
||||
IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL -> MessageTypes.INCOMING_AUDIO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL -> MessageTypes.INCOMING_VIDEO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL -> MessageTypes.OUTGOING_AUDIO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL -> MessageTypes.OUTGOING_VIDEO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.MISSED_AUDIO_CALL -> MessageTypes.MISSED_AUDIO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.MISSED_VIDEO_CALL -> MessageTypes.MISSED_VIDEO_CALL_TYPE
|
||||
IndividualCallChatUpdate.Type.UNKNOWN -> typeFlags
|
||||
}
|
||||
updateMessage.individualCall != null -> {
|
||||
if (updateMessage.individualCall.state == IndividualCall.State.MISSED || updateMessage.individualCall.state == IndividualCall.State.MISSED_NOTIFICATION_PROFILE) {
|
||||
typeFlags = if (updateMessage.individualCall.type == IndividualCall.Type.AUDIO_CALL) MessageTypes.MISSED_AUDIO_CALL_TYPE else MessageTypes.MISSED_VIDEO_CALL_TYPE
|
||||
} else {
|
||||
typeFlags = if (updateMessage.individualCall.direction == IndividualCall.Direction.OUTGOING) {
|
||||
if (updateMessage.individualCall.type == IndividualCall.Type.AUDIO_CALL) MessageTypes.OUTGOING_AUDIO_CALL_TYPE else MessageTypes.OUTGOING_VIDEO_CALL_TYPE
|
||||
} else {
|
||||
if (updateMessage.individualCall.type == IndividualCall.Type.AUDIO_CALL) MessageTypes.INCOMING_AUDIO_CALL_TYPE else MessageTypes.INCOMING_VIDEO_CALL_TYPE
|
||||
}
|
||||
}
|
||||
// Calls don't use the incoming/outgoing flags, so we overwrite the flags here
|
||||
this.put(MessageTable.TYPE, typeFlags)
|
||||
}
|
||||
updateMessage.groupCall != null -> {
|
||||
this.put(MessageTable.BODY, GroupCallUpdateDetailsUtil.createBodyFromBackup(updateMessage.groupCall))
|
||||
this.put(MessageTable.TYPE, MessageTypes.GROUP_CALL_TYPE)
|
||||
}
|
||||
updateMessage.groupChange != null -> {
|
||||
put(MessageTable.BODY, "")
|
||||
put(
|
||||
@@ -446,10 +512,10 @@ class ChatItemImportInserter(
|
||||
GV2UpdateDescription(groupChangeUpdate = updateMessage.groupChange)
|
||||
).encode()
|
||||
)
|
||||
typeFlags = MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT
|
||||
typeFlags = getAsLong(MessageTable.TYPE) or MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT
|
||||
}
|
||||
}
|
||||
this.put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or typeFlags)
|
||||
this.put(MessageTable.TYPE, typeFlags)
|
||||
}
|
||||
|
||||
private fun ContentValues.addQuote(quote: Quote) {
|
||||
@@ -508,7 +574,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
|
||||
return BodyRangeList(
|
||||
ranges = this.map { bodyRange ->
|
||||
ranges = this.filter { it.mentionAci == null }.map { bodyRange ->
|
||||
BodyRangeList.BodyRange(
|
||||
mentionUuid = bodyRange.mentionAci?.let { UuidUtil.fromByteString(it) }?.toString(),
|
||||
style = bodyRange.style?.let {
|
||||
@@ -541,6 +607,78 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(contentType: String? = pointer?.contentType, fileName: String? = pointer?.fileName): Attachment? {
|
||||
if (pointer == null) return null
|
||||
if (pointer.attachmentLocator != null) {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
pointer.attachmentLocator.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(pointer.attachmentLocator.cdnKey),
|
||||
contentType,
|
||||
pointer.attachmentLocator.key.toByteArray(),
|
||||
Optional.ofNullable(pointer.attachmentLocator.size),
|
||||
Optional.empty(),
|
||||
pointer.width ?: 0,
|
||||
pointer.height ?: 0,
|
||||
Optional.ofNullable(pointer.attachmentLocator.digest.toByteArray()),
|
||||
Optional.ofNullable(pointer.incrementalMac?.toByteArray()),
|
||||
pointer.incrementalMacChunkSize ?: 0,
|
||||
Optional.ofNullable(fileName),
|
||||
flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
flag == MessageAttachment.Flag.BORDERLESS,
|
||||
flag == MessageAttachment.Flag.GIF,
|
||||
Optional.ofNullable(pointer.caption),
|
||||
Optional.ofNullable(pointer.blurHash),
|
||||
pointer.attachmentLocator.uploadTimestamp
|
||||
)
|
||||
return PointerAttachment.forPointer(
|
||||
pointer = Optional.of(signalAttachmentPointer),
|
||||
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
|
||||
).orNull()
|
||||
} else if (pointer.invalidAttachmentLocator != null) {
|
||||
return TombstoneAttachment(
|
||||
contentType = contentType,
|
||||
incrementalMac = pointer.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = pointer.incrementalMacChunkSize,
|
||||
width = pointer.width,
|
||||
height = pointer.height,
|
||||
caption = pointer.caption,
|
||||
blurHash = pointer.blurHash,
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
quote = false
|
||||
)
|
||||
} else if (pointer.backupLocator != null) {
|
||||
return ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = pointer.backupLocator.size.toLong(),
|
||||
cdn = pointer.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = pointer.backupLocator.key.toByteArray(),
|
||||
cdnKey = pointer.backupLocator.transitCdnKey,
|
||||
archiveCdn = pointer.backupLocator.cdnNumber,
|
||||
archiveMediaName = pointer.backupLocator.mediaName,
|
||||
archiveMediaId = backupState.backupKey.deriveMediaId(MediaName(pointer.backupLocator.mediaName)).encode(),
|
||||
digest = pointer.backupLocator.digest.toByteArray(),
|
||||
incrementalMac = pointer.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = pointer.incrementalMacChunkSize,
|
||||
width = pointer.width,
|
||||
height = pointer.height,
|
||||
caption = pointer.caption,
|
||||
blurHash = pointer.blurHash,
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
quote = false
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
|
||||
return thumbnail?.toLocalAttachment(this.contentType, this.fileName)
|
||||
?: if (this.contentType == null) null else PointerAttachment.forPointer(quotedAttachment = DataMessage.Quote.QuotedAttachment(contentType = this.contentType, fileName = this.fileName, thumbnail = null)).orNull()
|
||||
}
|
||||
|
||||
private class MessageInsert(val contentValues: ContentValues, val followUp: ((Long) -> Unit)?)
|
||||
|
||||
private class Buffer(
|
||||
|
||||
@@ -11,11 +11,12 @@ import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val TAG = Log.tag(MessageTable::class.java)
|
||||
private const val BASE_TYPE = "base_type"
|
||||
|
||||
fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
|
||||
fun MessageTable.getMessagesForBackup(backupTime: Long, archiveMedia: Boolean): ChatItemExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
MessageTable.ID,
|
||||
@@ -53,19 +54,17 @@ fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
.where(
|
||||
"""
|
||||
$BASE_TYPE IN (
|
||||
${MessageTypes.BASE_INBOX_TYPE},
|
||||
${MessageTypes.BASE_OUTBOX_TYPE},
|
||||
${MessageTypes.BASE_SENT_TYPE},
|
||||
${MessageTypes.BASE_SENDING_TYPE},
|
||||
${MessageTypes.BASE_SENT_FAILED_TYPE}
|
||||
) OR ${MessageTable.IS_CALL_TYPE_CLAUSE}
|
||||
(
|
||||
${MessageTable.EXPIRE_STARTED} = 0
|
||||
OR
|
||||
(${MessageTable.EXPIRES_IN} > 0 AND (${MessageTable.EXPIRE_STARTED} + ${MessageTable.EXPIRES_IN}) > $backupTime + ${TimeUnit.DAYS.toMillis(1)})
|
||||
)
|
||||
"""
|
||||
)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
|
||||
.run()
|
||||
|
||||
return ChatItemExportIterator(cursor, 100)
|
||||
return ChatItemExportIterator(cursor, 100, archiveMedia)
|
||||
}
|
||||
|
||||
fun MessageTable.createChatItemInserter(backupState: BackupState): ChatItemImportInserter {
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.nullIfBlank
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
@@ -23,8 +24,16 @@ import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
|
||||
import org.signal.storageservice.protos.groups.AccessControl
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Contact
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Group
|
||||
@@ -44,6 +53,8 @@ import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.api.util.toByteArray
|
||||
@@ -103,7 +114,8 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
|
||||
"${RecipientTable.TABLE_NAME}.${RecipientTable.EXTRAS}",
|
||||
"${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}",
|
||||
"${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}",
|
||||
"${GroupTable.TABLE_NAME}.${GroupTable.TITLE}"
|
||||
"${GroupTable.TABLE_NAME}.${GroupTable.TITLE}",
|
||||
"${GroupTable.TABLE_NAME}.${GroupTable.V2_DECRYPTED_GROUP}"
|
||||
)
|
||||
.from(
|
||||
"""
|
||||
@@ -117,25 +129,6 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
|
||||
return BackupGroupIterator(cursor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a [BackupRecipient] and writes it into the database.
|
||||
*/
|
||||
fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backupState: BackupState): RecipientId? {
|
||||
// TODO Need to handle groups
|
||||
// TODO Also, should we move this when statement up to mimic the export? Kinda weird that this calls distributionListTable functions
|
||||
return when {
|
||||
recipient.contact != null -> restoreContactFromBackup(recipient.contact)
|
||||
recipient.group != null -> restoreGroupFromBackup(recipient.group)
|
||||
recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState)
|
||||
recipient.self != null -> Recipient.self().id
|
||||
recipient.releaseNotes != null -> restoreReleaseNotes()
|
||||
else -> {
|
||||
Log.w(TAG, "Unrecognized recipient type!")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given [AccountData], this will insert the necessary data for the local user into the [RecipientTable].
|
||||
*/
|
||||
@@ -175,7 +168,7 @@ fun RecipientTable.clearAllDataForBackupRestore() {
|
||||
ApplicationDependencies.getRecipientCache().clearSelf()
|
||||
}
|
||||
|
||||
private fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
|
||||
fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
|
||||
val id = getAndPossiblyMergePnpVerified(
|
||||
aci = ACI.parseOrNull(contact.aci?.toByteArray()),
|
||||
pni = PNI.parseOrNull(contact.pni?.toByteArray()),
|
||||
@@ -206,7 +199,7 @@ private fun RecipientTable.restoreContactFromBackup(contact: Contact): Recipient
|
||||
return id
|
||||
}
|
||||
|
||||
private fun RecipientTable.restoreReleaseNotes(): RecipientId {
|
||||
fun RecipientTable.restoreReleaseNotes(): RecipientId {
|
||||
val releaseChannelId: RecipientId = insertReleaseChannelRecipient()
|
||||
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelId)
|
||||
|
||||
@@ -215,13 +208,16 @@ private fun RecipientTable.restoreReleaseNotes(): RecipientId {
|
||||
return releaseChannelId
|
||||
}
|
||||
|
||||
private fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
|
||||
fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
|
||||
val masterKey = GroupMasterKey(group.masterKey.toByteArray())
|
||||
val groupId = GroupId.v2(masterKey)
|
||||
|
||||
val placeholderState = DecryptedGroup.Builder()
|
||||
.revision(GroupsV2StateProcessor.PLACEHOLDER_REVISION)
|
||||
.build()
|
||||
val operations = ApplicationDependencies.getGroupsV2Operations().forGroup(GroupSecretParams.deriveFromMasterKey(masterKey))
|
||||
val decryptedState = if (group.snapshot == null) {
|
||||
DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION)
|
||||
} else {
|
||||
group.snapshot.toDecryptedGroup(operations)
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(RecipientTable.GROUP_ID, groupId.toString())
|
||||
@@ -236,20 +232,154 @@ private fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
|
||||
}
|
||||
|
||||
val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values)
|
||||
val groupValues = ContentValues().apply {
|
||||
put(GroupTable.RECIPIENT_ID, recipientId)
|
||||
put(GroupTable.GROUP_ID, groupId.toString())
|
||||
put(GroupTable.TITLE, group.name)
|
||||
put(GroupTable.V2_MASTER_KEY, masterKey.serialize())
|
||||
put(GroupTable.V2_DECRYPTED_GROUP, placeholderState.encode())
|
||||
put(GroupTable.V2_REVISION, placeholderState.revision)
|
||||
put(GroupTable.SHOW_AS_STORY_STATE, group.storySendMode.toGroupShowAsStoryState().code)
|
||||
val restoredId = SignalDatabase.groups.create(masterKey, decryptedState)
|
||||
if (restoredId != null) {
|
||||
SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toGroupShowAsStoryState())
|
||||
}
|
||||
writableDatabase.insert(GroupTable.TABLE_NAME, null, groupValues)
|
||||
|
||||
return RecipientId.from(recipientId)
|
||||
}
|
||||
|
||||
private fun Group.AccessControl.AccessRequired.toLocal(): AccessControl.AccessRequired {
|
||||
return when (this) {
|
||||
Group.AccessControl.AccessRequired.UNKNOWN -> AccessControl.AccessRequired.UNKNOWN
|
||||
Group.AccessControl.AccessRequired.ANY -> AccessControl.AccessRequired.ANY
|
||||
Group.AccessControl.AccessRequired.MEMBER -> AccessControl.AccessRequired.MEMBER
|
||||
Group.AccessControl.AccessRequired.ADMINISTRATOR -> AccessControl.AccessRequired.ADMINISTRATOR
|
||||
Group.AccessControl.AccessRequired.UNSATISFIABLE -> AccessControl.AccessRequired.UNSATISFIABLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun Group.AccessControl.toLocal(): AccessControl {
|
||||
return AccessControl(members = this.members.toLocal(), attributes = this.attributes.toLocal(), addFromInviteLink = this.addFromInviteLink.toLocal())
|
||||
}
|
||||
|
||||
private fun Group.Member.Role.toLocal(): Member.Role {
|
||||
return when (this) {
|
||||
Group.Member.Role.UNKNOWN -> Member.Role.UNKNOWN
|
||||
Group.Member.Role.DEFAULT -> Member.Role.DEFAULT
|
||||
Group.Member.Role.ADMINISTRATOR -> Member.Role.ADMINISTRATOR
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccessControl.AccessRequired.toSnapshot(): Group.AccessControl.AccessRequired {
|
||||
return when (this) {
|
||||
AccessControl.AccessRequired.UNKNOWN -> Group.AccessControl.AccessRequired.UNKNOWN
|
||||
AccessControl.AccessRequired.ANY -> Group.AccessControl.AccessRequired.ANY
|
||||
AccessControl.AccessRequired.MEMBER -> Group.AccessControl.AccessRequired.MEMBER
|
||||
AccessControl.AccessRequired.ADMINISTRATOR -> Group.AccessControl.AccessRequired.ADMINISTRATOR
|
||||
AccessControl.AccessRequired.UNSATISFIABLE -> Group.AccessControl.AccessRequired.UNSATISFIABLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccessControl.toSnapshot(): Group.AccessControl {
|
||||
return Group.AccessControl(members = members.toSnapshot(), attributes = attributes.toSnapshot(), addFromInviteLink = addFromInviteLink.toSnapshot())
|
||||
}
|
||||
|
||||
private fun Member.Role.toSnapshot(): Group.Member.Role {
|
||||
return when (this) {
|
||||
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
|
||||
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
|
||||
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
|
||||
}
|
||||
}
|
||||
|
||||
private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
|
||||
if (revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) {
|
||||
return null
|
||||
}
|
||||
return Group.GroupSnapshot(
|
||||
title = Group.GroupAttributeBlob(title = title),
|
||||
avatarUrl = avatar,
|
||||
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = disappearingMessagesTimer?.duration ?: 0),
|
||||
accessControl = accessControl?.toSnapshot(),
|
||||
version = revision,
|
||||
members = members.map { it.toSnapshot() },
|
||||
membersPendingProfileKey = pendingMembers.map { it.toSnapshot() },
|
||||
membersPendingAdminApproval = requestingMembers.map { it.toSnapshot() },
|
||||
inviteLinkPassword = inviteLinkPassword,
|
||||
description = Group.GroupAttributeBlob(descriptionText = description),
|
||||
announcements_only = isAnnouncementGroup == EnabledState.ENABLED,
|
||||
members_banned = bannedMembers.map { it.toSnapshot() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.Member.toLocal(): DecryptedMember {
|
||||
return DecryptedMember(aciBytes = userId, role = role.toLocal(), profileKey = profileKey, joinedAtRevision = joinedAtVersion)
|
||||
}
|
||||
|
||||
private fun DecryptedMember.toSnapshot(): Group.Member {
|
||||
return Group.Member(userId = aciBytes, role = role.toSnapshot(), profileKey = profileKey, joinedAtVersion = joinedAtRevision)
|
||||
}
|
||||
|
||||
private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember {
|
||||
return DecryptedPendingMember(
|
||||
serviceIdBytes = member!!.userId,
|
||||
role = member.role.toLocal(),
|
||||
addedByAci = addedByUserId,
|
||||
timestamp = timestamp,
|
||||
serviceIdCipherText = operations.encryptServiceId(ServiceId.Companion.parseOrNull(member.userId))
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedPendingMember.toSnapshot(): Group.MemberPendingProfileKey {
|
||||
return Group.MemberPendingProfileKey(
|
||||
member = Group.Member(
|
||||
userId = serviceIdBytes,
|
||||
role = role.toSnapshot()
|
||||
),
|
||||
addedByUserId = addedByAci,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMember {
|
||||
return DecryptedRequestingMember(
|
||||
aciBytes = userId,
|
||||
profileKey = profileKey,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedRequestingMember.toSnapshot(): Group.MemberPendingAdminApproval {
|
||||
return Group.MemberPendingAdminApproval(
|
||||
userId = aciBytes,
|
||||
profileKey = profileKey,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.MemberBanned.toLocal(): DecryptedBannedMember {
|
||||
return DecryptedBannedMember(
|
||||
serviceIdBytes = userId,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedBannedMember.toSnapshot(): Group.MemberBanned {
|
||||
return Group.MemberBanned(
|
||||
userId = serviceIdBytes,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.GroupSnapshot.toDecryptedGroup(operations: GroupsV2Operations.GroupOperations): DecryptedGroup {
|
||||
return DecryptedGroup(
|
||||
title = title?.title ?: "",
|
||||
avatar = avatarUrl,
|
||||
disappearingMessagesTimer = DecryptedTimer(duration = disappearingMessagesTimer?.disappearingMessagesDuration ?: 0),
|
||||
accessControl = accessControl?.toLocal(),
|
||||
revision = version,
|
||||
members = members.map { member -> member.toLocal() },
|
||||
pendingMembers = membersPendingProfileKey.map { pending -> pending.toLocal(operations) },
|
||||
requestingMembers = membersPendingAdminApproval.map { requesting -> requesting.toLocal() },
|
||||
inviteLinkPassword = inviteLinkPassword,
|
||||
description = description?.descriptionText ?: "",
|
||||
isAnnouncementGroup = if (announcements_only) EnabledState.ENABLED else EnabledState.DISABLED,
|
||||
bannedMembers = members_banned.map { it.toLocal() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun Contact.toLocalExtras(): RecipientExtras {
|
||||
return RecipientExtras(
|
||||
hideStory = this.hideStory
|
||||
@@ -331,6 +461,8 @@ class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient
|
||||
val extras = RecipientTableCursorUtil.getExtras(cursor)
|
||||
val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE))
|
||||
|
||||
val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!)
|
||||
|
||||
return BackupRecipient(
|
||||
id = cursor.requireLong(RecipientTable.ID),
|
||||
group = BackupGroup(
|
||||
@@ -338,7 +470,7 @@ class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient
|
||||
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
|
||||
hideStory = extras?.hideStory() ?: false,
|
||||
storySendMode = showAsStoryState.toGroupStorySendMode(),
|
||||
name = cursor.requireString(GroupTable.TITLE) ?: ""
|
||||
snapshot = decryptedGroup.toSnapshot()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,29 +7,28 @@ package org.thoughtcrime.securesms.backup.v2.processor
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getCallsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getAdhocCallsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreCallLogFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call
|
||||
object AdHocCallBackupProcessor {
|
||||
|
||||
object CallLogBackupProcessor {
|
||||
|
||||
val TAG = Log.tag(CallLogBackupProcessor::class.java)
|
||||
val TAG = Log.tag(AdHocCallBackupProcessor::class.java)
|
||||
|
||||
fun export(emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.calls.getCallsForBackup().use { reader ->
|
||||
SignalDatabase.calls.getAdhocCallsForBackup().use { reader ->
|
||||
for (callLog in reader) {
|
||||
if (callLog != null) {
|
||||
emitter.emit(Frame(call = callLog))
|
||||
emitter.emit(Frame(adHocCall = callLog))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun import(call: BackupCall, backupState: BackupState) {
|
||||
fun import(call: AdHocCall, backupState: BackupState) {
|
||||
SignalDatabase.calls.restoreCallLogFromBackup(call, backupState)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ object ChatItemBackupProcessor {
|
||||
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
|
||||
|
||||
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
SignalDatabase.messages.getMessagesForBackup().use { chatItems ->
|
||||
SignalDatabase.messages.getMessagesForBackup(exportState.backupTime, exportState.allowMediaBackup).use { chatItems ->
|
||||
for (chatItem in chatItems) {
|
||||
if (exportState.threadIds.contains(chatItem.chatId)) {
|
||||
emitter.emit(Frame(chatItem = chatItem))
|
||||
|
||||
@@ -10,9 +10,13 @@ import org.thoughtcrime.securesms.backup.v2.BackupState
|
||||
import org.thoughtcrime.securesms.backup.v2.ExportState
|
||||
import org.thoughtcrime.securesms.backup.v2.database.BackupRecipient
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getCallLinksForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.getGroupsForBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreRecipientFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreContactFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreGroupFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.database.restoreReleaseNotes
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.ReleaseNotes
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
@@ -60,10 +64,26 @@ object RecipientBackupProcessor {
|
||||
state.recipientIds.add(it.id)
|
||||
emitter.emit(Frame(recipient = it))
|
||||
}
|
||||
|
||||
SignalDatabase.callLinks.getCallLinksForBackup().forEach {
|
||||
state.recipientIds.add(it.id)
|
||||
emitter.emit(Frame(recipient = it))
|
||||
}
|
||||
}
|
||||
|
||||
fun import(recipient: BackupRecipient, backupState: BackupState) {
|
||||
val newId = SignalDatabase.recipients.restoreRecipientFromBackup(recipient, backupState)
|
||||
val newId = when {
|
||||
recipient.contact != null -> SignalDatabase.recipients.restoreContactFromBackup(recipient.contact)
|
||||
recipient.group != null -> SignalDatabase.recipients.restoreGroupFromBackup(recipient.group)
|
||||
recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState)
|
||||
recipient.self != null -> Recipient.self().id
|
||||
recipient.releaseNotes != null -> SignalDatabase.recipients.restoreReleaseNotes()
|
||||
recipient.callLink != null -> SignalDatabase.callLinks.restoreFromBackup(recipient.callLink)
|
||||
else -> {
|
||||
Log.w(TAG, "Unrecognized recipient type!")
|
||||
null
|
||||
}
|
||||
}
|
||||
if (newId != null) {
|
||||
backupState.backupToLocalRecipientId[recipient.id] = newId
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.signal.libsignal.protocol.kdf.HKDF
|
||||
import java.io.FilterOutputStream
|
||||
import java.io.OutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class BackupEncryptedOutputStream(key: ByteArray, backupId: ByteArray, wrapped: OutputStream) : FilterOutputStream(wrapped) {
|
||||
|
||||
val cipher: Cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
val mac: Mac = Mac.getInstance("HmacSHA256")
|
||||
|
||||
var finalMac: ByteArray? = null
|
||||
|
||||
init {
|
||||
if (key.size != 32) {
|
||||
throw IllegalArgumentException("Key must be 32 bytes!")
|
||||
}
|
||||
|
||||
if (backupId.size != 16) {
|
||||
throw IllegalArgumentException("BackupId must be 32 bytes!")
|
||||
}
|
||||
|
||||
val extendedKey = HKDF.deriveSecrets(key, backupId, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
|
||||
val macKey = extendedKey.copyOfRange(0, 32)
|
||||
val cipherKey = extendedKey.copyOfRange(32, 64)
|
||||
val iv = extendedKey.copyOfRange(64, 80)
|
||||
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||
}
|
||||
|
||||
override fun write(b: Int) {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
override fun write(data: ByteArray) {
|
||||
write(data, 0, data.size)
|
||||
}
|
||||
|
||||
override fun write(data: ByteArray, off: Int, len: Int) {
|
||||
cipher.update(data, off, len)?.let { ciphertext ->
|
||||
mac.update(ciphertext)
|
||||
super.write(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
cipher.doFinal()?.let { ciphertext ->
|
||||
mac.update(ciphertext)
|
||||
super.write(ciphertext)
|
||||
}
|
||||
|
||||
finalMac = mac.doFinal()
|
||||
|
||||
super.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
flush()
|
||||
super.close()
|
||||
}
|
||||
|
||||
fun getMac(): ByteArray {
|
||||
return finalMac ?: throw IllegalStateException("Mac not yet available! You must call flush() before asking for the mac.")
|
||||
}
|
||||
}
|
||||
@@ -41,18 +41,21 @@ class EncryptedBackupReader(
|
||||
val stream: InputStream
|
||||
|
||||
init {
|
||||
val keyMaterial = key.deriveSecrets(aci)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
|
||||
}
|
||||
val keyMaterial = key.deriveBackupSecrets(aci)
|
||||
|
||||
validateMac(keyMaterial.macKey, streamLength, dataStream())
|
||||
|
||||
val inputStream = dataStream()
|
||||
val iv = inputStream.readNBytesOrThrow(16)
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv))
|
||||
}
|
||||
|
||||
stream = GZIPInputStream(
|
||||
CipherInputStream(
|
||||
TruncatingInputStream(
|
||||
wrapped = dataStream(),
|
||||
wrapped = inputStream,
|
||||
maxBytes = streamLength - MAC_SIZE
|
||||
),
|
||||
cipher
|
||||
|
||||
@@ -9,11 +9,11 @@ import org.signal.core.util.stream.MacOutputStream
|
||||
import org.signal.core.util.writeVarInt32
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherOutputStream
|
||||
import javax.crypto.Mac
|
||||
@@ -33,28 +33,29 @@ class EncryptedBackupWriter(
|
||||
private val append: (ByteArray) -> Unit
|
||||
) : BackupExportWriter {
|
||||
|
||||
private val mainStream: GZIPOutputStream
|
||||
private val mainStream: PaddedGzipOutputStream
|
||||
private val macStream: MacOutputStream
|
||||
|
||||
init {
|
||||
val keyMaterial = key.deriveSecrets(aci)
|
||||
val keyMaterial = key.deriveBackupSecrets(aci)
|
||||
|
||||
val iv: ByteArray = Util.getSecretBytes(16)
|
||||
outputStream.write(iv)
|
||||
outputStream.flush()
|
||||
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
|
||||
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv))
|
||||
}
|
||||
|
||||
val mac = Mac.getInstance("HmacSHA256").apply {
|
||||
init(SecretKeySpec(keyMaterial.macKey, "HmacSHA256"))
|
||||
update(iv)
|
||||
}
|
||||
|
||||
macStream = MacOutputStream(outputStream, mac)
|
||||
val cipherStream = CipherOutputStream(macStream, cipher)
|
||||
|
||||
mainStream = GZIPOutputStream(
|
||||
CipherOutputStream(
|
||||
macStream,
|
||||
cipher
|
||||
)
|
||||
)
|
||||
mainStream = PaddedGzipOutputStream(cipherStream)
|
||||
}
|
||||
|
||||
override fun write(header: BackupInfo) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.stream
|
||||
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.FilterOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
|
||||
/**
|
||||
* GZIPs the content of the provided [outputStream], but also adds padding to the end of the stream using the same algorithm as [PaddingInputStream].
|
||||
* We do this to fit files into a smaller number of size buckets to avoid fingerprinting. And it turns out that bolting on zeros to the end of a GZIP stream is
|
||||
* fine, because GZIP is smart enough to ignore it. This means readers of this data don't have to do anything special.
|
||||
*/
|
||||
class PaddedGzipOutputStream private constructor(private val outputStream: SizeObservingOutputStream) : GZIPOutputStream(outputStream) {
|
||||
|
||||
constructor(outputStream: OutputStream) : this(SizeObservingOutputStream(outputStream))
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
|
||||
val totalLength = outputStream.size
|
||||
val paddedSize: Long = PaddingInputStream.getPaddedSize(totalLength)
|
||||
val paddingToAdd: Int = (paddedSize - totalLength).toInt()
|
||||
|
||||
outputStream.write(ByteArray(paddingToAdd))
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to know the size of the *compressed* stream to know how much padding to add at the end.
|
||||
*/
|
||||
private class SizeObservingOutputStream(val wrapped: OutputStream) : FilterOutputStream(wrapped) {
|
||||
|
||||
var size: Long = 0L
|
||||
private set
|
||||
|
||||
override fun write(b: Int) {
|
||||
wrapped.write(b)
|
||||
size++
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray) {
|
||||
wrapped.write(b)
|
||||
size += b.size
|
||||
}
|
||||
|
||||
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||
wrapped.write(b, off, len)
|
||||
size += len
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.BundleCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Icons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
|
||||
/**
|
||||
* Notifies the user of an issue with their backup.
|
||||
*/
|
||||
class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_ALERT = "alert"
|
||||
|
||||
fun create(backupAlert: BackupAlert): BackupAlertBottomSheet {
|
||||
return BackupAlertBottomSheet().apply {
|
||||
arguments = bundleOf(ARG_ALERT to backupAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val backupAlert: BackupAlert by lazy(LazyThreadSafetyMode.NONE) {
|
||||
BundleCompat.getParcelable(requireArguments(), ARG_ALERT, BackupAlert::class.java)!!
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = backupAlert,
|
||||
onPrimaryActionClick = this::performPrimaryAction,
|
||||
onSecondaryActionClick = this::performSecondaryAction
|
||||
)
|
||||
}
|
||||
|
||||
@Stable
|
||||
private fun performPrimaryAction() {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> {
|
||||
// TODO [message-backups] -- Back up now
|
||||
}
|
||||
BackupAlert.PAYMENT_PROCESSING -> {
|
||||
// TODO [message-backups] -- Silence
|
||||
}
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> {
|
||||
// TODO [message-backups] -- Download media now
|
||||
}
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
|
||||
// TODO [message-backups] -- Download media now
|
||||
}
|
||||
}
|
||||
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
@Stable
|
||||
private fun performSecondaryAction() {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> {
|
||||
// TODO [message-backups] - Dismiss and notify later
|
||||
}
|
||||
BackupAlert.PAYMENT_PROCESSING -> error("PAYMENT_PROCESSING state does not support a secondary action.")
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> {
|
||||
// TODO [message-backups] - Silence and remind on last day
|
||||
}
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
|
||||
// TODO [message-backups] - Silence forever
|
||||
}
|
||||
}
|
||||
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupAlertSheetContent(
|
||||
backupAlert: BackupAlert,
|
||||
onPrimaryActionClick: () -> Unit,
|
||||
onSecondaryActionClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(modifier = Modifier.size(26.dp))
|
||||
|
||||
val iconColors = rememberBackupsIconColors(backupAlert = backupAlert)
|
||||
Icons.BrushedForeground(
|
||||
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [message-backups] final asset
|
||||
contentDescription = null,
|
||||
foregroundBrush = iconColors.foreground,
|
||||
modifier = Modifier
|
||||
.size(88.dp)
|
||||
.background(color = iconColors.background, shape = CircleShape)
|
||||
.padding(20.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = rememberTitleResource(backupAlert = backupAlert)),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
|
||||
)
|
||||
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> GenericBody()
|
||||
BackupAlert.PAYMENT_PROCESSING -> PaymentProcessingBody()
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> MediaBackupsAreOffBody()
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> MediaWillBeDeletedTodayBody()
|
||||
}
|
||||
|
||||
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
|
||||
val padBottom = if (secondaryActionResource > 0) 16.dp else 56.dp
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onPrimaryActionClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(top = 60.dp, bottom = padBottom)
|
||||
) {
|
||||
Text(text = stringResource(id = rememberPrimaryActionResource(backupAlert = backupAlert)))
|
||||
}
|
||||
|
||||
if (secondaryActionResource > 0) {
|
||||
TextButton(onClick = onSecondaryActionClick, modifier = Modifier.padding(bottom = 32.dp)) {
|
||||
Text(text = stringResource(id = secondaryActionResource))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenericBody() {
|
||||
Text(text = "TODO")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentProcessingBody() {
|
||||
Text(text = "TODO")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaBackupsAreOffBody() {
|
||||
Text(text = "TODO")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaWillBeDeletedTodayBody() {
|
||||
Text(text = "TODO")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColors {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC, BackupAlert.PAYMENT_PROCESSING -> BackupsIconColors.Warning
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF, BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> BackupsIconColors.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@StringRes
|
||||
private fun rememberTitleResource(backupAlert: BackupAlert): Int {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.PAYMENT_PROCESSING -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPrimaryActionResource(backupAlert: BackupAlert): Int {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.PAYMENT_PROCESSING -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> android.R.string.ok // TODO [message-backups] -- Finalized copy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
|
||||
return remember(backupAlert) {
|
||||
when (backupAlert) {
|
||||
BackupAlert.GENERIC -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.PAYMENT_PROCESSING -> -1
|
||||
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
|
||||
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewGeneric() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.GENERIC,
|
||||
onPrimaryActionClick = {},
|
||||
onSecondaryActionClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewPayment() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.PAYMENT_PROCESSING,
|
||||
onPrimaryActionClick = {},
|
||||
onSecondaryActionClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewMedia() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.MEDIA_BACKUPS_ARE_OFF,
|
||||
onPrimaryActionClick = {},
|
||||
onSecondaryActionClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun BackupAlertSheetContentPreviewDelete() {
|
||||
Previews.BottomSheetPreview {
|
||||
BackupAlertSheetContent(
|
||||
backupAlert = BackupAlert.MEDIA_WILL_BE_DELETED_TODAY,
|
||||
onPrimaryActionClick = {},
|
||||
onSecondaryActionClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
enum class BackupAlert : Parcelable {
|
||||
GENERIC,
|
||||
PAYMENT_PROCESSING,
|
||||
MEDIA_BACKUPS_ARE_OFF,
|
||||
MEDIA_WILL_BE_DELETED_TODAY
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
|
||||
sealed interface BackupsIconColors {
|
||||
@get:Composable
|
||||
val foreground: Brush
|
||||
|
||||
@get:Composable
|
||||
val background: Color
|
||||
|
||||
object Normal : BackupsIconColors {
|
||||
override val foreground: Brush
|
||||
@Composable get() = remember {
|
||||
Brush.linearGradient(
|
||||
colors = listOf(Color(0xFF316ED0), Color(0xFF558BE2)),
|
||||
start = Offset(x = 0f, y = Float.POSITIVE_INFINITY),
|
||||
end = Offset(x = Float.POSITIVE_INFINITY, y = 0f)
|
||||
)
|
||||
}
|
||||
|
||||
override val background: Color @Composable get() = MaterialTheme.colorScheme.primaryContainer
|
||||
}
|
||||
|
||||
object Warning : BackupsIconColors {
|
||||
override val foreground: Brush @Composable get() = SolidColor(Color(0xFFC86600))
|
||||
override val background: Color @Composable get() = Color(0xFFF9E4B6)
|
||||
}
|
||||
|
||||
object Error : BackupsIconColors {
|
||||
override val foreground: Brush @Composable get() = SolidColor(MaterialTheme.colorScheme.error)
|
||||
override val background: Color @Composable get() = Color(0xFFFFD9D9)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.restore
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.devicetransfer.moreoptions.MoreTransferOrRestoreOptionsMode
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Fragment which facilitates restoring from a backup during
|
||||
* registration.
|
||||
*/
|
||||
class RestoreFromBackupFragment : ComposeFragment() {
|
||||
|
||||
private val navArgs: RestoreFromBackupFragmentArgs by navArgs()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
RestoreFromBackupContent(
|
||||
features = persistentListOf(),
|
||||
onRestoreBackupClick = {
|
||||
// TODO [message-backups] Restore backup.
|
||||
},
|
||||
onCancelClick = {
|
||||
findNavController()
|
||||
.popBackStack()
|
||||
},
|
||||
onMoreOptionsClick = {
|
||||
findNavController()
|
||||
.safeNavigate(RestoreFromBackupFragmentDirections.actionRestoreFromBacakupFragmentToMoreOptions(MoreTransferOrRestoreOptionsMode.SELECTION))
|
||||
},
|
||||
cancelable = navArgs.cancelable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RestoreFromBackupContentPreview() {
|
||||
Previews.Preview {
|
||||
RestoreFromBackupContent(
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Your last 30 days of media"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
|
||||
label = "All of your text messages"
|
||||
)
|
||||
),
|
||||
onRestoreBackupClick = {},
|
||||
onCancelClick = {},
|
||||
onMoreOptionsClick = {},
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestoreFromBackupContent(
|
||||
features: ImmutableList<MessageBackupsTypeFeature>,
|
||||
onRestoreBackupClick: () -> Unit,
|
||||
onCancelClick: () -> Unit,
|
||||
onMoreOptionsClick: () -> Unit,
|
||||
cancelable: Boolean
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.padding(top = 40.dp, bottom = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Restore from backup", // TODO [message-backups] Finalized copy.
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
val yourLastBackupText = buildAnnotatedString {
|
||||
append("Your last backup was made on March 5, 2024 at 9:00am.") // TODO [message-backups] Finalized copy.
|
||||
append(" ")
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
append("Only media sent or received in the past 30 days is included.") // TODO [message-backups] Finalized copy.
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = yourLastBackupText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 28.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(top = 20.dp, bottom = 18.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Your backup includes:", // TODO [message-backups] Finalized copy.
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
features.forEach {
|
||||
MessageBackupsTypeFeatureRow(
|
||||
messageBackupsTypeFeature = it,
|
||||
iconTint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onRestoreBackupClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Restore backup" // TODO [message-backups] Finalized copy.
|
||||
)
|
||||
}
|
||||
|
||||
if (cancelable) {
|
||||
TextButton(
|
||||
onClick = onCancelClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = android.R.string.cancel)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = onMoreOptionsClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.TransferOrRestoreFragment__more_options)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.status
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Icons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
private const val NONE = -1
|
||||
|
||||
/**
|
||||
* Displays a "heads up" widget containing information about the current
|
||||
* status of the user's backup.
|
||||
*/
|
||||
@Composable
|
||||
fun BackupStatus(
|
||||
data: BackupStatusData,
|
||||
onActionClick: () -> Unit = {}
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp)
|
||||
) {
|
||||
val foreground: Brush = data.iconColors.foreground
|
||||
Icons.BrushedForeground(
|
||||
painter = painterResource(id = data.iconRes),
|
||||
contentDescription = null,
|
||||
foregroundBrush = foreground,
|
||||
modifier = Modifier
|
||||
.background(color = data.iconColors.background, shape = CircleShape)
|
||||
.padding(8.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = data.titleRes),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
if (data.progress >= 0f) {
|
||||
LinearProgressIndicator(
|
||||
progress = data.progress,
|
||||
strokeCap = StrokeCap.Round,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (data.statusRes != NONE) {
|
||||
Text(
|
||||
text = stringResource(id = data.statusRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.actionRes != NONE) {
|
||||
Buttons.Small(
|
||||
onClick = onActionClick,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = data.actionRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BackupStatusPreview() {
|
||||
Previews.Preview {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
BackupStatus(
|
||||
data = BackupStatusData.CouldNotCompleteBackup
|
||||
)
|
||||
|
||||
BackupStatus(
|
||||
data = BackupStatusData.NotEnoughFreeSpace
|
||||
)
|
||||
|
||||
BackupStatus(
|
||||
data = BackupStatusData.RestoringMedia(50, 100)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed interface describing status data to display in BackupStatus widget.
|
||||
*
|
||||
* TODO [message-requests] - Finalize assets and text
|
||||
*/
|
||||
sealed interface BackupStatusData {
|
||||
|
||||
@get:DrawableRes
|
||||
val iconRes: Int
|
||||
|
||||
@get:StringRes
|
||||
val titleRes: Int
|
||||
|
||||
val iconColors: BackupsIconColors
|
||||
|
||||
@get:StringRes
|
||||
val actionRes: Int get() = NONE
|
||||
|
||||
@get:StringRes
|
||||
val statusRes: Int get() = NONE
|
||||
|
||||
val progress: Float get() = NONE.toFloat()
|
||||
|
||||
/**
|
||||
* Generic failure
|
||||
*/
|
||||
object CouldNotCompleteBackup : BackupStatusData {
|
||||
override val iconRes: Int = R.drawable.symbol_backup_light
|
||||
override val titleRes: Int = R.string.default_error_msg
|
||||
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
|
||||
}
|
||||
|
||||
/**
|
||||
* User does not have enough space on their device to complete backup restoration
|
||||
*/
|
||||
object NotEnoughFreeSpace : BackupStatusData {
|
||||
override val iconRes: Int = R.drawable.symbol_backup_light
|
||||
override val titleRes: Int = R.string.default_error_msg
|
||||
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
|
||||
override val actionRes: Int = R.string.registration_activity__skip
|
||||
}
|
||||
|
||||
/**
|
||||
* Restoring media, finished, and paused states.
|
||||
*/
|
||||
data class RestoringMedia(
|
||||
val bytesDownloaded: Long,
|
||||
val bytesTotal: Long,
|
||||
val status: Status = Status.NONE
|
||||
) : BackupStatusData {
|
||||
override val iconRes: Int = R.drawable.symbol_backup_light
|
||||
override val iconColors: BackupsIconColors = BackupsIconColors.Normal
|
||||
|
||||
override val titleRes: Int = when (status) {
|
||||
Status.NONE -> R.string.default_error_msg
|
||||
Status.LOW_BATTERY -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_INTERNET -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_WIFI -> R.string.default_error_msg
|
||||
Status.FINISHED -> R.string.default_error_msg
|
||||
}
|
||||
|
||||
override val statusRes: Int = when (status) {
|
||||
Status.NONE -> R.string.default_error_msg
|
||||
Status.LOW_BATTERY -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_INTERNET -> R.string.default_error_msg
|
||||
Status.WAITING_FOR_WIFI -> R.string.default_error_msg
|
||||
Status.FINISHED -> R.string.default_error_msg
|
||||
}
|
||||
|
||||
override val progress: Float = if (bytesTotal > 0) {
|
||||
min(1f, max(0f, bytesDownloaded.toFloat() / bytesTotal))
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the status of an in-progress media download session.
|
||||
*/
|
||||
enum class Status {
|
||||
NONE,
|
||||
LOW_BATTERY,
|
||||
WAITING_FOR_INTERNET,
|
||||
WAITING_FOR_WIFI,
|
||||
FINISHED
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
@@ -30,23 +30,20 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsCheckoutSheet(
|
||||
messageBackupsType: MessageBackupsType,
|
||||
messageBackupTier: MessageBackupTier,
|
||||
availablePaymentGateways: List<GatewayResponse.Gateway>,
|
||||
onDismissRequest: () -> Unit,
|
||||
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
|
||||
@@ -57,7 +54,7 @@ fun MessageBackupsCheckoutSheet(
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupsType = messageBackupsType,
|
||||
messageBackupTier = messageBackupTier,
|
||||
availablePaymentGateways = availablePaymentGateways,
|
||||
onPaymentGatewaySelected = onPaymentGatewaySelected
|
||||
)
|
||||
@@ -66,13 +63,16 @@ fun MessageBackupsCheckoutSheet(
|
||||
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
messageBackupsType: MessageBackupsType,
|
||||
messageBackupTier: MessageBackupTier,
|
||||
availablePaymentGateways: List<GatewayResponse.Gateway>,
|
||||
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
|
||||
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
val backupTypeDetails = remember(messageBackupTier) {
|
||||
getTierDetails(messageBackupTier)
|
||||
}
|
||||
val formattedPrice = remember(backupTypeDetails.pricePerMonth) {
|
||||
FiatMoneyUtil.format(resources, backupTypeDetails.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -88,7 +88,7 @@ private fun SheetContent(
|
||||
)
|
||||
|
||||
MessageBackupsTypeBlock(
|
||||
messageBackupsType = messageBackupsType,
|
||||
messageBackupsType = backupTypeDetails,
|
||||
isSelected = false,
|
||||
onSelected = {},
|
||||
enabled = false,
|
||||
@@ -221,29 +221,6 @@ private fun CreditOrDebitCardButton(
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MessageBackupsCheckoutSheetPreview() {
|
||||
val paidTier = MessageBackupsType(
|
||||
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
|
||||
title = "Text + All your media",
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Full text message backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_album_compact_bold_16,
|
||||
label = "Full media backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "1TB of storage (~250K photos)"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
|
||||
label = "Thanks for supporting Signal!"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val availablePaymentGateways = GatewayResponse.Gateway.values().toList()
|
||||
|
||||
Previews.Preview {
|
||||
@@ -252,7 +229,7 @@ private fun MessageBackupsCheckoutSheetPreview() {
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupsType = paidTier,
|
||||
messageBackupTier = MessageBackupTier.PAID,
|
||||
availablePaymentGateways = availablePaymentGateways,
|
||||
onPaymentGatewaySelected = {}
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -32,6 +32,10 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
|
||||
|
||||
fun MessageBackupsScreen.next() {
|
||||
val nextScreen = viewModel.goToNextScreen(this)
|
||||
if (nextScreen == MessageBackupsScreen.COMPLETED) {
|
||||
finishAfterTransition()
|
||||
return
|
||||
}
|
||||
if (nextScreen != this) {
|
||||
navController.navigate(nextScreen.name)
|
||||
}
|
||||
@@ -53,7 +57,7 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = MessageBackupsScreen.EDUCATION.name,
|
||||
startDestination = if (state.currentMessageBackupTier == null) MessageBackupsScreen.EDUCATION.name else MessageBackupsScreen.TYPE_SELECTION.name,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
|
||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
|
||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
|
||||
@@ -88,9 +92,9 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
|
||||
|
||||
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
|
||||
MessageBackupsTypeSelectionScreen(
|
||||
selectedBackupsType = state.selectedMessageBackupsType,
|
||||
availableBackupsTypes = state.availableBackupsTypes,
|
||||
onMessageBackupsTypeSelected = viewModel::onMessageBackupsTypeUpdated,
|
||||
selectedBackupTier = state.selectedMessageBackupTier,
|
||||
availableBackupTiers = state.availableBackupTiers,
|
||||
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
|
||||
onNavigationClick = navController::popOrFinish,
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() }
|
||||
@@ -99,7 +103,7 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
|
||||
|
||||
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
|
||||
MessageBackupsCheckoutSheet(
|
||||
messageBackupsType = state.selectedMessageBackupsType!!,
|
||||
messageBackupTier = state.selectedMessageBackupTier!!,
|
||||
availablePaymentGateways = state.availablePaymentGateways,
|
||||
onDismissRequest = navController::popOrFinish,
|
||||
onPaymentGatewaySelected = {
|
||||
@@ -3,6 +3,6 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
class MessageBackupsFlowRepository
|
||||
@@ -3,15 +3,17 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
|
||||
data class MessageBackupsFlowState(
|
||||
val selectedMessageBackupsType: MessageBackupsType? = null,
|
||||
val availableBackupsTypes: List<MessageBackupsType> = emptyList(),
|
||||
val selectedMessageBackupTier: MessageBackupTier? = null,
|
||||
val currentMessageBackupTier: MessageBackupTier? = null,
|
||||
val availableBackupTiers: List<MessageBackupTier> = emptyList(),
|
||||
val selectedPaymentGateway: GatewayResponse.Gateway? = null,
|
||||
val availablePaymentGateways: List<GatewayResponse.Gateway> = emptyList(),
|
||||
val pin: String = "",
|
||||
@@ -3,16 +3,31 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash
|
||||
|
||||
class MessageBackupsFlowViewModel : ViewModel() {
|
||||
private val internalState = mutableStateOf(MessageBackupsFlowState())
|
||||
private val internalState = mutableStateOf(
|
||||
MessageBackupsFlowState(
|
||||
availableBackupTiers = if (!FeatureFlags.messageBackups()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
|
||||
},
|
||||
selectedMessageBackupTier = SignalStore.backup().backupTier
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<MessageBackupsFlowState> = internalState
|
||||
|
||||
@@ -40,16 +55,27 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
internalState.value = state.value.copy(selectedPaymentGateway = gateway)
|
||||
}
|
||||
|
||||
fun onMessageBackupsTypeUpdated(messageBackupsType: MessageBackupsType) {
|
||||
internalState.value = state.value.copy(selectedMessageBackupsType = messageBackupsType)
|
||||
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
|
||||
internalState.value = state.value.copy(selectedMessageBackupTier = messageBackupTier)
|
||||
}
|
||||
|
||||
private fun validatePinAndUpdateState(): MessageBackupsScreen {
|
||||
val pinHash = SignalStore.svr().localPinHash
|
||||
val pin = state.value.pin
|
||||
|
||||
if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) return MessageBackupsScreen.PIN_CONFIRMATION
|
||||
|
||||
if (!verifyLocalPinHash(pinHash, pin)) {
|
||||
return MessageBackupsScreen.PIN_CONFIRMATION
|
||||
}
|
||||
return MessageBackupsScreen.TYPE_SELECTION
|
||||
}
|
||||
|
||||
private fun validateTypeAndUpdateState(): MessageBackupsScreen {
|
||||
return MessageBackupsScreen.CHECKOUT_SHEET
|
||||
SignalStore.backup().canReadWriteToArchiveCdn = state.value.selectedMessageBackupTier == MessageBackupTier.PAID
|
||||
SignalStore.backup().areBackupsEnabled = true
|
||||
return MessageBackupsScreen.COMPLETED
|
||||
// return MessageBackupsScreen.CHECKOUT_SHEET TODO [message-backups] Switch back to payment flow
|
||||
}
|
||||
|
||||
private fun validateGatewayAndUpdateState(): MessageBackupsScreen {
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
@@ -32,6 +33,7 @@ import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -52,93 +54,95 @@ fun MessageBackupsPinConfirmationScreen(
|
||||
onNextClick: () -> Unit
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
LazyColumn(
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Enter your PIN", // TODO [message-backups] Finalized copy
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// TODO [message-backups] Confirm default focus state
|
||||
val keyboardType = remember(pinKeyboardType) {
|
||||
when (pinKeyboardType) {
|
||||
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
|
||||
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = "Enter your PIN", // TODO [message-backups] Finalized copy
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 40.dp)
|
||||
)
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = pin,
|
||||
onValueChange = onPinChanged,
|
||||
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onNextClick() }
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 72.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
item {
|
||||
Text(
|
||||
text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// TODO [message-backups] Confirm default focus state
|
||||
val keyboardType = remember(pinKeyboardType) {
|
||||
when (pinKeyboardType) {
|
||||
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
|
||||
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
|
||||
}
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = pin,
|
||||
onValueChange = onPinChanged,
|
||||
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onNextClick() }
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(top = 72.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
visualTransformation = PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 48.dp)
|
||||
) {
|
||||
PinKeyboardTypeToggle(
|
||||
pinKeyboardType = pinKeyboardType,
|
||||
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 48.dp)
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClick
|
||||
) {
|
||||
PinKeyboardTypeToggle(
|
||||
pinKeyboardType = pinKeyboardType,
|
||||
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
|
||||
Text(
|
||||
text = "Next" // TODO [message-backups] Finalized copy
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onNextClick
|
||||
) {
|
||||
Text(
|
||||
text = "Next" // TODO [message-backups] Finalized copy
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -96,22 +96,22 @@ fun MessageBackupsPinEducationScreen(
|
||||
}
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = onGeneratePinClick,
|
||||
onClick = onUseCurrentPinClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy
|
||||
text = "Use current Signal PIN" // TODO [message-backups] Finalized copy
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onUseCurrentPinClick,
|
||||
onClick = onGeneratePinClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Use current Signal PIN" // TODO [message-backups] Finalized copy
|
||||
text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
enum class MessageBackupsScreen {
|
||||
EDUCATION,
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.util.getLength
|
||||
import org.thoughtcrime.securesms.BaseActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil
|
||||
|
||||
class MessageBackupsTestRestoreActivity : BaseActivity() {
|
||||
companion object {
|
||||
fun getIntent(context: Context): Intent {
|
||||
return Intent(context, MessageBackupsTestRestoreActivity::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel: MessageBackupsTestRestoreViewModel by viewModels()
|
||||
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
private fun onPlaintextClicked() {
|
||||
viewModel.onPlaintextToggled()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
contentResolver.getLength(uri)?.let { length ->
|
||||
viewModel.import(length) { contentResolver.openInputStream(uri)!! }
|
||||
}
|
||||
} ?: Toast.makeText(this, "No URI selected", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
val state by viewModel.state
|
||||
Surface {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Buttons.LargePrimary(
|
||||
onClick = this@MessageBackupsTestRestoreActivity::restoreFromServer,
|
||||
enabled = !state.importState.inProgress
|
||||
) {
|
||||
Text("Restore")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StateLabel(text = "Plaintext?")
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Switch(
|
||||
checked = state.plaintext,
|
||||
onCheckedChange = { onPlaintextClicked() }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
importFileLauncher.launch(intent)
|
||||
},
|
||||
enabled = !state.importState.inProgress
|
||||
) {
|
||||
Text("Import from file")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = { continueRegistration() },
|
||||
enabled = !state.importState.inProgress
|
||||
) {
|
||||
Text("Continue Reg Flow")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.importState == MessageBackupsTestRestoreViewModel.ImportState.RESTORED) {
|
||||
SideEffect {
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
ApplicationDependencies.getJobManager().add(ProfileUploadJob())
|
||||
startActivity(MainActivity.clearTop(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreFromServer() {
|
||||
viewModel.restore()
|
||||
}
|
||||
|
||||
private fun continueRegistration() {
|
||||
if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) {
|
||||
val main = MainActivity.clearTop(this)
|
||||
val profile = CreateProfileActivity.getIntentForUserProfile(this)
|
||||
profile.putExtra("next_intent", main)
|
||||
startActivity(profile)
|
||||
} else {
|
||||
RegistrationUtil.maybeMarkRegistrationComplete()
|
||||
ApplicationDependencies.getJobManager().add(ProfileUploadJob())
|
||||
startActivity(MainActivity.clearTop(this))
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StateLabel(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.io.InputStream
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class MessageBackupsTestRestoreViewModel : ViewModel() {
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(importState = ImportState.NONE, plaintext = false))
|
||||
val state: State<ScreenState> = _state
|
||||
|
||||
fun import(length: Long, inputStreamFactory: () -> InputStream) {
|
||||
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
|
||||
|
||||
val self = Recipient.self()
|
||||
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
|
||||
|
||||
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = _state.value.plaintext) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
_state.value = _state.value.copy(importState = ImportState.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
fun restore() {
|
||||
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
|
||||
disposables += Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getJobManager()
|
||||
.startChain(BackupRestoreJob())
|
||||
.then(SyncArchivedMediaJob())
|
||||
.then(BackupRestoreMediaJob())
|
||||
.enqueueAndBlockUntilCompletion(120.seconds.inWholeMilliseconds)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
_state.value = _state.value.copy(importState = ImportState.RESTORED)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPlaintextToggled() {
|
||||
_state.value = _state.value.copy(plaintext = !_state.value.plaintext)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
data class ScreenState(
|
||||
val importState: ImportState,
|
||||
val plaintext: Boolean
|
||||
)
|
||||
|
||||
enum class ImportState(val inProgress: Boolean = false) {
|
||||
NONE, IN_PROGRESS(true), RESTORED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Represents a "Feature" included for a specify tier of message backups
|
||||
*/
|
||||
data class MessageBackupsTypeFeature(
|
||||
val iconResourceId: Int,
|
||||
val label: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Renders a "feature row" for a given feature.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBackupsTypeFeatureRow(
|
||||
messageBackupsTypeFeature: MessageBackupsTypeFeature,
|
||||
iconTint: Color = LocalContentColor.current,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = messageBackupsTypeFeature.iconResourceId),
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = messageBackupsTypeFeature.label,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun MessageBackupsTypeFeatureRowPreview() {
|
||||
Previews.Preview {
|
||||
MessageBackupsTypeFeatureRow(
|
||||
messageBackupsTypeFeature = MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_edit_24,
|
||||
label = "Content Label"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.backup.v2.ui
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -19,10 +18,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -40,16 +39,17 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withAnnotation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
@@ -60,9 +60,9 @@ import java.util.Currency
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
@Composable
|
||||
fun MessageBackupsTypeSelectionScreen(
|
||||
selectedBackupsType: MessageBackupsType?,
|
||||
availableBackupsTypes: List<MessageBackupsType>,
|
||||
onMessageBackupsTypeSelected: (MessageBackupsType) -> Unit,
|
||||
selectedBackupTier: MessageBackupTier?,
|
||||
availableBackupTiers: List<MessageBackupTier>,
|
||||
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
|
||||
onNavigationClick: () -> Unit,
|
||||
onReadMoreClicked: () -> Unit,
|
||||
onNextClicked: () -> Unit
|
||||
@@ -129,13 +129,16 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
availableBackupsTypes,
|
||||
{ _, item -> item.title }
|
||||
availableBackupTiers,
|
||||
{ _, item -> item }
|
||||
) { index, item ->
|
||||
val type = remember(item) {
|
||||
getTierDetails(item)
|
||||
}
|
||||
MessageBackupsTypeBlock(
|
||||
messageBackupsType = item,
|
||||
isSelected = item == selectedBackupsType,
|
||||
onSelected = { onMessageBackupsTypeSelected(item) },
|
||||
messageBackupsType = type,
|
||||
isSelected = item == selectedBackupTier,
|
||||
onSelected = { onMessageBackupsTierSelected(item) },
|
||||
modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp)
|
||||
)
|
||||
}
|
||||
@@ -155,54 +158,16 @@ fun MessageBackupsTypeSelectionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun MessageBackupsTypeSelectionScreenPreview() {
|
||||
val freeTier = MessageBackupsType(
|
||||
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
|
||||
title = "Text + 30 days of media",
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Full text message backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_album_compact_bold_16,
|
||||
label = "Last 30 days of media"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val paidTier = MessageBackupsType(
|
||||
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
|
||||
title = "Text + All your media",
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Full text message backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_album_compact_bold_16,
|
||||
label = "Full media backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "1TB of storage (~250K photos)"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
|
||||
label = "Thanks for supporting Signal!"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
var selectedBackupsType by remember { mutableStateOf(freeTier) }
|
||||
var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) }
|
||||
|
||||
Previews.Preview {
|
||||
MessageBackupsTypeSelectionScreen(
|
||||
selectedBackupsType = selectedBackupsType,
|
||||
availableBackupsTypes = listOf(freeTier, paidTier),
|
||||
onMessageBackupsTypeSelected = { selectedBackupsType = it },
|
||||
selectedBackupTier = MessageBackupTier.FREE,
|
||||
availableBackupTiers = listOf(MessageBackupTier.FREE, MessageBackupTier.PAID),
|
||||
onMessageBackupsTierSelected = { selectedBackupsType = it },
|
||||
onNavigationClick = {},
|
||||
onReadMoreClicked = {},
|
||||
onNextClicked = {}
|
||||
@@ -271,32 +236,53 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageBackupsTypeFeatureRow(messageBackupsTypeFeature: MessageBackupsTypeFeature) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = messageBackupsTypeFeature.iconResourceId),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = messageBackupsTypeFeature.label,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class MessageBackupsType(
|
||||
val tier: MessageBackupTier,
|
||||
val pricePerMonth: FiatMoney,
|
||||
val title: String,
|
||||
val features: ImmutableList<MessageBackupsTypeFeature>
|
||||
)
|
||||
|
||||
data class MessageBackupsTypeFeature(
|
||||
val iconResourceId: Int,
|
||||
val label: String
|
||||
)
|
||||
fun getTierDetails(tier: MessageBackupTier): MessageBackupsType {
|
||||
return when (tier) {
|
||||
MessageBackupTier.FREE -> MessageBackupsType(
|
||||
tier = MessageBackupTier.FREE,
|
||||
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
|
||||
title = "Text + 30 days of media",
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Full text message backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_album_compact_bold_16,
|
||||
label = "Last 30 days of media"
|
||||
)
|
||||
)
|
||||
)
|
||||
MessageBackupTier.PAID -> MessageBackupsType(
|
||||
tier = MessageBackupTier.PAID,
|
||||
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
|
||||
title = "Text + All your media",
|
||||
features = persistentListOf(
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "Full text message backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_album_compact_bold_16,
|
||||
label = "Full media backup"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
|
||||
label = "1TB of storage (~250K photos)"
|
||||
),
|
||||
MessageBackupsTypeFeature(
|
||||
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
|
||||
label = "Thanks for supporting Signal!"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient
|
||||
R.id.multiselect_container,
|
||||
MultiselectForwardFragment.create(
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = emptyList(),
|
||||
forceDisableAddMessage = true,
|
||||
selectSingleRecipient = true
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadgeAmounts
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.whispersystems.signalservice.internal.push.DonationsConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
@@ -28,7 +28,7 @@ class GiftFlowRepository {
|
||||
.getDonationsConfiguration(Locale.getDefault())
|
||||
}
|
||||
.flatMap { it.flattenResult() }
|
||||
.map { DonationsConfiguration.GIFT_LEVEL to it.getGiftBadges().first() }
|
||||
.map { SubscriptionsConfiguration.GIFT_LEVEL to it.getGiftBadges().first() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
@@ -128,19 +127,19 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
|
||||
icon = painterResource(id = R.drawable.symbol_forward_24),
|
||||
onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareViaSignalClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
|
||||
icon = painterResource(id = R.drawable.symbol_copy_android_24),
|
||||
onClick = this@CreateCallLinkBottomSheetDialogFragment::onCopyLinkClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_share_android_24),
|
||||
icon = painterResource(id = R.drawable.symbol_share_android_24),
|
||||
onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareLinkClicked
|
||||
)
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
|
||||
/**
|
||||
* Repository for creating new call links. This will delegate to the [SignalCallLinkManager]
|
||||
@@ -44,6 +46,13 @@ class CreateCallLinkRepository(
|
||||
)
|
||||
)
|
||||
|
||||
ApplicationDependencies.getJobManager().add(
|
||||
CallLinkUpdateSendJob(
|
||||
credentials.roomId,
|
||||
SyncMessage.CallLinkUpdate.Type.UPDATE
|
||||
)
|
||||
)
|
||||
|
||||
EnsureCallLinkCreatedResult.Success(
|
||||
Recipient.resolved(
|
||||
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()
|
||||
|
||||
@@ -17,10 +17,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ActivityCompat
|
||||
@@ -287,25 +285,25 @@ private fun CallLinkDetails(
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
|
||||
icon = painterResource(id = R.drawable.symbol_forward_24),
|
||||
onClick = callback::onShareLinkViaSignalClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
|
||||
icon = painterResource(id = R.drawable.symbol_copy_android_24),
|
||||
onClick = callback::onCopyClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
|
||||
icon = painterResource(id = R.drawable.symbol_link_24),
|
||||
onClick = callback::onShareClicked
|
||||
)
|
||||
|
||||
Rows.TextRow(
|
||||
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
|
||||
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
|
||||
icon = painterResource(id = R.drawable.symbol_trash_24),
|
||||
foregroundTint = MaterialTheme.colorScheme.error,
|
||||
onClick = callback::onDeleteClicked
|
||||
)
|
||||
|
||||
@@ -182,7 +182,7 @@ class CallLogAdapter(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
|
||||
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
|
||||
private val onStartVideoCallClicked: (Recipient) -> Unit
|
||||
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
|
||||
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallLinkModel) {
|
||||
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
|
||||
@@ -231,7 +231,7 @@ class CallLogAdapter(
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener {
|
||||
onStartVideoCallClicked(model.callLink.recipient)
|
||||
onStartVideoCallClicked(model.callLink.recipient, true)
|
||||
}
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
@@ -243,7 +243,7 @@ class CallLogAdapter(
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
|
||||
private val onStartAudioCallClicked: (Recipient) -> Unit,
|
||||
private val onStartVideoCallClicked: (Recipient) -> Unit
|
||||
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
|
||||
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallModel) {
|
||||
itemView.setOnClickListener {
|
||||
@@ -305,7 +305,7 @@ class CallLogAdapter(
|
||||
|
||||
val color = ContextCompat.getColor(
|
||||
context,
|
||||
if (call.record.event.isMissedCall()) {
|
||||
if (call.record.isDisplayedAsMissedCallInUi) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurfaceVariant
|
||||
@@ -333,7 +333,7 @@ class CallLogAdapter(
|
||||
CallTable.Type.VIDEO_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, true) }
|
||||
binding.callType.visible = true
|
||||
binding.groupCallButton.visible = false
|
||||
}
|
||||
@@ -341,8 +341,8 @@ class CallLogAdapter(
|
||||
CallTable.Type.GROUP_CALL, CallTable.Type.AD_HOC_CALL -> {
|
||||
binding.callType.setImageResource(R.drawable.symbol_video_24)
|
||||
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
|
||||
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, model.call.canUserBeginCall) }
|
||||
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer, model.call.canUserBeginCall) }
|
||||
|
||||
when (model.call.groupCallState) {
|
||||
CallLogRow.GroupCallState.NONE, CallLogRow.GroupCallState.FULL -> {
|
||||
@@ -371,11 +371,11 @@ class CallLogAdapter(
|
||||
private fun getCallStateDrawableRes(call: CallTable.Call): Int {
|
||||
return when (call.messageType) {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_compact_16
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_downleft_compact_16
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) R.drawable.symbol_missed_incoming_compact_16 else R.drawable.symbol_arrow_downleft_compact_16
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_compact_16
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.type == CallTable.Type.AD_HOC_CALL -> R.drawable.symbol_link_compact_16
|
||||
call.event.isMissedCall() -> R.drawable.symbol_missed_incoming_compact_16
|
||||
call.isDisplayedAsMissedCallInUi -> R.drawable.symbol_missed_incoming_compact_16
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_compact_16
|
||||
call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_compact_16
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
|
||||
@@ -389,23 +389,19 @@ class CallLogAdapter(
|
||||
@StringRes
|
||||
private fun getCallStateStringRes(call: CallTable.Call): Int {
|
||||
return when (call.messageType) {
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE,
|
||||
MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__missed_notification_profile
|
||||
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__incoming
|
||||
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__incoming
|
||||
MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__missed_notification_profile
|
||||
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__outgoing
|
||||
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__outgoing
|
||||
MessageTypes.GROUP_CALL_TYPE -> when {
|
||||
call.type == CallTable.Type.AD_HOC_CALL -> R.string.CallLogAdapter__call_link
|
||||
call.event == CallTable.Event.MISSED -> R.string.CallLogAdapter__missed
|
||||
call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE -> R.string.CallLogAdapter__missed_notification_profile
|
||||
call.isDisplayedAsMissedCallInUi -> R.string.CallLogAdapter__missed
|
||||
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
|
||||
call.direction == CallTable.Direction.INCOMING -> R.string.CallLogAdapter__incoming
|
||||
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
else -> error("Unexpected type ${call.messageType}")
|
||||
else -> if (call.isDisplayedAsMissedCallInUi) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__incoming
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,6 +468,6 @@ class CallLogAdapter(
|
||||
/**
|
||||
* Invoked when user presses the video icon
|
||||
*/
|
||||
fun onStartVideoCallClicked(recipient: Recipient)
|
||||
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.TransitionInflater
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -32,6 +33,7 @@ import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.addTo
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
@@ -45,6 +47,7 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick
|
||||
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationDialogs
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnCloseClicked
|
||||
@@ -119,10 +122,17 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
initializeSharedElementTransition()
|
||||
|
||||
viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick)
|
||||
viewLifecycleOwner.lifecycle.addObserver(viewModel.callLogPeekHelper)
|
||||
|
||||
val callLogAdapter = CallLogAdapter(this)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
callLogAdapter.setPagingController(viewModel.controller)
|
||||
callLogAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
(requireActivity() as? MainActivity)?.onFirstRender()
|
||||
callLogAdapter.unregisterAdapterDataObserver(this)
|
||||
}
|
||||
})
|
||||
|
||||
val scrollToPositionDelegate = ScrollToPositionDelegate(
|
||||
recyclerView = binding.recycler,
|
||||
@@ -376,8 +386,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
CommunicationActions.startVoiceCall(this, recipient)
|
||||
}
|
||||
|
||||
override fun onStartVideoCallClicked(recipient: Recipient) {
|
||||
CommunicationActions.startVideoCall(this, recipient)
|
||||
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
|
||||
if (canUserBeginCall) {
|
||||
CommunicationActions.startVideoCall(this, recipient)
|
||||
} else {
|
||||
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
override fun startSelection(call: CallLogRow) {
|
||||
@@ -467,7 +481,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
is CallLogDeletionResult.FailedToRevoke -> {
|
||||
errorDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(resources.getQuantityString(R.plurals.CallLogFragment__cant_delete_call_link, it.failedRevocations))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
CallLogDeletionResult.Success -> {
|
||||
|
||||
@@ -67,6 +67,7 @@ class CallLogPagedDataSource(
|
||||
callLogRows.add(CallLogRow.ClearFilter)
|
||||
}
|
||||
|
||||
repository.onCallTabPageLoaded(callLogRows)
|
||||
return callLogRows
|
||||
}
|
||||
|
||||
@@ -83,5 +84,6 @@ class CallLogPagedDataSource(
|
||||
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
fun getCallLinksCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
fun onCallTabPageLoaded(pageData: List<CallLogRow>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Peeks calls in the call log as data is loaded in, according to
|
||||
* an algorithm.
|
||||
*/
|
||||
class CallLogPeekHelper : DefaultLifecycleObserver {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallLogPeekHelper::class.java)
|
||||
private const val PEEK_SIZE = 10
|
||||
}
|
||||
|
||||
private val executor: Executor = SerialExecutor(SignalExecutors.BOUNDED_IO)
|
||||
private val debouncer = ThrottledDebouncer(30.seconds.inWholeMilliseconds)
|
||||
private val dataSet = mutableSetOf<PeekEntry>()
|
||||
private val peekQueue = mutableSetOf<PeekEntry>()
|
||||
|
||||
private var isFirstLoad = true
|
||||
private var isPaused = false
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
executor.execute {
|
||||
isPaused = false
|
||||
performPeeks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
executor.execute {
|
||||
isPaused = true
|
||||
debouncer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the underlying datasource has been invalidated.
|
||||
*/
|
||||
fun onDataSetInvalidated() {
|
||||
executor.execute {
|
||||
debouncer.clear()
|
||||
dataSet.clear()
|
||||
peekQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever a new page of data is loaded by the datasource.
|
||||
*/
|
||||
fun onPageLoaded(pageData: List<CallLogRow>) {
|
||||
executor.execute {
|
||||
handleActiveCallLinks(pageData)
|
||||
handleActiveGroupCalls(pageData)
|
||||
handleInactiveGroupCalls(pageData)
|
||||
handleInactiveCallLinks(pageData)
|
||||
performPeeks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds any and all active call links to our data set and queue
|
||||
*/
|
||||
private fun handleActiveCallLinks(pageData: List<CallLogRow>) {
|
||||
val activeUnusedCallLinks: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.CallLink>()
|
||||
.filter { it.callLinkPeekInfo?.isActive == true }
|
||||
.map { PeekEntry(it.recipient.id, PeekEntryIdentifier.CallLink(it.record.roomId, PeekEntryType.CALL_LINK)) }
|
||||
|
||||
val activeCallLinksFromEvents: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
|
||||
.filter { it.peer.isCallLink && it.callLinkPeekInfo?.isActive == true }
|
||||
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.CALL_LINK)) }
|
||||
|
||||
val activeCallLinks: List<PeekEntry> = activeUnusedCallLinks + activeCallLinksFromEvents
|
||||
dataSet.addAll(activeCallLinks)
|
||||
peekQueue.addAll(activeCallLinks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds any and all active group calls to our dataset and queue.
|
||||
*/
|
||||
private fun handleActiveGroupCalls(pageData: List<CallLogRow>) {
|
||||
val activeGroupCalls: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
|
||||
.filter { it.peer.isGroup && it.groupCallState != CallLogRow.GroupCallState.NONE }
|
||||
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.GROUP_CALL)) }
|
||||
|
||||
dataSet.addAll(activeGroupCalls)
|
||||
peekQueue.addAll(activeGroupCalls)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes any and all inactive group calls from our dataset and queue.
|
||||
*/
|
||||
private fun handleInactiveGroupCalls(pageData: List<CallLogRow>) {
|
||||
val inactiveGroupCalls: Set<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
|
||||
.filter { it.peer.isGroup && it.groupCallState == CallLogRow.GroupCallState.NONE }
|
||||
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.GROUP_CALL)) }
|
||||
.toSet()
|
||||
|
||||
peekQueue.removeAll(inactiveGroupCalls)
|
||||
dataSet.removeAll(inactiveGroupCalls)
|
||||
}
|
||||
|
||||
/**
|
||||
* On first load, adds all inactive call links to our queue. On subsequent calls, removes them from the dataset.
|
||||
*/
|
||||
private fun handleInactiveCallLinks(pageData: List<CallLogRow>) {
|
||||
val inactiveUnusedCallLinks: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.CallLink>()
|
||||
.filter { it.callLinkPeekInfo?.isActive != true }
|
||||
.map { PeekEntry(it.recipient.id, PeekEntryIdentifier.CallLink(it.record.roomId, PeekEntryType.CALL_LINK)) }
|
||||
|
||||
val inactiveCallLinksFromEvents: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
|
||||
.filter { it.callLinkPeekInfo?.isActive != true }
|
||||
.filter { it.record.timestamp <= 10.days.inWholeMilliseconds }
|
||||
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.CALL_LINK)) }
|
||||
|
||||
val inactiveCallLinks: Set<PeekEntry> = (inactiveUnusedCallLinks + inactiveCallLinksFromEvents).take(10).toSet()
|
||||
|
||||
if (isFirstLoad) {
|
||||
isFirstLoad = false
|
||||
|
||||
peekQueue.addAll(inactiveCallLinks)
|
||||
} else {
|
||||
dataSet.removeAll(inactiveCallLinks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performPeeks() {
|
||||
executor.execute {
|
||||
if (peekQueue.isEmpty() || isPaused) {
|
||||
return@execute
|
||||
}
|
||||
|
||||
Log.d(TAG, "Peeks in queue. Taking first $PEEK_SIZE.")
|
||||
|
||||
val items = peekQueue.take(PEEK_SIZE)
|
||||
val remaining = peekQueue.drop(PEEK_SIZE)
|
||||
|
||||
peekQueue.clear()
|
||||
peekQueue.addAll(remaining)
|
||||
|
||||
items.forEach {
|
||||
when (it.identifier.peekEntryType) {
|
||||
PeekEntryType.CALL_LINK -> ApplicationDependencies.getSignalCallManager().peekCallLinkCall(it.recipientId)
|
||||
PeekEntryType.GROUP_CALL -> ApplicationDependencies.getSignalCallManager().peekGroupCall(it.recipientId)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Began peeks for ${items.size} calls.")
|
||||
|
||||
peekQueue.addAll(dataSet)
|
||||
debouncer.publish { performPeeks() }
|
||||
}
|
||||
}
|
||||
|
||||
private enum class PeekEntryType {
|
||||
CALL_LINK,
|
||||
GROUP_CALL
|
||||
}
|
||||
|
||||
private sealed interface PeekEntryIdentifier {
|
||||
val peekEntryType: PeekEntryType
|
||||
|
||||
data class CallLink(private val roomId: CallLinkRoomId, override val peekEntryType: PeekEntryType = PeekEntryType.CALL_LINK) : PeekEntryIdentifier
|
||||
data class Call(private val callId: Long, override val peekEntryType: PeekEntryType) : PeekEntryIdentifier
|
||||
}
|
||||
|
||||
private data class PeekEntry(
|
||||
val recipientId: RecipientId,
|
||||
val identifier: PeekEntryIdentifier
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
|
||||
|
||||
class CallLogRepository(
|
||||
private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
|
||||
private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository(),
|
||||
private val callLogPeekHelper: CallLogPeekHelper
|
||||
) : CallLogPagedDataSource.CallRepository {
|
||||
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
|
||||
return SignalDatabase.calls.getCallsCount(query, filter)
|
||||
@@ -41,10 +42,17 @@ class CallLogRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCallTabPageLoaded(pageData: List<CallLogRow>) {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
callLogPeekHelper.onPageLoaded(pageData)
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllCallEventsRead() {
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
val latestCall = SignalDatabase.calls.getLatestCall() ?: return@execute
|
||||
SignalDatabase.calls.markAllCallEventsRead()
|
||||
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsRead(System.currentTimeMillis()))
|
||||
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forMarkedAsRead(latestCall))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,10 +103,10 @@ class CallLogRepository(
|
||||
fun deleteAllCallLogsOnOrBeforeNow(): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
val latestTimestamp = SignalDatabase.calls.getLatestTimestamp()
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(latestTimestamp)
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(latestTimestamp)
|
||||
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(latestTimestamp))
|
||||
val latestCall = SignalDatabase.calls.getLatestCall() ?: return@withinTransaction
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(latestCall.timestamp)
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(latestCall.timestamp)
|
||||
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(latestCall))
|
||||
}
|
||||
|
||||
SignalDatabase.callLinks.getAllAdminCallLinksExcept(emptySet())
|
||||
|
||||
@@ -41,6 +41,7 @@ sealed class CallLogRow {
|
||||
val children: Set<Long>,
|
||||
val searchQuery: String?,
|
||||
val callLinkPeekInfo: CallLinkPeekInfo?,
|
||||
val canUserBeginCall: Boolean,
|
||||
override val id: Id = Id.Call(children)
|
||||
) : CallLogRow()
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit
|
||||
* ViewModel for call log management.
|
||||
*/
|
||||
class CallLogViewModel(
|
||||
private val callLogRepository: CallLogRepository = CallLogRepository()
|
||||
val callLogPeekHelper: CallLogPeekHelper = CallLogPeekHelper(),
|
||||
private val callLogRepository: CallLogRepository = CallLogRepository(callLogPeekHelper = callLogPeekHelper)
|
||||
) : ViewModel() {
|
||||
private val callLogStore = RxStore(CallLogState())
|
||||
|
||||
@@ -77,6 +78,7 @@ class CallLogViewModel(
|
||||
}
|
||||
|
||||
disposables += callLogRepository.listenForChanges().subscribe {
|
||||
callLogPeekHelper.onDataSetInvalidated()
|
||||
controller.onDataInvalidated()
|
||||
}
|
||||
|
||||
@@ -92,6 +94,7 @@ class CallLogViewModel(
|
||||
.observeOn(Schedulers.computation())
|
||||
.distinctUntilChanged()
|
||||
.subscribe {
|
||||
callLogPeekHelper.onDataSetInvalidated()
|
||||
controller.onDataInvalidated()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
|
||||
when (result) {
|
||||
is RecipientRepository.LookupResult.Success -> {
|
||||
val resolved = Recipient.resolved(result.recipientId)
|
||||
if (resolved.isRegistered && resolved.hasServiceId()) {
|
||||
if (resolved.isRegistered && resolved.hasServiceId) {
|
||||
launch(resolved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +35,6 @@ public class AlertView extends AppCompatImageView {
|
||||
setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setPendingApproval() {
|
||||
setVisibility(View.VISIBLE);
|
||||
setColorFilter(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurfaceVariant));
|
||||
setContentDescription(getContext().getString(R.string.conversation_item_sent__pending_approval_description));
|
||||
}
|
||||
|
||||
public void setFailed() {
|
||||
setVisibility(View.VISIBLE);
|
||||
setColorFilter(ContextCompat.getColor(getContext(), R.color.signal_colorError));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user