Compare commits
593 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbbcadf09b | |||
| 3fc6ac3871 | |||
| 6a2ec01c52 | |||
| c1b3fb6d1b | |||
| 792d86f4d8 | |||
| 849856cde8 | |||
| 0646418d4d | |||
| ed540a2f9e | |||
| 00d86101f5 | |||
| 86e49cd564 | |||
| 21b7d64fcd | |||
| 42c0044096 | |||
| f2e8b83604 | |||
| 46b8ac6561 | |||
| 9089cc393e | |||
| 2ea59bef68 | |||
| 698fc38aed | |||
| 1d74b00b91 | |||
| ea861fff49 | |||
| 3b93edcdaf | |||
| 6722a28f98 | |||
| 16de2efa9e | |||
| 4d0919c9a8 | |||
| 01e1cb4d67 | |||
| 9d1d5142da | |||
| 49f0c2502b | |||
| 71ffc36e7f | |||
| 5941ff814d | |||
| 1661f3b5f7 | |||
| d682de08d2 | |||
| 5321f8124a | |||
| 5052f22d44 | |||
| 9a8cb1785b | |||
| a2065becdd | |||
| e85637a58d | |||
| 73c3d141e3 | |||
| eafba156ba | |||
| e6beafd612 | |||
| a9649fd017 | |||
| 4decae274b | |||
| dbb83d86e3 | |||
| 2aa27df95b | |||
| ec47b83f76 | |||
| 6eea4ba937 | |||
| 9f608337f1 | |||
| 28edcdf62d | |||
| 10d969ea35 | |||
| 38bac16640 | |||
| 93077ac457 | |||
| c069eb1b88 | |||
| e5cd18bf1e | |||
| 9e8ae7e26a | |||
| 00042b9579 | |||
| e750b81a31 | |||
| daec317f52 | |||
| 112514c221 | |||
| f43db8ace0 | |||
| 54df95727b | |||
| 022b4d9508 | |||
| 7411e725ec | |||
| 83a279f422 | |||
| 523066d093 | |||
| de27343c24 | |||
| c36179293e | |||
| a79a91bafb | |||
| 13de1ede90 | |||
| b94f420393 | |||
| 4909f130cc | |||
| 0010386b9e | |||
| 02c760945d | |||
| a0247bb8cc | |||
| bcfec5de50 | |||
| b2215915ef | |||
| a0577cd8a2 | |||
| 9438646814 | |||
| 9dcf68581d | |||
| 4dd57460de | |||
| 6339b38dee | |||
| 1ce41edc7f | |||
| 3087116618 | |||
| 8fd2065253 | |||
| 09822d3ae9 | |||
| 10b0221e98 | |||
| db4def45f9 | |||
| 7dd6829bfa | |||
| 0e40acfdaa | |||
| c2e8cec042 | |||
| 04d2b3b0fe | |||
| 64cdff4638 | |||
| 59b42ac546 | |||
| 2e9fd87b06 | |||
| 0cf7705d4f | |||
| 5bcbbdf339 | |||
| a796316ad6 | |||
| 5655fcf973 | |||
| f4bd5fbe8b | |||
| fc448ecb59 | |||
| c4e7841ea3 | |||
| e248aee25c | |||
| 7c9268e326 | |||
| 8ffc2e7ab8 | |||
| b4404bb5b4 | |||
| 155bba2f81 | |||
| 639438b863 | |||
| b374a90ffe | |||
| d333503838 | |||
| 2efc115410 | |||
| 43a1c93961 | |||
| 39529af4e9 | |||
| d1e2fc0423 | |||
| 5e4865be73 | |||
| 5903c1bbf5 | |||
| 45da9fbfc0 | |||
| 0dacc4e8dc | |||
| 74935c963a | |||
| 02d245ac0c | |||
| e100ffbc14 | |||
| d4b3328151 | |||
| 0e82a43be7 | |||
| 0960c0dfea | |||
| 8503c49db0 | |||
| 88b95ce6a5 | |||
| 6a1d06486c | |||
| 824f0af00b | |||
| 03a6d8c12f | |||
| f1b231ca38 | |||
| dca4351b8b | |||
| b2a18f7202 | |||
| d68e541ee6 | |||
| 1c6f093b4c | |||
| b87486163d | |||
| 1ee341daac | |||
| 6baebfe140 | |||
| a6816df0e8 | |||
| 5b8c894512 | |||
| 439760e773 | |||
| 7560896e2d | |||
| fe18def67e | |||
| 413962a093 | |||
| e518eca9a1 | |||
| b70322b5a6 | |||
| 047516c80b | |||
| 0a45b9b5e3 | |||
| 99b0061127 | |||
| 7b11cc1676 | |||
| 663e0a616e | |||
| d05338cee0 | |||
| ce294dbc0b | |||
| d0efd8d4b0 | |||
| c8875b5ad1 | |||
| 188458f772 | |||
| ed7fd10749 | |||
| 2ffbf09b1b | |||
| 799e57dbe9 | |||
| 572c11ee6d | |||
| 4dd5a4ee53 | |||
| 370fca3c89 | |||
| d91f130238 | |||
| bb20432417 | |||
| 8138ea5f8f | |||
| f235aa0599 | |||
| c7d719e983 | |||
| cf71d43a2f | |||
| 1e70e825a3 | |||
| cce1979716 | |||
| ad7e9c0fd7 | |||
| bd3e1e8059 | |||
| adb9e2173f | |||
| 958c6f451f | |||
| ab090236a1 | |||
| 23698dbc28 | |||
| 0542262c49 | |||
| e2d4ca9a4c | |||
| e54f3f501a | |||
| 638d4997d1 | |||
| cbd05c4dff | |||
| ef396b5758 | |||
| 1d36ecafe1 | |||
| 07329c5b0d | |||
| 7fc4ec3006 | |||
| 9e7477bbeb | |||
| c83054906b | |||
| 011dc3495f | |||
| 41b833e788 | |||
| e11f7225d3 | |||
| bb261a3d85 | |||
| 116f702be6 | |||
| 4d09776277 | |||
| f32184c27e | |||
| 5fc037b324 | |||
| fc9d3e11e8 | |||
| a951c7edfe | |||
| 9d1714d452 | |||
| 9c2825f202 | |||
| a8969b34a4 | |||
| 1f59f3c2c4 | |||
| c6d91dce6e | |||
| 40c4633d41 | |||
| edfe89683b | |||
| cc3bedd154 | |||
| 56803a8850 | |||
| 2fdb712b38 | |||
| 3d39045d1b | |||
| 90385b4e1c | |||
| a02b66601c | |||
| a83c57ff73 | |||
| 3d063b38be | |||
| 03d20cb46a | |||
| 561186df90 | |||
| fdcd21132c | |||
| 1043851423 | |||
| 9bcbacc3d8 | |||
| c2d7ee6926 | |||
| ceecacb47e | |||
| f4986273e4 | |||
| 5f60adbe69 | |||
| db6efeaf3d | |||
| 9b98b03971 | |||
| dfbdf30535 | |||
| d567555047 | |||
| 7658f6c36c | |||
| 51bd2d51c6 | |||
| a00978d96e | |||
| b700529c3b | |||
| 4051cf739c | |||
| 6031fc9113 | |||
| 454fe86dda | |||
| 92927ec69b | |||
| 9fa587b7e4 | |||
| 552361dff4 | |||
| 78a25a6186 | |||
| 58fcc07578 | |||
| 8cd92a400c | |||
| 5d207932c9 | |||
| 7c147982c4 | |||
| bde1a94122 | |||
| 2b66d7485a | |||
| 017b902c3c | |||
| 357fbfa8aa | |||
| 0ce667f4af | |||
| c4d78243c8 | |||
| 51e12b2c76 | |||
| 4dea1d8aa1 | |||
| 89c645dea3 | |||
| cd01d5f0b7 | |||
| 8730e28282 | |||
| 82046dd55f | |||
| 76e30ab09f | |||
| f680256f1d | |||
| da590a3241 | |||
| 91f73b473f | |||
| 53023517b3 | |||
| 7f831e6806 | |||
| 77a18111e1 | |||
| 2a699a23dd | |||
| 5643ffc1a9 | |||
| 90207b7dd7 | |||
| 5b7f668251 | |||
| 798bf3ec3e | |||
| 1c77c9d3fb | |||
| dd52d78ee0 | |||
| 4b1acca119 | |||
| 195fe60927 | |||
| f427f31303 | |||
| fa19ed7ffc | |||
| e5e99d4e03 | |||
| 26d1a7ada7 | |||
| 5dd11e26e4 | |||
| 9877b13c6e | |||
| d7d0fd3622 | |||
| 2439506c05 | |||
| 6088024f76 | |||
| 9decd81cfc | |||
| f27773a4e3 | |||
| 8d8c974a19 | |||
| 1a3e81dcb0 | |||
| d5f85c0661 | |||
| 91458f2702 | |||
| 6650ffc2c6 | |||
| ab0102a372 | |||
| a797bbf850 | |||
| 3804890265 | |||
| fcdbf93626 | |||
| f1b61f8f7e | |||
| ce582249ec | |||
| b21a72153a | |||
| 2a8bd20bb0 | |||
| c30e3cc1b7 | |||
| 5fedd81921 | |||
| 24069dc42e | |||
| ff15c8417a | |||
| cbf770d3ea | |||
| 676ab1ab6f | |||
| 9cc47942f2 | |||
| 45e6e06c01 | |||
| d2243707b5 | |||
| 48cd1c1da0 | |||
| 330a5aece2 | |||
| 8c4f614d17 | |||
| f40bcb73fa | |||
| 905a6f1a6b | |||
| 8f78471849 | |||
| 82df20190d | |||
| 7f6e96a522 | |||
| eded335766 | |||
| 7e4736969c | |||
| 78940ffc17 | |||
| 086883e565 | |||
| e9cdf0368e | |||
| 7be273f461 | |||
| e6cbb0073c | |||
| 469421fcf3 | |||
| 6d6d277277 | |||
| 8a5faba985 | |||
| 7aadc208e1 | |||
| 3c68e29679 | |||
| 4756b8d70b | |||
| c2d927029a | |||
| 629b96dd20 | |||
| 01705459cf | |||
| c449f72786 | |||
| 773d6c36dc | |||
| b4bfb67a44 | |||
| 3165c854df | |||
| f5cb1b0efa | |||
| 179908fba6 | |||
| d6ec4bfbd3 | |||
| 237ac9f94a | |||
| 66f69854cf | |||
| 8f47592fc0 | |||
| 3ea7bf77e0 | |||
| 2b67b1c44f | |||
| ebccc6db30 | |||
| 98d9b12438 | |||
| 5db8463c70 | |||
| 813252989b | |||
| 0319adbce4 | |||
| de584ccb7d | |||
| bd89c7fc39 | |||
| bef4bb40ca | |||
| b57d922cdf | |||
| 8c1cc03c6f | |||
| f0109f3e6b | |||
| ed89f3a78e | |||
| faa6a1d3f0 | |||
| 969635d942 | |||
| 7665ae1464 | |||
| 9c18e3698e | |||
| df406633ff | |||
| d121f9402b | |||
| 5310c19b99 | |||
| cd92feb2b7 | |||
| 3b603f08ed | |||
| 281f062b29 | |||
| b054a7eb76 | |||
| 33b9c88ecd | |||
| 253d36ae13 | |||
| 8306f8ec5b | |||
| 69b6d7ef9a | |||
| aeeba3d2df | |||
| dfd2f7baf9 | |||
| 5de17a971d | |||
| 001896d244 | |||
| 1844b128e1 | |||
| 08623cc0c4 | |||
| f93a948169 | |||
| 76476191be | |||
| d00bb28ee4 | |||
| 453e5bede7 | |||
| c7c108bd77 | |||
| fb81574d35 | |||
| e6d3de091c | |||
| 99b8a6020d | |||
| 88b21b6113 | |||
| 256ee9b1aa | |||
| e2feaaf74c | |||
| 17def87c17 | |||
| d90e9919ae | |||
| 38baf17938 | |||
| 3f7707985f | |||
| a61072b249 | |||
| 80ff64ddd3 | |||
| 95c0467bda | |||
| ff88d259fd | |||
| 6e747019d4 | |||
| 9e7a40a63d | |||
| 38eed43046 | |||
| 4c76cb682e | |||
| c47adb7482 | |||
| 3c2ccef9a8 | |||
| fb0c4757f2 | |||
| b8b9a632b5 | |||
| 9b4a13a491 | |||
| 1cdd49721d | |||
| 8b895738c0 | |||
| 6ab3cd3390 | |||
| 11c8a726ec | |||
| 264447a6d9 | |||
| a7bb2831f8 | |||
| e05586a1c9 | |||
| 0e8dedf4d0 | |||
| 0e11a1fe3e | |||
| f1ebd2dc81 | |||
| 8ea90c8a43 | |||
| 6456dcf657 | |||
| bb151c91e9 | |||
| ce6f39ae68 | |||
| 58e8ea08c2 | |||
| 4dd74d9ab4 | |||
| 3ef3a516b3 | |||
| 518a81c7fa | |||
| f81325e7ca | |||
| cc847cb229 | |||
| 7320a0ef46 | |||
| 7c45686440 | |||
| 8b5b83e974 | |||
| a4a3861398 | |||
| 01bdaaea84 | |||
| 1f02fba696 | |||
| aeb9054a63 | |||
| bb33945a93 | |||
| 3d2ceef47f | |||
| 892e6bd853 | |||
| 78e1a407a6 | |||
| 48d766ecff | |||
| d6d3226fcd | |||
| ed4944f806 | |||
| eb2dfb3fb6 | |||
| 265f71dff3 | |||
| 01d1769e4c | |||
| 97d099c7f1 | |||
| 0a957bc97c | |||
| 5df7552506 | |||
| 75334abe0f | |||
| 8524d20de5 | |||
| 495e2e043e | |||
| dec9eb613e | |||
| d6e7030dd0 | |||
| 6e43e931b2 | |||
| 430a55f89f | |||
| d717aad03d | |||
| efd86ad2fc | |||
| b284835545 | |||
| 4dd30f4ec3 | |||
| a48938f3d8 | |||
| 01989ad6e7 | |||
| f37f67c6c0 | |||
| 36f7c60a99 | |||
| 3f067654d9 | |||
| 0ce3eab3cd | |||
| b0f7c36cc2 | |||
| 966e208be5 | |||
| a80d353e04 | |||
| 080fa88bfb | |||
| 172e3d129e | |||
| 52d5947c0a | |||
| 7334ebfce1 | |||
| 2c98bbaf7e | |||
| 5a91dba56e | |||
| 535c5a1574 | |||
| b61c54c0e2 | |||
| 5ac5d45fc6 | |||
| 79ba929e70 | |||
| 3e9146a6f5 | |||
| 0c4c280a50 | |||
| ebea499a5a | |||
| d6b39e9f0a | |||
| 787eaee6a0 | |||
| 5ecb3d8832 | |||
| b2e8666c9f | |||
| 8af41e4b2c | |||
| 5eaf1000c8 | |||
| 4ed6773983 | |||
| 0de0441f65 | |||
| 9e1b4a9a8c | |||
| bf28b90e89 | |||
| a0a962a94f | |||
| abe0b2ebca | |||
| 7b4fe7ff40 | |||
| 1ba9793943 | |||
| 14d4228e86 | |||
| 3d2c51c14b | |||
| 72d75e9cd5 | |||
| e125fa6bfb | |||
| 57574126bb | |||
| 833c81a99e | |||
| 5ca17dfe52 | |||
| 5e058bb655 | |||
| ce87b50a07 | |||
| 2ad14800d1 | |||
| f04a0533cb | |||
| 5ae51f844e | |||
| 4ce2c6ef73 | |||
| 4442f26f53 | |||
| 849fce5a89 | |||
| 482fce6a25 | |||
| e7e69ab064 | |||
| 4b768419da | |||
| 2cca01d30f | |||
| e0c69dc485 | |||
| 1dd79efdb2 | |||
| dbb3c8def9 | |||
| 562185f46d | |||
| f6c7c6de73 | |||
| 1ca3a9ca73 | |||
| c76c3f65f2 | |||
| 59c27797d6 | |||
| c5c720b1c9 | |||
| caa09c82d0 | |||
| d45f80f25d | |||
| 6a248f617a | |||
| 2959e05ea7 | |||
| 17faf56388 | |||
| f533ad1533 | |||
| 25452fefa5 | |||
| 9702728c19 | |||
| 43f19d14d8 | |||
| 467c154ea6 | |||
| d72c742ab6 | |||
| 567bf0facc | |||
| d5329d0794 | |||
| ff04e5c5c3 | |||
| e529fbd1bc | |||
| 068eaff801 | |||
| e0bb3a48c2 | |||
| f2e4881026 | |||
| b605148ac4 | |||
| 2b9126d74b | |||
| 206f6d84e7 | |||
| 01836b3a7c | |||
| e68691c966 | |||
| 957f473e77 | |||
| 8a023100ea | |||
| 5bfdca509c | |||
| 9a837254ec | |||
| 3f27769d20 | |||
| 4f260c2063 | |||
| 75df16e842 | |||
| fce6651e26 | |||
| b06783bc90 | |||
| 72a1a9b0ff | |||
| 5568a14490 | |||
| 378ebb00c4 | |||
| c81f40eb74 | |||
| d97bde3959 | |||
| 4d301a4f66 | |||
| 9941b2d123 | |||
| 089d8a50b2 | |||
| eb8ad5218d | |||
| 21b1401fc4 | |||
| 58ea9a1f48 | |||
| 2bb9578ef9 | |||
| c3b8768570 | |||
| 94e3dabc20 | |||
| 542a820e22 | |||
| 8f7cc52255 | |||
| 63888f1c99 | |||
| a588522c9b | |||
| 7a2eca3bd5 | |||
| a8ba0dccca | |||
| 782c83cc4e | |||
| 46e6ae915c | |||
| 8a887b65a1 | |||
| 08491579dd | |||
| 25b01a30be | |||
| 48374e6950 | |||
| 6496f236ea | |||
| e767434c2b | |||
| bb6507a456 | |||
| c3f9e5d972 | |||
| 34d87cf6e1 | |||
| e657a4adf3 | |||
| 9594599d60 | |||
| a0c0acb8fc | |||
| 0896718e5c | |||
| be4bf27ede | |||
| 7253aaaa14 | |||
| 72cbe61f6c | |||
| 78d3db319c | |||
| c7a6c7ad9e | |||
| 8bc183b994 | |||
| ef6e5abc17 | |||
| e96e6e8d18 | |||
| cee33a23ac | |||
| c5de7581ee | |||
| 5dc626078f | |||
| 9de75b3e1f | |||
| f09bf5b14c | |||
| c7ec3ab837 | |||
| 2a7b58bf46 | |||
| 7d5b0b1565 | |||
| 3620db3a92 | |||
| 69cad04875 |
@@ -20,6 +20,7 @@ ktlint_standard_unnecessary-parentheses-before-trailing-lambda = disabled
|
||||
ktlint_standard_value-parameter-comment = disabled
|
||||
ktlint_standard_class-signature = disabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
ktlint_standard_blank-line-between-when-conditions = disabled
|
||||
|
||||
# Disable ktlint on generated source code, see
|
||||
# https://github.com/JLLeitschuh/ktlint-gradle/issues/746
|
||||
|
||||
@@ -17,7 +17,7 @@ body:
|
||||
label: "Guidelines"
|
||||
description: "Search issues here: https://github.com/signalapp/Signal-Android/issues/?q=is%3Aissue+"
|
||||
options:
|
||||
- label: I have searched searched open and closed issues for duplicates
|
||||
- label: I have searched open and closed issues for duplicates
|
||||
required: true
|
||||
- label: I am submitting a bug report for existing functionality that does not work as intended
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
|
||||
# Dependabot will update both the SHA and the inline version comment (e.g. # v6)
|
||||
# while leaving any extra documentation comments intact.
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
labels:
|
||||
- "dependencies"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "actions/*"
|
||||
gradle-actions:
|
||||
patterns:
|
||||
- "gradle/*"
|
||||
peter-evans:
|
||||
patterns:
|
||||
- "peter-evans/*"
|
||||
usefulness:
|
||||
patterns:
|
||||
- "usefulness/*"
|
||||
@@ -1,23 +0,0 @@
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
|
||||
issues:
|
||||
exemptLabels:
|
||||
- acknowledged
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
closeComment: >
|
||||
This issue has been closed due to inactivity.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 1
|
||||
@@ -16,26 +16,30 @@ jobs:
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
cache: gradle
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v5
|
||||
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew qa
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
|
||||
with:
|
||||
name: reports
|
||||
path: '*/build/reports'
|
||||
|
||||
@@ -14,15 +14,17 @@ jobs:
|
||||
assemble-base:
|
||||
if: ${{ github.repository != 'signalapp/Signal-Android' }}
|
||||
runs-on: ubuntu-latest-8-cores
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
# gh api repos/actions/setup-java/commits/v5 --jq '.sha'
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -32,11 +34,13 @@ jobs:
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v5
|
||||
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
|
||||
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
|
||||
|
||||
- name: Cache base apk
|
||||
id: cache-base
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
# gh api repos/actions/cache/commits/v5 --jq '.sha'
|
||||
with:
|
||||
path: diffuse-base.apk
|
||||
key: diffuse-${{ github.event.pull_request.base.sha }}
|
||||
@@ -49,7 +53,8 @@ jobs:
|
||||
if: steps.cache-base.outputs.cache-hit != 'true'
|
||||
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
with:
|
||||
submodules: true
|
||||
clean: 'false'
|
||||
@@ -61,18 +66,21 @@ jobs:
|
||||
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-new.apk
|
||||
|
||||
- id: diffuse
|
||||
uses: usefulness/diffuse-action@v1
|
||||
uses: usefulness/diffuse-action@41995fe8ff6be0a8847e63bdc5a4679c704b455c # v1
|
||||
# gh api repos/usefulness/diffuse-action/commits/v1 --jq '.sha'
|
||||
with:
|
||||
old-file-path: diffuse-base.apk
|
||||
new-file-path: diffuse-new.apk
|
||||
|
||||
- uses: peter-evans/find-comment@v2
|
||||
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4
|
||||
# gh api repos/peter-evans/find-comment/commits/v4 --jq '.sha'
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
body-includes: Diffuse output
|
||||
|
||||
- uses: peter-evans/create-or-update-comment@v3
|
||||
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5
|
||||
# gh api repos/peter-evans/create-or-update-comment/commits/v5 --jq '.sha'
|
||||
with:
|
||||
body: |
|
||||
Diffuse output:
|
||||
@@ -83,7 +91,8 @@ jobs:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
# gh api repos/actions/upload-artifact/commits/v7 --jq '.sha'
|
||||
with:
|
||||
name: diffuse-output
|
||||
path: ${{ steps.diffuse.outputs.diff-file }}
|
||||
|
||||
@@ -11,7 +11,8 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
|
||||
- name: Build image
|
||||
run: |
|
||||
cd reproducible-builds
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Mark stale issues and PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # daily at 02:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
|
||||
# gh api repos/actions/stale/commits/v10 --jq '.sha'
|
||||
with:
|
||||
days-before-stale: 60
|
||||
days-before-close: 7
|
||||
exempt-issue-labels: 'acknowledged'
|
||||
exempt-pr-labels: 'acknowledged'
|
||||
stale-issue-label: 'wontfix'
|
||||
stale-pr-label: 'wontfix'
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
stale-pr-message: >
|
||||
This pull request has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions!
|
||||
close-issue-message: >
|
||||
This issue has been closed due to inactivity.
|
||||
close-pr-message: >
|
||||
This pull request has been closed due to inactivity.
|
||||
operations-per-run: 150
|
||||
@@ -1,6 +1,6 @@
|
||||
# Signal Android
|
||||
|
||||
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely.
|
||||
Signal is a simple, powerful, and secure messenger that uses your phone's data connection (WiFi/4G/5G) to communicate securely.
|
||||
|
||||
Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected.
|
||||
|
||||
@@ -63,7 +63,7 @@ The form and manner of this distribution makes it eligible for export under the
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2013-2025 Signal Messenger, LLC
|
||||
Copyright 2013 Signal Messenger, LLC
|
||||
|
||||
Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
import com.android.build.api.artifact.ArtifactTransformationRequest
|
||||
import com.android.build.api.artifact.SingleArtifact
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -10,7 +13,6 @@ import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
@@ -22,21 +24,22 @@ plugins {
|
||||
id("licenses")
|
||||
}
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
|
||||
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
|
||||
|
||||
val canonicalVersionCode = 1669
|
||||
val canonicalVersionName = "8.4.0"
|
||||
val canonicalVersionCode = 1695
|
||||
val canonicalVersionName = "8.12.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
// We don't want versions to ever end in 0 so that they don't conflict with nightly versions
|
||||
val possibleHotfixVersions = (0 until maxHotfixVersions).toList().filter { it % 10 != 0 }
|
||||
|
||||
val debugKeystorePropertiesProvider = providers.of(PropertiesFileValueSource::class.java) {
|
||||
val debugKeystorePropertiesProvider: Provider<Properties> = providers.of(PropertiesFileValueSource::class.java) {
|
||||
parameters.file.set(rootProject.layout.projectDirectory.file("keystore.debug.properties"))
|
||||
}
|
||||
|
||||
val languagesProvider = providers.of(LanguageListValueSource::class.java) {
|
||||
val languagesProvider: Provider<List<String>> = providers.of(LanguageListValueSource::class.java) {
|
||||
parameters.resDir.set(layout.projectDirectory.dir("src/main/res"))
|
||||
}
|
||||
|
||||
@@ -81,6 +84,27 @@ val selectableVariants = listOf(
|
||||
"githubProdRelease"
|
||||
)
|
||||
|
||||
// Wire 5.x iterates Android source sets and expects matching Kotlin source sets.
|
||||
// AGP 9.0's built-in Kotlin doesn't create all source sets automatically.
|
||||
val kotlinExt = extensions.getByName("kotlin") as KotlinAndroidProjectExtension
|
||||
android.sourceSets.all {
|
||||
kotlinExt.sourceSets.findByName(name) ?: kotlinExt.sourceSets.create(name)
|
||||
}
|
||||
// AGP 9.0's built-in Kotlin doesn't pick up extra java.srcDir entries from Android
|
||||
// source sets, so add shared dirs directly to the relevant Kotlin compile tasks.
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach {
|
||||
val isTestTask = name.contains("UnitTest") || name.contains("AndroidTest")
|
||||
if (isTestTask) {
|
||||
source("$projectDir/src/testShared")
|
||||
}
|
||||
if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) {
|
||||
source("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
if (isTestTask && name.contains("AndroidTest")) {
|
||||
source("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
}
|
||||
|
||||
wire {
|
||||
kotlin {
|
||||
javaInterop = true
|
||||
@@ -92,9 +116,8 @@ wire {
|
||||
|
||||
protoPath {
|
||||
srcDir("${project.rootDir}/lib/libsignal-service/src/main/protowire")
|
||||
srcDir("${project.rootDir}/lib/archive/src/main/protowire")
|
||||
}
|
||||
// Handled by libsignal
|
||||
prune("signalservice.DecryptionErrorMessage")
|
||||
}
|
||||
|
||||
ktlint {
|
||||
@@ -105,7 +128,7 @@ android {
|
||||
namespace = "org.thoughtcrime.securesms"
|
||||
|
||||
buildToolsVersion = libs.versions.buildTools.get()
|
||||
compileSdkVersion = libs.versions.compileSdk.get()
|
||||
compileSdkVersion(libs.versions.compileSdk.get())
|
||||
ndkVersion = libs.versions.ndk.get()
|
||||
|
||||
flavorDimensions += listOf("distribution", "environment")
|
||||
@@ -113,13 +136,7 @@ android {
|
||||
|
||||
android.bundle.language.enableSplit = false
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = libs.versions.kotlinJvmTarget.get()
|
||||
freeCompilerArgs = listOf("-Xjvm-default=all")
|
||||
suppressWarnings = true
|
||||
}
|
||||
|
||||
debugKeystorePropertiesProvider.orNull?.let { properties ->
|
||||
debugKeystorePropertiesProvider.get().takeIf { it.isNotEmpty() }?.let { properties ->
|
||||
signingConfigs.getByName("debug").apply {
|
||||
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
|
||||
storePassword = properties.getProperty("storePassword")
|
||||
@@ -136,8 +153,8 @@ android {
|
||||
}
|
||||
|
||||
managedDevices {
|
||||
devices {
|
||||
create<ManagedVirtualDevice>("pixel3api30") {
|
||||
localDevices {
|
||||
create("pixel3api30") {
|
||||
device = "Pixel 3"
|
||||
apiLevel = 30
|
||||
systemImageSource = "google-atd"
|
||||
@@ -154,6 +171,7 @@ android {
|
||||
|
||||
getByName("androidTest") {
|
||||
java.srcDir("$projectDir/src/testShared")
|
||||
java.srcDir("$projectDir/src/benchmarkShared/java")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,10 +212,6 @@ android {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
if (currentHotfixVersion >= maxHotfixVersions) {
|
||||
throw AssertionError("Hotfix version offset is too large!")
|
||||
@@ -262,7 +276,6 @@ android {
|
||||
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
|
||||
buildConfigField("boolean", "TRACING_ENABLED", "false")
|
||||
buildConfigField("boolean", "LINK_DEVICE_UX_ENABLED", "false")
|
||||
buildConfigField("boolean", "USE_STRING_ID", "false")
|
||||
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -278,7 +291,11 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
testInstrumentationRunner = if (project.hasProperty("imoTests")) {
|
||||
"org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner"
|
||||
} else {
|
||||
"org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
}
|
||||
testInstrumentationRunnerArguments["clearPackageData"] = "true"
|
||||
}
|
||||
|
||||
@@ -290,7 +307,7 @@ android {
|
||||
isDefault = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android.txt"),
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard/proguard-firebase-messaging.pro",
|
||||
"proguard/proguard-google-play-services.pro",
|
||||
"proguard/proguard-jackson.pro",
|
||||
@@ -307,6 +324,7 @@ android {
|
||||
"proguard/proguard-retrolambda.pro",
|
||||
"proguard/proguard-okhttp.pro",
|
||||
"proguard/proguard-ez-vcard.pro",
|
||||
"proguard/proguard-dnsjava.pro",
|
||||
"proguard/proguard.cfg"
|
||||
)
|
||||
testProguardFiles(
|
||||
@@ -335,6 +353,7 @@ android {
|
||||
|
||||
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
|
||||
buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}")
|
||||
}
|
||||
|
||||
create("spinner") {
|
||||
@@ -452,8 +471,8 @@ android {
|
||||
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
|
||||
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE_LEGACY", "\"97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535\"")
|
||||
buildConfigField("String", "SVR2_MRENCLAVE", "\"3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302\"")
|
||||
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\", \"BYhU6tPjqP46KGZEzRs1OL4U39V5dlPJ/X09ha4rErkm\"}")
|
||||
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\"")
|
||||
@@ -479,70 +498,6 @@ android {
|
||||
lintConfig = rootProject.file("lint.xml")
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
beforeVariants { variant ->
|
||||
variant.enable = variant.name in selectableVariants
|
||||
}
|
||||
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
|
||||
// Include the test-only library on debug builds.
|
||||
if (variant.buildType != "instrumentation") {
|
||||
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
|
||||
}
|
||||
|
||||
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
|
||||
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
|
||||
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
|
||||
variant.packaging.jniLibs.useLegacyPackaging.set(true)
|
||||
}
|
||||
|
||||
// Version overrides
|
||||
if (variant.name.contains("nightly", ignoreCase = true)) {
|
||||
var tag = getNightlyTagForCurrentCommit()
|
||||
if (!tag.isNullOrEmpty()) {
|
||||
if (tag.startsWith("v")) {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
|
||||
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
|
||||
val nightlyBuffer = (5 * maxHotfixVersions)
|
||||
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
|
||||
|
||||
variant.outputs.forEach { output ->
|
||||
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
|
||||
output.versionCode.set(nightlyVersionCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("quickstart")) { variant ->
|
||||
val environment = variant.flavorName?.let { name ->
|
||||
when {
|
||||
name.contains("staging", ignoreCase = true) -> "staging"
|
||||
name.contains("prod", ignoreCase = true) -> "prod"
|
||||
else -> "prod"
|
||||
}
|
||||
} ?: "prod"
|
||||
|
||||
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
|
||||
if (quickstartCredentialsDir != null) {
|
||||
inputDir.set(File(quickstartCredentialsDir))
|
||||
}
|
||||
filePrefix.set("${environment}_")
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("benchmark")) { variant ->
|
||||
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
|
||||
if (benchmarkBackupFile != null) {
|
||||
inputFile.set(File(benchmarkBackupFile))
|
||||
}
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
}
|
||||
|
||||
val releaseDir = "$projectDir/src/release/java"
|
||||
val debugDir = "$projectDir/src/debug/java"
|
||||
|
||||
@@ -564,15 +519,82 @@ android {
|
||||
manifest.srcFile("$projectDir/src/benchmarkShared/AndroidManifest.xml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.configureEach {
|
||||
outputs.configureEach {
|
||||
if (this is com.android.build.gradle.internal.api.BaseVariantOutputImpl) {
|
||||
val fileVersionName = versionName.substringBefore(" |")
|
||||
outputFileName = outputFileName.replace(".apk", "-$fileVersionName.apk")
|
||||
androidComponents {
|
||||
beforeVariants { variant ->
|
||||
variant.enable = variant.name in selectableVariants
|
||||
if (variant.enable) {
|
||||
(variant as? com.android.build.api.variant.HasUnitTestBuilder)?.enableUnitTest = true
|
||||
}
|
||||
}
|
||||
onVariants(selector().all()) { variant: com.android.build.api.variant.ApplicationVariant ->
|
||||
// Rename APK to include version name
|
||||
val renameTask = tasks.register<RenameApkTask>("renameApk${variant.name.replaceFirstChar { it.uppercase() }}")
|
||||
val renameRequest = variant.artifacts.use(renameTask)
|
||||
.wiredWithDirectories(RenameApkTask::apkFolder, RenameApkTask::outFolder)
|
||||
.toTransformMany(SingleArtifact.APK)
|
||||
renameTask.configure {
|
||||
transformationRequest.set(renameRequest)
|
||||
}
|
||||
|
||||
// Include the test-only library on debug builds.
|
||||
if (variant.buildType != "instrumentation") {
|
||||
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
|
||||
}
|
||||
|
||||
// Starting with minSdk 23, Android leaves native libraries uncompressed, which is fine for the Play Store, but not for our self-distributed APKs.
|
||||
// This reverts it to the legacy behavior, compressing the native libraries, and drastically reducing the APK file size.
|
||||
if (variant.name.contains("website", ignoreCase = true) || variant.name.contains("github", ignoreCase = true)) {
|
||||
variant.packaging.jniLibs.useLegacyPackaging.set(true)
|
||||
}
|
||||
|
||||
// Version overrides
|
||||
if (variant.name.contains("nightly", ignoreCase = true)) {
|
||||
var tag = getNightlyTagForCurrentCommit()
|
||||
if (!tag.isNullOrEmpty()) {
|
||||
if (tag.startsWith("v")) {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
|
||||
// We add a multiple of maxHotfixVersions to nightlies to ensure we're always at least that many versions ahead
|
||||
val nightlyBuffer = (5 * maxHotfixVersions)
|
||||
val nightlyVersionCode = (canonicalVersionCode * maxHotfixVersions) + (getNightlyBuildNumber(tag) * 10) + nightlyBuffer
|
||||
|
||||
variant.outputs.forEach { output ->
|
||||
output.versionName.set("$tag | ${getLastCommitDateTimeUtc()}")
|
||||
output.versionCode.set(nightlyVersionCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("quickstart")) { variant ->
|
||||
val environment = variant.flavorName?.let { name ->
|
||||
when {
|
||||
name.contains("staging", ignoreCase = true) -> "staging"
|
||||
name.contains("prod", ignoreCase = true) -> "prod"
|
||||
else -> "prod"
|
||||
}
|
||||
} ?: "prod"
|
||||
|
||||
val taskProvider = tasks.register<CopyQuickstartCredentialsTask>("copyQuickstartCredentials${variant.name.capitalize()}") {
|
||||
if (quickstartCredentialsDir != null) {
|
||||
inputDir.set(File(quickstartCredentialsDir))
|
||||
}
|
||||
filePrefix.set("${environment}_")
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
|
||||
onVariants(selector().withBuildType("benchmark")) { variant ->
|
||||
val taskProvider = tasks.register<CopyBenchmarkBackupTask>("copyBenchmarkBackup${variant.name.capitalize()}") {
|
||||
if (benchmarkBackupFile != null) {
|
||||
inputFile.set(File(benchmarkBackupFile))
|
||||
}
|
||||
}
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(taskProvider) { it.outputDir }
|
||||
}
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
@@ -589,12 +611,22 @@ baselineProfile {
|
||||
dexLayoutOptimization = false
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.kotlinJvmTarget.get())
|
||||
freeCompilerArgs.addAll("-Xjvm-default=all")
|
||||
suppressWarnings = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks(project(":lintchecks"))
|
||||
ktlintRuleset(libs.ktlint.twitter.compose)
|
||||
coreLibraryDesugaring(libs.android.tools.desugar)
|
||||
|
||||
implementation(project(":lib:archive"))
|
||||
implementation(project(":lib:libsignal-service"))
|
||||
implementation(project(":lib:network"))
|
||||
implementation(project(":lib:paging"))
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":lib:glide"))
|
||||
@@ -613,13 +645,10 @@ dependencies {
|
||||
implementation(project(":core:models-jvm"))
|
||||
implementation(project(":feature:camera"))
|
||||
implementation(project(":feature:registration"))
|
||||
implementation(project(":lib:apng"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly("1.6.1")
|
||||
}
|
||||
}
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.window.window)
|
||||
implementation(libs.androidx.window.java)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
@@ -668,6 +697,7 @@ dependencies {
|
||||
implementation(libs.google.play.services.maps)
|
||||
implementation(libs.google.play.services.auth)
|
||||
implementation(libs.google.signin)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.bundles.media3)
|
||||
implementation(libs.conscrypt.android)
|
||||
implementation(libs.signal.aesgcmprovider)
|
||||
@@ -675,7 +705,6 @@ dependencies {
|
||||
implementation(libs.mobilecoin)
|
||||
implementation(libs.signal.ringrtc)
|
||||
implementation(libs.leolin.shortcutbadger)
|
||||
implementation(libs.emilsjolander.stickylistheaders)
|
||||
implementation(libs.glide.glide)
|
||||
implementation(libs.roundedimageview)
|
||||
implementation(libs.materialish.progress)
|
||||
@@ -686,10 +715,6 @@ dependencies {
|
||||
implementation(libs.subsampling.scale.image.view) {
|
||||
exclude(group = "com.android.support", module = "support-annotations")
|
||||
}
|
||||
implementation(libs.android.tooltips) {
|
||||
exclude(group = "com.android.support", module = "appcompat-v7")
|
||||
}
|
||||
implementation(libs.stream)
|
||||
implementation(libs.lottie)
|
||||
implementation(libs.lottie.compose)
|
||||
implementation(libs.signal.android.database.sqlcipher)
|
||||
@@ -744,9 +769,11 @@ dependencies {
|
||||
}
|
||||
testImplementation(testLibs.conscrypt.openjdk.uber)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testFixtures(project(":core:ui")))
|
||||
testImplementation(testFixtures(project(":lib:libsignal-service")))
|
||||
testImplementation(testLibs.espresso.core)
|
||||
testImplementation(testLibs.kotlinx.coroutines.test)
|
||||
testImplementation(testLibs.sqlite.jdbc)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
||||
"perfImplementation"(libs.androidx.compose.ui.test.manifest)
|
||||
@@ -857,7 +884,7 @@ abstract class LanguageListValueSource : ValueSource<List<String>, LanguageListV
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFileValueSource.Params> {
|
||||
abstract class PropertiesFileValueSource : ValueSource<Properties, PropertiesFileValueSource.Params> {
|
||||
interface Params : ValueSourceParameters {
|
||||
@get:InputFile
|
||||
@get:Optional
|
||||
@@ -865,9 +892,9 @@ abstract class PropertiesFileValueSource : ValueSource<Properties?, PropertiesFi
|
||||
val file: RegularFileProperty
|
||||
}
|
||||
|
||||
override fun obtain(): Properties? {
|
||||
override fun obtain(): Properties {
|
||||
val f: File = parameters.file.asFile.get()
|
||||
if (!f.exists()) return null
|
||||
if (!f.exists()) return Properties()
|
||||
|
||||
return Properties().apply {
|
||||
f.inputStream().use { load(it) }
|
||||
@@ -937,3 +964,30 @@ abstract class CopyBenchmarkBackupTask : DefaultTask() {
|
||||
backupFile.copyTo(dest.resolve("backup.binproto"), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class RenameApkTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
abstract val apkFolder: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outFolder: DirectoryProperty
|
||||
|
||||
@get:Internal
|
||||
abstract val transformationRequest: Property<ArtifactTransformationRequest<RenameApkTask>>
|
||||
|
||||
@TaskAction
|
||||
fun rename() {
|
||||
transformationRequest.get().submit(this) { artifact ->
|
||||
val originalFile = File(artifact.outputFile)
|
||||
val versionName = artifact.versionName?.substringBefore(" |")
|
||||
val newName = if (!versionName.isNullOrEmpty()) {
|
||||
originalFile.name.replace(".apk", "-$versionName.apk")
|
||||
} else {
|
||||
originalFile.name
|
||||
}
|
||||
val newFile = File(outFolder.get().asFile, newName)
|
||||
originalFile.copyTo(newFile, overwrite = true)
|
||||
newFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +513,17 @@
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="SoonBlockedPrivateApi"
|
||||
message="Reflective access to mAttachInfo will throw an exception when targeting API 35 and above"
|
||||
errorLine1=" Field attachInfoField = View.class.getDeclaredField("mAttachInfo");"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java"
|
||||
line="157"
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="SimpleDateFormat"
|
||||
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
|
||||
@@ -26454,61 +26465,6 @@
|
||||
column="7"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor mmsCursor = db.query("mms", new String[] {"_id"},"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="298"
|
||||
column="38"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor partCursor = db.query("part", new String[] {"_id", "ct", "_data", "encrypted"},"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="310"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor threadCursor = db.query("thread", new String[] {"_id"}, null, null, null, null, null);"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="708"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" Cursor cursor = db.rawQuery("SELECT DISTINCT date AS date_received, status, " +"
|
||||
errorLine2=" ~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="713"
|
||||
column="28"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `Cursor` should be freed up after use with `#close()`"
|
||||
errorLine1=" cursor = db.query("mms", new String[] {"_id", "network_failures"}, "network_failures IS NOT NULL", null, null, null, null);"
|
||||
errorLine2=" ~~~~~">
|
||||
<location
|
||||
file="src/main/java/org/thoughtcrime/securesms/database/helpers/ClassicOpenHelper.java"
|
||||
line="1037"
|
||||
column="19"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ObsoleteSdkInt"
|
||||
message="Unnecessary; SDK_INT is always >= 21"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# dnsjava references desktop/server-only classes that are absent on Android.
|
||||
-dontwarn com.sun.jna.**
|
||||
-dontwarn javax.naming.**
|
||||
-dontwarn lombok.Generated
|
||||
@@ -7,6 +7,7 @@
|
||||
-keep class org.signal.libsignal.usernames.** { *; }
|
||||
-keep class org.thoughtcrime.securesms.** { *; }
|
||||
-keep class org.signal.donations.json.** { *; }
|
||||
-keep class org.signal.network.** { *; }
|
||||
-keepclassmembers class ** {
|
||||
public void onEvent*(**);
|
||||
}
|
||||
@@ -16,6 +17,14 @@
|
||||
|
||||
-keep class androidx.window.** { *; }
|
||||
|
||||
# Workaround for R8 non-determinism in AGP 9.x. R8 inconsistently keeps or strips
|
||||
# the Signature attribute on this Kotlin lambda subclass of the generic
|
||||
# LottieValueCallback, causing intermittent dex byte differences. Explicitly
|
||||
# keeping the class stabilizes R8's attribute decisions.
|
||||
-keep class com.airbnb.lottie.compose.LottieDynamicPropertiesKt$toValueCallback$1 {
|
||||
*;
|
||||
}
|
||||
|
||||
-keepclassmembers class * extends androidx.constraintlayout.motion.widget.Key {
|
||||
public <init>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.signal.core.util.logging.AndroidLogger
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverDependencyProvider
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner
|
||||
|
||||
/**
|
||||
* Application used when running `IncomingMessageObserver` instrumentation tests. Installs
|
||||
* [IncomingMessageObserverDependencyProvider] so the websocket and job manager are replaced
|
||||
* with test-friendly implementations. Selected by [IncomingMessageObserverTestRunner] when
|
||||
* gradle is invoked with `-PimoTests`.
|
||||
*/
|
||||
class IncomingMessageObserverInstrumentationApplicationContext : ApplicationContext() {
|
||||
|
||||
override fun initializeAppDependencies() {
|
||||
val default = ApplicationDependencyProvider(this)
|
||||
AppDependencies.init(this, IncomingMessageObserverDependencyProvider(this, default))
|
||||
AppDependencies.deadlockDetector.start()
|
||||
}
|
||||
|
||||
override fun initializeLogging() {
|
||||
Log.initialize({ true }, AndroidLogger)
|
||||
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
|
||||
}
|
||||
|
||||
override fun beginJobLoop() = Unit
|
||||
|
||||
fun beginJobLoopForTests() {
|
||||
super.beginJobLoop()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.archive.proto.Frame
|
||||
import org.signal.archive.stream.PlainTextBackupReader
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -20,8 +22,6 @@ import org.signal.core.util.readFully
|
||||
import org.signal.libsignal.messagebackup.ComparableBackup
|
||||
import org.signal.libsignal.messagebackup.MessageBackup
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
@@ -16,6 +16,7 @@ import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
@@ -26,7 +27,6 @@ import org.thoughtcrime.securesms.testing.InAppPaymentsRule
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.actions.RecyclerViewScrollToBottomAction
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.math.BigDecimal
|
||||
|
||||
@@ -358,5 +358,9 @@ class V2ConversationItemShapeTest {
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit
|
||||
|
||||
override fun onViewPinnedMessage(messageId: Long) = Unit
|
||||
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Util
|
||||
import org.signal.network.api.AttachmentUploadResult
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ForeignKeyConstraint
|
||||
import org.signal.core.util.Index
|
||||
import org.signal.core.util.getForeignKeys
|
||||
import org.signal.core.util.getIndexes
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.thoughtcrime.securesms.database.helpers.SignalDatabaseMigrations
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSQLiteDatabase
|
||||
|
||||
/**
|
||||
* A test that guarantees that a freshly-created database looks the same as one that went through the upgrade path.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseConsistencyTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Test
|
||||
fun testUpgradeConsistency() {
|
||||
val currentVersionStatements = SignalDatabase.rawDatabase.getAllCreateStatements()
|
||||
val testHelper = InMemoryTestHelper(AppDependencies.application).also {
|
||||
it.onUpgrade(it.writableDatabase, 181, SignalDatabaseMigrations.DATABASE_VERSION)
|
||||
}
|
||||
|
||||
val upgradedStatements = testHelper.readableDatabase.getAllCreateStatements()
|
||||
|
||||
if (currentVersionStatements != upgradedStatements) {
|
||||
var message = "\n"
|
||||
|
||||
val currentByName = currentVersionStatements.associateBy { it.name }
|
||||
val upgradedByName = upgradedStatements.associateBy { it.name }
|
||||
|
||||
if (currentByName.keys != upgradedByName.keys) {
|
||||
val exclusiveToCurrent = currentByName.keys - upgradedByName.keys
|
||||
val exclusiveToUpgrade = upgradedByName.keys - currentByName.keys
|
||||
|
||||
message += "SQL entities exclusive to the newly-created database: $exclusiveToCurrent\n"
|
||||
message += "SQL entities exclusive to the upgraded database: $exclusiveToUpgrade\n\n"
|
||||
} else {
|
||||
for (currentEntry in currentByName) {
|
||||
val upgradedValue: Statement = upgradedByName[currentEntry.key]!!
|
||||
if (upgradedValue.sql != currentEntry.value.sql) {
|
||||
message += "Statement differed:\n"
|
||||
message += "newly-created:\n"
|
||||
message += "${currentEntry.value.sql}\n\n"
|
||||
message += "upgraded:\n"
|
||||
message += "${upgradedValue.sql}\n\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(message, false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testForeignKeyIndexCoverage() {
|
||||
/** We may deem certain indexes non-critical if deletion frequency is low or table size is small. */
|
||||
val ignoredColumns: List<Pair<String, String>> = listOf(
|
||||
StorySendTable.TABLE_NAME to StorySendTable.DISTRIBUTION_ID
|
||||
)
|
||||
|
||||
val foreignKeys: List<ForeignKeyConstraint> = SignalDatabase.rawDatabase.getForeignKeys()
|
||||
val indexesByFirstColumn: List<Index> = SignalDatabase.rawDatabase.getIndexes()
|
||||
|
||||
val notFound: List<Pair<String, String>> = foreignKeys
|
||||
.filterNot { ignoredColumns.contains(it.table to it.column) }
|
||||
.filterNot { foreignKey ->
|
||||
indexesByFirstColumn.hasPrimaryIndexFor(foreignKey.table, foreignKey.column)
|
||||
}
|
||||
.map { it.table to it.column }
|
||||
|
||||
assertTrue("Missing indexes to cover: $notFound", notFound.isEmpty())
|
||||
}
|
||||
|
||||
private fun List<Index>.hasPrimaryIndexFor(table: String, column: String): Boolean {
|
||||
return this.any { index -> index.table == table && index.columns[0] == column }
|
||||
}
|
||||
|
||||
private data class Statement(
|
||||
val name: String,
|
||||
val sql: String
|
||||
)
|
||||
|
||||
private fun SQLiteDatabase.getAllCreateStatements(): List<Statement> {
|
||||
return this.rawQuery("SELECT name, sql FROM sqlite_schema WHERE sql NOT NULL AND name != 'sqlite_sequence'")
|
||||
.readToList { cursor ->
|
||||
Statement(
|
||||
name = cursor.requireNonNullString("name"),
|
||||
sql = cursor.requireNonNullString("sql").normalizeSql()
|
||||
)
|
||||
}
|
||||
.filterNot { it.name.startsWith("sqlite_stat") }
|
||||
.sortedBy { it.name }
|
||||
}
|
||||
|
||||
private fun String.normalizeSql(): String {
|
||||
return this
|
||||
.split("\n")
|
||||
.map { it.trim() }
|
||||
.joinToString(separator = " ")
|
||||
.replace(Regex.fromLiteral(" ,"), ",")
|
||||
.replace(",([^\\s])".toRegex(), ", $1")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex.fromLiteral("( "), "(")
|
||||
.replace(Regex.fromLiteral(" )"), ")")
|
||||
.replace(Regex("CREATE TABLE \"([a-zA-Z_]+)\""), "CREATE TABLE $1") // for some reason SQLite will wrap table names in quotes for upgraded tables. This unwraps them.
|
||||
}
|
||||
|
||||
private class InMemoryTestHelper(private val application: Application) : SQLiteOpenHelper(application, null, null, 1) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
for (statement in SNAPSHOT_V181) {
|
||||
db.execSQL(statement.sql)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
SignalDatabaseMigrations.migrate(application, SignalSQLiteDatabase(db), 181, SignalDatabaseMigrations.DATABASE_VERSION)
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the list of statements that existed at version 181. Never change this.
|
||||
*/
|
||||
private val SNAPSHOT_V181 = listOf(
|
||||
Statement(
|
||||
name = "message",
|
||||
sql = "CREATE TABLE message (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n date_sent INTEGER NOT NULL,\n date_received INTEGER NOT NULL,\n date_server INTEGER DEFAULT -1,\n thread_id INTEGER NOT NULL REFERENCES thread (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n recipient_device_id INTEGER,\n type INTEGER NOT NULL,\n body TEXT,\n read INTEGER DEFAULT 0,\n ct_l TEXT,\n exp INTEGER,\n m_type INTEGER,\n m_size INTEGER,\n st INTEGER,\n tr_id TEXT,\n subscription_id INTEGER DEFAULT -1, \n receipt_timestamp INTEGER DEFAULT -1, \n delivery_receipt_count INTEGER DEFAULT 0, \n read_receipt_count INTEGER DEFAULT 0, \n viewed_receipt_count INTEGER DEFAULT 0,\n mismatched_identities TEXT DEFAULT NULL,\n network_failures TEXT DEFAULT NULL,\n expires_in INTEGER DEFAULT 0,\n expire_started INTEGER DEFAULT 0,\n notified INTEGER DEFAULT 0,\n quote_id INTEGER DEFAULT 0,\n quote_author INTEGER DEFAULT 0,\n quote_body TEXT DEFAULT NULL,\n quote_missing INTEGER DEFAULT 0,\n quote_mentions BLOB DEFAULT NULL,\n quote_type INTEGER DEFAULT 0,\n shared_contacts TEXT DEFAULT NULL,\n unidentified INTEGER DEFAULT 0,\n link_previews TEXT DEFAULT NULL,\n view_once INTEGER DEFAULT 0,\n reactions_unread INTEGER DEFAULT 0,\n reactions_last_seen INTEGER DEFAULT -1,\n remote_deleted INTEGER DEFAULT 0,\n mentions_self INTEGER DEFAULT 0,\n notified_timestamp INTEGER DEFAULT 0,\n server_guid TEXT DEFAULT NULL,\n message_ranges BLOB DEFAULT NULL,\n story_type INTEGER DEFAULT 0,\n parent_story_id INTEGER DEFAULT 0,\n export_state BLOB DEFAULT NULL,\n exported INTEGER DEFAULT 0,\n scheduled_date INTEGER DEFAULT -1\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "part",
|
||||
sql = "CREATE TABLE part (_id INTEGER PRIMARY KEY, mid INTEGER, seq INTEGER DEFAULT 0, ct TEXT, name TEXT, chset INTEGER, cd TEXT, fn TEXT, cid TEXT, cl TEXT, ctt_s INTEGER, ctt_t TEXT, encrypted INTEGER, pending_push INTEGER, _data TEXT, data_size INTEGER, file_name TEXT, unique_id INTEGER NOT NULL, digest BLOB, fast_preflight_id TEXT, voice_note INTEGER DEFAULT 0, borderless INTEGER DEFAULT 0, video_gif INTEGER DEFAULT 0, data_random BLOB, quote INTEGER DEFAULT 0, width INTEGER DEFAULT 0, height INTEGER DEFAULT 0, caption TEXT DEFAULT NULL, sticker_pack_id TEXT DEFAULT NULL, sticker_pack_key DEFAULT NULL, sticker_id INTEGER DEFAULT -1, sticker_emoji STRING DEFAULT NULL, data_hash TEXT DEFAULT NULL, blur_hash TEXT DEFAULT NULL, transform_properties TEXT DEFAULT NULL, transfer_file TEXT DEFAULT NULL, display_order INTEGER DEFAULT 0, upload_timestamp INTEGER DEFAULT 0, cdn_number INTEGER DEFAULT 0)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread",
|
||||
sql = "CREATE TABLE thread (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n date INTEGER DEFAULT 0, \n meaningful_messages INTEGER DEFAULT 0,\n recipient_id INTEGER NOT NULL UNIQUE REFERENCES recipient (_id) ON DELETE CASCADE,\n read INTEGER DEFAULT 1, \n type INTEGER DEFAULT 0, \n error INTEGER DEFAULT 0, \n snippet TEXT, \n snippet_type INTEGER DEFAULT 0, \n snippet_uri TEXT DEFAULT NULL, \n snippet_content_type TEXT DEFAULT NULL, \n snippet_extras TEXT DEFAULT NULL, \n unread_count INTEGER DEFAULT 0, \n archived INTEGER DEFAULT 0, \n status INTEGER DEFAULT 0, \n delivery_receipt_count INTEGER DEFAULT 0, \n read_receipt_count INTEGER DEFAULT 0, \n expires_in INTEGER DEFAULT 0, \n last_seen INTEGER DEFAULT 0, \n has_sent INTEGER DEFAULT 0, \n last_scrolled INTEGER DEFAULT 0, \n pinned INTEGER DEFAULT 0, \n unread_self_mention_count INTEGER DEFAULT 0\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "identities",
|
||||
sql = "CREATE TABLE identities (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n address INTEGER UNIQUE, \n identity_key TEXT, \n first_use INTEGER DEFAULT 0, \n timestamp INTEGER DEFAULT 0, \n verified INTEGER DEFAULT 0, \n nonblocking_approval INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "drafts",
|
||||
sql = "CREATE TABLE drafts (\n _id INTEGER PRIMARY KEY, \n thread_id INTEGER, \n type TEXT, \n value TEXT\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "push",
|
||||
sql = "CREATE TABLE push (_id INTEGER PRIMARY KEY, type INTEGER, source TEXT, source_uuid TEXT, device_id INTEGER, body TEXT, content TEXT, timestamp INTEGER, server_timestamp INTEGER DEFAULT 0, server_delivered_timestamp INTEGER DEFAULT 0, server_guid TEXT DEFAULT NULL)"
|
||||
),
|
||||
Statement(
|
||||
name = "groups",
|
||||
sql = "CREATE TABLE groups (\n _id INTEGER PRIMARY KEY, \n group_id TEXT, \n recipient_id INTEGER,\n title TEXT,\n avatar_id INTEGER, \n avatar_key BLOB,\n avatar_content_type TEXT, \n avatar_relay TEXT,\n timestamp INTEGER,\n active INTEGER DEFAULT 1,\n avatar_digest BLOB, \n mms INTEGER DEFAULT 0, \n master_key BLOB, \n revision BLOB, \n decrypted_group BLOB, \n expected_v2_id TEXT DEFAULT NULL, \n former_v1_members TEXT DEFAULT NULL, \n distribution_id TEXT DEFAULT NULL, \n display_as_story INTEGER DEFAULT 0, \n auth_service_id TEXT DEFAULT NULL, \n last_force_update_timestamp INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "group_membership",
|
||||
sql = "CREATE TABLE group_membership ( _id INTEGER PRIMARY KEY, group_id TEXT NOT NULL, recipient_id INTEGER NOT NULL, UNIQUE(group_id, recipient_id) )"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient",
|
||||
sql = "CREATE TABLE recipient (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n uuid TEXT UNIQUE DEFAULT NULL,\n username TEXT UNIQUE DEFAULT NULL,\n phone TEXT UNIQUE DEFAULT NULL,\n email TEXT UNIQUE DEFAULT NULL,\n group_id TEXT UNIQUE DEFAULT NULL,\n group_type INTEGER DEFAULT 0,\n blocked INTEGER DEFAULT 0,\n message_ringtone TEXT DEFAULT NULL, \n message_vibrate INTEGER DEFAULT 0, \n call_ringtone TEXT DEFAULT NULL, \n call_vibrate INTEGER DEFAULT 0, \n notification_channel TEXT DEFAULT NULL, \n mute_until INTEGER DEFAULT 0, \n color TEXT DEFAULT NULL, \n seen_invite_reminder INTEGER DEFAULT 0,\n default_subscription_id INTEGER DEFAULT -1,\n message_expiration_time INTEGER DEFAULT 0,\n registered INTEGER DEFAULT 0,\n system_given_name TEXT DEFAULT NULL, \n system_family_name TEXT DEFAULT NULL, \n system_display_name TEXT DEFAULT NULL, \n system_photo_uri TEXT DEFAULT NULL, \n system_phone_label TEXT DEFAULT NULL, \n system_phone_type INTEGER DEFAULT -1, \n system_contact_uri TEXT DEFAULT NULL, \n system_info_pending INTEGER DEFAULT 0, \n profile_key TEXT DEFAULT NULL, \n profile_key_credential TEXT DEFAULT NULL, \n signal_profile_name TEXT DEFAULT NULL, \n profile_family_name TEXT DEFAULT NULL, \n profile_joined_name TEXT DEFAULT NULL, \n signal_profile_avatar TEXT DEFAULT NULL, \n profile_sharing INTEGER DEFAULT 0, \n last_profile_fetch INTEGER DEFAULT 0, \n unidentified_access_mode INTEGER DEFAULT 0, \n force_sms_selection INTEGER DEFAULT 0, \n storage_service_key TEXT UNIQUE DEFAULT NULL, \n mention_setting INTEGER DEFAULT 0, \n storage_proto TEXT DEFAULT NULL,\n capabilities INTEGER DEFAULT 0,\n last_session_reset BLOB DEFAULT NULL,\n wallpaper BLOB DEFAULT NULL,\n wallpaper_file TEXT DEFAULT NULL,\n about TEXT DEFAULT NULL,\n about_emoji TEXT DEFAULT NULL,\n extras BLOB DEFAULT NULL,\n groups_in_common INTEGER DEFAULT 0,\n chat_colors BLOB DEFAULT NULL,\n custom_chat_colors_id INTEGER DEFAULT 0,\n badges BLOB DEFAULT NULL,\n pni TEXT DEFAULT NULL,\n distribution_list_id INTEGER DEFAULT NULL,\n needs_pni_signature INTEGER DEFAULT 0,\n unregistered_timestamp INTEGER DEFAULT 0,\n hidden INTEGER DEFAULT 0,\n reporting_token BLOB DEFAULT NULL,\n system_nickname TEXT DEFAULT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_receipts",
|
||||
sql = "CREATE TABLE group_receipts (\n _id INTEGER PRIMARY KEY, \n mms_id INTEGER, \n address INTEGER, \n status INTEGER, \n timestamp INTEGER, \n unidentified INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "one_time_prekeys",
|
||||
sql = "CREATE TABLE one_time_prekeys (\n _id INTEGER PRIMARY KEY,\n account_id TEXT NOT NULL,\n key_id INTEGER UNIQUE, \n public_key TEXT NOT NULL, \n private_key TEXT NOT NULL,\n UNIQUE(account_id, key_id)\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "signed_prekeys",
|
||||
sql = "CREATE TABLE signed_prekeys (\n _id INTEGER PRIMARY KEY,\n account_id TEXT NOT NULL,\n key_id INTEGER UNIQUE, \n public_key TEXT NOT NULL,\n private_key TEXT NOT NULL,\n signature TEXT NOT NULL, \n timestamp INTEGER DEFAULT 0,\n UNIQUE(account_id, key_id)\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "sessions",
|
||||
sql = "CREATE TABLE sessions (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n account_id TEXT NOT NULL,\n address TEXT NOT NULL,\n device INTEGER NOT NULL,\n record BLOB NOT NULL,\n UNIQUE(account_id, address, device)\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "sender_keys",
|
||||
sql = "CREATE TABLE sender_keys (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n address TEXT NOT NULL, \n device INTEGER NOT NULL, \n distribution_id TEXT NOT NULL,\n record BLOB NOT NULL, \n created_at INTEGER NOT NULL, \n UNIQUE(address,device, distribution_id) ON CONFLICT REPLACE\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "sender_key_shared",
|
||||
sql = "CREATE TABLE sender_key_shared (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n distribution_id TEXT NOT NULL, \n address TEXT NOT NULL, \n device INTEGER NOT NULL, \n timestamp INTEGER DEFAULT 0, \n UNIQUE(distribution_id,address, device) ON CONFLICT REPLACE\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_retry_receipts",
|
||||
sql = "CREATE TABLE pending_retry_receipts(_id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL, device INTEGER NOT NULL, sent_timestamp INTEGER NOT NULL, received_timestamp TEXT NOT NULL, thread_id INTEGER NOT NULL, UNIQUE(author,sent_timestamp) ON CONFLICT REPLACE)"
|
||||
),
|
||||
Statement(
|
||||
name = "sticker",
|
||||
sql = "CREATE TABLE sticker (_id INTEGER PRIMARY KEY AUTOINCREMENT, pack_id TEXT NOT NULL, pack_key TEXT NOT NULL, pack_title TEXT NOT NULL, pack_author TEXT NOT NULL, sticker_id INTEGER, cover INTEGER, pack_order INTEGER, emoji TEXT NOT NULL, content_type TEXT DEFAULT NULL, last_used INTEGER, installed INTEGER,file_path TEXT NOT NULL, file_length INTEGER, file_random BLOB, UNIQUE(pack_id, sticker_id, cover) ON CONFLICT IGNORE)"
|
||||
),
|
||||
Statement(
|
||||
name = "storage_key",
|
||||
sql = "CREATE TABLE storage_key (_id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER, key TEXT UNIQUE)"
|
||||
),
|
||||
Statement(
|
||||
name = "mention",
|
||||
sql = "CREATE TABLE mention(_id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER, message_id INTEGER, recipient_id INTEGER, range_start INTEGER, range_length INTEGER)"
|
||||
),
|
||||
Statement(
|
||||
name = "payments",
|
||||
sql = "CREATE TABLE payments(_id INTEGER PRIMARY KEY, uuid TEXT DEFAULT NULL, recipient INTEGER DEFAULT 0, recipient_address TEXT DEFAULT NULL, timestamp INTEGER, note TEXT DEFAULT NULL, direction INTEGER, state INTEGER, failure_reason INTEGER, amount BLOB NOT NULL, fee BLOB NOT NULL, transaction_record BLOB DEFAULT NULL, receipt BLOB DEFAULT NULL, payment_metadata BLOB DEFAULT NULL, receipt_public_key TEXT DEFAULT NULL, block_index INTEGER DEFAULT 0, block_timestamp INTEGER DEFAULT 0, seen INTEGER, UNIQUE(uuid) ON CONFLICT ABORT)"
|
||||
),
|
||||
Statement(
|
||||
name = "chat_colors",
|
||||
sql = "CREATE TABLE chat_colors (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n chat_colors BLOB\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "emoji_search",
|
||||
sql = "CREATE TABLE emoji_search (\n _id INTEGER PRIMARY KEY,\n label TEXT NOT NULL,\n emoji TEXT NOT NULL,\n rank INTEGER DEFAULT 2147483647 \n )"
|
||||
),
|
||||
Statement(
|
||||
name = "avatar_picker",
|
||||
sql = "CREATE TABLE avatar_picker (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n last_used INTEGER DEFAULT 0,\n group_id TEXT DEFAULT NULL,\n avatar BLOB NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_call_ring",
|
||||
sql = "CREATE TABLE group_call_ring (\n _id INTEGER PRIMARY KEY,\n ring_id INTEGER UNIQUE,\n date_received INTEGER,\n ring_state INTEGER\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "reaction",
|
||||
sql = "CREATE TABLE reaction (\n _id INTEGER PRIMARY KEY,\n message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,\n author_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n emoji TEXT NOT NULL,\n date_sent INTEGER NOT NULL,\n date_received INTEGER NOT NULL,\n UNIQUE(message_id, author_id) ON CONFLICT REPLACE\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "donation_receipt",
|
||||
sql = "CREATE TABLE donation_receipt (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n receipt_type TEXT NOT NULL,\n receipt_date INTEGER NOT NULL,\n amount TEXT NOT NULL,\n currency TEXT NOT NULL,\n subscription_level INTEGER NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "story_sends",
|
||||
sql = "CREATE TABLE story_sends (\n _id INTEGER PRIMARY KEY,\n message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n sent_timestamp INTEGER NOT NULL,\n allows_replies INTEGER NOT NULL,\n distribution_id TEXT NOT NULL REFERENCES distribution_list (distribution_id) ON DELETE CASCADE\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "cds",
|
||||
sql = "CREATE TABLE cds (\n _id INTEGER PRIMARY KEY,\n e164 TEXT NOT NULL UNIQUE ON CONFLICT IGNORE,\n last_seen_at INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "remote_megaphone",
|
||||
sql = "CREATE TABLE remote_megaphone (\n _id INTEGER PRIMARY KEY,\n uuid TEXT UNIQUE NOT NULL,\n priority INTEGER NOT NULL,\n countries TEXT,\n minimum_version INTEGER NOT NULL,\n dont_show_before INTEGER NOT NULL,\n dont_show_after INTEGER NOT NULL,\n show_for_days INTEGER NOT NULL,\n conditional_id TEXT,\n primary_action_id TEXT,\n secondary_action_id TEXT,\n image_url TEXT,\n image_uri TEXT DEFAULT NULL,\n title TEXT NOT NULL,\n body TEXT NOT NULL,\n primary_action_text TEXT,\n secondary_action_text TEXT,\n shown_at INTEGER DEFAULT 0,\n finished_at INTEGER DEFAULT 0,\n primary_action_data TEXT DEFAULT NULL,\n secondary_action_data TEXT DEFAULT NULL,\n snoozed_at INTEGER DEFAULT 0,\n seen_count INTEGER DEFAULT 0\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_pni_signature_message",
|
||||
sql = "CREATE TABLE pending_pni_signature_message (\n _id INTEGER PRIMARY KEY,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n sent_timestamp INTEGER NOT NULL,\n device_id INTEGER NOT NULL\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "call",
|
||||
sql = "CREATE TABLE call (\n _id INTEGER PRIMARY KEY,\n call_id INTEGER NOT NULL UNIQUE,\n message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,\n peer INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,\n type INTEGER NOT NULL,\n direction INTEGER NOT NULL,\n event INTEGER NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "message_fts",
|
||||
sql = "CREATE VIRTUAL TABLE message_fts USING fts5(body, thread_id UNINDEXED, content=message, content_rowid=_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "remapped_recipients",
|
||||
sql = "CREATE TABLE remapped_recipients (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n old_id INTEGER UNIQUE, \n new_id INTEGER\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "remapped_threads",
|
||||
sql = "CREATE TABLE remapped_threads (\n _id INTEGER PRIMARY KEY AUTOINCREMENT, \n old_id INTEGER UNIQUE, \n new_id INTEGER\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_payload",
|
||||
sql = "CREATE TABLE msl_payload (\n _id INTEGER PRIMARY KEY,\n date_sent INTEGER NOT NULL,\n content BLOB NOT NULL,\n content_hint INTEGER NOT NULL,\n urgent INTEGER NOT NULL DEFAULT 1\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_recipient",
|
||||
sql = "CREATE TABLE msl_recipient (\n _id INTEGER PRIMARY KEY,\n payload_id INTEGER NOT NULL REFERENCES msl_payload (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL, \n device INTEGER NOT NULL\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_message",
|
||||
sql = "CREATE TABLE msl_message (\n _id INTEGER PRIMARY KEY,\n payload_id INTEGER NOT NULL REFERENCES msl_payload (_id) ON DELETE CASCADE,\n message_id INTEGER NOT NULL\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile",
|
||||
sql = "CREATE TABLE notification_profile (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT NOT NULL UNIQUE,\n emoji TEXT NOT NULL,\n color TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n allow_all_calls INTEGER NOT NULL DEFAULT 0,\n allow_all_mentions INTEGER NOT NULL DEFAULT 0\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_schedule",
|
||||
sql = "CREATE TABLE notification_profile_schedule (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n notification_profile_id INTEGER NOT NULL REFERENCES notification_profile (_id) ON DELETE CASCADE,\n enabled INTEGER NOT NULL DEFAULT 0,\n start INTEGER NOT NULL,\n end INTEGER NOT NULL,\n days_enabled TEXT NOT NULL\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_allowed_members",
|
||||
sql = "CREATE TABLE notification_profile_allowed_members (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n notification_profile_id INTEGER NOT NULL REFERENCES notification_profile (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL,\n UNIQUE(notification_profile_id, recipient_id) ON CONFLICT REPLACE\n)"
|
||||
),
|
||||
Statement(
|
||||
name = "distribution_list",
|
||||
sql = "CREATE TABLE distribution_list (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n name TEXT UNIQUE NOT NULL,\n distribution_id TEXT UNIQUE NOT NULL,\n recipient_id INTEGER UNIQUE REFERENCES recipient (_id),\n allows_replies INTEGER DEFAULT 1,\n deletion_timestamp INTEGER DEFAULT 0,\n is_unknown INTEGER DEFAULT 0,\n privacy_mode INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "distribution_list_member",
|
||||
sql = "CREATE TABLE distribution_list_member (\n _id INTEGER PRIMARY KEY AUTOINCREMENT,\n list_id INTEGER NOT NULL REFERENCES distribution_list (_id) ON DELETE CASCADE,\n recipient_id INTEGER NOT NULL REFERENCES recipient (_id),\n privacy_mode INTEGER DEFAULT 0\n )"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient_group_type_index",
|
||||
sql = "CREATE INDEX recipient_group_type_index ON recipient (group_type)"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient_pni_index",
|
||||
sql = "CREATE UNIQUE INDEX recipient_pni_index ON recipient (pni)"
|
||||
),
|
||||
Statement(
|
||||
name = "recipient_service_id_profile_key",
|
||||
sql = "CREATE INDEX recipient_service_id_profile_key ON recipient (uuid, profile_key) WHERE uuid NOT NULL AND profile_key NOT NULL"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_read_and_notified_and_thread_id_index",
|
||||
sql = "CREATE INDEX mms_read_and_notified_and_thread_id_index ON message (read, notified, thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_type_index",
|
||||
sql = "CREATE INDEX mms_type_index ON message (type)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_date_sent_index",
|
||||
sql = "CREATE INDEX mms_date_sent_index ON message (date_sent, recipient_id, thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_date_server_index",
|
||||
sql = "CREATE INDEX mms_date_server_index ON message (date_server)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_thread_date_index",
|
||||
sql = "CREATE INDEX mms_thread_date_index ON message (thread_id, date_received)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_reactions_unread_index",
|
||||
sql = "CREATE INDEX mms_reactions_unread_index ON message (reactions_unread)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_story_type_index",
|
||||
sql = "CREATE INDEX mms_story_type_index ON message (story_type)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_parent_story_id_index",
|
||||
sql = "CREATE INDEX mms_parent_story_id_index ON message (parent_story_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_thread_story_parent_story_scheduled_date_index",
|
||||
sql = "CREATE INDEX mms_thread_story_parent_story_scheduled_date_index ON message (thread_id, date_received, story_type, parent_story_id, scheduled_date)"
|
||||
),
|
||||
Statement(
|
||||
name = "message_quote_id_quote_author_scheduled_date_index",
|
||||
sql = "CREATE INDEX message_quote_id_quote_author_scheduled_date_index ON message (quote_id, quote_author, scheduled_date)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_exported_index",
|
||||
sql = "CREATE INDEX mms_exported_index ON message (exported)"
|
||||
),
|
||||
Statement(
|
||||
name = "mms_id_type_payment_transactions_index",
|
||||
sql = "CREATE INDEX mms_id_type_payment_transactions_index ON message (_id,type) WHERE type & 12884901888 != 0"
|
||||
),
|
||||
Statement(
|
||||
name = "part_mms_id_index",
|
||||
sql = "CREATE INDEX part_mms_id_index ON part (mid)"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_push_index",
|
||||
sql = "CREATE INDEX pending_push_index ON part (pending_push)"
|
||||
),
|
||||
Statement(
|
||||
name = "part_sticker_pack_id_index",
|
||||
sql = "CREATE INDEX part_sticker_pack_id_index ON part (sticker_pack_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "part_data_hash_index",
|
||||
sql = "CREATE INDEX part_data_hash_index ON part (data_hash)"
|
||||
),
|
||||
Statement(
|
||||
name = "part_data_index",
|
||||
sql = "CREATE INDEX part_data_index ON part (_data)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread_recipient_id_index",
|
||||
sql = "CREATE INDEX thread_recipient_id_index ON thread (recipient_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "archived_count_index",
|
||||
sql = "CREATE INDEX archived_count_index ON thread (archived, meaningful_messages)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread_pinned_index",
|
||||
sql = "CREATE INDEX thread_pinned_index ON thread (pinned)"
|
||||
),
|
||||
Statement(
|
||||
name = "thread_read",
|
||||
sql = "CREATE INDEX thread_read ON thread (read)"
|
||||
),
|
||||
Statement(
|
||||
name = "draft_thread_index",
|
||||
sql = "CREATE INDEX draft_thread_index ON drafts (thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_id_index",
|
||||
sql = "CREATE UNIQUE INDEX group_id_index ON groups (group_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_recipient_id_index",
|
||||
sql = "CREATE UNIQUE INDEX group_recipient_id_index ON groups (recipient_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "expected_v2_id_index",
|
||||
sql = "CREATE UNIQUE INDEX expected_v2_id_index ON groups (expected_v2_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_distribution_id_index",
|
||||
sql = "CREATE UNIQUE INDEX group_distribution_id_index ON groups(distribution_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "group_receipt_mms_id_index",
|
||||
sql = "CREATE INDEX group_receipt_mms_id_index ON group_receipts (mms_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "sticker_pack_id_index",
|
||||
sql = "CREATE INDEX sticker_pack_id_index ON sticker (pack_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "sticker_sticker_id_index",
|
||||
sql = "CREATE INDEX sticker_sticker_id_index ON sticker (sticker_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "storage_key_type_index",
|
||||
sql = "CREATE INDEX storage_key_type_index ON storage_key (type)"
|
||||
),
|
||||
Statement(
|
||||
name = "mention_message_id_index",
|
||||
sql = "CREATE INDEX mention_message_id_index ON mention (message_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "mention_recipient_id_thread_id_index",
|
||||
sql = "CREATE INDEX mention_recipient_id_thread_id_index ON mention (recipient_id, thread_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "timestamp_direction_index",
|
||||
sql = "CREATE INDEX timestamp_direction_index ON payments (timestamp, direction)"
|
||||
),
|
||||
Statement(
|
||||
name = "timestamp_index",
|
||||
sql = "CREATE INDEX timestamp_index ON payments (timestamp)"
|
||||
),
|
||||
Statement(
|
||||
name = "receipt_public_key_index",
|
||||
sql = "CREATE UNIQUE INDEX receipt_public_key_index ON payments (receipt_public_key)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_payload_date_sent_index",
|
||||
sql = "CREATE INDEX msl_payload_date_sent_index ON msl_payload (date_sent)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_recipient_recipient_index",
|
||||
sql = "CREATE INDEX msl_recipient_recipient_index ON msl_recipient (recipient_id, device, payload_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_recipient_payload_index",
|
||||
sql = "CREATE INDEX msl_recipient_payload_index ON msl_recipient (payload_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_message_message_index",
|
||||
sql = "CREATE INDEX msl_message_message_index ON msl_message (message_id, payload_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "date_received_index",
|
||||
sql = "CREATE INDEX date_received_index on group_call_ring (date_received)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_schedule_profile_index",
|
||||
sql = "CREATE INDEX notification_profile_schedule_profile_index ON notification_profile_schedule (notification_profile_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "notification_profile_allowed_members_profile_index",
|
||||
sql = "CREATE INDEX notification_profile_allowed_members_profile_index ON notification_profile_allowed_members (notification_profile_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "donation_receipt_type_index",
|
||||
sql = "CREATE INDEX donation_receipt_type_index ON donation_receipt (receipt_type)"
|
||||
),
|
||||
Statement(
|
||||
name = "donation_receipt_date_index",
|
||||
sql = "CREATE INDEX donation_receipt_date_index ON donation_receipt (receipt_date)"
|
||||
),
|
||||
Statement(
|
||||
name = "story_sends_recipient_id_sent_timestamp_allows_replies_index",
|
||||
sql = "CREATE INDEX story_sends_recipient_id_sent_timestamp_allows_replies_index ON story_sends (recipient_id, sent_timestamp, allows_replies)"
|
||||
),
|
||||
Statement(
|
||||
name = "story_sends_message_id_distribution_id_index",
|
||||
sql = "CREATE INDEX story_sends_message_id_distribution_id_index ON story_sends (message_id, distribution_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "distribution_list_member_list_id_recipient_id_privacy_mode_index",
|
||||
sql = "CREATE UNIQUE INDEX distribution_list_member_list_id_recipient_id_privacy_mode_index ON distribution_list_member (list_id, recipient_id, privacy_mode)"
|
||||
),
|
||||
Statement(
|
||||
name = "pending_pni_recipient_sent_device_index",
|
||||
sql = "CREATE UNIQUE INDEX pending_pni_recipient_sent_device_index ON pending_pni_signature_message (recipient_id, sent_timestamp, device_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "call_call_id_index",
|
||||
sql = "CREATE INDEX call_call_id_index ON call (call_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "call_message_id_index",
|
||||
sql = "CREATE INDEX call_message_id_index ON call (message_id)"
|
||||
),
|
||||
Statement(
|
||||
name = "message_ai",
|
||||
sql = "CREATE TRIGGER message_ai AFTER INSERT ON message BEGIN\n INSERT INTO message_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "message_ad",
|
||||
sql = "CREATE TRIGGER message_ad AFTER DELETE ON message BEGIN\n INSERT INTO message_fts(message_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "message_au",
|
||||
sql = "CREATE TRIGGER message_au AFTER UPDATE ON message BEGIN\n INSERT INTO message_fts(message_fts, rowid, body, thread_id) VALUES('delete', old._id, old.body, old.thread_id);\n INSERT INTO message_fts(rowid, body, thread_id) VALUES (new._id, new.body, new.thread_id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_message_delete",
|
||||
sql = "CREATE TRIGGER msl_message_delete AFTER DELETE ON message \n BEGIN \n \tDELETE FROM msl_payload WHERE _id IN (SELECT payload_id FROM msl_message WHERE message_id = old._id);\n END"
|
||||
),
|
||||
Statement(
|
||||
name = "msl_attachment_delete",
|
||||
sql = "CREATE TRIGGER msl_attachment_delete AFTER DELETE ON part\n BEGIN\n \tDELETE FROM msl_payload WHERE _id IN (SELECT payload_id FROM msl_message WHERE message_id = old.mid);\n END"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
timestamp = wallClock,
|
||||
groupId = groupId,
|
||||
update = updateDescription,
|
||||
isGroupAdd = false,
|
||||
isNotifiable = false,
|
||||
serverGuid = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ThreadTableTest_recents {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var recipient: Recipient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARecentRecipient_whenIBlockAndGetRecents_thenIDoNotExpectToSeeThatRecipient() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.recipients.setBlocked(recipient.id, true)
|
||||
val results: MutableList<RecipientId> = SignalDatabase.threads.getRecentConversationList(10, false, false, false, false, false, false).use { cursor ->
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
// THEN
|
||||
assertFalse(recipient.id in results)
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,17 @@ import android.app.Application
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import org.signal.core.util.billing.BillingApi
|
||||
import org.signal.network.api.ArchiveApi
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
import org.whispersystems.signalservice.api.SignalServiceDataStore
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender
|
||||
import org.whispersystems.signalservice.api.account.AccountApi
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveApi
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentApi
|
||||
import org.whispersystems.signalservice.api.donations.DonationsApi
|
||||
import org.whispersystems.signalservice.api.keys.KeysApi
|
||||
import org.whispersystems.signalservice.api.message.MessageApi
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
return recipientCache
|
||||
}
|
||||
|
||||
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi {
|
||||
override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi {
|
||||
return mockk()
|
||||
}
|
||||
|
||||
@@ -52,12 +52,11 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
|
||||
override fun provideSignalServiceMessageSender(
|
||||
protocolStore: SignalServiceDataStore,
|
||||
pushServiceSocket: PushServiceSocket,
|
||||
attachmentApi: AttachmentApi,
|
||||
messageApi: MessageApi,
|
||||
keysApi: KeysApi
|
||||
): SignalServiceMessageSender {
|
||||
if (signalServiceMessageSender == null) {
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi))
|
||||
signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi))
|
||||
}
|
||||
return signalServiceMessageSender!!
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
@@ -27,8 +29,6 @@ import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
|
||||
class BackupDeleteJobTest {
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import org.signal.core.util.billing.BillingPurchaseState
|
||||
import org.signal.core.util.billing.BillingResponseCode
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.network.NetworkResult
|
||||
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
@@ -42,8 +44,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.SignalNetwork
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.storage.IAPSubscriptionId
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.media.Media
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.conversation.ConversationArgs
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* End-to-end launch tests for [MainActivity], covering cold-launch and onNewIntent paths
|
||||
* through [MainNavigationViewModel].
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MainNavigationLaunchTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 2)
|
||||
|
||||
private val context: Context get() = harness.context
|
||||
private val recipient: RecipientId get() = harness.others.first()
|
||||
|
||||
/**
|
||||
* Share-target cold-launch regression test. Pre-fix, wrapNavigator() re-routed the
|
||||
* early-staged Conversation through goTo(), whose async wallpaper-prefetch path emitted
|
||||
* a SECOND internalDetailLocation with a fresh ConversationArgs — recreating the
|
||||
* fragment and dropping share data.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_shareIntent_createsFragmentExactlyOnceWithShareData() {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val mimeType = "image/jpeg"
|
||||
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
|
||||
val intent = shareToConversationIntent(
|
||||
recipient = recipient,
|
||||
blob = blob,
|
||||
mimeType = mimeType,
|
||||
shareDataTimestamp = timestamp
|
||||
)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
|
||||
try {
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
val state = runOnMainSync {
|
||||
buildString {
|
||||
appendLine("--- diagnostic dump ---")
|
||||
appendLine("fragments observed: ${recorder.allCreated}")
|
||||
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
|
||||
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
|
||||
appendLine("vm.earlyNavigationDetailLocationRequested: ${vm.earlyNavigationDetailLocationRequested}")
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("${e.message}\n$state", e)
|
||||
}
|
||||
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
// Give the post-navigator wallpaper-prefetch path a chance to emit a (pre-fix)
|
||||
// duplicate second nav before we count fragments.
|
||||
Thread.sleep(750)
|
||||
|
||||
check(recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
val args = recorder.createdArgs.single()
|
||||
check(args.shareDataTimestamp == timestamp) {
|
||||
"Expected shareDataTimestamp=$timestamp, got ${args.shareDataTimestamp}"
|
||||
}
|
||||
check(args.recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${args.recipientId}"
|
||||
}
|
||||
check(args.draftMedia == blob) {
|
||||
"Expected draftMedia=$blob, got ${args.draftMedia}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Image-share cold-launch: the dispatch path through `ShareOrDraftData.StartSendMedia`
|
||||
* that hops the user from the conversation into the media-send screen
|
||||
* ([MediaSelectionActivity]). Asserts that the secondary activity actually launches and
|
||||
* that its [MediaReviewFragment] surfaces the recipient's display name in the top
|
||||
* corner — i.e. it knows who the share is targeted at.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_shareImageIntent_opensMediaSendForRecipient() {
|
||||
val media = realJpegMedia()
|
||||
val intent = shareImageIntent(recipient = recipient, media = media)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val mediaSend = launched.awaitActivity(MediaSelectionActivity::class.java, timeoutMs = 20_000)
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
|
||||
await(timeoutMs = 15_000, description = "recipient label populated in MediaReviewFragment") {
|
||||
// await() already runs the predicate on the main thread; nesting another
|
||||
// runOnMainSync here would throw "can not be called from the main application thread".
|
||||
mediaSend.findViewById<TextView>(R.id.recipient)?.text?.toString() == expectedName
|
||||
}
|
||||
|
||||
// Exactly one ConversationFragment should have been created — the share dispatch
|
||||
// happens from inside it, then it stays put while the media editor sits on top.
|
||||
check(launched.recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment for image share, got ${launched.recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text-share cold-launch: the dispatch path through `ShareOrDraftData.SetText`. Asserts
|
||||
* the navigation boundary — one ConversationFragment, no secondary activity pushed on
|
||||
* top — *and* that the draft text actually shows up in the composer the user sees.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_shareTextIntent_opensConversationWithDraftText() {
|
||||
val draftText = "hello from share"
|
||||
val intent = shareTextIntent(recipient = recipient, text = draftText)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
|
||||
awaitComposerText(launched, draftText)
|
||||
|
||||
// Give a beat for any spurious second navigation to surface.
|
||||
Thread.sleep(750)
|
||||
|
||||
check(recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
val args = recorder.createdArgs.single()
|
||||
check(args.recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${args.recipientId}"
|
||||
}
|
||||
check(args.draftMedia == null) {
|
||||
"Expected no draftMedia, got ${args.draftMedia}"
|
||||
}
|
||||
check(launched.nonMainActivities().isEmpty()) {
|
||||
"Text share should not launch a secondary activity, got ${launched.nonMainActivities().map { it::class.simpleName }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_notificationIntent_opensConversation() {
|
||||
val intent = notificationToConversationIntent(recipient)
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment to be added") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
check(recorder.createdArgs.size == 1) {
|
||||
"Expected exactly one ConversationFragment, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
val args = recorder.createdArgs.single()
|
||||
check(args.recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${args.recipientId}"
|
||||
}
|
||||
check(args.threadId > 0) {
|
||||
"Expected threadId > 0, got ${args.threadId}"
|
||||
}
|
||||
check(args.draftMedia == null) {
|
||||
"Expected no draftMedia, got ${args.draftMedia}"
|
||||
}
|
||||
check(args.shareDataTimestamp == -1L) {
|
||||
"Expected shareDataTimestamp=-1 for notification path, got ${args.shareDataTimestamp}"
|
||||
}
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected currentListLocation=CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_tabIntent_setsListLocation() {
|
||||
val intent = tabIntent(MainNavigationListLocation.CALLS)
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
|
||||
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
Thread.sleep(750)
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for tab launch, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks down present cold-launch behaviour for KEY_DETAIL_LOCATION: today it is only
|
||||
* consumed by onNewIntent. If a future change starts handling it on cold launch, this
|
||||
* test should fail and force a deliberate decision.
|
||||
*/
|
||||
@Test
|
||||
fun coldLaunch_detailLocationIntent_isNoOpToday() {
|
||||
val intent = detailLocationIntent(MainNavigationDetailLocation.Chats.ConversationSettings(recipient))
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
Thread.sleep(1500)
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"KEY_DETAIL_LOCATION is currently only handled by onNewIntent. If a future change " +
|
||||
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
|
||||
}
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.earlyNavigationDetailLocationRequested == null) {
|
||||
"Expected no early detail to be staged, got ${vm.earlyNavigationDetailLocationRequested}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_deepLinkIntent_reachesChatsList() {
|
||||
val intent = deepLinkIntent(Uri.parse("https://signal.org/test-not-a-real-deeplink"))
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected CHATS for deep-link launch, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for deep-link launch, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldLaunch_noExtras_defaultsToChats() {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
Thread.sleep(750)
|
||||
check(vm.earlyNavigationDetailLocationRequested == null) {
|
||||
"Expected no early detail, got ${vm.earlyNavigationDetailLocationRequested}"
|
||||
}
|
||||
check(recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun warmStart_onNewIntent_conversationIntent_opensConversation() {
|
||||
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
// Let the bare list settle so we know any further fragment adds came from onNewIntent.
|
||||
Thread.sleep(1000)
|
||||
val baseline = recorder.createdArgs.size
|
||||
|
||||
val warmIntent = notificationToConversationIntent(recipient)
|
||||
runOnMainSync {
|
||||
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
|
||||
}
|
||||
|
||||
await(timeoutMs = 10_000, description = "ConversationFragment after onNewIntent") {
|
||||
recorder.createdArgs.size > baseline
|
||||
}
|
||||
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
val newArgs = recorder.createdArgs.drop(baseline)
|
||||
check(newArgs.size == 1) { "Expected one new ConversationFragment, got ${newArgs.size}" }
|
||||
check(newArgs.single().recipientId == recipient) {
|
||||
"Expected recipient=$recipient, got ${newArgs.single().recipientId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-conversation onNewIntent with `KEY_DETAIL_LOCATION = Empty` — the contract used
|
||||
* by [ConversationSettingsFragment.goToConversationList] to drop back to the chat list
|
||||
* on phones. No new ConversationFragment should be added.
|
||||
*/
|
||||
@Test
|
||||
fun warmStart_onNewIntent_emptyDetailIntent_returnsToList() {
|
||||
launchSync(notificationToConversationIntent(recipient)).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
val baseline = recorder.createdArgs.size
|
||||
|
||||
val warmIntent = detailLocationIntent(MainNavigationDetailLocation.Empty)
|
||||
runOnMainSync {
|
||||
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
|
||||
}
|
||||
|
||||
await(description = "no new ConversationFragment after Empty detail intent") {
|
||||
recorder.createdArgs.size == baseline
|
||||
}
|
||||
// The user-visible signal that we're "back on the list" is the chat list fragment
|
||||
// being attached, not just the VM saying CHATS.
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
|
||||
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun warmStart_onNewIntent_tabIntent_switchesList() {
|
||||
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
|
||||
awaitListFragment(launched, MainNavigationListLocation.CHATS)
|
||||
|
||||
val warmIntent = tabIntent(MainNavigationListLocation.CALLS)
|
||||
runOnMainSync {
|
||||
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
|
||||
}
|
||||
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
|
||||
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
|
||||
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
|
||||
}
|
||||
check(launched.recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment for tab switch, got ${launched.recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recreate_midConversation_restoresState() {
|
||||
launchSync(notificationToConversationIntent(recipient)).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
val initial = recorder.createdArgs.first()
|
||||
|
||||
runOnMainSync { launched.activity.recreate() }
|
||||
|
||||
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
|
||||
recorder.createdArgs.size >= 2
|
||||
}
|
||||
// Verify the user-visible title rebinds after recreate, not just the args.
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
val recreated = recorder.createdArgs[1]
|
||||
check(recreated.recipientId == initial.recipientId) {
|
||||
"Recipient changed across recreate: ${initial.recipientId} -> ${recreated.recipientId}"
|
||||
}
|
||||
check(recreated.threadId == initial.threadId) {
|
||||
"Thread changed across recreate: ${initial.threadId} -> ${recreated.threadId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recreate_midTab_restoresTab() {
|
||||
launchSync(tabIntent(MainNavigationListLocation.CALLS)).use { launched ->
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
runOnMainSync { launched.activity.recreate() }
|
||||
|
||||
// Verify the user-visible tab content rebinds after recreate, not just the VM. The
|
||||
// recorder removes destroyed fragments, so this only passes once the post-recreate
|
||||
// CallLogFragment instance is attached.
|
||||
awaitListFragment(launched, MainNavigationListLocation.CALLS)
|
||||
|
||||
// launched.activity returns the *latest* MainActivity (the holder updates in
|
||||
// onActivityCreated), so this reads the post-recreate VM instance.
|
||||
val location = runOnMainSync {
|
||||
launched.activity.mainNavigationViewModel().mainNavigationState.value.currentListLocation
|
||||
}
|
||||
check(location == MainNavigationListLocation.CALLS) {
|
||||
"Expected VM CALLS post-recreate, got $location"
|
||||
}
|
||||
check(launched.recorder.createdArgs.isEmpty()) {
|
||||
"Expected no ConversationFragment across tab recreate, got ${launched.recorder.createdArgs.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recreate_midShareConversation_preservesShareData() {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val mimeType = "image/jpeg"
|
||||
val blob = realBlob(byteArrayOf(0x01, 0x02, 0x03), mimeType)
|
||||
val intent = shareToConversationIntent(
|
||||
recipient = recipient,
|
||||
blob = blob,
|
||||
mimeType = mimeType,
|
||||
shareDataTimestamp = timestamp
|
||||
)
|
||||
|
||||
launchSync(intent).use { launched ->
|
||||
val recorder = launched.recorder
|
||||
await(timeoutMs = 10_000, description = "initial ConversationFragment") {
|
||||
recorder.createdArgs.isNotEmpty()
|
||||
}
|
||||
val expectedName = runOnMainSync { Recipient.resolved(recipient).getDisplayName(context) }
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
val initialCount = recorder.createdArgs.size
|
||||
|
||||
runOnMainSync { launched.activity.recreate() }
|
||||
|
||||
await(timeoutMs = 15_000, description = "ConversationFragment after recreate") {
|
||||
recorder.createdArgs.size > initialCount
|
||||
}
|
||||
awaitConversationTitle(launched, expectedName)
|
||||
|
||||
val recreated = recorder.createdArgs.last()
|
||||
check(recreated.shareDataTimestamp == timestamp) {
|
||||
"shareDataTimestamp not preserved across recreate: $timestamp -> ${recreated.shareDataTimestamp}"
|
||||
}
|
||||
check(recreated.draftMedia == blob) {
|
||||
"draftMedia not preserved across recreate: $blob -> ${recreated.draftMedia}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// region Helpers
|
||||
|
||||
/**
|
||||
* Mirrors [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]. We
|
||||
* deliberately drop the producer's `clearTop` flags (NEW_TASK | CLEAR_TOP | SINGLE_TOP)
|
||||
* — they are launch-routing concerns that are incompatible with our lifecycle monitor.
|
||||
*/
|
||||
private fun shareToConversationIntent(
|
||||
recipient: RecipientId,
|
||||
blob: Uri,
|
||||
mimeType: String,
|
||||
draftText: String? = null,
|
||||
shareDataTimestamp: Long = System.currentTimeMillis()
|
||||
): Intent {
|
||||
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
|
||||
val conversationIntent = builder
|
||||
.withDataUri(blob)
|
||||
.withDataType(mimeType)
|
||||
.withMedia(emptyList())
|
||||
.withDraftText(draftText)
|
||||
.withStickerLocator(null)
|
||||
.asBorderless(false)
|
||||
.withShareDataTimestamp(shareDataTimestamp)
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the image-share path through [org.thoughtcrime.securesms.sharing.v2.ShareActivity.openConversation]:
|
||||
* a non-empty `media` list is what flips dispatch to `ShareOrDraftData.StartSendMedia`,
|
||||
* which is what triggers the hop to the media-send screen.
|
||||
*/
|
||||
private fun shareImageIntent(recipient: RecipientId, media: Media): Intent {
|
||||
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
|
||||
val conversationIntent = builder
|
||||
.withDataUri(media.uri)
|
||||
.withDataType(media.contentType)
|
||||
.withMedia(listOf(media))
|
||||
.withStickerLocator(null)
|
||||
.asBorderless(false)
|
||||
.withShareDataTimestamp(System.currentTimeMillis())
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a text-only share. Empty media list + non-null draft text routes dispatch to
|
||||
* `ShareOrDraftData.SetText`.
|
||||
*/
|
||||
private fun shareTextIntent(recipient: RecipientId, text: String): Intent {
|
||||
val builder = ConversationIntents.createBuilder(context, recipient, -1L).blockingGet()
|
||||
val conversationIntent = builder
|
||||
.withMedia(emptyList())
|
||||
.withDraftText(text)
|
||||
.withStickerLocator(null)
|
||||
.asBorderless(false)
|
||||
.withShareDataTimestamp(System.currentTimeMillis())
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationToConversationIntent(recipient: RecipientId): Intent {
|
||||
val conversationIntent = ConversationIntents.createBuilder(context, recipient, -1L)
|
||||
.blockingGet()
|
||||
.build()
|
||||
|
||||
return Intent(context, MainActivity::class.java).apply {
|
||||
action = ConversationIntents.ACTION
|
||||
putExtras(conversationIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tabIntent(tab: MainNavigationListLocation): Intent {
|
||||
return Intent(context, MainActivity::class.java)
|
||||
.putExtra("STARTING_TAB", tab)
|
||||
}
|
||||
|
||||
private fun detailLocationIntent(location: MainNavigationDetailLocation): Intent {
|
||||
return Intent(context, MainActivity::class.java)
|
||||
.putExtra("DETAIL_LOCATION", location)
|
||||
}
|
||||
|
||||
private fun realBlob(bytes: ByteArray, mimeType: String): Uri {
|
||||
return BlobProvider.getInstance()
|
||||
.forData(bytes)
|
||||
.withMimeType(mimeType)
|
||||
.createForSingleSessionInMemory()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a [Media] backed by a real 1×1 JPEG. The media-send screen attempts to decode
|
||||
* the image during MediaReviewFragment setup, so a fake byte array won't survive — we
|
||||
* need genuine JPEG bytes for the fragment to reach the state where `R.id.recipient`
|
||||
* is populated.
|
||||
*/
|
||||
private fun realJpegMedia(): Media {
|
||||
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||
val bytes = ByteArrayOutputStream().use { stream ->
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, stream)
|
||||
stream.toByteArray()
|
||||
}
|
||||
bitmap.recycle()
|
||||
val uri = realBlob(bytes, "image/jpeg")
|
||||
return Media(
|
||||
uri = uri,
|
||||
contentType = "image/jpeg",
|
||||
date = 0L,
|
||||
width = 1,
|
||||
height = 1,
|
||||
size = bytes.size.toLong(),
|
||||
duration = 0L,
|
||||
isBorderless = false,
|
||||
isVideoGif = false,
|
||||
bucketId = null,
|
||||
caption = null,
|
||||
transformProperties = null,
|
||||
fileName = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors [org.thoughtcrime.securesms.deeplinks.DeepLinkEntryActivity]: bare clearTop
|
||||
* plus a [Uri] in the data field.
|
||||
*/
|
||||
private fun deepLinkIntent(data: Uri): Intent {
|
||||
return Intent(context, MainActivity::class.java).setData(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously launch [MainActivity] and return the running instance plus a fragment
|
||||
* recorder wired up *before* the activity is created.
|
||||
*
|
||||
* We bypass [androidx.test.core.app.ActivityScenario] and
|
||||
* [android.app.Instrumentation.startActivitySync] because both fail for our case:
|
||||
* ActivityScenario's lifecycle tracker misses CREATED/STARTED/RESUMED for activities
|
||||
* launched with a custom-action intent, and `startActivitySync` waits for main-thread
|
||||
* idle which never arrives while MainActivity's composition + ConversationFragment
|
||||
* setup keeps the looper busy.
|
||||
*/
|
||||
private fun launchSync(intent: Intent): LaunchedActivity {
|
||||
val recorder = ConversationFragmentRecorder()
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
||||
val resumed = CountDownLatch(1)
|
||||
val activityHolder = arrayOfNulls<MainActivity>(1)
|
||||
val allActivities: MutableList<Activity> = Collections.synchronizedList(mutableListOf())
|
||||
val callbacks = object : Application.ActivityLifecycleCallbacks {
|
||||
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
||||
allActivities += activity
|
||||
if (activity is MainActivity) {
|
||||
activityHolder[0] = activity
|
||||
activity.supportFragmentManager.registerFragmentLifecycleCallbacks(recorder, true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityStarted(activity: Activity) = Unit
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
if (activity is MainActivity) resumed.countDown()
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) = Unit
|
||||
override fun onActivityStopped(activity: Activity) = Unit
|
||||
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||
override fun onActivityDestroyed(activity: Activity) {
|
||||
allActivities.remove(activity)
|
||||
}
|
||||
}
|
||||
app.registerActivityLifecycleCallbacks(callbacks)
|
||||
|
||||
// Application.startActivity from a non-Activity context requires FLAG_ACTIVITY_NEW_TASK.
|
||||
val launchIntent = Intent(intent).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
|
||||
try {
|
||||
app.startActivity(launchIntent)
|
||||
} catch (t: Throwable) {
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
throw t
|
||||
}
|
||||
|
||||
if (!resumed.await(15, TimeUnit.SECONDS)) {
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
error("MainActivity did not reach RESUMED within 15s")
|
||||
}
|
||||
return LaunchedActivity(activityHolder, recorder, app, callbacks, allActivities)
|
||||
}
|
||||
|
||||
private fun <T> runOnMainSync(block: () -> T): T {
|
||||
var result: Result<T> = Result.failure(IllegalStateException("runOnMainSync did not produce a result"))
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
result = runCatching(block)
|
||||
}
|
||||
return result.getOrThrow()
|
||||
}
|
||||
|
||||
private fun await(
|
||||
timeoutMs: Long = 5_000,
|
||||
pollMs: Long = 50,
|
||||
description: String = "condition",
|
||||
predicate: () -> Boolean
|
||||
) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (runOnMainSync(predicate)) return
|
||||
Thread.sleep(pollMs)
|
||||
}
|
||||
error("Timed out after ${timeoutMs}ms waiting for $description")
|
||||
}
|
||||
|
||||
private fun MainActivity.mainNavigationViewModel(): MainNavigationViewModel {
|
||||
return ViewModelProvider(this as FragmentActivity, MainNavigationViewModel.Factory())[MainNavigationViewModel::class.java]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the latest [ConversationFragment]'s composer EditText shows [expected].
|
||||
* setDraftText is invoked off the InputReadyState/ShareOrDraftData reactive chain, so the
|
||||
* text won't be present at fragment-create time — we have to poll the rendered view.
|
||||
*/
|
||||
private fun awaitComposerText(launched: LaunchedActivity, expected: String) {
|
||||
await(timeoutMs = 15_000, description = "composer shows \"$expected\"") {
|
||||
val frag = launched.recorder.latestActive() ?: return@await false
|
||||
val view = frag.view ?: return@await false
|
||||
view.findViewById<TextView>(R.id.embedded_text_editor)?.text?.toString() == expected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the latest [ConversationFragment]'s toolbar shows [expected]. Scoped through
|
||||
* R.id.conversation_title_view to avoid colliding with other R.id.title uses.
|
||||
*/
|
||||
private fun awaitConversationTitle(launched: LaunchedActivity, expected: String) {
|
||||
await(timeoutMs = 15_000, description = "conversation title shows \"$expected\"") {
|
||||
val frag = launched.recorder.latestActive() ?: return@await false
|
||||
val view = frag.view ?: return@await false
|
||||
val titleHost = view.findViewById<View>(R.id.conversation_title_view) ?: return@await false
|
||||
titleHost.findViewById<TextView>(R.id.title)?.text?.toString() == expected
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MainActivity hosts each tab as a different [Fragment] via Compose's `AndroidFragment`
|
||||
* (see MainActivity.kt:662-698). The user sees the content of whichever one is currently
|
||||
* attached, so a tab assertion that reads the FragmentManager is a real user-visible
|
||||
* signal — strictly stronger than reading the VM's `currentListLocation`.
|
||||
*/
|
||||
private fun listFragmentClass(location: MainNavigationListLocation): Class<out Fragment> = when (location) {
|
||||
MainNavigationListLocation.CHATS -> ConversationListFragment::class.java
|
||||
MainNavigationListLocation.ARCHIVE -> ConversationListArchiveFragment::class.java
|
||||
MainNavigationListLocation.CALLS -> CallLogFragment::class.java
|
||||
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
|
||||
}
|
||||
|
||||
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
|
||||
val expected = listFragmentClass(location)
|
||||
try {
|
||||
await(timeoutMs = 10_000, description = "${expected.simpleName} attached for $location") {
|
||||
launched.recorder.isAttached(expected)
|
||||
}
|
||||
} catch (e: IllegalStateException) {
|
||||
throw IllegalStateException("${e.message}; currently attached: ${launched.recorder.attachedNames()}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Types
|
||||
|
||||
/**
|
||||
* Records every [ConversationFragment] added under an activity's fragment manager,
|
||||
* capturing each fragment's arguments at create-time.
|
||||
*/
|
||||
private class ConversationFragmentRecorder : FragmentManager.FragmentLifecycleCallbacks() {
|
||||
val createdArgs: MutableList<ConversationArgs> = mutableListOf()
|
||||
val allCreated: MutableList<String> = mutableListOf()
|
||||
private val active: MutableList<ConversationFragment> = mutableListOf()
|
||||
private val attached: MutableList<Fragment> = mutableListOf()
|
||||
var destroyedCount: Int = 0
|
||||
private set
|
||||
|
||||
/** Most-recently-added still-attached ConversationFragment, or null. Main-thread read. */
|
||||
fun latestActive(): ConversationFragment? = active.lastOrNull()
|
||||
|
||||
/**
|
||||
* Exact class match (not [Class.isInstance]) — `ConversationListArchiveFragment`
|
||||
* extends `ConversationListFragment`, so an `isInstance` check for CHATS would falsely
|
||||
* pass when the archive list is attached.
|
||||
*/
|
||||
fun isAttached(clazz: Class<out Fragment>): Boolean = attached.any { it::class.java == clazz }
|
||||
|
||||
fun attachedNames(): List<String> = attached.map { it::class.simpleName ?: it::class.java.name }
|
||||
|
||||
override fun onFragmentCreated(fm: FragmentManager, f: Fragment, savedInstanceState: android.os.Bundle?) {
|
||||
allCreated += f::class.simpleName ?: f::class.java.name
|
||||
attached += f
|
||||
if (f is ConversationFragment) {
|
||||
createdArgs += ConversationIntents.readArgsFromBundle(f.requireArguments())
|
||||
active += f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
|
||||
attached.remove(f)
|
||||
if (f is ConversationFragment) {
|
||||
active.remove(f)
|
||||
destroyedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LaunchedActivity(
|
||||
private val activityHolder: Array<MainActivity?>,
|
||||
val recorder: ConversationFragmentRecorder,
|
||||
private val app: Application,
|
||||
private val callbacks: Application.ActivityLifecycleCallbacks,
|
||||
private val allActivities: MutableList<Activity>
|
||||
) : AutoCloseable {
|
||||
/**
|
||||
* Always returns the *latest* MainActivity instance so reads follow `recreate()`.
|
||||
*/
|
||||
val activity: MainActivity get() = checkNotNull(activityHolder[0]) { "No active MainActivity" }
|
||||
|
||||
/**
|
||||
* Poll until an activity of [clazz] has been created, then return it. Used to assert
|
||||
* the share-image flow's hop into MediaSelectionActivity.
|
||||
*/
|
||||
fun <T : Activity> awaitActivity(clazz: Class<T>, timeoutMs: Long = 10_000): T {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val match = synchronized(allActivities) {
|
||||
allActivities.firstOrNull { clazz.isInstance(it) }
|
||||
}
|
||||
if (match != null) return clazz.cast(match)!!
|
||||
Thread.sleep(50)
|
||||
}
|
||||
val seen = synchronized(allActivities) { allActivities.map { it::class.simpleName } }
|
||||
error("Timed out after ${timeoutMs}ms waiting for ${clazz.simpleName}; saw $seen")
|
||||
}
|
||||
|
||||
fun nonMainActivities(): List<Activity> = synchronized(allActivities) {
|
||||
allActivities.filter { it !is MainActivity }.toList()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// Don't wait for looper idle — secondary activities (e.g. MediaSelectionActivity
|
||||
// opened by share processing) can keep it busy indefinitely. Finish every tracked
|
||||
// activity so subsequent tests start from a clean slate.
|
||||
val toFinish = synchronized(allActivities) { allActivities.toList() }
|
||||
if (toFinish.isNotEmpty()) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync {
|
||||
toFinish.forEach { it.finish() }
|
||||
}
|
||||
}
|
||||
app.unregisterActivityLifecycleCallbacks(callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
@@ -316,7 +316,7 @@ class DataMessageProcessorTest_polls {
|
||||
|
||||
private fun insertPoll(allowMultiple: Boolean = true): Long {
|
||||
val envelope = MessageContentFuzzer.envelope(100)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.clientTimestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
|
||||
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
|
||||
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
|
||||
return messageId.messageId
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.network.api.AttachmentUploadResult
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
@@ -37,7 +38,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil
|
||||
import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isTrue
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncMessageProcessorTest_synchronizePniChangeNumber {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(createGroup = true)
|
||||
|
||||
private lateinit var messageHelper: MessageHelper
|
||||
|
||||
private val newPniUuid: UUID = UUID.randomUUID()
|
||||
private val newPni: ServiceId.PNI = ServiceId.PNI.from(newPniUuid)
|
||||
|
||||
// 16-byte raw UUID — matches the actual wire format the server sends (per proto comment and
|
||||
// iOS/Desktop behavior). Do NOT use `newPni.toByteString()` here — that produces libsignal's
|
||||
// 17-byte ServiceIdBinary form, which is a different format.
|
||||
private val newPniBytes: ByteString = UuidUtil.toByteArray(newPniUuid).toByteString()
|
||||
private val newE164 = "+15555550199"
|
||||
private val newPniIdentity: IdentityKeyPair = IdentityKeyPair.generate()
|
||||
private val newSignedPreKey: SignedPreKeyRecord = PreKeyUtil.generateSignedPreKey(1234, newPniIdentity.privateKey)
|
||||
private val newLastResortKyber: KyberPreKeyRecord = PreKeyUtil.generateLastResortKyberPreKey(5678, newPniIdentity.privateKey)
|
||||
private val newRegistrationId = 4242
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
messageHelper = MessageHelper(harness)
|
||||
SignalStore.account.deviceId = 2
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
messageHelper.tearDown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesAllStateOnHappyPath() {
|
||||
sendPniChangeNumber()
|
||||
|
||||
assertThat(SignalStore.account.e164).isEqualTo(newE164)
|
||||
assertThat(SignalStore.account.pni).isEqualTo(newPni)
|
||||
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
|
||||
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
|
||||
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
|
||||
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
|
||||
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
|
||||
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(newLastResortKyber.id)
|
||||
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
|
||||
|
||||
val self = Recipient.self().fresh()
|
||||
assertThat(self.requireE164()).isEqualTo(newE164)
|
||||
assertThat(self.pni.orNull()).isEqualTo(newPni)
|
||||
|
||||
val pniProtocolStore = AppDependencies.protocolStore.pni()
|
||||
val storedSigned = pniProtocolStore.loadSignedPreKey(newSignedPreKey.id)
|
||||
assertThat(storedSigned.serialize().toByteString()).isEqualTo(newSignedPreKey.serialize().toByteString())
|
||||
val storedKyber = pniProtocolStore.loadLastResortKyberPreKeys().firstOrNull { it.id == newLastResortKyber.id }
|
||||
assertThat(storedKyber).isNotNull()
|
||||
assertThat(storedKyber!!.serialize().toByteString()).isEqualTo(newLastResortKyber.serialize().toByteString())
|
||||
|
||||
// The IdentityTable cache is keyed by ServiceId string, not RecipientId — for self, that's
|
||||
// separate ACI and PNI rows. We want the PNI row, so look it up by the new PNI directly.
|
||||
val selfPniIdentity = pniProtocolStore.getIdentity(SignalProtocolAddress(newPni.toString(), SignalServiceAddress.DEFAULT_DEVICE_ID))
|
||||
assertThat(selfPniIdentity).isNotNull()
|
||||
assertThat(selfPniIdentity!!.publicKey.serialize().toByteString())
|
||||
.isEqualTo(newPniIdentity.publicKey.serialize().toByteString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesStateWhenLastResortKyberAbsent() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(lastResortKyberPreKey = null)
|
||||
|
||||
assertThat(SignalStore.account.e164).isEqualTo(newE164)
|
||||
assertThat(SignalStore.account.pni).isEqualTo(newPni)
|
||||
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(newRegistrationId)
|
||||
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(newSignedPreKey.id)
|
||||
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isTrue()
|
||||
// No kyber was supplied, so kyber metadata should be unchanged.
|
||||
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
|
||||
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenPrimaryDevice() {
|
||||
SignalStore.account.deviceId = SignalServiceAddress.DEFAULT_DEVICE_ID
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber()
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenSourceIsNotPrimaryDevice() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(sourceDeviceId = 3)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenEnvelopePniMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(envelopePniBinary = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenIdentityKeyPairMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(identityKeyPair = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenSignedPreKeyMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(signedPreKey = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenRegistrationIdMissing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(registrationId = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenRegistrationIdZero() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(registrationId = 0)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenNewE164Missing() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(e164 = null)
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenNewE164Empty() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(e164 = "")
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenNewE164NotValid() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(e164 = "not a phone number")
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsOnMalformedIdentityKeyPair() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(identityKeyPair = malformedBytes())
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsOnMalformedSignedPreKey() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(signedPreKey = malformedBytes())
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsOnMalformedLastResortKyber() {
|
||||
val original = captureOriginalState()
|
||||
|
||||
sendPniChangeNumber(lastResortKyberPreKey = malformedBytes())
|
||||
|
||||
assertOriginalStatePreserved(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsRedeliveryWhenPniAlreadyMatches() {
|
||||
sendPniChangeNumber()
|
||||
val afterFirstApply = captureOriginalState()
|
||||
|
||||
val otherIdentity = IdentityKeyPair.generate()
|
||||
val otherSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, otherIdentity.privateKey)
|
||||
|
||||
sendPniChangeNumber(
|
||||
identityKeyPair = otherIdentity.serialize().toByteString(),
|
||||
signedPreKey = otherSignedPreKey.serialize().toByteString(),
|
||||
e164 = "+15555550100",
|
||||
timestamp = messageHelper.nextStartTime() + 1000
|
||||
)
|
||||
|
||||
assertOriginalStatePreserved(afterFirstApply)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bailsWhenServerTimestampStale() {
|
||||
sendPniChangeNumber()
|
||||
val afterFirstApply = captureOriginalState()
|
||||
|
||||
val otherPniUuid = UUID.randomUUID()
|
||||
val otherPniBytes = UuidUtil.toByteArray(otherPniUuid).toByteString()
|
||||
|
||||
sendPniChangeNumber(
|
||||
envelopePniBinary = otherPniBytes,
|
||||
e164 = "+15555550100",
|
||||
timestamp = messageHelper.nextStartTime() - 100_000L
|
||||
)
|
||||
|
||||
assertOriginalStatePreserved(afterFirstApply)
|
||||
}
|
||||
|
||||
private fun captureOriginalState(): OriginalState {
|
||||
val self = Recipient.self().fresh()
|
||||
return OriginalState(
|
||||
e164 = SignalStore.account.e164,
|
||||
pni = SignalStore.account.pni,
|
||||
pniRegistrationId = SignalStore.account.pniRegistrationId,
|
||||
isSignedPreKeyRegistered = SignalStore.account.pniPreKeys.isSignedPreKeyRegistered,
|
||||
activeSignedPreKeyId = SignalStore.account.pniPreKeys.activeSignedPreKeyId,
|
||||
lastResortKyberPreKeyId = SignalStore.account.pniPreKeys.lastResortKyberPreKeyId,
|
||||
pniIdentityPublicKey = SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString(),
|
||||
selfE164 = self.e164.orNull(),
|
||||
selfPni = self.pni.orNull(),
|
||||
forcePniSignedPreKeyRotation = SignalStore.misc.forcePniSignedPreKeyRotation
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertOriginalStatePreserved(original: OriginalState) {
|
||||
assertThat(SignalStore.account.e164).isEqualTo(original.e164)
|
||||
assertThat(SignalStore.account.pni).isEqualTo(original.pni)
|
||||
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(original.pniRegistrationId)
|
||||
assertThat(SignalStore.account.pniPreKeys.isSignedPreKeyRegistered).isEqualTo(original.isSignedPreKeyRegistered)
|
||||
assertThat(SignalStore.account.pniPreKeys.activeSignedPreKeyId).isEqualTo(original.activeSignedPreKeyId)
|
||||
assertThat(SignalStore.account.pniPreKeys.lastResortKyberPreKeyId).isEqualTo(original.lastResortKyberPreKeyId)
|
||||
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
|
||||
.isEqualTo(original.pniIdentityPublicKey)
|
||||
assertThat(SignalStore.misc.forcePniSignedPreKeyRotation).isEqualTo(original.forcePniSignedPreKeyRotation)
|
||||
val self = Recipient.self().fresh()
|
||||
assertThat(self.e164.orNull()).isEqualTo(original.selfE164)
|
||||
assertThat(self.pni.orNull()).isEqualTo(original.selfPni)
|
||||
}
|
||||
|
||||
private data class OriginalState(
|
||||
val e164: String?,
|
||||
val pni: ServiceId.PNI?,
|
||||
val pniRegistrationId: Int,
|
||||
val isSignedPreKeyRegistered: Boolean,
|
||||
val activeSignedPreKeyId: Int,
|
||||
val lastResortKyberPreKeyId: Int,
|
||||
val pniIdentityPublicKey: ByteString,
|
||||
val selfE164: String?,
|
||||
val selfPni: ServiceId.PNI?,
|
||||
val forcePniSignedPreKeyRotation: Boolean
|
||||
)
|
||||
|
||||
private fun malformedBytes(): ByteString = byteArrayOf(0x00, 0x01, 0x02).toByteString()
|
||||
|
||||
private fun sendPniChangeNumber(
|
||||
identityKeyPair: ByteString? = newPniIdentity.serialize().toByteString(),
|
||||
signedPreKey: ByteString? = newSignedPreKey.serialize().toByteString(),
|
||||
lastResortKyberPreKey: ByteString? = newLastResortKyber.serialize().toByteString(),
|
||||
registrationId: Int? = newRegistrationId,
|
||||
e164: String? = newE164,
|
||||
envelopePniBinary: ByteString? = newPniBytes,
|
||||
sourceDeviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID,
|
||||
timestamp: Long = messageHelper.nextStartTime()
|
||||
) {
|
||||
val content = Content(
|
||||
syncMessage = SyncMessage(
|
||||
pniChangeNumber = SyncMessage.PniChangeNumber(
|
||||
identityKeyPair = identityKeyPair,
|
||||
signedPreKey = signedPreKey,
|
||||
lastResortKyberPreKey = lastResortKyberPreKey,
|
||||
registrationId = registrationId,
|
||||
newE164 = e164
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val envelope = MessageContentFuzzer.envelope(
|
||||
timestamp = timestamp,
|
||||
updatedPniBinary = envelopePniBinary
|
||||
)
|
||||
|
||||
messageHelper.processor.process(
|
||||
envelope = envelope,
|
||||
content = content,
|
||||
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = sourceDeviceId),
|
||||
serverDeliveredTimestamp = timestamp + 10
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms.messages.incomingmessageobserver
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertNoMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DecryptionErrorTest {
|
||||
|
||||
@get:Rule
|
||||
val rule = IncomingMessageObserverRule(peerCount = 2)
|
||||
|
||||
@Test
|
||||
fun malformedEnvelope_dropsMessage_butPipelineRecovers() {
|
||||
val peer = rule.peers[0]
|
||||
|
||||
rule.deliver { malformedEnvelope() from peer }
|
||||
assertNoMessageReceived(from = peer, body = "subsequent")
|
||||
|
||||
rule.deliver { text("subsequent") from peer }
|
||||
assertMessageReceived(from = peer, body = "subsequent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.messages.incomingmessageobserver
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertGroupMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IncomingGroupMessageTest {
|
||||
|
||||
@get:Rule
|
||||
val rule = IncomingMessageObserverRule(peerCount = 5)
|
||||
|
||||
@Test
|
||||
fun deliveredGroupText_isPersistedInGroupThread() {
|
||||
val group = rule.testGroup
|
||||
|
||||
rule.deliver { groupText("hello group", group = group) from rule.peers[0] }
|
||||
|
||||
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "hello group")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleGroupMembers_messagesPersistedFromEach() {
|
||||
val group = rule.testGroup
|
||||
|
||||
rule.deliver {
|
||||
groupText("from peer 0", group = group) from rule.peers[0]
|
||||
groupText("from peer 1", group = group) from rule.peers[1]
|
||||
groupText("from peer 2", group = group) from rule.peers[2]
|
||||
}
|
||||
|
||||
assertGroupMessageReceived(from = rule.peers[0], group = group, body = "from peer 0")
|
||||
assertGroupMessageReceived(from = rule.peers[1], group = group, body = "from peer 1")
|
||||
assertGroupMessageReceived(from = rule.peers[2], group = group, body = "from peer 2")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.messages.incomingmessageobserver
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived
|
||||
import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class IncomingTextMessageTest {
|
||||
|
||||
@get:Rule
|
||||
val rule = IncomingMessageObserverRule(peerCount = 2)
|
||||
|
||||
@Test
|
||||
fun deliveredOneToOneText_isPersisted() {
|
||||
rule.deliver { text("hello world") from rule.peers[0] }
|
||||
|
||||
assertMessageReceived(from = rule.peers[0], body = "hello world")
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,9 @@ package org.thoughtcrime.securesms.testing
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import io.mockk.every
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.network.NetworkResult
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,11 +41,12 @@ object MessageContentFuzzer {
|
||||
/**
|
||||
* Create an [Envelope].
|
||||
*/
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID()): Envelope {
|
||||
fun envelope(timestamp: Long, serverGuid: UUID = UUID.randomUUID(), updatedPniBinary: ByteString? = null): Envelope {
|
||||
return Envelope.Builder()
|
||||
.timestamp(timestamp)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuidBinary(serverGuid.toByteArray().toByteString())
|
||||
.also { if (updatedPniBinary != null) it.updatedPniBinary(updatedPniBinary) }
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -292,7 +293,7 @@ object MessageContentFuzzer {
|
||||
body = string()
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = quoted.envelope.timestamp
|
||||
id = quoted.envelope.clientTimestamp
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
|
||||
@@ -304,7 +305,7 @@ object MessageContentFuzzer {
|
||||
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
|
||||
id = random.nextLong(quoted.envelope.clientTimestamp!! - 1000000, quoted.envelope.clientTimestamp!!)
|
||||
authorAciBinary = quoted.metadata.sourceServiceId.toByteString()
|
||||
text = quoted.content.dataMessage?.body
|
||||
}
|
||||
@@ -333,7 +334,7 @@ object MessageContentFuzzer {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorAciBinary = reactTo.metadata.sourceServiceId.toByteString()
|
||||
targetSentTimestamp = reactTo.envelope.timestamp
|
||||
targetSentTimestamp = reactTo.envelope.clientTimestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.whispersystems.signalservice.internal.push.PreKeyResponse
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataJson
|
||||
import org.whispersystems.signalservice.internal.push.SenderCertificate
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import java.security.SecureRandom
|
||||
@@ -27,8 +26,6 @@ import java.security.SecureRandom
|
||||
*/
|
||||
object MockProvider {
|
||||
|
||||
val senderCertificate = SenderCertificate().apply { certificate = ByteArray(0) }
|
||||
|
||||
val lockedFailure = PushServiceSocket.RegistrationLockFailure().apply {
|
||||
svr1Credentials = AuthCredentials.create("username", "password")
|
||||
svr2Credentials = AuthCredentials.create("username", "password")
|
||||
@@ -75,8 +72,8 @@ object MockProvider {
|
||||
val device = PreKeyResponseItem().apply {
|
||||
this.deviceId = deviceId
|
||||
registrationId = KeyHelper.generateRegistrationId(false)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
|
||||
}
|
||||
|
||||
return PreKeyResponse().apply {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isTrue
|
||||
import org.signal.benchmark.setup.OtherClient
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
|
||||
/**
|
||||
* Reads database state produced by [IncomingMessageObserverRule]-driven tests. Import members
|
||||
* individually (e.g. `import …IncomingMessageObserverAssertions.assertMessageReceived`) so test
|
||||
* bodies stay terse.
|
||||
*/
|
||||
object IncomingMessageObserverAssertions {
|
||||
|
||||
fun OtherClient.recipientId(): RecipientId = Recipient.externalPush(SignalServiceAddress(serviceId, e164)).id
|
||||
|
||||
fun findIncomingMessage(from: OtherClient, body: String): MessageRecord? {
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(from.recipientId()) ?: return null
|
||||
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
|
||||
MessageTable.MmsReader(cursor).use { reader -> reader.firstOrNull { it.body == body } }
|
||||
}
|
||||
}
|
||||
|
||||
fun findIncomingGroupMessage(from: OtherClient, group: GroupHandle, body: String): MessageRecord? {
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId) ?: return null
|
||||
return SignalDatabase.messages.getConversation(threadId).use { cursor ->
|
||||
MessageTable.MmsReader(cursor).use { reader ->
|
||||
reader.firstOrNull { it.body == body && it.fromRecipient.id == from.recipientId() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun assertMessageReceived(from: OtherClient, body: String) {
|
||||
val record = findIncomingMessage(from, body)
|
||||
assertThat(record, "incoming message with body \"$body\" from ${from.serviceId} not found").isNotNull()
|
||||
assertThat(record!!.fromRecipient.id, "incoming message sender mismatch for body \"$body\"").isEqualTo(from.recipientId())
|
||||
}
|
||||
|
||||
fun assertGroupMessageReceived(from: OtherClient, group: GroupHandle, body: String) {
|
||||
val record = findIncomingGroupMessage(from, group, body)
|
||||
assertThat(record, "group message \"$body\" from ${from.serviceId} in ${group.groupId} not found").isNotNull()
|
||||
}
|
||||
|
||||
fun assertNoMessageReceived(from: OtherClient, body: String) {
|
||||
val record = findIncomingMessage(from, body)
|
||||
assertThat(record == null, "expected no message with body \"$body\" from ${from.serviceId}, but found one").isTrue()
|
||||
}
|
||||
|
||||
fun assertNoMessagesInThread(recipientId: RecipientId) {
|
||||
val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return
|
||||
val count = SignalDatabase.messages.getConversation(threadId).use { cursor -> cursor.count }
|
||||
assertThat(count, "expected thread for $recipientId to be empty, but message count was").isEqualTo(0)
|
||||
}
|
||||
|
||||
fun assertDeliveryReceipt(outgoingMessageId: Long) {
|
||||
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
|
||||
assertThat(record.hasDeliveryReceipt(), "expected delivery receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
|
||||
}
|
||||
|
||||
fun assertReadReceipt(outgoingMessageId: Long) {
|
||||
val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId)
|
||||
assertThat(record.hasReadReceipt(), "expected read receipt on outgoing message $outgoingMessageId, but none recorded").isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.benchmark.setup.NoOpJob
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import java.util.function.Supplier
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Dependency provider used by [org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext].
|
||||
* Composes [InstrumentationApplicationDependencyProvider] (so existing mocks for the account /
|
||||
* archive / donations / billing APIs are reused) and overrides:
|
||||
*
|
||||
* - the auth and unauth websocket factories with [BenchmarkWebSocketConnection], so tests can
|
||||
* inject encrypted envelopes through the real ingest pipeline;
|
||||
* - the job manager, swapping the startup network jobs handled by [NoOpJob.replaceFactories]
|
||||
* to no-ops so they can't fire against unstubbed mocks during a test.
|
||||
*/
|
||||
class IncomingMessageObserverDependencyProvider(
|
||||
private val application: Application,
|
||||
default: ApplicationDependencyProvider
|
||||
) : AppDependencies.Provider by InstrumentationApplicationDependencyProvider(application, default) {
|
||||
|
||||
override fun provideAuthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.AuthenticatedWebSocket {
|
||||
return SignalWebSocket.AuthenticatedWebSocket(
|
||||
connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideUnauthWebSocket(
|
||||
signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>,
|
||||
libSignalNetworkSupplier: Supplier<Network>
|
||||
): SignalWebSocket.UnauthenticatedWebSocket {
|
||||
return SignalWebSocket.UnauthenticatedWebSocket(
|
||||
connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() },
|
||||
canConnect = { true },
|
||||
sleepTimer = UptimeSleepTimer(),
|
||||
disconnectTimeoutMs = 15.seconds.inWholeMilliseconds
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
|
||||
val config = configurationBuilder
|
||||
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
|
||||
.build()
|
||||
return JobManager(application, config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assume
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.benchmark.setup.Generator
|
||||
import org.signal.benchmark.setup.Harness
|
||||
import org.signal.benchmark.setup.OtherClient
|
||||
import org.signal.benchmark.setup.TestUsers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.MarkerJob
|
||||
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* JUnit rule that drives [org.thoughtcrime.securesms.messages.IncomingMessageObserver] from
|
||||
* instrumentation tests. Sets up self, registers [peerCount] simulated peers from
|
||||
* [Harness.otherClients], establishes a Signal double-ratchet session with each, and exposes a
|
||||
* small DSL for delivering encrypted envelopes through the real ingest pipeline:
|
||||
*
|
||||
* ```
|
||||
* @get:Rule val rule = IncomingMessageObserverRule(peerCount = 2)
|
||||
*
|
||||
* @Test fun example() {
|
||||
* rule.deliver { text("hi") from rule.peers[0] }
|
||||
* rule.deliver { groupText("hi all", group = rule.testGroup) from rule.peers[0] }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Run with `-PimoTests`; tests are skipped under the default runner. Throws on drain timeout.
|
||||
* Mutually exclusive with `SignalDatabaseRule` / `SignalActivityRule` — all three claim the
|
||||
* local identity.
|
||||
*/
|
||||
class IncomingMessageObserverRule(
|
||||
private val peerCount: Int = 2,
|
||||
private val drainTimeout: Duration = 30.seconds
|
||||
) : ExternalResource() {
|
||||
|
||||
lateinit var self: Recipient
|
||||
private set
|
||||
|
||||
lateinit var peers: List<OtherClient>
|
||||
private set
|
||||
|
||||
/** Lazily-created group. Touching this from a test triggers setup; tests that don't use groups pay nothing. */
|
||||
val testGroup: GroupHandle by lazy {
|
||||
val gid = TestUsers.setupGroup(withLabels = false)
|
||||
GroupHandle(gid, Recipient.externalGroupExact(gid).id)
|
||||
}
|
||||
|
||||
override fun before() {
|
||||
Assume.assumeTrue(
|
||||
"IncomingMessageObserverRule requires the IMO test runner — run with -PimoTests",
|
||||
AppDependencies.application is IncomingMessageObserverInstrumentationApplicationContext
|
||||
)
|
||||
|
||||
self = TestUsers.setupSelf()
|
||||
TestUsers.setupTestClients(peerCount)
|
||||
peers = Harness.otherClients.take(peerCount)
|
||||
|
||||
val app = AppDependencies.application as IncomingMessageObserverInstrumentationApplicationContext
|
||||
app.beginJobLoopForTests()
|
||||
|
||||
// IncomingMessageObserver caches `canProcessMessages` from restoreDecisionState at thread
|
||||
// construction. If it was built before setupSelf() flipped the state it will silently drop
|
||||
// every message; reset network so a fresh observer is constructed.
|
||||
AppDependencies.incomingMessageObserver.notifyRestoreDecisionMade()
|
||||
AppDependencies.startNetwork()
|
||||
forceObserverConstruction()
|
||||
|
||||
val handshakeEnvelopes = peers.map { client ->
|
||||
client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
|
||||
}
|
||||
deliverEnvelopes(handshakeEnvelopes)
|
||||
peers.forEach { it.completeSession() }
|
||||
}
|
||||
|
||||
fun deliver(builder: DeliveryBuilder.() -> Unit) {
|
||||
val collected = DeliveryBuilder().apply(builder).specs
|
||||
if (collected.isEmpty()) return
|
||||
deliverEnvelopes(collected.map { it.materialize() })
|
||||
}
|
||||
|
||||
private fun forceObserverConstruction() {
|
||||
AppDependencies.incomingMessageObserver
|
||||
}
|
||||
|
||||
private fun deliverEnvelopes(envelopes: List<Envelope>) {
|
||||
val jobManager = AppDependencies.jobManager
|
||||
val seenQueues = CopyOnWriteArraySet<String>()
|
||||
val queueListener = object : JobTracker.JobListener {
|
||||
override fun onStateChanged(job: Job, jobState: JobTracker.JobState) {
|
||||
job.parameters.queue?.let { queue ->
|
||||
if (queue.startsWith(PushProcessMessageJob.QUEUE_PREFIX)) {
|
||||
seenQueues += queue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
jobManager.addListener({ job: Job -> job.parameters.queue?.startsWith(PushProcessMessageJob.QUEUE_PREFIX) == true }, queueListener)
|
||||
|
||||
try {
|
||||
BenchmarkWebSocketConnection.addPendingMessages(envelopes.map { it.toWebSocketPayload() })
|
||||
BenchmarkWebSocketConnection.addQueueEmptyMessage()
|
||||
BenchmarkWebSocketConnection.releaseMessages()
|
||||
|
||||
val consumed = BenchmarkWebSocketConnection.awaitAllMessagesConsumed(drainTimeout.inWholeMilliseconds)
|
||||
check(consumed) { "Timed out waiting for benchmark websocket to consume ${envelopes.size} envelope(s)" }
|
||||
|
||||
// PushProcessMessageJob enqueue happens on a background thread after the websocket marks
|
||||
// messages consumed; this tick lets that settle before we snapshot the queues to wait on.
|
||||
Thread.sleep(100)
|
||||
|
||||
val queuesToDrain = seenQueues.toSet()
|
||||
Log.d(TAG, "Awaiting ${queuesToDrain.size} PushProcessMessageJob queue(s): $queuesToDrain")
|
||||
for (queue in queuesToDrain) {
|
||||
val state = jobManager.runSynchronously(MarkerJob(queue), drainTimeout.inWholeMilliseconds)
|
||||
check(state.isPresent) { "Timed out waiting for queue $queue to drain" }
|
||||
}
|
||||
} finally {
|
||||
jobManager.removeListener(queueListener)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(IncomingMessageObserverRule::class)
|
||||
|
||||
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage = WebSocketRequestMessage(
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: $serverTimestamp"),
|
||||
body = encodeByteString()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifies the test group created by [IncomingMessageObserverRule]. Hold a reference to pass into the [DeliveryBuilder.groupText] DSL. */
|
||||
data class GroupHandle(val groupId: GroupId.V2, val recipientId: RecipientId)
|
||||
|
||||
/**
|
||||
* Receiver of the DSL passed to [IncomingMessageObserverRule.deliver]. Construct content with
|
||||
* [text] / [groupText] / [deliveryReceipts] / [readReceipts] / [malformedEnvelope] and chain
|
||||
* with the [from] infix to attach a sending peer. Each `from` adds the resulting envelope to
|
||||
* the batch that will be delivered when the lambda returns.
|
||||
*/
|
||||
class DeliveryBuilder internal constructor() {
|
||||
internal val specs = mutableListOf<EnvelopeSpec>()
|
||||
|
||||
fun text(body: String, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group = null)
|
||||
|
||||
fun groupText(body: String, group: GroupHandle, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group)
|
||||
|
||||
fun deliveryReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.DeliveryReceipt(targets, sentAt)
|
||||
|
||||
fun readReceipts(targets: List<Long>, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.ReadReceipt(targets, sentAt)
|
||||
|
||||
fun malformedEnvelope(timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Malformed(timestamp)
|
||||
|
||||
infix fun EnvelopeContentSpec.from(peer: OtherClient) {
|
||||
specs += EnvelopeSpec(this, peer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opaque envelope content returned by [DeliveryBuilder]. Tests never construct or inspect variants directly; the type only appears as a return / receiver of the DSL methods. */
|
||||
sealed class EnvelopeContentSpec {
|
||||
internal data class Text(val body: String, val timestamp: Long, val group: GroupHandle?) : EnvelopeContentSpec()
|
||||
internal data class DeliveryReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
|
||||
internal data class ReadReceipt(val targets: List<Long>, val sentAt: Long) : EnvelopeContentSpec()
|
||||
internal data class Malformed(val timestamp: Long) : EnvelopeContentSpec()
|
||||
}
|
||||
|
||||
internal data class EnvelopeSpec(val content: EnvelopeContentSpec, val peer: OtherClient) {
|
||||
fun materialize(): Envelope = when (val c = content) {
|
||||
is EnvelopeContentSpec.Text ->
|
||||
peer.encrypt(Generator.encryptedTextMessage(c.timestamp, c.body, c.group?.let { Harness.groupMasterKey }))
|
||||
is EnvelopeContentSpec.DeliveryReceipt ->
|
||||
peer.encrypt(Generator.encryptedDeliveryReceipt(c.sentAt, c.targets), c.sentAt)
|
||||
is EnvelopeContentSpec.ReadReceipt ->
|
||||
peer.encrypt(Generator.encryptedReadReceipt(c.sentAt, c.targets), c.sentAt)
|
||||
is EnvelopeContentSpec.Malformed -> {
|
||||
val valid = peer.encrypt(Generator.encryptedTextMessage(c.timestamp))
|
||||
val original = valid.content ?: error("Encrypted envelope unexpectedly had no content")
|
||||
val corrupted = original.toByteArray().also { it[it.size / 2] = (it[it.size / 2].toInt() xor 0x01).toByte() }
|
||||
valid.copy(content = corrupted.toByteString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.testing.incomingmessageobserver
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext
|
||||
|
||||
/**
|
||||
* Test runner that swaps in [IncomingMessageObserverInstrumentationApplicationContext] so the
|
||||
* `IncomingMessageObserver` test harness can drive a faked websocket. Selected automatically by
|
||||
* the build when `-PimoTests` is set.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class IncomingMessageObserverTestRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
|
||||
return super.newApplication(cl, IncomingMessageObserverInstrumentationApplicationContext::class.java.name, context)
|
||||
}
|
||||
}
|
||||
@@ -6,55 +6,13 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.benchmark.setup.NoOpJob
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager
|
||||
import org.thoughtcrime.securesms.jobmanager.JobMigrator
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
|
||||
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.FastJobStorage
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob
|
||||
import org.thoughtcrime.securesms.jobs.IndividualSendJob
|
||||
import org.thoughtcrime.securesms.jobs.JobManagerFactories
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob
|
||||
import org.thoughtcrime.securesms.jobs.MarkerJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
|
||||
import org.thoughtcrime.securesms.jobs.PushProcessMessageJob
|
||||
import org.thoughtcrime.securesms.jobs.ReactionSendJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
|
||||
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
|
||||
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.TypingSendJob
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.util.UptimeSleepTimer
|
||||
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
@@ -97,85 +55,11 @@ class BenchmarkApplicationContext : ApplicationContext() {
|
||||
)
|
||||
}
|
||||
|
||||
override fun provideJobManager(): JobManager {
|
||||
val config = JobManager.Configuration.Builder()
|
||||
.setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application)))
|
||||
.setConstraintFactories(JobManagerFactories.getConstraintFactories(application))
|
||||
.setConstraintObservers(JobManagerFactories.getConstraintObservers(application))
|
||||
.setJobStorage(FastJobStorage(JobDatabase.getInstance(application)))
|
||||
.setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application)))
|
||||
.addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY))
|
||||
.addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY))
|
||||
.addReservedJobRunner(
|
||||
FactoryJobPredicate(
|
||||
IndividualSendJob.KEY,
|
||||
PushGroupSendJob.KEY,
|
||||
ReactionSendJob.KEY,
|
||||
TypingSendJob.KEY,
|
||||
GroupCallUpdateSendJob.KEY,
|
||||
SendDeliveryReceiptJob.KEY
|
||||
)
|
||||
)
|
||||
override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager {
|
||||
val config = configurationBuilder
|
||||
.setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application)))
|
||||
.build()
|
||||
return JobManager(application, config)
|
||||
}
|
||||
|
||||
private fun filterJobFactories(jobFactories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> {
|
||||
val blockedJobs = setOf(
|
||||
AccountConsistencyWorkerJob.KEY,
|
||||
ArchiveBackupIdReservationJob.KEY,
|
||||
AvatarGroupsV2DownloadJob.KEY,
|
||||
CreateReleaseChannelJob.KEY,
|
||||
DirectoryRefreshJob.KEY,
|
||||
DownloadLatestEmojiDataJob.KEY,
|
||||
EmojiSearchIndexDownloadJob.KEY,
|
||||
FontDownloaderJob.KEY,
|
||||
GroupRingCleanupJob.KEY,
|
||||
GroupV2UpdateSelfProfileKeyJob.KEY,
|
||||
LinkedDeviceInactiveCheckJob.KEY,
|
||||
MultiDeviceProfileKeyUpdateJob.KEY,
|
||||
PostRegistrationBackupRedemptionJob.KEY,
|
||||
PreKeysSyncJob.KEY,
|
||||
ProfileUploadJob.KEY,
|
||||
RefreshAttributesJob.KEY,
|
||||
RefreshSvrCredentialsJob.KEY,
|
||||
RequestGroupV2InfoJob.KEY,
|
||||
ResetSvrGuessCountJob.KEY,
|
||||
RestoreOptimizedMediaJob.KEY,
|
||||
RetrieveProfileAvatarJob.KEY,
|
||||
RetrieveProfileJob.KEY,
|
||||
RetrieveRemoteAnnouncementsJob.KEY,
|
||||
RotateCertificateJob.KEY,
|
||||
StickerPackDownloadJob.KEY,
|
||||
StorageSyncJob.KEY,
|
||||
StoryOnboardingDownloadJob.KEY
|
||||
)
|
||||
|
||||
return jobFactories.mapValues {
|
||||
if (it.key in blockedJobs) {
|
||||
NoOpJob.Factory()
|
||||
} else {
|
||||
it.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NoOpJob(parameters: Parameters) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
const val KEY = "NoOpJob"
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray? = null
|
||||
override fun getFactoryKey(): String = KEY
|
||||
override fun run(): Result = Result.success()
|
||||
override fun onFailure() = Unit
|
||||
|
||||
class Factory : Job.Factory<NoOpJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob {
|
||||
return NoOpJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
@@ -212,7 +212,7 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
|
||||
verb = "PUT",
|
||||
path = "/api/v1/message",
|
||||
id = Random.nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.serverTimestamp}"),
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ object Generator {
|
||||
val serverGuid = UUID.randomUUID()
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.sourceDeviceId(1)
|
||||
.clientTimestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.destinationServiceIdBinary(destination.toByteString())
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package org.signal.benchmark.setup
|
||||
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob
|
||||
import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
|
||||
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.MultiDeviceProfileKeyUpdateJob
|
||||
import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
|
||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
|
||||
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
|
||||
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
|
||||
/**
|
||||
* A [Job] that does nothing and always succeeds. Test setups substitute this for jobs whose
|
||||
* real implementations would hit the network at startup (and so would either generate noise
|
||||
* against the [DeviceTransferBlockingInterceptor][org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor]
|
||||
* or fail against unstubbed mocks). Use [replaceFactories] to apply the swap.
|
||||
*/
|
||||
class NoOpJob(parameters: Parameters) : Job(parameters) {
|
||||
override fun serialize(): ByteArray? = null
|
||||
override fun getFactoryKey(): String = KEY
|
||||
override fun run(): Result = Result.success()
|
||||
override fun onFailure() = Unit
|
||||
|
||||
class Factory : Job.Factory<NoOpJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob = NoOpJob(parameters)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY = "NoOpJob"
|
||||
|
||||
private val STARTUP_NETWORK_JOB_KEYS: Set<String> = setOf(
|
||||
AccountConsistencyWorkerJob.KEY,
|
||||
ArchiveBackupIdReservationJob.KEY,
|
||||
AvatarGroupsV2DownloadJob.KEY,
|
||||
CreateReleaseChannelJob.KEY,
|
||||
DirectoryRefreshJob.KEY,
|
||||
DownloadLatestEmojiDataJob.KEY,
|
||||
EmojiSearchIndexDownloadJob.KEY,
|
||||
FontDownloaderJob.KEY,
|
||||
GroupRingCleanupJob.KEY,
|
||||
GroupV2UpdateSelfProfileKeyJob.KEY,
|
||||
LinkedDeviceInactiveCheckJob.KEY,
|
||||
MultiDeviceProfileKeyUpdateJob.KEY,
|
||||
PostRegistrationBackupRedemptionJob.KEY,
|
||||
PreKeysSyncJob.KEY,
|
||||
ProfileUploadJob.KEY,
|
||||
RefreshAttributesJob.KEY,
|
||||
RefreshSvrCredentialsJob.KEY,
|
||||
RequestGroupV2InfoJob.KEY,
|
||||
ResetSvrGuessCountJob.KEY,
|
||||
RestoreOptimizedMediaJob.KEY,
|
||||
RetrieveProfileAvatarJob.KEY,
|
||||
RetrieveProfileJob.KEY,
|
||||
RetrieveRemoteAnnouncementsJob.KEY,
|
||||
RotateCertificateJob.KEY,
|
||||
StickerPackDownloadJob.KEY,
|
||||
StorageSyncJob.KEY,
|
||||
StoryOnboardingDownloadJob.KEY
|
||||
)
|
||||
|
||||
fun replaceFactories(factories: Map<String, Job.Factory<*>>): Map<String, Job.Factory<*>> =
|
||||
factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value }
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPai
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
|
||||
if (!aciStore.containsSession(getAliceProtocolAddress())) {
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress()))
|
||||
val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress(), SignalProtocolAddress(serviceAddress.identifier, 1)))
|
||||
sessionBuilder.process(getAlicePreKeyBundle())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ package org.whispersystems.signalservice.internal.websocket
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.network.websocket.WebSocketRequestMessage
|
||||
import org.signal.network.websocket.WebSocketResponseMessage
|
||||
import org.signal.network.websocket.WebsocketResponse
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.SignalTrace
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
@@ -65,6 +68,18 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
|
||||
fun addQueueEmptyMessage() {
|
||||
authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() }
|
||||
}
|
||||
|
||||
fun awaitAllMessagesConsumed(timeoutMs: Long): Boolean {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val activeInstances = synchronized(this) { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).toList() }
|
||||
if (activeInstances.isNotEmpty() && activeInstances.all { it.incomingRequests.isEmpty() && it.incomingSemaphore.availablePermits() == 0 }) {
|
||||
return true
|
||||
}
|
||||
Thread.sleep(25)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override val name: String = "bench-${System.identityHashCode(this)}"
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationM
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
|
||||
import org.thoughtcrime.securesms.database.CollapsedState
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
@@ -122,7 +123,10 @@ class ConversationElementGenerator {
|
||||
false,
|
||||
0,
|
||||
null,
|
||||
null
|
||||
CollapsedState.NONE,
|
||||
0,
|
||||
null,
|
||||
false
|
||||
)
|
||||
|
||||
val conversationMessage = ConversationMessageFactory.createWithUnresolvedData(
|
||||
|
||||
@@ -353,5 +353,13 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
|
||||
override fun onViewPinnedMessage(messageId: Long) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onExpandEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onCollapseEvents(messageId: Long, itemView: View, collapsedSize: Int) {
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
|
||||
|
||||
<!-- ======================================= -->
|
||||
<!-- Features -->
|
||||
<!-- ======================================= -->
|
||||
@@ -40,6 +38,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||
@@ -458,6 +457,13 @@
|
||||
android:label="@string/AndroidManifest__select_contacts"
|
||||
android:windowSoftInputMode="stateHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".search.SingleContactSelectionActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"
|
||||
android:label="@string/AndroidManifest__select_contacts"
|
||||
android:windowSoftInputMode="stateHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".giph.ui.GiphyActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -475,7 +481,7 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name="org.signal.mediasend.MediaSendActivity"
|
||||
android:name=".mediasend.v3.MediaSendV3Activity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
@@ -668,6 +674,13 @@
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".starred.StarredMessagesActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateHidden" />
|
||||
|
||||
<activity
|
||||
android:name=".mediaoverview.MediaOverviewActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -1391,7 +1404,7 @@
|
||||
<service
|
||||
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
|
||||
android:foregroundServiceType="dataSync|microphone|camera|phoneCall|mediaProjection" />
|
||||
|
||||
<service
|
||||
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
|
||||
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 119 KiB |