Compare commits

..

235 Commits

Author SHA1 Message Date
Alex Hart
72a2ef275c Bump version to 5.29.7 2022-01-25 10:26:22 -04:00
Alex Hart
318f3589d6 Remove clear-cache call from database migration. 2022-01-25 10:25:27 -04:00
Greyson Parrelli
c7fb0e2ab8 Bump version to 5.29.6 2022-01-24 11:23:34 -05:00
Greyson Parrelli
f6cd7b1f3c Updated language translations. 2022-01-24 11:23:19 -05:00
Greyson Parrelli
d40254aa69 Download latest emoji. 2022-01-24 11:17:15 -05:00
Greyson Parrelli
75a13aa22a Bump version to 5.29.5 2022-01-21 12:56:54 -05:00
Greyson Parrelli
5a884d8fc8 Updated language translations. 2022-01-21 12:56:30 -05:00
Greyson Parrelli
b5dcf8e8f1 Improve handling of inbound UD messages. 2022-01-21 12:51:22 -05:00
Cody Henthorne
bfdedd57d1 Update jumbomoji processing and downloading. 2022-01-21 10:31:43 -05:00
Cody Henthorne
2b021f5237 Fix camera icon asset in permission dialog. 2022-01-20 21:06:06 -05:00
Cody Henthorne
791c1ee8dd Fix group send with sender key failure due to mistmatch identity. 2022-01-20 20:41:06 -05:00
Greyson Parrelli
c2f953b097 Ensure reactions are deleted for 'delete for everyone'. 2022-01-19 11:12:14 -05:00
Greyson Parrelli
4984cc8eb4 Bump version to 5.29.4 2022-01-18 17:18:11 -05:00
Greyson Parrelli
23e4856c5e Updated language translations. 2022-01-18 17:17:42 -05:00
Greyson Parrelli
e50787ae20 Trim abandoned reactions from backups.
When you create a backup (or do a device transfer), we skip messages
with expiration timers. However, we still (unintentionally) include the
reactions for those messages in the backup.

These 'abandoned' reactions were being associated with newly-sent
messages because the new messages had the same ID's as the expiring
messages we skipped in the backup.

It's worth noting that in order to hit this bug, you have to:
- Have messages that are expiring, but have not expired yet
- Those messages have to have reactions
- Those message have to be the most recent messages in your message table

Fixes #11327
2022-01-18 17:08:53 -05:00
Cody Henthorne
64e4bcf46a Fix error reporting for failed group sends. 2022-01-18 16:08:13 -05:00
Cody Henthorne
693a82f133 Fix crash blocking a group after leaving it. 2022-01-18 12:17:25 -05:00
Brian Moyer
79d73c9e74 Fix WiFi SMS setting.
Fixes #11898
2022-01-18 10:43:38 -05:00
Rashad Sookram
5a51544cae Fix infinite post loop when parent is GONE. 2022-01-18 09:47:42 -05:00
Rashad Sookram
bf2ab74ca4 Fix radio button clicks in multiselect mode. 2022-01-14 11:19:56 -05:00
Cody Henthorne
d39ec479ba Bump version to 5.29.3 2022-01-13 16:48:51 -05:00
Cody Henthorne
48fa81a8b8 Updated language translations. 2022-01-13 16:44:21 -05:00
Alex Hart
2de96dcfbf Ensure display name is unique on insert on API 28+. 2022-01-13 16:40:48 -05:00
Greyson Parrelli
01047e90ad Refactor SignalLocalMetrics to be more resiliant to certain errors. 2022-01-13 16:40:48 -05:00
Rashad Sookram
cd4320c0ef Dynamically determine height of bottom bar.
This prevents the last message from being obscured when a label in the
bar spans more than one line.
2022-01-13 16:40:48 -05:00
Greyson Parrelli
c55b0357f1 Fix issue where lastVersionCode was unset. 2022-01-13 10:51:45 -05:00
Alex Hart
7551dd77c5 Downsize buttons to 48dp on call screen on very narrow devices. 2022-01-13 10:36:37 -04:00
Rashad Sookram
2f3c7097a9 Fix first layout of bottom action bar in RTL. 2022-01-12 16:37:38 -05:00
Rashad Sookram
9de519eb3d Update selection menu icons in dark mode. 2022-01-12 16:36:44 -05:00
Cody Henthorne
c0008f7383 Bump version to 5.29.2 2022-01-12 12:39:13 -05:00
Cody Henthorne
08cb0967c9 Updated language translations. 2022-01-12 12:30:15 -05:00
Cody Henthorne
50b37e0402 Fix notification profile disable bug.
Fixes #11892
2022-01-12 12:27:06 -05:00
Rashad Sookram
89b918fbd2 Update ktlint-gradle. 2022-01-12 12:27:06 -05:00
Cody Henthorne
856bd54059 Fix NPE in media record. 2022-01-12 12:27:06 -05:00
Rashad Sookram
3943e670b2 Implement bottom selection menu in chat. 2022-01-12 12:27:06 -05:00
Cody Henthorne
917744f091 Fix NPE in HackyPager 2022-01-10 09:58:45 -05:00
Greyson Parrelli
552fdcce98 Bump version to 5.29.1 2022-01-07 17:29:30 -05:00
Greyson Parrelli
e1151bfce4 Updated language translations. 2022-01-07 17:29:05 -05:00
Greyson Parrelli
37b0d3d755 Revert "Implement bottom selection menu in chat."
This reverts commit 829c06ab1a.
2022-01-07 17:24:29 -05:00
Greyson Parrelli
b68bd9179c Revert changes to message bubble sizing.
This reverts commit 4e67752850.
This reverts commit 449acaf9df.
This reverts commit e8882a8076.
2022-01-07 17:24:11 -05:00
Alex Hart
ea92280cea Bump version to 5.29.0 2022-01-07 14:43:55 -04:00
Alex Hart
8521b87147 Updated language translations. 2022-01-07 14:41:42 -04:00
Rashad Sookram
829c06ab1a Implement bottom selection menu in chat. 2022-01-07 14:41:42 -04:00
Greyson Parrelli
a8d9933265 Simplify megaphone priority. (#2063) 2022-01-07 14:41:42 -04:00
Greyson Parrelli
62f5088553 Delete old megaphones. 2022-01-07 14:41:42 -04:00
Cody Henthorne
3922bfacf5 Hide bubble for jumbomoji. 2022-01-07 14:41:42 -04:00
Rashad Sookram
4e67752850 Fix horizontal resize bug with quotes in messages. 2022-01-07 14:41:42 -04:00
Greyson Parrelli
7ff2b1ab33 Log timestamps of read syncs. 2022-01-07 14:41:42 -04:00
Cody Henthorne
34f679b10b Add support for jumbo emoji. 2022-01-07 14:41:42 -04:00
Rashad Sookram
449acaf9df Fix footer collapsing for single line messages. 2022-01-07 14:41:42 -04:00
Greyson Parrelli
d52c66d601 Fix the study builds. 2022-01-07 14:41:42 -04:00
Greyson Parrelli
47134e19f1 Default to sofware AEC. 2022-01-07 14:41:42 -04:00
Cody Henthorne
0aabf9945f Fix ISE in mutli-share flow. 2022-01-07 14:41:42 -04:00
Greyson Parrelli
8bc7d1b7f5 Drop messages that have a story context. 2022-01-07 14:41:42 -04:00
Cody Henthorne
4dae424a5c Add group update paging feature flag. 2022-01-07 14:41:42 -04:00
Alex Hart
ee48a1ae25 Move checkbox to end of group recipient row item. 2022-01-07 14:41:42 -04:00
Rashad Sookram
e8882a8076 Fix timestamp overlapping text in messages. 2022-01-07 14:41:42 -04:00
Cody Henthorne
e48c1bf207 Fix bad UI state when changing backup status. 2022-01-07 14:41:42 -04:00
Rashad Sookram
d1eab086f1 Constrain ConversationItem's width to AudioView.
When text was also shown, the text's width was used when it was wider
than the AudioView.
2022-01-07 14:41:42 -04:00
Cody Henthorne
e41c73f293 Fix OOM when paging lots of group updates. 2022-01-07 14:41:42 -04:00
Cody Henthorne
3eb8db00aa Separate network and processing of profile fetches. 2022-01-07 14:41:42 -04:00
Cody Henthorne
bbadda5656 Fix navigation from camera crash. 2022-01-07 14:41:41 -04:00
Alex Hart
92df5b9564 Bump version to 5.28.10 2022-01-05 15:07:30 -04:00
Alex Hart
e0b892b630 Updated language translations. 2022-01-05 15:06:26 -04:00
Cody Henthorne
1a499e23d9 Handle ISE with new voice note recording. 2022-01-05 09:52:22 -05:00
Greyson Parrelli
f0d40685df Bump version to 5.28.9 2022-01-03 19:40:54 -05:00
Greyson Parrelli
4cbed24244 Updated language translations. 2022-01-03 19:40:22 -05:00
Greyson Parrelli
0d0c74f358 Fix in-memory message updates.
We can also switch to using the message-specific update route for
receipts too.
2022-01-03 18:47:11 -05:00
Cody Henthorne
0dd2397fb4 Fix notification profile manually enabled and scheduled bug. 2022-01-03 18:47:11 -05:00
Cody Henthorne
3781e1dd60 Add notification profile information to debug log. 2022-01-03 18:47:11 -05:00
Cody Henthorne
ae40a65924 Fix MediaRecorder crash when no data captured. 2022-01-03 18:47:11 -05:00
Greyson Parrelli
8968ef1b85 Remove routine GV1 migration checks.
We will now only migrate GV1 groups upon opening them.
2022-01-03 14:00:39 -05:00
Greyson Parrelli
25ab9a5ad6 Fix older database migrations that may recursively open database. 2022-01-03 11:59:06 -05:00
Greyson Parrelli
5c27842a01 Include queue in job logs. 2022-01-03 11:28:10 -05:00
Art Chaidarun
49a1a4a123 Fix large images sometimes not respecting EXIF orientation.
Fixes #11614
2022-01-03 10:31:04 -05:00
Alan Evans
ac90eeb42f Protoc update to 3.18.0
Windows and M1 gradle verification sha256 values.

Using: gradlew --write-verification-metadata sha256 help

But aapt2 artifact had to be manually added.

Fixes #11871, Fixes #11878, Closes #11877
2022-01-03 10:17:17 -05:00
Greyson Parrelli
302e653d2f Only put message in the media queue if it has an attachment. 2022-01-03 09:03:12 -05:00
Greyson Parrelli
3d6ffe25f0 Bump version to 5.28.8 2021-12-22 14:17:26 -05:00
Greyson Parrelli
363eb22462 Updated language translations. 2021-12-22 14:17:26 -05:00
Cody Henthorne
b04ae3a8b3 Use MediaRecorder for voice notes on capable devices.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2021-12-22 14:17:26 -05:00
Rashad Sookram
e6451db888 Revert "Use localized AM/PM strings."
This reverts commit bdd48629c6.
2021-12-22 14:17:26 -05:00
Greyson Parrelli
2bbceaabd3 Fix possible crash for unregistered users. 2021-12-22 14:17:26 -05:00
Greyson Parrelli
fefbf595cd Disable the notification profiles megaphone for fresh installs. 2021-12-22 14:17:26 -05:00
Rashad Sookram
85b3947150 Fix line wrap for EmojiTextViews with ImageSpans. 2021-12-22 14:17:26 -05:00
Alex Hart
a0d70a955a Generate request credential presentation before submitting subscription job. 2021-12-22 14:17:26 -05:00
Greyson Parrelli
a5c2595796 Bump version to 5.28.7 2021-12-21 16:41:04 -05:00
Greyson Parrelli
4193f7bcbd Updated language translations. 2021-12-21 16:35:36 -05:00
Greyson Parrelli
5102f5215c Use proper processing queue for sync messages. 2021-12-21 16:15:47 -05:00
Rashad Sookram
bdd48629c6 Use localized AM/PM strings. 2021-12-21 16:15:47 -05:00
Alex Hart
fde1e5ab77 Prevent KeepAlive Job from alerting user on 409 error. 2021-12-21 16:15:47 -05:00
Greyson Parrelli
46dd7f8a06 Improve performance of processing read syncs. 2021-12-21 16:15:47 -05:00
Alex Hart
282639469d Ensure unique names are used when saving batches of files. 2021-12-21 16:15:47 -05:00
Greyson Parrelli
bad1cc1571 Improve logging around message processing. 2021-12-21 10:48:00 -05:00
Greyson Parrelli
3757449b8f Bump version to 5.28.6 2021-12-20 13:43:05 -05:00
Greyson Parrelli
0af65d1367 Updated language translations. 2021-12-20 13:42:47 -05:00
Cody Henthorne
adcb1bae13 Fix bug with schedule end being set to midnight. 2021-12-20 13:31:18 -05:00
Cody Henthorne
b69ffe4e15 Fix crash when processing group updates with remapped members. 2021-12-20 13:31:18 -05:00
Cody Henthorne
8c34357cc6 Fix phone number format crash. 2021-12-20 13:31:18 -05:00
Greyson Parrelli
a17fd447a7 Add logging to help diagnose ratelimit issues. 2021-12-20 13:31:18 -05:00
Cody Henthorne
eaf72b194f Include exception message in stack trace. 2021-12-20 13:31:18 -05:00
Jim Gustafson
09a3391761 Update to RingRTC v2.16.1 2021-12-20 13:31:18 -05:00
Cody Henthorne
e0e25da6a9 Fix empty media send illegal state exception. 2021-12-20 13:31:18 -05:00
Cody Henthorne
90d4069d0a Fix crash and UI issues in call screen. 2021-12-20 13:31:18 -05:00
Cody Henthorne
0dcae81dba Fix share selection crash. 2021-12-20 13:31:18 -05:00
Alex Hart
9177f5637a Add support for manual cancellation proto field. 2021-12-20 13:31:18 -05:00
Cody Henthorne
5918227bff Add PagingMappingAdapter and convert GiphyMp4Adapter. 2021-12-20 13:31:18 -05:00
Rashad Sookram
dd79688f48 Fix ellipsis bug in group names with emoji.
The bug was that system emoji and emoji from `EmojiSpan` had different
sizes. The `View` was being measured based off the `EmojiSpan`
(smaller), but ellipsizing was based off the size of system emoji.

Fixes #11772
2021-12-20 13:31:18 -05:00
Cody Henthorne
dbce4be31d Refactor MappingAdapter code into package. 2021-12-20 13:31:18 -05:00
Cody Henthorne
4275877b47 Fix crash in media preview when scrolling and receiving new media. 2021-12-20 13:31:18 -05:00
Rashad Sookram
11221315e4 Add workaround for message line wrap bug. 2021-12-20 13:31:18 -05:00
Cody Henthorne
a4f44a96fd Fix illegal argument navigation exceptions. 2021-12-20 13:31:18 -05:00
Cody Henthorne
ba54051f8c Fix duplicate notification channels being created. 2021-12-20 13:31:18 -05:00
Cody Henthorne
130b796564 Fix registration crash. 2021-12-20 13:31:18 -05:00
Cody Henthorne
a15ba60252 Fix navigation bug after deleting notification profile. 2021-12-20 13:31:18 -05:00
Cody Henthorne
8014a70134 Show backup progress as a percentage. 2021-12-20 13:31:18 -05:00
Alex Hart
4f73e36d72 Always generate a unique filename when saving files. 2021-12-20 13:31:18 -05:00
Alex Hart
68bd9c6e1e Refactor ShareableGroupLinkDialogFragment into a normal Fragment.
Co-authored-by: Rashad Sookram <rashad@signal.org>
2021-12-20 13:31:17 -05:00
Alex Hart
20d2c43356 Migrate identity verification activity to fragment. 2021-12-16 14:48:25 -05:00
Rashad Sookram
b94624fd5a Treat SVGs as document attachments. 2021-12-16 14:48:25 -05:00
Rashad Sookram
4ae129d2af Use Gradle dependency verification.
Generated by running:
./gradlew --write-verification-metadata sha256 qa --rerun-tasks
2021-12-16 14:48:25 -05:00
Rashad Sookram
158505c8a8 Move Glide annotation processing out of the main module. 2021-12-16 14:48:25 -05:00
Rashad Sookram
c98fd1a452 Speed up Gradle qa task. 2021-12-16 14:48:25 -05:00
Alex Hart
c6d0ef218a Bump version to 5.28.5 2021-12-14 10:46:56 -04:00
Alex Hart
ba5b3e01f2 Updated language translations. 2021-12-14 10:46:27 -04:00
Rashad Sookram
e84ae83c28 Fix unverified banner theme. 2021-12-14 09:17:11 -05:00
Cody Henthorne
33eeca9e3e Bump version to 5.28.4 2021-12-13 12:44:14 -05:00
Cody Henthorne
6288dc19e9 Updated language translations. 2021-12-13 12:37:59 -05:00
Cody Henthorne
b9ba1a3568 Fix notification schedule bug. 2021-12-13 12:34:53 -05:00
Cody Henthorne
93270b90df Fix 24hr time format bug on older OSes. 2021-12-13 10:16:27 -05:00
Cody Henthorne
a0235cbc6c Bump version to 5.28.3 2021-12-10 13:20:37 -05:00
Cody Henthorne
28f0724d90 Updated language translations. 2021-12-10 13:09:11 -05:00
Cody Henthorne
08a305cb0f Fix bug when changing schedule end time and end is before start. 2021-12-10 13:05:17 -05:00
Greyson Parrelli
50d2faf381 Fix bug in reaction bottom sheet data observation. 2021-12-10 13:05:17 -05:00
Greyson Parrelli
49b9d5c3aa Use borderless ripple for emoji search buttons. 2021-12-10 13:05:17 -05:00
Alex Hart
7385112115 Apply selected state to custom boost field instead of relying on focus. 2021-12-10 13:05:17 -05:00
Alex Hart
3feb73789d Set custom amount on focus, do not clear on loss of focus. 2021-12-10 13:05:17 -05:00
Cody Henthorne
b80c844a0b Fix schedule edit day backgrounds for older devices. 2021-12-10 13:05:17 -05:00
Alex Hart
755a25519a Add explicit log-line with status code for redemption success. 2021-12-10 09:37:06 -04:00
Cody Henthorne
bbb9eab148 Bump version to 5.28.2 2021-12-09 15:09:00 -05:00
Cody Henthorne
c8e62e5f60 Updated language translations. 2021-12-09 15:01:48 -05:00
Cody Henthorne
19818443ff Sort profiles by created at descending when shown in a list. 2021-12-09 14:58:08 -05:00
Cody Henthorne
c30a43ef45 Fix conflict when manually enabled an older profile with a schedule overlap with a newer profile. 2021-12-09 14:58:08 -05:00
Alex Hart
76539ff0f2 Remove focus shade when displaying reactions bottom sheet. 2021-12-09 14:58:08 -05:00
Alex Hart
39f4ca10ef Fix crash when leaving groups during account deletion. 2021-12-09 14:58:08 -05:00
Cody Henthorne
3d77ce0d57 Only initially show up to 5 members on profile details. 2021-12-09 14:58:08 -05:00
Cody Henthorne
3b9cfc8e5a Fix unable to select contact from list bug. 2021-12-09 14:58:08 -05:00
Cody Henthorne
761d70851c Show recents and groups in add to notification profile. 2021-12-09 14:58:08 -05:00
Cody Henthorne
4c28619010 Update Schedule UI and use locale specific first day of week. 2021-12-09 14:58:08 -05:00
Greyson Parrelli
884710fc30 Fix crash in ProfileSharingUpdateMigrationJob.
A typo introduced in the java -> kt conversion.

Fixes #11824
2021-12-09 14:58:08 -05:00
Greyson Parrelli
2e96042578 Fix SMS contacts not showing in contact search results.
Introduced a typo in the java -> kt conversion.
2021-12-09 14:58:08 -05:00
Rashad Sookram
5b49be47f9 Fix conversation select menu options not showing.
Also fix unpin option being shown incorrectly.
2021-12-09 14:58:08 -05:00
Cody Henthorne
d6a42daef7 Change copy for notification profiles setting to clarify feature. 2021-12-09 14:58:08 -05:00
Cody Henthorne
d5679ef95f Fix notification profile selection UI bugs. 2021-12-09 14:58:08 -05:00
Cody Henthorne
60e54fb2af Bump version to 5.28.1 2021-12-08 20:56:43 -05:00
Cody Henthorne
575e00dcf8 Updated language translations. 2021-12-08 20:52:40 -05:00
Cody Henthorne
a8a104242a Fix various issues regarding Notification Profile scheduling.
- Timezone conversion when detecting scheduled profile
- Not automatically enabling a scheduled profile on creation regardless
  of when other profiles were enabled/disabled
2021-12-08 20:49:45 -05:00
Cody Henthorne
372b0d9f2b Fix notification profiles megaphone typo. 2021-12-08 16:02:55 -05:00
Cody Henthorne
d3b061c6a4 Bump version to 5.28.0 2021-12-08 15:35:45 -05:00
Cody Henthorne
1086749244 Updated language translations. 2021-12-08 15:28:14 -05:00
Rashad Sookram
c16115f71a Fix color of close button on normal reminders.
This change also prevents the title from overlapping with the close
button.
2021-12-08 15:07:22 -05:00
Cody Henthorne
6c608e955e Add Notification profiles. 2021-12-08 15:07:22 -05:00
Cody Henthorne
31e0696395 Use long in exporter. 2021-12-08 15:07:22 -05:00
Alex Hart
99f43b997c Add logging for selected badge density. 2021-12-08 15:07:22 -05:00
Rashad Sookram
b6f84dfa16 Add tooltip to opt-out of bubbles. 2021-12-08 15:07:22 -05:00
Ehren Kret
63c98e92f2 Remove old chat server hostnames. 2021-12-08 15:07:22 -05:00
Alex Hart
34d4c910f7 Only notify voice note progress handler if activity is not null. 2021-12-08 15:07:22 -05:00
Jordan Rose
7dc3454b37 Use low-bandwidth mode if call is believed to be on cellular 2021-12-08 15:06:44 -05:00
Greyson Parrelli
60047aecb9 Keep JobManagerFactories in alphabetical order. 2021-12-06 16:02:51 -05:00
Rashad Sookram
738c5db7c2 Fix post loop when View is GONE. 2021-12-06 15:54:40 -05:00
Greyson Parrelli
c93457402c Store your own PNI. 2021-12-06 12:18:42 -05:00
Jim Gustafson
0a84f7f505 Update to RingRTC v2.16.0 2021-12-06 10:38:18 -05:00
Alex Hart
c91a1e13d9 Fix checks on conversation list and recipient row. 2021-12-03 18:10:01 -05:00
Greyson Parrelli
a346dd33d9 Do not allow recipient merges to remove your own E164.
This would only happen in some niche change number cases where an
unregistered device would continue to send sealed sender messages to you
using your old number.
2021-12-03 18:10:01 -05:00
Rashad Sookram
398fdd84b9 Ensure all conversations are loaded before selecting all.
They might not be loaded yet due to pagination.
2021-12-03 18:10:01 -05:00
Cody Henthorne
2c5f57486c Enable Change Number via FeatureFlags. 2021-12-03 18:10:01 -05:00
Cody Henthorne
0fa3b2f8f9 Revert "Enable Change Number."
This reverts commit 97642f555e8a1cb89c7bae209b218a1c63532ada.
2021-12-03 18:10:01 -05:00
Greyson Parrelli
88aa67b847 Fix typo that caused some invalid ContactRecords to slip through.
We were doing .equals() between an ACI and a UUID, so it was always
returning false. Fixed by swithing to the proper check.
2021-12-03 18:10:01 -05:00
Alex Hart
6154ff36c1 Keep around info logs from SubscriptionKeepAlive job. 2021-12-03 18:10:01 -05:00
Greyson Parrelli
c0a83e7956 Migrate RecipientDatabase to Kotlin. 2021-12-03 18:10:01 -05:00
Cody Henthorne
59ad8bf76a Fix deadlock with web socket health monitor. 2021-12-03 18:10:01 -05:00
Greyson Parrelli
4ba4df706e Properly handle LockedException during PIN guess. 2021-12-03 18:10:01 -05:00
Cody Henthorne
d48632d09d Enable Change Number. 2021-12-03 18:10:01 -05:00
Greyson Parrelli
8cb4cc5ac3 Disable scrolling while the context menu is showing. 2021-12-03 18:10:01 -05:00
Jim Gustafson
83d3e56dcf Update to RingRTC v2.15.0
Also adds audio processing option for internal users.
2021-12-03 18:10:01 -05:00
Greyson Parrelli
deddb4f77d Use new endpoint for determining if ACI is a registered user. 2021-12-03 18:09:52 -05:00
Rashad Sookram
479ab10578 Allow dialog buttons to span two lines.
... so that buttons with long strings don't get truncated.

Fixes #11793
2021-12-03 18:09:52 -05:00
Alex Hart
321c84583b Ensure user leaves groups before deleting account. 2021-12-03 18:09:52 -05:00
Rashad Sookram
3242d97c75 Fix crash when opening contacts app when none are present.
This can also happen when the system contacts app has been disabled.

Fixes #11794
2021-12-03 18:09:52 -05:00
Greyson Parrelli
562a255478 Update libsignal-client to 0.11.0 2021-12-03 18:09:52 -05:00
Alex Hart
a6a70f23e9 Bump version to 5.27.13 2021-12-03 18:04:14 -04:00
Alex Hart
e3638791d9 Revert "Improve text entry for boosts."
This reverts commit 84833c9ad3.
2021-12-03 17:58:03 -04:00
Alex Hart
8501fdffc6 Bump version to 5.27.12 2021-12-03 14:06:14 -04:00
Alex Hart
4c4cbecd85 Updated language translations. 2021-12-03 14:05:53 -04:00
Alex Hart
84833c9ad3 Improve text entry for boosts. 2021-12-03 13:55:42 -04:00
Alex Hart
131a400921 Bump version to 5.27.11 2021-12-02 17:23:55 -04:00
Alex Hart
54d937d036 Improve text entry for donations. 2021-12-02 17:20:21 -04:00
Alex Hart
a8659bf8e5 Bump version to 5.27.10 2021-12-02 13:55:21 -04:00
Alex Hart
dfe78cdae6 Updated language translations. 2021-12-02 13:55:21 -04:00
Alex Hart
9434894dff Add additional logging around boost amounts. 2021-12-02 13:55:21 -04:00
Alex Hart
d028165b51 Only display donate megaphone for those with Play Services. 2021-12-02 13:29:55 -04:00
Alex Hart
8771dbf49f Bump version to 5.27.9 2021-12-01 15:59:52 -04:00
Alex Hart
4615b0d32d Updated language translations. 2021-12-01 15:59:23 -04:00
Alex Hart
f9a2208832 Fix non-standard numeral entry. 2021-12-01 15:53:55 -04:00
Greyson Parrelli
b981ac4fe4 Bump version to 5.27.8 2021-11-30 17:38:07 -05:00
Greyson Parrelli
a3219348b6 Updated language translations. 2021-11-30 17:38:07 -05:00
Greyson Parrelli
a35a35cee8 Make the donation megaphone flag hot-swappable. 2021-11-30 17:38:07 -05:00
Greyson Parrelli
85f1f27b13 Remove donor badges warning in comment. 2021-11-30 17:17:44 -05:00
Alex Hart
388c91410b Fix vector related crash with Button strip on Kitkat. 2021-11-30 17:13:14 -05:00
Alex Hart
3c0afe4b24 Fix recipient bottom sheet buttons on devices with very large font selected. 2021-11-30 17:13:14 -05:00
Alex Hart
ae1f834619 Add new subscription multi device sync message. 2021-11-30 17:13:14 -05:00
Alex Hart
9f9bf3c604 Remove TextDrawable dependency. 2021-11-30 17:13:14 -05:00
Greyson Parrelli
f9c4fe736a Add a network constraint to ReactionSendJob. 2021-11-30 17:13:14 -05:00
Greyson Parrelli
638bae6de3 Update SQLCipher error handling. 2021-11-30 17:13:14 -05:00
Sgn-32
e363bac1a3 Replace only the first emoji in message body. 2021-11-30 17:13:12 -05:00
Alex Hart
e690e9bd69 Display an error if we cannot open picker instead of crashing. 2021-11-30 17:13:12 -05:00
Alex Hart
c0ac2176c1 Clean up dead code from database refactor. 2021-11-30 17:13:12 -05:00
Ehren Kret
dccfafa9e8 Remove dead code referencing the old way directory lookup was performed. 2021-11-30 17:13:10 -05:00
Alex Hart
0edfb0bd68 Check whether recipient is blockable before allowing blocking. 2021-11-29 11:21:40 -04:00
Alex Hart
31a815013e Update onError signature in donations sample app. 2021-11-29 10:25:37 -04:00
Greyson Parrelli
4364e9513f Do not assume e164 is populated in storage service insert. 2021-11-28 18:01:44 -05:00
Greyson Parrelli
1621c060b5 Bump version to 5.27.7 2021-11-28 10:53:50 -05:00
Greyson Parrelli
b1c32476b0 Updated language translations. 2021-11-28 10:52:56 -05:00
Greyson Parrelli
ba96db2ae0 Update permission string at Google's request to reflect private contact discovery. 2021-11-27 18:12:23 -05:00
Greyson Parrelli
182a112cdd Bump version to 5.27.6 2021-11-24 16:41:12 -05:00
Greyson Parrelli
a45e26ab6b Updated language translations. 2021-11-24 16:41:12 -05:00
Greyson Parrelli
b6022be41f Fix possible crash in MediaPreviewActivity. 2021-11-24 16:41:12 -05:00
Greyson Parrelli
0801a0e329 Fix typo in badge density selection. 2021-11-24 16:41:12 -05:00
Greyson Parrelli
89cbfd3299 Log errorCode when a stripe request fails. 2021-11-24 16:41:12 -05:00
Cody Henthorne
5b2ca6a1d3 Display badge on Payment Details. 2021-11-24 16:41:12 -05:00
Cody Henthorne
c5d7188dcb Fix country specific translations for badges. 2021-11-24 16:41:12 -05:00
Cody Henthorne
818eb81f87 Fix Boost bottom sheet dark theme bugs. 2021-11-24 16:41:12 -05:00
Cody Henthorne
510a295198 Fix crash in custom boost input. 2021-11-24 16:41:12 -05:00
Greyson Parrelli
98fab95683 Bump version to 5.27.5 2021-11-23 20:22:06 -05:00
Greyson Parrelli
79d45bb497 Updated language translations. 2021-11-23 20:18:58 -05:00
Greyson Parrelli
fc3d77ed9a Remove emails from payments. 2021-11-23 20:18:57 -05:00
700 changed files with 35730 additions and 13274 deletions

View File

@@ -43,11 +43,13 @@
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
<value>
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="JAVA">
<option name="BRACE_STYLE" value="5" />
@@ -213,13 +215,5 @@
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@@ -1,12 +1,9 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.google.protobuf'
apply plugin: 'androidx.navigation.safeargs'
apply plugin: 'witness'
apply plugin: 'org.jlleitschuh.gradle.ktlint'
apply from: 'translations.gradle'
apply from: 'witness-verifications.gradle'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'app.cash.exhaustive'
apply plugin: 'kotlin-parcelize'
@@ -24,12 +21,6 @@ repositories {
includeGroupByRegex "org\\.signal.*"
}
}
maven { // textdrawable
url 'https://dl.bintray.com/amulyakhare/maven'
content {
includeGroupByRegex "com\\.amulyakhare.*"
}
}
maven {
url "https://www.jitpack.io"
}
@@ -46,7 +37,6 @@ repositories {
includeVersion "cn.carbswang.android", "NumberPickerView", "1.0.9"
includeVersion "com.takisoft.fix", "colorpicker", "0.9.1"
includeVersion "com.codewaves.stickyheadergrid", "stickyheadergrid", "0.9.4"
includeVersion "com.amulyakhare", "com.amulyakhare.textdrawable", "1.0.1"
includeVersion "com.google.android", "flexbox", "0.3.0"
}
}
@@ -54,7 +44,7 @@ repositories {
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.11.4'
artifact = 'com.google.protobuf:protoc:3.18.0'
}
generateProtoTasks {
all().each { task ->
@@ -67,15 +57,20 @@ protobuf {
}
}
def canonicalVersionCode = 963
def canonicalVersionName = "5.27.4"
ktlint {
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
version = "0.43.2"
}
def canonicalVersionCode = 990
def canonicalVersionName = "5.29.7"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
'armeabi-v7a' : 1,
'arm64-v8a' : 2,
'x86' : 3,
'x86_64' : 4]
def abiPostFix = ['universal' : 5,
'armeabi-v7a' : 6,
'arm64-v8a' : 7,
'x86' : 8,
'x86_64' : 9]
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
@@ -138,7 +133,7 @@ android {
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service.whispersystems.org\""
buildConfigField "String", "SIGNAL_URL", "\"https://chat.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
@@ -160,7 +155,7 @@ android {
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXQ==\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
@@ -197,6 +192,16 @@ android {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
sourceSets {
test {
java.srcDirs += "$projectDir/src/testShared"
}
androidTest {
java.srcDirs += "$projectDir/src/testShared"
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JAVA_VERSION
@@ -325,7 +330,7 @@ android {
applicationIdSuffix ".staging"
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service-staging.whispersystems.org\""
buildConfigField "String", "SIGNAL_URL", "\"https://chat.staging.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
@@ -337,7 +342,7 @@ android {
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARB\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXQ==\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
@@ -378,6 +383,7 @@ android {
}
lintOptions {
checkReleaseBuilds false
abortOnError true
baseline file("lint-baseline.xml")
disable "LintError"
@@ -445,6 +451,7 @@ dependencies {
implementation project(':libsignal-service')
implementation project(':paging')
implementation project(':core-util')
implementation project(':glide-config')
implementation project(':video')
implementation project(':device-transfer')
implementation project(':image-editor')
@@ -471,8 +478,6 @@ dependencies {
implementation libs.apache.httpclient.android
implementation libs.photoview
implementation libs.glide.glide
kapt libs.glide.compiler
kapt libs.androidx.annotation
implementation libs.roundedimageview
implementation libs.materialish.progress
implementation libs.greenrobot.eventbus
@@ -480,7 +485,6 @@ dependencies {
implementation libs.floatingactionbutton
implementation libs.google.zxing.android.integration
implementation libs.time.duration.picker
implementation libs.textdrawable
implementation libs.google.zxing.core
implementation (libs.subsampling.scale.image.view) {
exclude group: 'com.android.support', module: 'support-annotations'
@@ -547,14 +551,11 @@ dependencies {
implementation libs.rxjava3.rxandroid
implementation libs.rxjava3.rxkotlin
implementation libs.rxdogtag
androidTestUtil 'androidx.test:orchestrator:1.4.0'
}
dependencyVerification {
configuration = '(play|website)(Prod|Staging)(Debug|Release)RuntimeClasspath'
}
def getLastCommitTimestamp() {
if (!(new File('.git').exists())) {
return System.currentTimeMillis().toString()

View File

@@ -8,6 +8,11 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.libsignal.util.guava.Optional
@@ -214,6 +219,29 @@ class RecipientDatabaseTest {
assertEquals(E164_A, existingRecipient.requireE164())
}
/** We never want to remove the e164 of our own contact entry. So basically treat this as a low-trust case, and leave the e164 alone. */
@Test
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_e164BelongsToLocalUser_highTrust() {
val dataSet = KeyValueDataSet().apply {
putString(AccountValues.KEY_E164, E164_A)
putString(AccountValues.KEY_ACI, ACI_A.toString())
}
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
assertNotEquals(existingId, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_B, retrievedRecipient.requireAci())
assertFalse(retrievedRecipient.hasE164())
val existingRecipient = Recipient.resolved(existingId)
assertEquals(ACI_A, existingRecipient.requireAci())
assertEquals(E164_A, existingRecipient.requireE164())
}
// ==============================================================
// If both the ACI and E164 map to an existing user
// ==============================================================
@@ -324,6 +352,30 @@ class RecipientDatabaseTest {
assertEquals(retrievedId, recipientWithId2.id)
}
/** We never want to remove the e164 of our own contact entry. So basically treat this as a low-trust case, and leave the e164 alone. */
@Test
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_e164BelongsToLocalUser() {
val dataSet = KeyValueDataSet().apply {
putString(AccountValues.KEY_E164, E164_A)
putString(AccountValues.KEY_ACI, ACI_B.toString())
}
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
assertEquals(existingId2, retrievedId)
val retrievedRecipient = Recipient.resolved(retrievedId)
assertEquals(ACI_A, retrievedRecipient.requireAci())
assertFalse(retrievedRecipient.hasE164())
val recipientWithId1 = Recipient.resolved(existingId1)
assertEquals(ACI_B, recipientWithId1.requireAci())
assertEquals(E164_A, recipientWithId1.requireE164())
}
// ==============================================================
// Misc
// ==============================================================
@@ -378,7 +430,7 @@ class RecipientDatabaseTest {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val E164_A = "+12221234567"
val E164_B = "+13331234567"
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
}
}

View File

@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.database
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
@@ -14,12 +16,14 @@ import org.junit.runner.RunWith
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.signal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingTextMessage
@@ -45,6 +49,7 @@ class RecipientDatabaseTest_merges {
private lateinit var sessionDatabase: SessionDatabase
private lateinit var mentionDatabase: MentionDatabase
private lateinit var reactionDatabase: ReactionDatabase
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
@Before
fun setup() {
@@ -58,6 +63,7 @@ class RecipientDatabaseTest_merges {
sessionDatabase = SignalDatabase.sessions
mentionDatabase = SignalDatabase.mentions
reactionDatabase = SignalDatabase.reactions
notificationProfileDatabase = SignalDatabase.notificationProfiles
ensureDbEmpty()
}
@@ -68,6 +74,7 @@ class RecipientDatabaseTest_merges {
// Setup
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_B)
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
@@ -97,6 +104,14 @@ class RecipientDatabaseTest_merges {
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
val profile1: NotificationProfile = notificationProfile(name = "Test")
val profile2: NotificationProfile = notificationProfile(name = "Test2")
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
// Merge
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
@@ -171,6 +186,13 @@ class RecipientDatabaseTest_merges {
assertEquals(1, reactionsMms.size)
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
// Notification Profile validation
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
}
private val context: Application
@@ -220,6 +242,10 @@ class RecipientDatabaseTest_merges {
}
}
private fun notificationProfile(name: String): NotificationProfile {
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
}
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
data class MentionModel(
val recipientId: RecipientId,

View File

@@ -380,7 +380,7 @@
android:label="@string/AndroidManifest__change_passphrase"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".VerifyIdentityActivity"
<activity android:name=".verify.VerifyIdentityActivity"
android:theme="@style/Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

View File

@@ -33,10 +33,9 @@ public final class AppInitialization {
InsightsOptOut.userRequestedOptOut(context);
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
TextSecurePreferences.setPasswordDisabled(context, true);
TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setReadReceiptsEnabled(context, true);
TextSecurePreferences.setTypingIndicatorsEnabled(context, true);
TextSecurePreferences.setHasSeenWelcomeScreen(context, false);
@@ -73,10 +72,9 @@ public final class AppInitialization {
InsightsOptOut.userRequestedOptOut(context);
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
TextSecurePreferences.setHasSeenStickerIntroTooltip(context, true);
TextSecurePreferences.setPasswordDisabled(context, true);
TextSecurePreferences.setLastExperienceVersionCode(context, Util.getCanonicalVersionCode());
ApplicationDependencies.getMegaphoneRepository().onFirstEverAppLaunch();
SignalStore.onFirstEverAppLaunch();
ApplicationDependencies.getJobManager().add(StickerPackDownloadJob.forInstall(BlessedPacks.ZOZO.getPackId(), BlessedPacks.ZOZO.getPackKey(), false));

View File

@@ -35,6 +35,8 @@ import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
@@ -62,6 +64,7 @@ import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
@@ -79,7 +82,6 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ProfileUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -88,11 +90,17 @@ import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
import java.io.IOException;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.security.Security;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import io.reactivex.rxjava3.schedulers.Schedulers;
import rxdogtag2.RxDogTag;
/**
* Will be called once when the TextSecure process is created.
@@ -138,10 +146,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
Log.i(TAG, "onCreate()");
})
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", () -> {
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
})
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
@@ -165,6 +170,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
})
.addBlocking("blob-provider", this::initializeBlobProvider)
.addBlocking("feature-flags", FeatureFlags::init)
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
@@ -187,6 +193,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -208,7 +215,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
FeatureFlags.refreshIfNecessary();
ApplicationDependencies.getRecipientCache().warmUp();
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
GroupV1MigrationJob.enqueueRoutineMigrationsIfNecessary(this);
executePendingContactSync();
KeyCachingService.onAppForegrounded(this);
ApplicationDependencies.getShakeToReport().enable();
@@ -280,6 +286,30 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
Thread.setDefaultUncaughtExceptionHandler(new SignalUncaughtExceptionHandler(originalHandler));
}
private void initializeRx() {
RxDogTag.install();
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
RxJavaPlugins.setErrorHandler(e -> {
boolean wasWrapped = false;
while ((e instanceof UndeliverableException || e instanceof AssertionError || e instanceof OnErrorNotImplementedException) && e.getCause() != null) {
wasWrapped = true;
e = e.getCause();
}
if (wasWrapped && (e instanceof SocketException || e instanceof SocketTimeoutException || e instanceof InterruptedException)) {
return;
}
Thread.UncaughtExceptionHandler uncaughtExceptionHandler = Thread.currentThread().getUncaughtExceptionHandler();
if (uncaughtExceptionHandler == null) {
uncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
}
uncaughtExceptionHandler.uncaughtException(Thread.currentThread(), e);
});
}
private void initializeApplicationMigrations() {
ApplicationMigrations.onApplicationCreate(this, ApplicationDependencies.getJobManager());
}

View File

@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -13,8 +15,8 @@ public interface BindableConversationListItem extends Unbindable {
void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull Set<Long> selectedThreads, boolean batchMode);
@NonNull ConversationSet selectedConversations);
void setBatchMode(boolean batchMode);
void setSelectedConversations(@NonNull ConversationSet conversations);
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
}

View File

@@ -765,8 +765,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
public interface OnContactSelectedListener {
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback);
void onContactDeselected(Optional<RecipientId> recipientId, String number);
void onBeforeContactSelected(Optional<RecipientId> recipientId, @Nullable String number, Consumer<Boolean> callback);
void onContactDeselected(Optional<RecipientId> recipientId, @Nullable String number);
void onSelectionChanged();
}

View File

@@ -120,7 +120,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
}
@Override
public void onQrDataFound(final String data) {
public void onQrDataFound(@NonNull final String data) {
ThreadUtil.runOnMain(() -> {
((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
Uri uri = Uri.parse(data);

View File

@@ -73,6 +73,7 @@ import org.thoughtcrime.securesms.sharing.ShareActivity;
import org.thoughtcrime.securesms.util.AttachmentUtil;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
import org.thoughtcrime.securesms.util.StorageUtil;
@@ -534,7 +535,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
}
public static boolean isContentTypeSupported(final String contentType) {
return contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video/"));
return MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType);
}
@Override
@@ -600,7 +601,10 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
if (adapter != null) {
MediaItem item = adapter.getMediaItemFor(position);
if (item.recipient != null) item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar());
if (item != null && item.recipient != null) {
item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar());
}
viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
initializeActionBar();
}
@@ -613,7 +617,9 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
if (adapter != null) {
MediaItem item = adapter.getMediaItemFor(position);
if (item.recipient != null) item.recipient.live().removeObservers(MediaPreviewActivity.this);
if (item != null && item.recipient != null) {
item.recipient.live().removeObservers(MediaPreviewActivity.this);
}
adapter.pause(position);
}
@@ -663,7 +669,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
}
@Override
public MediaItem getMediaItemFor(int position) {
public @Nullable MediaItem getMediaItemFor(int position) {
return new MediaItem(null, null, null, uri, mediaType, -1, true);
}
@@ -769,8 +775,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
super.destroyItem(container, position, object);
}
public MediaItem getMediaItemFor(int position) {
cursor.moveToPosition(getCursorPosition(position));
public @Nullable MediaItem getMediaItemFor(int position) {
int cursorPosition = getCursorPosition(position);
if (cursor.isClosed() || cursorPosition < 0) {
Log.w(TAG, "Invalid cursor state! Closed: " + cursor.isClosed() + " Position: " + cursorPosition);
return null;
}
cursor.moveToPosition(cursorPosition);
MediaRecord mediaRecord = MediaRecord.from(context, cursor);
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
@@ -838,7 +851,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
}
interface MediaItemAdapter {
MediaItem getMediaItemFor(int position);
@Nullable MediaItem getMediaItemFor(int position);
void pause(int position);
@Nullable View getPlaybackControls(int position);
boolean hasFragmentFor(int position);

View File

@@ -1,774 +0,0 @@
/*
* Copyright (C) 2016-2017 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms;
import android.Manifest;
import android.animation.TypeEvaluator;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.PorterDuff;
import android.graphics.drawable.BitmapDrawable;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Vibrator;
import android.text.Html;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.view.ContextMenu;
import android.view.ContextMenu.ContextMenuInfo;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.animation.Animation;
import android.view.animation.AnticipateInterpolator;
import android.view.animation.ScaleAnimation;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.ScrollView;
import android.widget.TextSwitcher;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.core.view.OneShotPreDrawListener;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ShapeScrim;
import org.thoughtcrime.securesms.components.camera.CameraView;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.qr.QrCode;
import org.thoughtcrime.securesms.qr.ScanListener;
import org.thoughtcrime.securesms.qr.ScanningThread;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.fingerprint.Fingerprint;
import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.nio.charset.Charset;
import java.util.Locale;
/**
* Activity for verifying identity keys.
*
* @author Moxie Marlinspike
*/
@SuppressLint("StaticFieldLeak")
public class VerifyIdentityActivity extends PassphraseRequiredActivity implements ScanListener, View.OnClickListener {
private static final String TAG = Log.tag(VerifyIdentityActivity.class);
private static final String RECIPIENT_EXTRA = "recipient_id";
private static final String IDENTITY_EXTRA = "recipient_identity";
private static final String VERIFIED_EXTRA = "verified_state";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final VerifyDisplayFragment displayFragment = new VerifyDisplayFragment();
private final VerifyScanFragment scanFragment = new VerifyScanFragment();
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityRecord identityRecord)
{
return newIntent(context,
identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
identityRecord.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED);
}
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityRecord identityRecord,
boolean verified)
{
return newIntent(context,
identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
verified);
}
public static Intent newIntent(@NonNull Context context,
@NonNull RecipientId recipientId,
@NonNull IdentityKey identityKey,
boolean verified)
{
Intent intent = new Intent(context, VerifyIdentityActivity.class);
intent.putExtra(RECIPIENT_EXTRA, recipientId);
intent.putExtra(IDENTITY_EXTRA, new IdentityKeyParcelable(identityKey));
intent.putExtra(VERIFIED_EXTRA, verified);
return intent;
}
@Override
public void onPreCreate() {
dynamicTheme.onCreate(this);
}
@Override
protected void onCreate(Bundle state, boolean ready) {
Bundle extras = new Bundle();
extras.putParcelable(VerifyDisplayFragment.RECIPIENT_ID, getIntent().getParcelableExtra(RECIPIENT_EXTRA));
extras.putParcelable(VerifyDisplayFragment.REMOTE_IDENTITY, getIntent().getParcelableExtra(IDENTITY_EXTRA));
extras.putParcelable(VerifyDisplayFragment.LOCAL_IDENTITY, new IdentityKeyParcelable(IdentityKeyUtil.getIdentityKey(this)));
extras.putString(VerifyDisplayFragment.LOCAL_NUMBER, Recipient.self().requireE164());
extras.putBoolean(VerifyDisplayFragment.VERIFIED_STATE, getIntent().getBooleanExtra(VERIFIED_EXTRA, false));
scanFragment.setScanListener(this);
displayFragment.setClickListener(this);
initFragment(android.R.id.content, displayFragment, Locale.getDefault(), extras);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home: finish(); return true;
}
return false;
}
@Override
public void onQrDataFound(final String data) {
ThreadUtil.runOnMain(() -> {
((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
getSupportFragmentManager().popBackStack();
displayFragment.setScannedFingerprint(data);
});
}
@Override
public void onClick(View v) {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied))
.onAllGranted(() -> {
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
transaction.setCustomAnimations(R.anim.slide_from_top, R.anim.slide_to_bottom,
R.anim.slide_from_bottom, R.anim.slide_to_top);
transaction.replace(android.R.id.content, scanFragment)
.addToBackStack(null)
.commitAllowingStateLoss();
})
.onAnyDenied(() -> Toast.makeText(this, R.string.VerifyIdentityActivity_unable_to_scan_qr_code_without_camera_permission, Toast.LENGTH_LONG).show())
.execute();
}
@SuppressLint("MissingSuperCall")
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
public static class VerifyDisplayFragment extends Fragment implements ViewTreeObserver.OnScrollChangedListener {
public static final String RECIPIENT_ID = "recipient_id";
public static final String REMOTE_NUMBER = "remote_number";
public static final String REMOTE_IDENTITY = "remote_identity";
public static final String LOCAL_IDENTITY = "local_identity";
public static final String LOCAL_NUMBER = "local_number";
public static final String VERIFIED_STATE = "verified_state";
private LiveRecipient recipient;
private IdentityKey localIdentity;
private IdentityKey remoteIdentity;
private Fingerprint fingerprint;
private Toolbar toolbar;
private ScrollView scrollView;
private View container;
private View numbersContainer;
private View loading;
private View qrCodeContainer;
private ImageView qrCode;
private ImageView qrVerified;
private TextSwitcher tapLabel;
private TextView description;
private View.OnClickListener clickListener;
private Button verifyButton;
private View toolbarShadow;
private View bottomShadow;
private TextView[] codes = new TextView[12];
private boolean animateSuccessOnDraw = false;
private boolean animateFailureOnDraw = false;
private boolean currentVerifiedState = false;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
this.toolbar = container.findViewById(R.id.toolbar);
this.scrollView = container.findViewById(R.id.scroll_view);
this.numbersContainer = container.findViewById(R.id.number_table);
this.loading = container.findViewById(R.id.loading);
this.qrCodeContainer = container.findViewById(R.id.qr_code_container);
this.qrCode = container.findViewById(R.id.qr_code);
this.verifyButton = container.findViewById(R.id.verify_button);
this.qrVerified = container.findViewById(R.id.qr_verified);
this.description = container.findViewById(R.id.description);
this.tapLabel = container.findViewById(R.id.tap_label);
this.toolbarShadow = container.findViewById(R.id.toolbar_shadow);
this.bottomShadow = container.findViewById(R.id.verify_identity_bottom_shadow);
this.codes[0] = container.findViewById(R.id.code_first);
this.codes[1] = container.findViewById(R.id.code_second);
this.codes[2] = container.findViewById(R.id.code_third);
this.codes[3] = container.findViewById(R.id.code_fourth);
this.codes[4] = container.findViewById(R.id.code_fifth);
this.codes[5] = container.findViewById(R.id.code_sixth);
this.codes[6] = container.findViewById(R.id.code_seventh);
this.codes[7] = container.findViewById(R.id.code_eighth);
this.codes[8] = container.findViewById(R.id.code_ninth);
this.codes[9] = container.findViewById(R.id.code_tenth);
this.codes[10] = container.findViewById(R.id.code_eleventh);
this.codes[11] = container.findViewById(R.id.code_twelth);
this.qrCodeContainer.setOnClickListener(clickListener);
this.registerForContextMenu(numbersContainer);
updateVerifyButton(getArguments().getBoolean(VERIFIED_STATE, false), false);
this.verifyButton.setOnClickListener((button -> updateVerifyButton(!currentVerifiedState, true)));
this.scrollView.getViewTreeObserver().addOnScrollChangedListener(this);
((AppCompatActivity)requireActivity()).setSupportActionBar(toolbar);
((AppCompatActivity)requireActivity()).setTitle(R.string.AndroidManifest__verify_safety_number);
return container;
}
@Override public void onDestroyView() {
this.scrollView.getViewTreeObserver().removeOnScrollChangedListener(this);
super.onDestroyView();
}
@Override
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
RecipientId recipientId = getArguments().getParcelable(RECIPIENT_ID);
IdentityKeyParcelable localIdentityParcelable = getArguments().getParcelable(LOCAL_IDENTITY);
IdentityKeyParcelable remoteIdentityParcelable = getArguments().getParcelable(REMOTE_IDENTITY);
if (recipientId == null) throw new AssertionError("RecipientId required");
if (localIdentityParcelable == null) throw new AssertionError("local identity required");
if (remoteIdentityParcelable == null) throw new AssertionError("remote identity required");
this.localIdentity = localIdentityParcelable.get();
this.recipient = Recipient.live(recipientId);
this.remoteIdentity = remoteIdentityParcelable.get();
int version;
byte[] localId;
byte[] remoteId;
//noinspection WrongThread
Recipient resolved = recipient.resolve();
if (FeatureFlags.verifyV2() && resolved.getAci().isPresent()) {
Log.i(TAG, "Using UUID (version 2).");
version = 2;
localId = Recipient.self().requireAci().toByteArray();
remoteId = resolved.requireAci().toByteArray();
} else if (!FeatureFlags.verifyV2() && resolved.getE164().isPresent()) {
Log.i(TAG, "Using E164 (version 1).");
version = 1;
localId = Recipient.self().requireE164().getBytes();
remoteId = resolved.requireE164().getBytes();
} else {
Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", FeatureFlags.verifyV2(), resolved.getAci().isPresent(), resolved.getE164().isPresent()));
new MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, resolved.getDisplayName(requireContext())))
.setPositiveButton(android.R.string.ok, (dialog, which) -> requireActivity().finish())
.setOnDismissListener(dialog -> {
requireActivity().finish();
dialog.dismiss();
})
.show();
return;
}
this.recipient.observe(this, this::setRecipientText);
new AsyncTask<Void, Void, Fingerprint>() {
@Override
protected Fingerprint doInBackground(Void... params) {
return new NumericFingerprintGenerator(5200).createFor(version,
localId, localIdentity,
remoteId, remoteIdentity);
}
@Override
protected void onPostExecute(Fingerprint fingerprint) {
if (getActivity() == null) return;
VerifyDisplayFragment.this.fingerprint = fingerprint;
setFingerprintViews(fingerprint, true);
getActivity().supportInvalidateOptionsMenu();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
setHasOptionsMenu(true);
}
@Override
public void onResume() {
super.onResume();
setRecipientText(recipient.get());
if (fingerprint != null) {
setFingerprintViews(fingerprint, false);
}
if (animateSuccessOnDraw) {
animateSuccessOnDraw = false;
animateVerifiedSuccess();
} else if (animateFailureOnDraw) {
animateFailureOnDraw = false;
animateVerifiedFailure();
}
ThreadUtil.postToMain(this::onScrollChanged);
}
@Override
public void onCreateContextMenu(ContextMenu menu, View view,
ContextMenuInfo menuInfo)
{
super.onCreateContextMenu(menu, view, menuInfo);
if (fingerprint != null) {
MenuInflater inflater = getActivity().getMenuInflater();
inflater.inflate(R.menu.verify_display_fragment_context_menu, menu);
}
}
@Override
public boolean onContextItemSelected(MenuItem item) {
if (fingerprint == null) return super.onContextItemSelected(item);
switch (item.getItemId()) {
case R.id.menu_copy: handleCopyToClipboard(fingerprint, codes.length); return true;
case R.id.menu_compare: handleCompareWithClipboard(fingerprint); return true;
default: return super.onContextItemSelected(item);
}
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
if (fingerprint != null) {
inflater.inflate(R.menu.verify_identity, menu);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.verify_identity__share: handleShare(fingerprint, codes.length); return true;
}
return false;
}
public void setScannedFingerprint(String scanned) {
try {
if (fingerprint.getScannableFingerprint().compareTo(scanned.getBytes("ISO-8859-1"))) {
this.animateSuccessOnDraw = true;
} else {
this.animateFailureOnDraw = true;
}
} catch (FingerprintVersionMismatchException e) {
Log.w(TAG, e);
if (e.getOurVersion() < e.getTheirVersion()) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal, Toast.LENGTH_LONG).show();
} else {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show();
}
this.animateFailureOnDraw = true;
} catch (Exception e) {
Log.w(TAG, e);
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show();
this.animateFailureOnDraw = true;
}
}
public void setClickListener(View.OnClickListener listener) {
this.clickListener = listener;
}
private @NonNull String getFormattedSafetyNumbers(@NonNull Fingerprint fingerprint, int segmentCount) {
String[] segments = getSegments(fingerprint, segmentCount);
StringBuilder result = new StringBuilder();
for (int i = 0; i < segments.length; i++) {
result.append(segments[i]);
if (i != segments.length - 1) {
if (((i+1) % 4) == 0) result.append('\n');
else result.append(' ');
}
}
return result.toString();
}
private void handleCopyToClipboard(Fingerprint fingerprint, int segmentCount) {
Util.writeTextToClipboard(getActivity(), getFormattedSafetyNumbers(fingerprint, segmentCount));
}
private void handleCompareWithClipboard(Fingerprint fingerprint) {
String clipboardData = Util.readTextFromClipboard(getActivity());
if (clipboardData == null) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
return;
}
String numericClipboardData = clipboardData.replaceAll("\\D", "");
if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length() != 60) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
return;
}
if (fingerprint.getDisplayableFingerprint().getDisplayText().equals(numericClipboardData)) {
animateVerifiedSuccess();
} else {
animateVerifiedFailure();
}
}
private void handleShare(@NonNull Fingerprint fingerprint, int segmentCount) {
String shareString =
getString(R.string.VerifyIdentityActivity_our_signal_safety_number) + "\n" +
getFormattedSafetyNumbers(fingerprint, segmentCount) + "\n";
Intent intent = new Intent();
intent.setAction(Intent.ACTION_SEND);
intent.putExtra(Intent.EXTRA_TEXT, shareString);
intent.setType("text/plain");
try {
startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via)));
} catch (ActivityNotFoundException e) {
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_app_to_share_to, Toast.LENGTH_LONG).show();
}
}
private void setRecipientText(Recipient recipient) {
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
description.setMovementMethod(LinkMovementMethod.getInstance());
}
private void setFingerprintViews(Fingerprint fingerprint, boolean animate) {
String[] segments = getSegments(fingerprint, codes.length);
for (int i=0;i<codes.length;i++) {
if (animate) setCodeSegment(codes[i], segments[i]);
else codes[i].setText(segments[i]);
}
byte[] qrCodeData = fingerprint.getScannableFingerprint().getSerialized();
String qrCodeString = new String(qrCodeData, Charset.forName("ISO-8859-1"));
Bitmap qrCodeBitmap = QrCode.create(qrCodeString);
qrCode.setImageBitmap(qrCodeBitmap);
if (animate) {
ViewUtil.fadeIn(qrCode, 1000);
ViewUtil.fadeIn(tapLabel, 1000);
ViewUtil.fadeOut(loading, 300, View.GONE);
} else {
qrCode.setVisibility(View.VISIBLE);
tapLabel.setVisibility(View.VISIBLE);
loading.setVisibility(View.GONE);
}
}
private void setCodeSegment(final TextView codeView, String segment) {
ValueAnimator valueAnimator = new ValueAnimator();
valueAnimator.setObjectValues(0, Integer.parseInt(segment));
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int value = (int) animation.getAnimatedValue();
codeView.setText(String.format(Locale.getDefault(), "%05d", value));
}
});
valueAnimator.setEvaluator(new TypeEvaluator<Integer>() {
public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
return Math.round(startValue + (endValue - startValue) * fraction);
}
});
valueAnimator.setDuration(1000);
valueAnimator.start();
}
private String[] getSegments(Fingerprint fingerprint, int segmentCount) {
String[] segments = new String[segmentCount];
String digits = fingerprint.getDisplayableFingerprint().getDisplayText();
int partSize = digits.length() / segmentCount;
for (int i=0;i<segmentCount;i++) {
segments[i] = digits.substring(i * partSize, (i * partSize) + partSize);
}
return segments;
}
private Bitmap createVerifiedBitmap(int width, int height, @DrawableRes int id) {
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Bitmap check = BitmapFactory.decodeResource(getResources(), id);
float offset = (width - check.getWidth()) / 2;
canvas.drawBitmap(check, offset, offset, null);
return bitmap;
}
private void animateVerifiedSuccess() {
Bitmap qrBitmap = ((BitmapDrawable)qrCode.getDrawable()).getBitmap();
Bitmap qrSuccess = createVerifiedBitmap(qrBitmap.getWidth(), qrBitmap.getHeight(), R.drawable.ic_check_white_48dp);
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__successful_match));
animateVerified();
}
private void animateVerifiedFailure() {
Bitmap qrBitmap = ((BitmapDrawable)qrCode.getDrawable()).getBitmap();
Bitmap qrSuccess = createVerifiedBitmap(qrBitmap.getWidth(), qrBitmap.getHeight(), R.drawable.ic_close_white_48dp);
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__failed_to_verify_safety_number));
animateVerified();
}
private void animateVerified() {
ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
scaleAnimation.setInterpolator(new FastOutSlowInInterpolator());
scaleAnimation.setDuration(800);
scaleAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {}
@Override
public void onAnimationEnd(Animation animation) {
qrVerified.postDelayed(new Runnable() {
@Override
public void run() {
ScaleAnimation scaleAnimation = new ScaleAnimation(1, 0, 1, 0,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
scaleAnimation.setInterpolator(new AnticipateInterpolator());
scaleAnimation.setDuration(500);
ViewUtil.animateOut(qrVerified, scaleAnimation, View.GONE);
ViewUtil.fadeIn(qrCode, 800);
qrCodeContainer.setEnabled(true);
tapLabel.setText(getString(R.string.verify_display_fragment__tap_to_scan));
}
}, 2000);
}
@Override
public void onAnimationRepeat(Animation animation) {}
});
ViewUtil.fadeOut(qrCode, 200, View.INVISIBLE);
ViewUtil.animateIn(qrVerified, scaleAnimation);
qrCodeContainer.setEnabled(false);
}
private void updateVerifyButton(boolean verified, boolean update) {
currentVerifiedState = verified;
if (verified) {
verifyButton.setText(R.string.verify_display_fragment__clear_verification);
} else {
verifyButton.setText(R.string.verify_display_fragment__mark_as_verified);
}
if (update) {
final RecipientId recipientId = recipient.getId();
SignalExecutors.BOUNDED.execute(() -> {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
if (verified) {
Log.i(TAG, "Saving identity: " + recipientId);
ApplicationDependencies.getIdentityStore()
.saveIdentityWithoutSideEffects(recipientId,
remoteIdentity,
VerifiedStatus.VERIFIED,
false,
System.currentTimeMillis(),
true);
} else {
ApplicationDependencies.getIdentityStore().setVerified(recipientId, remoteIdentity, VerifiedStatus.DEFAULT);
}
ApplicationDependencies.getJobManager()
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
remoteIdentity,
verified ? VerifiedStatus.VERIFIED
: VerifiedStatus.DEFAULT));
StorageSyncHelper.scheduleSyncForDataChange();
IdentityUtil.markIdentityVerified(getActivity(), recipient.get(), verified, false);
}
});
}
}
@Override public void onScrollChanged() {
if (scrollView.canScrollVertically(-1)) {
if (toolbarShadow.getVisibility() != View.VISIBLE) {
ViewUtil.fadeIn(toolbarShadow, 250);
}
} else {
if (toolbarShadow.getVisibility() != View.GONE) {
ViewUtil.fadeOut(toolbarShadow, 250);
}
}
if (scrollView.canScrollVertically(1)) {
if (bottomShadow.getVisibility() != View.VISIBLE) {
ViewUtil.fadeIn(bottomShadow, 250);
}
} else {
ViewUtil.fadeOut(bottomShadow, 250);
}
}
}
public static class VerifyScanFragment extends Fragment {
private View container;
private CameraView cameraView;
private ShapeScrim cameraScrim;
private ImageView cameraMarks;
private ScanningThread scanningThread;
private ScanListener scanListener;
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
this.cameraView = container.findViewById(R.id.scanner);
this.cameraScrim = container.findViewById(R.id.camera_scrim);
this.cameraMarks = container.findViewById(R.id.camera_marks);
OneShotPreDrawListener.add(cameraScrim, () -> {
int width = cameraScrim.getScrimWidth();
int height = cameraScrim.getScrimHeight();
ViewUtil.updateLayoutParams(cameraMarks, width, height);
});
return container;
}
@Override
public void onResume() {
super.onResume();
this.scanningThread = new ScanningThread();
this.scanningThread.setScanListener(scanListener);
this.scanningThread.setCharacterSet("ISO-8859-1");
this.cameraView.onResume();
this.cameraView.setPreviewCallback(scanningThread);
this.scanningThread.start();
}
@Override
public void onPause() {
super.onPause();
this.cameraView.onPause();
this.scanningThread.stopScanning();
}
@Override
public void onConfigurationChanged(Configuration newConfiguration) {
super.onConfigurationChanged(newConfiguration);
this.cameraView.onPause();
this.cameraView.onResume();
this.cameraView.setPreviewCallback(scanningThread);
}
public void setScanListener(ScanListener listener) {
if (this.scanningThread != null) scanningThread.setScanListener(listener);
this.scanListener = listener;
}
}
}

View File

@@ -256,7 +256,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void processIntent(@NonNull Intent intent) {
if (ANSWER_ACTION.equals(intent.getAction())) {
viewModel.setRecipient(EventBus.getDefault().getStickyEvent(WebRtcViewModel.class).getRecipient());
handleAnswerWithAudio();
} else if (DENY_ACTION.equals(intent.getAction())) {
handleDenyCall();
@@ -403,24 +402,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
private void handleAnswerWithAudio() {
Recipient recipient = viewModel.getRecipient().get();
Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO)
.ifNecessary()
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_give_signal_access_to_your_microphone),
R.drawable.ic_mic_solid_24)
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
.onAllGranted(() -> {
callScreen.setStatus(getString(R.string.RedPhone_answering));
if (!recipient.equals(Recipient.UNKNOWN)) {
Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO)
.ifNecessary()
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
R.drawable.ic_mic_solid_24)
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
.onAllGranted(() -> {
callScreen.setRecipient(recipient);
callScreen.setStatus(getString(R.string.RedPhone_answering));
ApplicationDependencies.getSignalCallManager().acceptCall(false);
})
.onAnyDenied(this::handleDenyCall)
.execute();
}
ApplicationDependencies.getSignalCallManager().acceptCall(false);
})
.onAnyDenied(this::handleDenyCall)
.execute();
}
private void handleAnswerWithVideo() {

View File

@@ -1,13 +1,12 @@
package org.thoughtcrime.securesms.audio;
import android.annotation.TargetApi;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaCodec;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.media.MediaRecorder;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.logging.Log;
@@ -17,8 +16,7 @@ import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public class AudioCodec {
public class AudioCodec implements Recorder {
private static final String TAG = Log.tag(AudioCodec.class);
@@ -51,12 +49,19 @@ public class AudioCodec {
}
}
@Override
public void start(ParcelFileDescriptor fileDescriptor) {
Log.i(TAG, "Recording voice note using AudioCodec.");
start(new ParcelFileDescriptor.AutoCloseOutputStream(fileDescriptor));
}
@Override
public synchronized void stop() {
running = false;
while (!finished) Util.wait(this, 0);
}
public void start(final OutputStream outputStream) {
private void start(final OutputStream outputStream) {
new Thread(new Runnable() {
@Override
public void run() {

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.audio;
import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
@@ -13,6 +12,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
@@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public class AudioRecorder {
private static final String TAG = Log.tag(AudioRecorder.class);
@@ -29,8 +28,8 @@ public class AudioRecorder {
private final Context context;
private AudioCodec audioCodec;
private Uri captureUri;
private Recorder recorder;
private Uri captureUri;
public AudioRecorder(@NonNull Context context) {
this.context = context;
@@ -42,7 +41,7 @@ public class AudioRecorder {
executor.execute(() -> {
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
try {
if (audioCodec != null) {
if (recorder != null) {
throw new AssertionError("We can only record once at a time.");
}
@@ -52,9 +51,9 @@ public class AudioRecorder {
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
audioCodec = new AudioCodec();
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
recorder = Build.VERSION.SDK_INT >= 26 && FeatureFlags.voiceNoteRecordingV2() ? new MediaRecorderWrapper() : new AudioCodec();
recorder.start(fds[1]);
} catch (IOException e) {
Log.w(TAG, e);
}
@@ -67,12 +66,12 @@ public class AudioRecorder {
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
executor.execute(() -> {
if (audioCodec == null) {
if (recorder == null) {
sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
return;
}
audioCodec.stop();
recorder.stop();
try {
long size = MediaUtil.getMediaSize(context, captureUri);
@@ -82,7 +81,7 @@ public class AudioRecorder {
sendToFuture(future, ioe);
}
audioCodec = null;
recorder = null;
captureUri = null;
});

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.audio;
import android.media.MediaRecorder;
import android.os.ParcelFileDescriptor;
import org.signal.core.util.logging.Log;
import java.io.IOException;
/**
* Wrap Android's {@link MediaRecorder} for use with voice notes.
*/
public class MediaRecorderWrapper implements Recorder {
private static final String TAG = Log.tag(MediaRecorderWrapper.class);
private static final int SAMPLE_RATE = 44100;
private static final int CHANNELS = 1;
private static final int BIT_RATE = 32000;
private MediaRecorder recorder = null;
@Override
public void start(ParcelFileDescriptor fileDescriptor) throws IOException {
Log.i(TAG, "Recording voice note using MediaRecorderWrapper.");
recorder = new MediaRecorder();
try {
recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
recorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
recorder.setOutputFile(fileDescriptor.getFileDescriptor());
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
recorder.setAudioSamplingRate(SAMPLE_RATE);
recorder.setAudioEncodingBitRate(BIT_RATE);
recorder.setAudioChannels(CHANNELS);
recorder.prepare();
recorder.start();
} catch (IllegalStateException e) {
Log.w(TAG, "Unable to start recording", e);
recorder.release();
recorder = null;
throw new IOException(e);
}
}
@Override
public void stop() {
if (recorder == null) {
return;
}
try {
recorder.stop();
} catch (RuntimeException e) {
if (e.getClass() != RuntimeException.class) {
throw e;
} else {
Log.d(TAG, "Recording stopped with no data captured.");
}
} finally {
recorder.release();
recorder = null;
}
}
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.audio;
import android.os.ParcelFileDescriptor;
import java.io.IOException;
/**
* Simple abstraction of the interface for the original voice note recording and the new.
*/
public interface Recorder {
void start(ParcelFileDescriptor fileDescriptor) throws IOException;
void stop();
}

View File

@@ -4,9 +4,10 @@ import android.view.View
import android.widget.ImageView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit
@@ -20,7 +21,7 @@ data class AvatarColorItem(
companion object {
fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
}
}

View File

@@ -34,14 +34,13 @@ class TextAvatarDrawable(
val textSize = Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f)
val width = bounds.width()
val candidates = EmojiProvider.getCandidates(avatar.text)
var hasEmoji = false
textPaint.textSize = textSize
val newText = if (candidates == null || candidates.size() == 0) {
SpannableString(avatar.text)
} else {
EmojiProvider.emojify(context, candidates, avatar.text, textPaint, synchronous)
EmojiProvider.emojify(context, candidates, avatar.text, textPaint, synchronous, true)
}
if (newText == null) return

View File

@@ -28,8 +28,9 @@ import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
/**
@@ -198,18 +199,18 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
private fun openPhotoEditor(photo: Avatar.Photo) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
}
private fun openVectorEditor(vector: Avatar.Vector) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
}
private fun openTextEditor(text: Avatar.Text?) {
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
}
@Suppress("DEPRECATION")

View File

@@ -14,9 +14,10 @@ import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
@@ -27,7 +28,7 @@ object AvatarPickerItem {
private val SELECTION_CHANGED = Any()
fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
}
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel<Model> {

View File

@@ -27,8 +27,8 @@ import org.thoughtcrime.securesms.components.BoldSelectionTabItem
import org.thoughtcrime.securesms.components.ControllableTabLayout
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* Fragment to create an avatar based off of a Vector or Text (via a pager)

View File

@@ -15,8 +15,8 @@ import org.thoughtcrime.securesms.avatar.AvatarBundler
import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
/**
* Fragment to create an avatar based off a default vector.

View File

@@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.backup
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
import org.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.database.SmsDatabase
/**
* Queries used by backup exporter to estimate total counts for various complicated tables.
*/
object BackupCountQueries {
const val mmsCount: String = "SELECT COUNT(*) FROM ${MmsDatabase.TABLE_NAME} WHERE ${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.VIEW_ONCE} <= 0"
const val smsCount: String = "SELECT COUNT(*) FROM ${SmsDatabase.TABLE_NAME} WHERE ${SmsDatabase.EXPIRES_IN} <= 0"
@get:JvmStatic
val groupReceiptCount: String = """
SELECT COUNT(*) FROM ${GroupReceiptDatabase.TABLE_NAME}
INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${GroupReceiptDatabase.TABLE_NAME}.${GroupReceiptDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
""".trimIndent()
@get:JvmStatic
val attachmentCount: String = """
SELECT COUNT(*) FROM ${AttachmentDatabase.TABLE_NAME}
INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${AttachmentDatabase.TABLE_NAME}.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
""".trimIndent()
}

View File

@@ -13,13 +13,12 @@ import java.security.NoSuchAlgorithmException;
public abstract class FullBackupBase {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(FullBackupBase.class);
private static final int DIGEST_ROUNDS = 250_000;
static class BackupStream {
static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) {
try {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] input = passphrase.replace(" ", "").getBytes();
@@ -27,8 +26,8 @@ public abstract class FullBackupBase {
if (salt != null) digest.update(salt);
for (int i=0;i<250000;i++) {
if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
for (int i = 0; i < DIGEST_ROUNDS; i++) {
if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
digest.update(hash);
hash = digest.digest(input);
}
@@ -47,20 +46,34 @@ public abstract class FullBackupBase {
}
private final Type type;
private final int count;
private final long count;
private final long estimatedTotalCount;
BackupEvent(Type type, int count) {
this.type = type;
this.count = count;
BackupEvent(Type type, long count, long estimatedTotalCount) {
this.type = type;
this.count = count;
this.estimatedTotalCount = estimatedTotalCount;
}
public Type getType() {
return type;
}
public int getCount() {
public long getCount() {
return count;
}
public long getEstimatedTotalCount() {
return estimatedTotalCount;
}
public double getCompletionPercentage() {
if (estimatedTotalCount == 0) {
return 0;
}
return Math.min(99.9f, (double) count * 100L / (double) estimatedTotalCount);
}
}
}

View File

@@ -27,10 +27,12 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.KeyValueDatabase;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
import org.thoughtcrime.securesms.database.PendingRetryReceiptDatabase;
import org.thoughtcrime.securesms.database.ReactionDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase;
import org.thoughtcrime.securesms.database.SenderKeyDatabase;
import org.thoughtcrime.securesms.database.SenderKeySharedDatabase;
@@ -39,10 +41,12 @@ import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.SetUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -75,6 +79,11 @@ public class FullBackupExporter extends FullBackupBase {
private static final String TAG = Log.tag(FullBackupExporter.class);
private static final long DATABASE_VERSION_RECORD_COUNT = 1L;
private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L;
private static final long IDENTITY_KEY_BACKUP_RECORD_COUNT = 2L;
private static final long FINAL_MESSAGE_COUNT = 1L;
private static final Set<String> BLACKLISTED_TABLES = SetUtil.newHashSet(
SignedPreKeyDatabase.TABLE_NAME,
OneTimePreKeyDatabase.TABLE_NAME,
@@ -134,58 +143,66 @@ public class FullBackupExporter extends FullBackupBase {
@NonNull BackupCancellationSignal cancellationSignal)
throws IOException
{
BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
int count = 0;
BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
int count = 0;
long estimatedCountOutside = 0L;
try {
outputStream.writeDatabaseVersion(input.getVersion());
count++;
List<String> tables = exportSchema(input, outputStream);
count += tables.size() * 3;
count += tables.size() * TABLE_RECORD_COUNT_MULTIPLIER;
final long estimatedCount = calculateCount(context, input, tables);
estimatedCountOutside = estimatedCount;
Stopwatch stopwatch = new Stopwatch("Backup");
for (String table : tables) {
throwIfCanceled(cancellationSignal);
if (table.equals(MmsDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, cancellationSignal);
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, estimatedCount, cancellationSignal);
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, cancellationSignal);
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, estimatedCount, cancellationSignal);
} else if (table.equals(ReactionDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionDatabase.MESSAGE_ID), CursorUtil.requireBoolean(cursor, ReactionDatabase.IS_MMS))), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(MentionDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, cancellationSignal);
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal);
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount), count, cancellationSignal);
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount), count, cancellationSignal);
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
count = exportTable(table, input, outputStream, null, null, count, cancellationSignal);
count = exportTable(table, input, outputStream, null, null, count, estimatedCount, cancellationSignal);
}
stopwatch.split("table::" + table);
}
for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
throwIfCanceled(cancellationSignal);
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(preference);
}
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
throwIfCanceled(cancellationSignal);
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(preference);
}
stopwatch.split("prefs");
count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, cancellationSignal);
count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, estimatedCount, cancellationSignal);
stopwatch.split("key_values");
for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) {
throwIfCanceled(cancellationSignal);
if (avatar != null) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
}
}
@@ -198,7 +215,49 @@ public class FullBackupExporter extends FullBackupBase {
if (closeOutputStream) {
outputStream.close();
}
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside));
}
}
private static long calculateCount(@NonNull Context context, @NonNull SQLiteDatabase input, List<String> tables) {
long count = DATABASE_VERSION_RECORD_COUNT + TABLE_RECORD_COUNT_MULTIPLIER * tables.size();
for (String table : tables) {
if (table.equals(MmsDatabase.TABLE_NAME)) {
count += getCount(input, BackupCountQueries.mmsCount);
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
count += getCount(input, BackupCountQueries.smsCount);
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
count += getCount(input, BackupCountQueries.getGroupReceiptCount());
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
count += getCount(input, BackupCountQueries.getAttachmentCount());
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
count += getCount(input, "SELECT COUNT(*) FROM " + table);
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
count += getCount(input, "SELECT COUNT(*) FROM " + table);
}
}
count += IDENTITY_KEY_BACKUP_RECORD_COUNT;
count += TextSecurePreferences.getPreferencesToSaveToBackupCount(context);
KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication())
.getDataSet();
for (String key : SignalStore.getKeysToIncludeInBackup()) {
if (dataSet.containsKey(key)) {
count++;
}
}
count += AvatarHelper.getAvatarCount(context);
return count + FINAL_MESSAGE_COUNT;
}
private static long getCount(@NonNull SQLiteDatabase input, @NonNull String query) {
try (Cursor cursor = input.rawQuery(query)) {
return cursor.moveToFirst() ? cursor.getLong(0) : 0;
}
}
@@ -244,6 +303,7 @@ public class FullBackupExporter extends FullBackupBase {
@Nullable Predicate<Cursor> predicate,
@Nullable PostProcessor postProcess,
int count,
long estimatedCount,
@NonNull BackupCancellationSignal cancellationSignal)
throws IOException
{
@@ -283,7 +343,7 @@ public class FullBackupExporter extends FullBackupBase {
statement.append(')');
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
if (postProcess != null) {
@@ -296,7 +356,7 @@ public class FullBackupExporter extends FullBackupBase {
return count;
}
private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count) {
private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
try {
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
@@ -321,7 +381,7 @@ public class FullBackupExporter extends FullBackupBase {
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
}
} catch (IOException e) {
@@ -331,7 +391,7 @@ public class FullBackupExporter extends FullBackupBase {
return count;
}
private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count) {
private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
try {
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
@@ -340,7 +400,7 @@ public class FullBackupExporter extends FullBackupBase {
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
if (!TextUtils.isEmpty(data) && size > 0) {
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
outputStream.writeSticker(rowId, inputStream, size);
}
@@ -371,6 +431,7 @@ public class FullBackupExporter extends FullBackupBase {
private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream,
@NonNull List<String> keysToIncludeInBackup,
int count,
long estimatedCount,
BackupCancellationSignal cancellationSignal) throws IOException
{
KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication())
@@ -401,7 +462,7 @@ public class FullBackupExporter extends FullBackupBase {
throw new AssertionError("Unknown type: " + type);
}
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(builder.build());
}
@@ -409,23 +470,44 @@ public class FullBackupExporter extends FullBackupBase {
}
private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) {
return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
}
private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) {
return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
}
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, @NonNull MessageId messageId) {
if (messageId.isMms()) {
return isForNonExpiringMmsMessage(db, messageId.getId());
} else {
return isForNonExpiringSmsMessage(db, messageId.getId());
}
}
private static boolean isForNonExpiringSmsMessage(@NonNull SQLiteDatabase db, long smsId) {
String[] columns = new String[] { SmsDatabase.EXPIRES_IN };
String where = SmsDatabase.ID + " = ?";
String[] args = new String[] { String.valueOf(smsId) };
try (Cursor cursor = db.query(SmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return isNonExpiringSmsMessage(cursor);
}
}
return false;
}
private static boolean isForNonExpiringMmsMessage(@NonNull SQLiteDatabase db, long mmsId) {
String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
String where = MmsDatabase.ID + " = ?";
String[] args = new String[] { String.valueOf(mmsId) };
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
if (mmsCursor != null && mmsCursor.moveToFirst()) {
return mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.EXPIRES_IN)) == 0 &&
mmsCursor.getLong(mmsCursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 0;
return isNonExpiringMmsMessage(mmsCursor);
}
}

View File

@@ -95,7 +95,7 @@ public class FullBackupImporter extends FullBackupBase {
BackupFrame frame;
while (!(frame = inputStream.readFrame()).getEnd()) {
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count));
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0));
count++;
if (frame.hasVersion()) processVersion(db, frame.getVersion());
@@ -115,7 +115,7 @@ public class FullBackupImporter extends FullBackupBase {
keyValueDatabase.endTransaction();
}
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count));
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count, 0));
}
private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{

View File

@@ -8,6 +8,7 @@ import com.google.android.flexbox.FlexDirection
import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.flexbox.JustifyContent
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
@@ -20,6 +21,9 @@ import java.math.BigDecimal
import java.sql.Timestamp
object Badges {
private val TAG: String = Log.tag(Badges::class.java)
fun DSLConfiguration.displayBadges(
context: Context,
badges: List<Badge>,
@@ -69,7 +73,9 @@ object Badges {
"hdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[2]), "hdpi")
"xxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[4]), "xxhdpi")
"xxxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[5]), "xxxhdpi")
else -> Pair(getBadgeImageUri(serviceBadge.sprites6[3]), "xdpi")
else -> Pair(getBadgeImageUri(serviceBadge.sprites6[3]), "xhdpi")
}.also {
Log.d(TAG, "Selected badge density ${it.second()}")
}
}

View File

@@ -14,9 +14,10 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import java.security.MessageDigest
typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
@@ -165,8 +166,8 @@ data class Badge(
private val SELECTION_CHANGED = Any()
fun register(mappingAdapter: MappingAdapter, onBadgeClicked: OnBadgeClicked) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view))
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view))
mappingAdapter.registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
}
}
}

View File

@@ -6,14 +6,15 @@ import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object BadgePreview {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(SubscriptionModel::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
mappingAdapter.registerFactory(SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
}
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {

View File

@@ -4,8 +4,9 @@ import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
object ExpiredBadge {
@@ -29,6 +30,6 @@ object ExpiredBadge {
}
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
}
}

View File

@@ -4,9 +4,10 @@ import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
data class LargeBadge(
val badge: Badge
@@ -51,8 +52,8 @@ data class LargeBadge(
companion object {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
mappingAdapter.registerFactory(EmptyModel::class.java, MappingAdapter.LayoutFactory({ EmptyViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
mappingAdapter.registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
}
}
}

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Fragment to allow user to manage options related to the badges they've unlocked.
@@ -37,7 +38,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
override fun bindAdapter(adapter: DSLSettingsAdapter) {
Badge.register(adapter) { badge, _, isFaded ->
if (badge.isExpired() || isFaded) {
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge))
} else {
ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge)
}
@@ -83,7 +84,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
summary = state.featuredBadge?.name?.let { DSLSettingsText.from(it) },
isEnabled = state.stage == BadgesOverviewState.Stage.READY && state.hasUnexpiredBadges && state.hasInternet,
onClick = {
findNavController().navigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToFeaturedBadgeFragment())
}
)
}

View File

@@ -24,9 +24,9 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.visible
import kotlin.math.ceil
import kotlin.math.max

View File

@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import org.thoughtcrime.securesms.R
@@ -24,7 +26,9 @@ class ButtonStripItemView @JvmOverloads constructor(
val array = context.obtainStyledAttributes(attrs, R.styleable.ButtonStripItemView)
val icon = array.getDrawable(R.styleable.ButtonStripItemView_bsiv_icon)
val iconId = array.getResourceId(R.styleable.ButtonStripItemView_bsiv_icon, -1)
val icon: Drawable? = if (iconId > 0) AppCompatResources.getDrawable(context, iconId) else null
val contentDescription = array.getString(R.styleable.ButtonStripItemView_bsiv_icon_contentDescription)
val label = array.getString(R.styleable.ButtonStripItemView_bsiv_label)

View File

@@ -96,6 +96,7 @@ public class InputPanel extends LinearLayout
private boolean hideForGroupState;
private boolean hideForBlockedState;
private boolean hideForSearch;
private boolean hideForSelection;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
@@ -336,6 +337,11 @@ public class InputPanel extends LinearLayout
updateVisibility();
}
public void setHideForSelection(boolean hideForSelection) {
this.hideForSelection = hideForSelection;
updateVisibility();
}
@Override
public void onRecordPermissionRequired() {
if (listener != null) listener.onRecorderPermissionRequired();
@@ -515,7 +521,7 @@ public class InputPanel extends LinearLayout
}
private void updateVisibility() {
if (hideForGroupState || hideForBlockedState || hideForSearch) {
if (hideForGroupState || hideForBlockedState || hideForSearch || hideForSelection) {
setVisibility(GONE);
} else {
setVisibility(VISIBLE);

View File

@@ -50,7 +50,7 @@ public class SearchView extends androidx.appcompat.widget.SearchView {
result = new InputFilter[1];
}
result[0] = new EmojiFilter(view);
result[0] = new EmojiFilter(view, false);
return result;
}

View File

@@ -8,6 +8,7 @@ import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.exifinterface.media.ExifInterface;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.target.Target;
@@ -66,9 +67,6 @@ public class ZoomingImageView extends FrameLayout {
this.photoView = findViewById(R.id.image_view);
this.subsamplingImageView = findViewById(R.id.subsampling_image_view);
this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF);
this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF);
this.photoView.setZoomTransitionDuration(ZOOM_TRANSITION_DURATION);
this.photoView.setScaleLevels(ZOOM_LEVEL_MIN, SMALL_IMAGES_ZOOM_LEVEL_MID, SMALL_IMAGES_ZOOM_LEVEL_MAX);
@@ -129,6 +127,26 @@ public class ZoomingImageView extends FrameLayout {
subsamplingImageView.setVisibility(View.VISIBLE);
photoView.setVisibility(View.GONE);
// We manually set the orientation ourselves because using
// SubsamplingScaleImageView.ORIENTATION_USE_EXIF is unreliable:
// https://github.com/signalapp/Signal-Android/issues/11732#issuecomment-963203545
try {
final InputStream inputStream = PartAuthority.getAttachmentStream(getContext(), uri);
final int orientation = BitmapUtil.getExifOrientation(new ExifInterface(inputStream));
inputStream.close();
if (orientation == ExifInterface.ORIENTATION_ROTATE_90) {
subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_90);
} else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) {
subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_180);
} else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) {
subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_270);
} else {
subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_0);
}
} catch (IOException e) {
Log.w(TAG, e);
}
subsamplingImageView.setImage(ImageSource.uri(uri));
}

View File

@@ -1,18 +1,27 @@
package org.thoughtcrime.securesms.components.emoji;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Emoji {
private final List<String> variations;
private final List<String> rawVariations;
public Emoji(String... variations) {
this.variations = Arrays.asList(variations);
this(Arrays.asList(variations), Collections.emptyList());
}
public Emoji(List<String> variations) {
this(variations, Collections.emptyList());
}
public Emoji(List<String> variations, List<String> rawVariations) {
this.variations = variations;
this.rawVariations = rawVariations;
}
public String getValue() {
@@ -26,4 +35,11 @@ public class Emoji {
public boolean hasMultipleVariations() {
return variations.size() > 1;
}
public @Nullable String getRawVariation(int variationIndex) {
if (rawVariations != null && variationIndex >= 0 && variationIndex < rawVariations.size()) {
return rawVariations.get(variationIndex);
}
return null;
}
}

View File

@@ -33,10 +33,11 @@ public class EmojiEditText extends AppCompatEditText {
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
boolean forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
boolean jumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
if (!isInEditMode() && (forceCustom || !SignalStore.settings().isPreferSystemEmoji())) {
setFilters(appendEmojiFilter(this.getFilters()));
setFilters(appendEmojiFilter(this.getFilters(), jumboEmoji));
}
}
@@ -54,7 +55,7 @@ public class EmojiEditText extends AppCompatEditText {
else super.invalidateDrawable(drawable);
}
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters) {
private InputFilter[] appendEmojiFilter(@Nullable InputFilter[] originalFilters, boolean jumboEmoji) {
InputFilter[] result;
if (originalFilters != null) {
@@ -64,7 +65,7 @@ public class EmojiEditText extends AppCompatEditText {
result = new InputFilter[1];
}
result[0] = new EmojiFilter(this);
result[0] = new EmojiFilter(this, jumboEmoji);
return result;
}

View File

@@ -8,9 +8,11 @@ import android.widget.TextView;
public class EmojiFilter implements InputFilter {
private TextView view;
private boolean jumboEmoji;
public EmojiFilter(TextView view) {
this.view = view;
public EmojiFilter(TextView view, boolean jumboEmoji) {
this.view = view;
this.jumboEmoji = jumboEmoji;
}
@Override
@@ -19,7 +21,7 @@ public class EmojiFilter implements InputFilter {
char[] v = new char[end - start];
TextUtils.getChars(source, start, end, v, 0);
Spannable emojified = EmojiProvider.emojify(new String(v), view);
Spannable emojified = EmojiProvider.emojify(new String(v), view, jumboEmoji);
if (source instanceof Spanned && emojified != null) {
TextUtils.copySpansFrom((Spanned) source, start, end, null, emojified, 0);

View File

@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.Emoj
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;

View File

@@ -10,9 +10,10 @@ import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.MappingAdapter;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingViewHolder;
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {

View File

@@ -19,15 +19,17 @@ import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiDrawInfo;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.emoji.EmojiFiles;
import org.thoughtcrime.securesms.emoji.EmojiPageCache;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.thoughtcrime.securesms.util.ListenableFutureTask;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
public class EmojiProvider {
@@ -39,23 +41,24 @@ public class EmojiProvider {
return new EmojiParser(EmojiSource.getLatest().getEmojiTree()).findCandidates(text);
}
static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv) {
static @Nullable Spannable emojify(@Nullable CharSequence text, @NonNull TextView tv, boolean jumboEmoji) {
if (tv.isInEditMode()) {
return null;
} else {
return emojify(getCandidates(text), text, tv);
return emojify(getCandidates(text), text, tv, jumboEmoji);
}
}
static @Nullable Spannable emojify(@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text,
@NonNull TextView tv)
@NonNull TextView tv,
boolean jumboEmoji)
{
if (matches == null || text == null || tv.isInEditMode()) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
for (EmojiParser.Candidate candidate : matches) {
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout);
Drawable drawable = getEmojiDrawable(tv.getContext(), candidate.getDrawInfo(), tv::requestLayout, jumboEmoji);
if (drawable != null) {
builder.setSpan(new EmojiSpan(drawable, tv), candidate.getStartIndex(), candidate.getEndIndex(),
@@ -70,7 +73,8 @@ public class EmojiProvider {
@Nullable EmojiParser.CandidateList matches,
@Nullable CharSequence text,
@NonNull Paint paint,
boolean synchronous)
boolean synchronous,
boolean jumboEmoji)
{
if (matches == null || text == null) return null;
SpannableStringBuilder builder = new SpannableStringBuilder(text);
@@ -78,9 +82,9 @@ public class EmojiProvider {
for (EmojiParser.Candidate candidate : matches) {
Drawable drawable;
if (synchronous) {
drawable = getEmojiDrawableSync(context, candidate.getDrawInfo());
drawable = getEmojiDrawableSync(context, candidate.getDrawInfo(), jumboEmoji);
} else {
drawable = getEmojiDrawable(context, candidate.getDrawInfo(), null);
drawable = getEmojiDrawable(context, candidate.getDrawInfo(), null, jumboEmoji);
}
if (drawable != null) {
@@ -93,8 +97,12 @@ public class EmojiProvider {
}
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji) {
return getEmojiDrawable(context, emoji, false);
}
static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable CharSequence emoji, boolean jumboEmoji) {
EmojiDrawInfo drawInfo = EmojiSource.getLatest().getEmojiTree().getEmoji(emoji, 0, emoji.length());
return getEmojiDrawable(context, drawInfo, null);
return getEmojiDrawable(context, drawInfo, null, jumboEmoji);
}
/**
@@ -104,7 +112,7 @@ public class EmojiProvider {
* @param drawInfo Information about the emoji being displayed
* @param onEmojiLoaded Runnable which will trigger when an emoji is loaded from disk
*/
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded) {
private static @Nullable Drawable getEmojiDrawable(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, @Nullable Runnable onEmojiLoaded, boolean jumboEmoji) {
if (drawInfo == null) {
return null;
}
@@ -112,6 +120,7 @@ public class EmojiProvider {
final int lowMemoryDecodeScale = DeviceProperties.isLowMemoryDevice(context) ? 2 : 1;
final EmojiSource source = EmojiSource.getLatest();
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
final AtomicBoolean jumboLoaded = new AtomicBoolean(false);
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
@@ -122,9 +131,11 @@ public class EmojiProvider {
@Override
public void onSuccess(Bitmap result) {
ThreadUtil.runOnMain(() -> {
drawable.setBitmap(result);
if (onEmojiLoaded != null) {
onEmojiLoaded.run();
if (!jumboLoaded.get()) {
drawable.setBitmap(result);
if (onEmojiLoaded != null) {
onEmojiLoaded.run();
}
}
});
}
@@ -138,6 +149,40 @@ public class EmojiProvider {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
if (jumboEmoji && drawInfo.getJumboSheet() != null) {
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
if (result instanceof JumboEmoji.LoadResult.Immediate) {
ThreadUtil.runOnMain(() -> {
jumboLoaded.set(true);
drawable.setSingleBitmap(((JumboEmoji.LoadResult.Immediate) result).getBitmap());
});
} else if (result instanceof JumboEmoji.LoadResult.Async) {
((JumboEmoji.LoadResult.Async) result).getTask().addListener(new FutureTaskListener<Bitmap>() {
@Override
public void onSuccess(Bitmap result) {
ThreadUtil.runOnMain(() -> {
jumboLoaded.set(true);
drawable.setSingleBitmap(result);
if (onEmojiLoaded != null) {
onEmojiLoaded.run();
}
});
}
@Override
public void onFailure(ExecutionException exception) {
if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
Log.i(TAG, "Download restrictions are preventing jumbomoji use");
} else {
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
}
}
});
}
return drawable;
}
return drawable;
}
@@ -147,7 +192,7 @@ public class EmojiProvider {
* @param context Context object used in reading and writing from disk
* @param drawInfo Information about the emoji being displayed
*/
private static @Nullable Drawable getEmojiDrawableSync(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo) {
private static @Nullable Drawable getEmojiDrawableSync(@NonNull Context context, @Nullable EmojiDrawInfo drawInfo, boolean jumboEmoji) {
ThreadUtil.assertNotMainThread();
if (drawInfo == null) {
return null;
@@ -157,24 +202,49 @@ public class EmojiProvider {
final EmojiSource source = EmojiSource.getLatest();
final EmojiDrawable drawable = new EmojiDrawable(source, drawInfo, lowMemoryDecodeScale);
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
Bitmap bitmap = null;
Bitmap bitmap = null;
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
Log.d(TAG, "Cached emoji page: " + drawInfo.getPage().getUri().toString());
bitmap = ((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap();
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
Log.d(TAG, "Loading emoji page: " + drawInfo.getPage().getUri().toString());
try {
bitmap = ((EmojiPageCache.LoadResult.Async) loadResult).getTask().get(2, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
if (jumboEmoji && drawInfo.getJumboSheet() != null) {
JumboEmoji.LoadResult result = JumboEmoji.loadJumboEmoji(context, drawInfo);
if (result instanceof JumboEmoji.LoadResult.Immediate) {
bitmap = ((JumboEmoji.LoadResult.Immediate) result).getBitmap();
} else if (result instanceof JumboEmoji.LoadResult.Async) {
try {
bitmap = ((JumboEmoji.LoadResult.Async) result).getTask().get(10, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
if (exception.getCause() instanceof JumboEmoji.CannotAutoDownload) {
Log.i(TAG, "Download restrictions are preventing jumbomoji use");
} else {
Log.d(TAG, "Failed to load jumbo emoji bitmap resource", exception);
}
}
}
if (bitmap != null) {
drawable.setSingleBitmap(bitmap);
}
} else {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
drawable.setBitmap(bitmap);
if (!jumboEmoji || bitmap == null) {
EmojiPageCache.LoadResult loadResult = EmojiPageCache.INSTANCE.load(context, drawInfo.getPage(), lowMemoryDecodeScale);
if (loadResult instanceof EmojiPageCache.LoadResult.Immediate) {
Log.d(TAG, "Cached emoji page: " + drawInfo.getPage().getUri().toString());
bitmap = ((EmojiPageCache.LoadResult.Immediate) loadResult).getBitmap();
} else if (loadResult instanceof EmojiPageCache.LoadResult.Async) {
Log.d(TAG, "Loading emoji page: " + drawInfo.getPage().getUri().toString());
try {
bitmap = ((EmojiPageCache.LoadResult.Async) loadResult).getTask().get(2, TimeUnit.SECONDS);
} catch (InterruptedException | ExecutionException | TimeoutException exception) {
Log.d(TAG, "Failed to load emoji bitmap resource", exception);
}
} else {
throw new IllegalStateException("Unexpected subclass " + loadResult.getClass());
}
drawable.setBitmap(bitmap);
}
return drawable;
}
@@ -183,7 +253,8 @@ public class EmojiProvider {
private final float intrinsicHeight;
private final Rect emojiBounds;
private Bitmap bmp;
private Bitmap bmp;
private boolean isSingleBitmap;
@Override
public int getIntrinsicWidth() {
@@ -219,12 +290,21 @@ public class EmojiProvider {
}
canvas.drawBitmap(bmp,
emojiBounds,
isSingleBitmap ? null : emojiBounds,
getBounds(),
PAINT);
}
public void setBitmap(Bitmap bitmap) {
setBitmap(bitmap, false);
}
public void setSingleBitmap(Bitmap bitmap) {
setBitmap(bitmap, true);
}
private void setBitmap(Bitmap bitmap, boolean isSingleBitmap) {
this.isSingleBitmap = isSingleBitmap;
if (bmp == null || !bmp.sameAs(bitmap)) {
bmp = bitmap;
invalidateSelf();

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Annotation;
import android.text.Layout;
import android.text.SpannableStringBuilder;
@@ -12,6 +13,7 @@ import android.text.TextDirectionHeuristic;
import android.text.TextDirectionHeuristics;
import android.text.TextUtils;
import android.text.method.TransformationMethod;
import android.text.style.MetricAffectingSpan;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.ViewGroup;
@@ -21,24 +23,29 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
import androidx.core.view.ViewKt;
import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
import kotlin.Unit;
public class EmojiTextView extends AppCompatTextView {
private final boolean scaleEmojis;
private static final char ELLIPSIS = '…';
private static final char ELLIPSIS = '…';
private static final float JUMBOMOJI_SCALE = 0.8f;
private boolean forceCustom;
private CharSequence previousText;
@@ -55,6 +62,7 @@ public class EmojiTextView extends AppCompatTextView {
private int lastLineWidth = -1;
private TextDirectionHeuristic textDirection;
private boolean isJumbomoji;
private boolean forceJumboEmoji;
private MentionRendererDelegate mentionRendererDelegate;
@@ -75,6 +83,7 @@ public class EmojiTextView extends AppCompatTextView {
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
measureLastLine = a.getBoolean(R.styleable.EmojiTextView_measureLastLine, false);
forceJumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
@@ -106,14 +115,13 @@ public class EmojiTextView extends AppCompatTextView {
public void setText(@Nullable CharSequence text, BufferType type) {
EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text);
if (scaleEmojis && candidates != null && candidates.allEmojis) {
if (scaleEmojis && candidates != null && candidates.allEmojis && (candidates.hasJumboForAll() || JumboEmoji.canDownloadJumbo(getContext()))) {
int emojis = candidates.size();
float scale = 1.0f;
if (emojis <= 8) scale += 0.25f;
if (emojis <= 6) scale += 0.25f;
if (emojis <= 4) scale += 0.25f;
if (emojis <= 2) scale += 0.25f;
if (emojis <= 5) scale += JUMBOMOJI_SCALE;
if (emojis <= 4) scale += JUMBOMOJI_SCALE;
if (emojis <= 2) scale += JUMBOMOJI_SCALE;
isJumbomoji = scale > 1.0f;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
@@ -135,7 +143,7 @@ public class EmojiTextView extends AppCompatTextView {
if (useSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(new SpannableStringBuilder(Optional.fromNullable(text).or("")), BufferType.SPANNABLE);
} else {
CharSequence emojified = EmojiProvider.emojify(candidates, text, this);
CharSequence emojified = EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji);
super.setText(new SpannableStringBuilder(emojified), BufferType.SPANNABLE);
}
@@ -155,6 +163,8 @@ public class EmojiTextView extends AppCompatTextView {
}
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
widthMeasureSpec = applyWidthMeasureRoundingFix(widthMeasureSpec);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
CharSequence text = getText();
if (getLayout() == null || !measureLastLine || text == null || text.length() == 0) {
@@ -175,6 +185,42 @@ public class EmojiTextView extends AppCompatTextView {
}
}
/**
* Starting from API 30, there can be a rounding error in text layout when a non-default font
* scale is used. This causes a line break to be inserted where there shouldn't be one. Force the
* width to be larger to work around this problem.
* https://issuetracker.google.com/issues/173574230
*
* @param widthMeasureSpec the original measure spec passed to {@link #onMeasure(int, int)}
* @return the measure spec with the workaround, or the original one.
*/
private int applyWidthMeasureRoundingFix(int widthMeasureSpec) {
if (Build.VERSION.SDK_INT >= 30 && Math.abs(getResources().getConfiguration().fontScale - 1f) > 0.01f) {
CharSequence text = getText();
if (text != null) {
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
float measuredTextWidth = hasMetricAffectingSpan(text) ? Layout.getDesiredWidth(text, getPaint()) : getPaint().measureText(text, 0, text.length());
int desiredWidth = (int) measuredTextWidth + getPaddingLeft() + getPaddingRight();
if (widthSpecMode == MeasureSpec.AT_MOST && desiredWidth < widthSpecSize) {
return MeasureSpec.makeMeasureSpec(desiredWidth + 1, MeasureSpec.EXACTLY);
}
}
}
return widthMeasureSpec;
}
private boolean hasMetricAffectingSpan(@NonNull CharSequence text) {
if (!(text instanceof Spanned)) {
return false;
}
return ((Spanned) text).nextSpanTransition(-1, text.length(), MetricAffectingSpan.class) != text.length();
}
public int getLastLineWidth() {
return lastLineWidth;
}
@@ -221,19 +267,14 @@ public class EmojiTextView extends AppCompatTextView {
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
super.setText(newContent, BufferType.SPANNABLE);
} else {
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
super.setText(emojified, BufferType.SPANNABLE);
}
}
}
private void ellipsizeEmojiTextForMaxLines() {
post(() -> {
if (getLayout() == null) {
ellipsizeEmojiTextForMaxLines();
return;
}
Runnable ellipsize = () -> {
int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this);
if (maxLines <= 0 && maxLength < 0) {
return;
@@ -241,10 +282,10 @@ public class EmojiTextView extends AppCompatTextView {
int lineCount = getLineCount();
if (lineCount > maxLines) {
int overflowStart = getLayout().getLineStart(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, getText().length());
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
int overflowStart = getLayout().getLineStart(maxLines - 1);
CharSequence overflow = getText().subSequence(overflowStart, getText().length());
float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f;
CharSequence ellipsized = TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END);
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, overflowStart))
@@ -252,11 +293,20 @@ public class EmojiTextView extends AppCompatTextView {
.append(Optional.fromNullable(overflowText).or(""));
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this);
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
super.setText(emojified, BufferType.SPANNABLE);
}
});
};
if (getLayout() != null) {
ellipsize.run();
} else {
ViewKt.doOnNextLayout(this, view -> {
ellipsize.run();
return Unit.INSTANCE;
});
}
}
private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) {

View File

@@ -27,20 +27,20 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
val endDrawableSize: Int = compoundDrawables[1]?.let { it.intrinsicWidth + compoundDrawablePadding } ?: 0
val adjustedWidth: Int = width - startDrawableSize - endDrawableSize
val newContent = if (width == 0 || maxLines == -1) {
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
val newText = if (newCandidates == null || newCandidates.size() == 0) {
text
} else {
TextUtils.ellipsize(text, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
EmojiProvider.emojify(newCandidates, text, this, false)
}
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
val newText = if (newCandidates == null || newCandidates.size() == 0) {
newContent
val newContent = if (width == 0 || maxLines == -1) {
newText
} else {
EmojiProvider.emojify(newCandidates, newContent, this)
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
}
bufferType = BufferType.SPANNABLE
super.setText(newText, type)
super.setText(newContent, type)
}
}

View File

@@ -1,33 +0,0 @@
package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.emoji.EmojiPage;
public class EmojiDrawInfo {
private final EmojiPage page;
private final int index;
public EmojiDrawInfo(final @NonNull EmojiPage page, final int index) {
this.page = page;
this.index = index;
}
public @NonNull EmojiPage getPage() {
return page;
}
public int getIndex() {
return index;
}
@Override
public @NonNull String toString() {
return "DrawInfo{" +
"page=" + page +
", index=" + index +
'}';
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.emoji.parsing
import org.thoughtcrime.securesms.emoji.EmojiPage
data class EmojiDrawInfo(val page: EmojiPage, val index: Int, private val emoji: String, val rawEmoji: String?, val jumboSheet: String?)

View File

@@ -24,6 +24,8 @@ package org.thoughtcrime.securesms.components.emoji.parsing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
@@ -127,6 +129,15 @@ public class EmojiParser {
return list.size();
}
public boolean hasJumboForAll() {
for (Candidate candidate : list) {
if (!JumboEmoji.hasJumboEmoji(candidate.drawInfo)) {
return false;
}
}
return true;
}
@Override
public @NonNull Iterator<Candidate> iterator() {
return list.iterator();

View File

@@ -54,12 +54,21 @@ class SignalBottomActionBar(context: Context, attributeSet: AttributeSet) : Line
present(this.items)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w != oldw) {
present(items)
}
}
private fun present(items: List<ActionItem>) {
if (width == 0) {
post { present(items) }
return
}
val wasLayoutRequested = isLayoutRequested
val widthDp: Float = ViewUtil.pxToDp(width.toFloat())
val minButtonWidthDp = 80
val maxButtons: Int = (widthDp / minButtonWidthDp).toInt()
@@ -96,6 +105,12 @@ class SignalBottomActionBar(context: Context, attributeSet: AttributeSet) : Line
)
)
}
if (wasLayoutRequested) {
post {
requestLayout()
}
}
}
private fun bindItem(view: View, item: ActionItem) {

View File

@@ -13,10 +13,11 @@ import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.Factory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
/**
* A custom context menu that will show next to an anchor view and display several options. Basically a PopupMenu with custom UI and positioning rules.
@@ -174,7 +175,7 @@ class SignalContextMenu private constructor(
}
}
private inner class ItemViewHolderFactory : MappingAdapter.Factory<DisplayItem> {
private inner class ItemViewHolderFactory : Factory<DisplayItem> {
override fun createViewHolder(parent: ViewGroup): MappingViewHolder<DisplayItem> {
return ItemViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.signal_context_menu_item, parent, false))
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import org.thoughtcrime.securesms.R
class BubbleOptOutReminder(context: Context) : Reminder(null, context.getString(R.string.BubbleOptOutTooltip__description)) {
init {
addAction(Action(context.getString(R.string.BubbleOptOutTooltip__turn_off), R.id.reminder_action_turn_off))
addAction(Action(context.getString(R.string.BubbleOptOutTooltip__not_now), R.id.reminder_action_not_now))
}
override fun isDismissable(): Boolean {
return false
}
}

View File

@@ -1,11 +1,14 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
@@ -15,10 +18,12 @@ import java.util.List;
final class ReminderActionsAdapter extends RecyclerView.Adapter<ReminderActionsAdapter.ActionViewHolder> {
private final Reminder.Importance importance;
private final List<Reminder.Action> actions;
private final ReminderView.OnActionClickListener actionClickListener;
ReminderActionsAdapter(List<Reminder.Action> actions, ReminderView.OnActionClickListener actionClickListener) {
ReminderActionsAdapter(Reminder.Importance importance, List<Reminder.Action> actions, ReminderView.OnActionClickListener actionClickListener) {
this.importance = importance;
this.actions = Collections.unmodifiableList(actions);
this.actionClickListener = actionClickListener;
}
@@ -26,7 +31,14 @@ final class ReminderActionsAdapter extends RecyclerView.Adapter<ReminderActionsA
@NonNull
@Override
public ActionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ActionViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reminder_action_button, parent, false));
Context context = parent.getContext();
TextView button = ((TextView) LayoutInflater.from(context).inflate(R.layout.reminder_action_button, parent, false));
if (importance == Reminder.Importance.NORMAL) {
button.setTextColor(ContextCompat.getColor(context, R.color.signal_accent_primary));
}
return new ActionViewHolder(button);
}
@Override

View File

@@ -17,6 +17,7 @@ import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.R;
import java.util.List;
@@ -28,6 +29,7 @@ public final class ReminderView extends FrameLayout {
private ProgressBar progressBar;
private TextView progressText;
private ViewGroup container;
private View background;
private ImageButton closeButton;
private TextView title;
private TextView text;
@@ -56,6 +58,7 @@ public final class ReminderView extends FrameLayout {
progressBar = findViewById(R.id.reminder_progress);
progressText = findViewById(R.id.reminder_progress_text);
container = findViewById(R.id.container);
background = findViewById(R.id.background);
closeButton = findViewById(R.id.cancel);
title = findViewById(R.id.reminder_title);
text = findViewById(R.id.reminder_text);
@@ -79,24 +82,30 @@ public final class ReminderView extends FrameLayout {
}
text.setText(reminder.getText());
text.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_button_primary_text));
switch (reminder.getImportance()) {
case NORMAL:
container.setBackgroundResource(R.drawable.reminder_background_normal);
background.setBackgroundResource(R.drawable.reminder_background_normal);
title.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
text.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
break;
case ERROR:
container.setBackgroundResource(R.drawable.reminder_background_error);
background.setBackgroundResource(R.drawable.reminder_background_error);
title.setTextColor(ContextCompat.getColor(getContext(), R.color.core_black));
text.setTextColor(ContextCompat.getColor(getContext(), R.color.core_black));
break;
case TERMINAL:
container.setBackgroundResource(R.drawable.reminder_background_terminal);
background.setBackgroundResource(R.drawable.reminder_background_terminal);
title.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_button_primary_text));
text.setTextColor(ContextCompat.getColor(getContext(), R.color.signal_button_primary_text));
break;
default:
throw new IllegalStateException();
}
setOnClickListener(reminder.getOkListener());
if (reminder.getOkListener() != null) {
setOnClickListener(reminder.getOkListener());
}
closeButton.setVisibility(reminder.isDismissable() ? View.VISIBLE : View.GONE);
closeButton.setOnClickListener(new OnClickListener() {
@@ -108,6 +117,10 @@ public final class ReminderView extends FrameLayout {
}
});
if (reminder.getImportance() == Reminder.Importance.NORMAL) {
closeButton.setColorFilter(ContextCompat.getColor(getContext(), R.color.signal_text_primary));
}
int progress = reminder.getProgress();
if (progress != -1) {
progressBar.setProgress(progress);
@@ -121,10 +134,12 @@ public final class ReminderView extends FrameLayout {
List<Reminder.Action> actions = reminder.getActions();
if (actions.isEmpty()) {
text.setPadding(0, 0, 0, ((int) DimensionUnit.DP.toPixels(16f)));
actionsRecycler.setVisibility(GONE);
} else {
text.setPadding(0, 0, 0, 0);
actionsRecycler.setVisibility(VISIBLE);
actionsRecycler.setAdapter(new ReminderActionsAdapter(actions, this::handleActionClicked));
actionsRecycler.setAdapter(new ReminderActionsAdapter(reminder.getImportance(), actions, this::handleActionClicked));
}
container.setVisibility(View.VISIBLE);

View File

@@ -3,7 +3,8 @@ package org.thoughtcrime.securesms.components.settings;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.MappingAdapter;
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
/**
* Reusable adapter for generic settings list.

View File

@@ -13,7 +13,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.MappingModelList;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import java.io.Serializable;
import java.util.Objects;

View File

@@ -7,8 +7,8 @@ import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.Group;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingViewHolder;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
import java.util.Objects;

View File

@@ -18,9 +18,10 @@ import org.thoughtcrime.securesms.components.settings.models.Button
import org.thoughtcrime.securesms.components.settings.models.Space
import org.thoughtcrime.securesms.components.settings.models.Text
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
class DSLSettingsAdapter : MappingAdapter() {

View File

@@ -5,6 +5,7 @@ import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.EdgeEffect
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
@@ -24,9 +25,10 @@ abstract class DSLSettingsFragment(
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
) : Fragment(layoutId) {
private lateinit var recyclerView: RecyclerView
private lateinit var scrollAnimationHelper: OnScrollAnimationHelper
private var recyclerView: RecyclerView? = null
private var scrollAnimationHelper: OnScrollAnimationHelper? = null
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
@@ -44,16 +46,23 @@ abstract class DSLSettingsFragment(
toolbar.setOnMenuItemClickListener { onOptionsItemSelected(it) }
}
recyclerView = view.findViewById(R.id.recycler)
recyclerView.edgeEffectFactory = EdgeEffectFactory()
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
val adapter = DSLSettingsAdapter()
val settingsAdapter = DSLSettingsAdapter()
recyclerView.layoutManager = layoutManagerProducer(requireContext())
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(scrollAnimationHelper)
recyclerView = view.findViewById<RecyclerView>(R.id.recycler).apply {
edgeEffectFactory = EdgeEffectFactory()
layoutManager = layoutManagerProducer(requireContext())
adapter = settingsAdapter
addOnScrollListener(scrollAnimationHelper!!)
}
bindAdapter(adapter)
bindAdapter(settingsAdapter)
}
override fun onDestroyView() {
super.onDestroyView()
recyclerView = null
scrollAnimationHelper = null
}
protected open fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {

View File

@@ -53,6 +53,9 @@ sealed class DSLSettingsText {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.color(textColor, charSequence)
}
override fun equals(other: Any?): Boolean = textColor == (other as? ColorModifier)?.textColor
override fun hashCode(): Int = textColor
}
object CenterModifier : Modifier {
@@ -68,6 +71,9 @@ sealed class DSLSettingsText {
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
return SpanUtil.textAppearance(context, textAppearance, charSequence)
}
override fun equals(other: Any?): Boolean = textAppearance == (other as? TextAppearanceModifier)?.textAppearance
override fun hashCode(): Int = textAppearance
}
object BoldModifier : Modifier {

View File

@@ -7,8 +7,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingViewHolder;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
import java.util.Objects;

View File

@@ -4,8 +4,8 @@ import android.view.View;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingViewHolder;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
/**
* Simple progress indicator that can be used multiple times (if provided with different {@link Item#id}s).

View File

@@ -10,8 +10,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingViewHolder;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
import java.util.Objects;

View File

@@ -9,6 +9,7 @@ import io.reactivex.rxjava3.subjects.Subject
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
import org.thoughtcrime.securesms.help.HelpFragment
@@ -17,8 +18,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val START_LOCATION = "app.settings.start.location"
private const val START_ARGUMENTS = "app.settings.start.arguments"
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
@@ -50,6 +53,11 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions()
StartLocation.BOOST -> AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment()
StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations()
StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
StartLocation.NOTIFICATION_PROFILE_DETAILS -> AppSettingsFragmentDirections.actionDirectToNotificationProfileDetails(
EditNotificationProfileScheduleFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).profileId
)
}
}
@@ -58,7 +66,7 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
}
startingAction?.let {
navController.navigate(it)
navController.safeNavigate(it)
}
SignalStore.settings().onConfigurationSettingChanged.observe(this) { key ->
@@ -131,6 +139,22 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
@JvmStatic
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_SUBSCRIPTIONS)
@JvmStatic
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILES)
@JvmStatic
fun createNotificationProfile(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CREATE_NOTIFICATION_PROFILE)
@JvmStatic
fun notificationProfileDetails(context: Context, profileId: Long): Intent {
val arguments = EditNotificationProfileScheduleFragmentArgs.Builder(profileId, false)
.build()
.toBundle()
return getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILE_DETAILS)
.putExtra(START_ARGUMENTS, arguments)
}
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
@@ -147,7 +171,10 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
CHANGE_NUMBER(5),
SUBSCRIPTIONS(6),
BOOST(7),
MANAGE_SUBSCRIPTIONS(8);
MANAGE_SUBSCRIPTIONS(8),
NOTIFICATION_PROFILES(9),
CREATE_NOTIFICATION_PROFILE(10),
NOTIFICATION_PROFILE_DETAILS(11);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.settings.app
import android.view.View
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -22,9 +21,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__menu_settings) {
@@ -35,9 +35,9 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(BioPreference::class.java, MappingAdapter.LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
adapter.registerFactory(PaymentsPreference::class.java, MappingAdapter.LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
adapter.registerFactory(SubscriptionPreference::class.java, MappingAdapter.LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item))
adapter.registerFactory(BioPreference::class.java, LayoutFactory(::BioPreferenceViewHolder, R.layout.bio_preference_item))
adapter.registerFactory(PaymentsPreference::class.java, LayoutFactory(::PaymentsPreferenceViewHolder, R.layout.dsl_payments_preference))
adapter.registerFactory(SubscriptionPreference::class.java, LayoutFactory(::SubscriptionPreferenceViewHolder, R.layout.dsl_preference_item))
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
@@ -54,7 +54,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
customPref(
BioPreference(state.self) {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
}
)
@@ -62,7 +62,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.AccountSettingsFragment__account),
icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
}
)
@@ -70,7 +70,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences__linked_devices),
icon = DSLSettingsIcon.from(R.drawable.ic_linked_devices_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_deviceActivity)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_deviceActivity)
}
)
@@ -79,7 +79,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
PaymentsPreference(
unreadCount = state.unreadPaymentsCount
) {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_paymentsActivity)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_paymentsActivity)
}
)
}
@@ -90,7 +90,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences__appearance),
icon = DSLSettingsIcon.from(R.drawable.ic_appearance_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
}
)
@@ -98,7 +98,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences_chats__chats),
icon = DSLSettingsIcon.from(R.drawable.ic_message_tinted_bitmap_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
}
)
@@ -106,7 +106,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences__notifications),
icon = DSLSettingsIcon.from(R.drawable.ic_bell_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
}
)
@@ -114,7 +114,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences__privacy),
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
}
)
@@ -122,7 +122,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences__data_and_storage),
icon = DSLSettingsIcon.from(R.drawable.ic_archive_24dp),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
}
)
@@ -132,7 +132,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences__help),
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
}
)
@@ -140,7 +140,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.AppSettingsFragment__invite_your_friends),
icon = DSLSettingsIcon.from(R.drawable.ic_invite_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_inviteActivity)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_inviteActivity)
}
)
@@ -158,9 +158,9 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
isActive = state.hasActiveSubscription,
onClick = { isActive ->
if (isActive) {
findNavController().navigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToManageDonationsFragment())
} else {
findNavController().navigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscribeFragment())
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToSubscribeFragment())
}
}
)
@@ -169,7 +169,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
title = DSLSettingsText.from(R.string.preferences__signal_boost),
icon = DSLSettingsIcon.from(R.drawable.ic_boost_24),
onClick = {
findNavController().navigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment())
findNavController().safeNavigate(AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment())
}
)
} else {
@@ -186,7 +186,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_preferences),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_internalSettingsFragment)
}
)
}

View File

@@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFragment__account) {
@@ -98,7 +99,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.preferences__advanced_pin_settings),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_advancedPinSettingsActivity)
}
)
@@ -110,7 +111,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
}
)
}
@@ -119,14 +120,14 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_accountSettingsFragment_to_deleteAccountFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_deleteAccountFragment)
}
)
}

View File

@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__appearance) {
@@ -44,7 +45,7 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appearanceSettings_to_wallpaperActivity)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_wallpaperActivity)
}
)

View File

@@ -7,6 +7,7 @@ import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_number_confirm) {
private lateinit var viewModel: ChangeNumberViewModel
@@ -28,6 +29,6 @@ class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_num
editNumber.setOnClickListener { findNavController().navigateUp() }
val changeNumber: View = view.findViewById(R.id.change_number_confirm_change_number)
changeNumber.setOnClickListener { findNavController().navigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment) }
changeNumber.setOnClickListener { findNavController().safeNavigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment) }
}
}

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs
import org.thoughtcrime.securesms.registration.util.RegistrationNumberInputController
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country"
private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
@@ -73,7 +74,7 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
override fun onPickCountry(view: View) {
val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(OLD_NUMBER_COUNTRY_SELECT).build()
findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
}
override fun setNationalNumber(number: String) {
@@ -110,7 +111,7 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
override fun onPickCountry(view: View) {
val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(NEW_NUMBER_COUNTRY_SELECT).build()
findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
}
override fun setNationalNumber(number: String) {
@@ -157,7 +158,7 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
}
when (viewModel.canContinue()) {
ContinueStatus.CAN_CONTINUE -> findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment)
ContinueStatus.CAN_CONTINUE -> findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment)
ContinueStatus.INVALID_NUMBER -> {
Dialogs.showAlertDialog(
context, getString(R.string.RegistrationActivity_invalid_number), String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.number.e164Number)

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.fragments.BaseEnterSmsCodeFragment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChangeNumberEnterSmsCodeFragment : BaseEnterSmsCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
@@ -50,14 +51,14 @@ class ChangeNumberEnterSmsCodeFragment : BaseEnterSmsCodeFragment<ChangeNumberVi
}
override fun navigateToCaptcha() {
findNavController().navigate(R.id.action_changeNumberEnterCodeFragment_to_captchaFragment, getCaptchaArguments())
findNavController().safeNavigate(R.id.action_changeNumberEnterCodeFragment_to_captchaFragment, getCaptchaArguments())
}
override fun navigateToRegistrationLock(timeRemaining: Long) {
findNavController().navigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
findNavController().safeNavigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
}
override fun navigateToKbsAccountLocked() {
findNavController().navigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
findNavController().safeNavigate(ChangeNumberEnterSmsCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
}
}

View File

@@ -6,6 +6,7 @@ import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChangeNumberFragment : LoggingFragment(R.layout.fragment_change_phone_number) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -13,7 +14,7 @@ class ChangeNumberFragment : LoggingFragment(R.layout.fragment_change_phone_numb
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
view.findViewById<View>(R.id.change_phone_number_continue).setOnClickListener {
findNavController().navigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment)
findNavController().safeNavigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment)
}
}
}

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewMod
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil.cancelSpinning
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
@@ -38,7 +39,7 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
}
override fun navigateToAccountLocked() {
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
findNavController().safeNavigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
}
override fun handleSuccessfulPinEntry(pin: String) {
@@ -47,7 +48,7 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
cancelSpinning(pinButton)
if (pinsDiffer) {
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberPinDiffers())
findNavController().safeNavigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberPinDiffers())
} else {
changeNumberSuccess()
}

View File

@@ -18,6 +18,7 @@ import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import java.io.IOException
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
@@ -47,6 +48,8 @@ class ChangeNumberRepository(private val context: Context) {
ServiceResponse.forExecutionError(e)
} catch (e: KeyBackupSystemNoDataException) {
ServiceResponse.forExecutionError(e)
} catch (e: IOException) {
ServiceResponse.forExecutionError(e)
}
}.subscribeOn(Schedulers.io())
}

View File

@@ -9,6 +9,7 @@ import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.fragments.CaptchaFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Helpers for various aspects of the change number flow.
@@ -35,7 +36,7 @@ object ChangeNumberUtil {
}
fun Fragment.changeNumberSuccess() {
findNavController().navigate(R.id.action_pop_app_settings_change_number)
findNavController().safeNavigate(R.id.action_pop_app_settings_change_number)
Toast.makeText(requireContext(), R.string.ChangeNumber__your_phone_number_has_been_changed, Toast.LENGTH_SHORT).show()
}
}

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
@@ -52,13 +53,13 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processor ->
if (processor.hasResult()) {
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.localRateLimit()) {
Log.i(TAG, "Unable to request sms code due to local rate limit")
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.captchaRequired()) {
Log.i(TAG, "Unable to request sms code due to captcha required")
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())
requestingCaptcha = true
} else if (processor.rateLimit()) {
Log.i(TAG, "Unable to request sms code due to rate limit")

View File

@@ -8,11 +8,17 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__chats) {
private lateinit var viewModel: ChatsSettingsViewModel
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
val repository = ChatsSettingsRepository()
val factory = ChatsSettingsViewModel.Factory(repository)
@@ -29,7 +35,7 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
clickPref(
title = DSLSettingsText.from(R.string.preferences__sms_mms),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_chatsSettingsFragment_to_smsSettingsFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_smsSettingsFragment)
}
)
@@ -79,7 +85,7 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
title = DSLSettingsText.from(R.string.preferences_chats__chat_backups),
summary = DSLSettingsText.from(if (state.chatBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment)
}
)
}

View File

@@ -6,7 +6,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceConfigurationUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -29,9 +28,6 @@ class ChatsSettingsRepository {
isLinkPreviewsEnabled
)
)
if (isLinkPreviewsEnabled) {
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.LINK_PREVIEWS)
}
}
}
}

View File

@@ -52,6 +52,13 @@ class ChatsSettingsViewModel(private val repository: ChatsSettingsRepository) :
SignalStore.settings().isEnterKeySends = enabled
}
fun refresh() {
val backupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
if (store.state.chatBackupsEnabled != backupsEnabled) {
store.update { it.copy(chatBackupsEnabled = backupsEnabled) }
}
}
class Factory(private val repository: ChatsSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(ChatsSettingsViewModel(repository)))

View File

@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.SmsUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val SMS_REQUEST_CODE: Short = 1234
@@ -76,7 +77,7 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__advanced_mms_access_point_names),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_smsSettingsFragment_to_mmsPreferencesFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_smsSettingsFragment_to_mmsPreferencesFragment)
}
)
}

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.webrtc.CallBandwidthMode
import kotlin.math.abs
@@ -47,7 +48,7 @@ class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences_
title = DSLSettingsText.from(R.string.preferences_data_and_storage__manage_storage),
summary = DSLSettingsText.from(Util.getPrettyFileSize(state.totalStorageUse)),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_dataAndStorageSettingsFragment_to_storagePreferenceFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_storagePreferenceFragment)
}
)
@@ -125,7 +126,7 @@ class DataAndStorageSettingsFragment : DSLSettingsFragment(R.string.preferences_
title = DSLSettingsText.from(R.string.preferences_use_proxy),
summary = DSLSettingsText.from(if (state.isProxyEnabled) R.string.preferences_on else R.string.preferences_off),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_dataAndStorageSettingsFragment_to_editProxyFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_dataAndStorageSettingsFragment_to_editProxyFragment)
}
)
}

View File

@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
@@ -25,7 +26,7 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
clickPref(
title = DSLSettingsText.from(R.string.HelpSettingsFragment__contact_us),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_helpSettingsFragment_to_helpFragment)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_helpSettingsFragment_to_helpFragment)
}
)
@@ -39,7 +40,7 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
clickPref(
title = DSLSettingsText.from(R.string.HelpSettingsFragment__debug_log),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_helpSettingsFragment_to_submitDebugLogActivity)
Navigation.findNavController(requireView()).safeNavigate(R.id.action_helpSettingsFragment_to_submitDebugLogActivity)
}
)

View File

@@ -8,6 +8,7 @@ import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@@ -321,6 +322,19 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
}
sectionHeaderPref(R.string.preferences__internal_audio)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_audio_processing_method),
listItems = CallManager.AudioProcessingMethod.values().map { it.name }.toTypedArray(),
selected = CallManager.AudioProcessingMethod.values().indexOf(state.audioProcessingMethod),
onSelected = {
viewModel.setInternalAudioProcessingMethod(CallManager.AudioProcessingMethod.values()[it])
}
)
dividerPref()
if (FeatureFlags.donorBadges() && SignalStore.donationsValues().getSubscriber() != null) {
sectionHeaderPref(R.string.preferences__internal_badges)

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.internal
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.emoji.EmojiFiles
data class InternalSettingsState(
@@ -13,6 +14,7 @@ data class InternalSettingsState(
val disableAutoMigrationNotification: Boolean,
val forceCensorship: Boolean,
val callingServer: String,
val audioProcessingMethod: CallManager.AudioProcessingMethod,
val useBuiltInEmojiSet: Boolean,
val emojiVersion: EmojiFiles.Version?,
val removeSenderKeyMinimium: Boolean,

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.internal
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.keyvalue.InternalValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.livedata.Store
@@ -90,6 +91,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setInternalAudioProcessingMethod(method: CallManager.AudioProcessingMethod) {
preferenceDataStore.putInt(InternalValues.AUDIO_PROCESSING_METHOD, method.ordinal)
refresh()
}
private fun refresh() {
store.update { getState().copy(emojiVersion = it.emojiVersion) }
}
@@ -105,6 +111,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
disableAutoMigrationNotification = SignalStore.internalValues().disableGv1AutoMigrateNotification(),
forceCensorship = SignalStore.internalValues().forcedCensorship(),
callingServer = SignalStore.internalValues().groupCallingServer(),
audioProcessingMethod = SignalStore.internalValues().audioProcessingMethod(),
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
emojiVersion = null,
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.notifications
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.graphics.ColorFilter
import android.graphics.PorterDuff
@@ -10,9 +11,11 @@ import android.net.Uri
import android.provider.Settings
import android.text.TextUtils
import android.view.View
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@@ -26,9 +29,10 @@ import org.thoughtcrime.securesms.components.settings.RadioListPreferenceViewHol
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.RingtoneUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private const val MESSAGE_SOUND_SELECT: Int = 1
private const val CALL_RINGTONE_SELECT: Int = 2
@@ -65,7 +69,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
override fun bindAdapter(adapter: DSLSettingsAdapter) {
adapter.registerFactory(
LedColorPreference::class.java,
MappingAdapter.LayoutFactory(::LedColorPreferenceViewHolder, R.layout.dsl_preference_item)
LayoutFactory(::LedColorPreferenceViewHolder, R.layout.dsl_preference_item)
)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
@@ -216,6 +220,18 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notification_profiles)
clickPref(
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__profiles),
summary = DSLSettingsText.from(R.string.NotificationsSettingsFragment__create_a_profile_to_receive_notifications_only_from_people_and_groups_you_choose),
onClick = {
findNavController().safeNavigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationsSettingsFragment__notify_when)
switchPref(
@@ -241,7 +257,6 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
}
}
@Suppress("DEPRECATION")
private fun launchMessageSoundSelectionIntent() {
val current = SignalStore.settings().messageNotificationSound
@@ -255,7 +270,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
startActivityForResult(intent, MESSAGE_SOUND_SELECT)
openRingtonePicker(intent, MESSAGE_SOUND_SELECT)
}
@RequiresApi(26)
@@ -269,7 +284,6 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
startActivity(intent)
}
@Suppress("DEPRECATION")
private fun launchCallRingtoneSelectionIntent() {
val current = SignalStore.settings().callRingtone
@@ -283,7 +297,16 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
)
intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, current)
startActivityForResult(intent, CALL_RINGTONE_SELECT)
openRingtonePicker(intent, CALL_RINGTONE_SELECT)
}
@Suppress("DEPRECATION")
private fun openRingtonePicker(intent: Intent, requestCode: Int) {
try {
startActivityForResult(intent, requestCode)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.NotificationSettingsFragment__failed_to_open_picker, Toast.LENGTH_LONG).show()
}
}
private class LedColorPreference(

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.models.NotificationProfileSelection
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* BottomSheetDialogFragment that allows a user to select a notification profile to manually enable/disable.
*/
class NotificationProfileSelectionFragment : DSLSettingsBottomSheetFragment() {
private val viewModel: NotificationProfileSelectionViewModel by viewModels(
factoryProducer = {
NotificationProfileSelectionViewModel.Factory(NotificationProfilesRepository())
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfileSelection.register(adapter)
recyclerView.itemAnimator = null
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList())
}
}
private fun getConfiguration(state: NotificationProfileSelectionState): DSLConfiguration {
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(state.notificationProfiles)
return configure {
state.notificationProfiles.sortedDescending().forEach { profile ->
customPref(
NotificationProfileSelection.Entry(
isOn = profile == activeProfile,
summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else DSLSettingsText.from(R.string.NotificationProfileDetails__off),
notificationProfile = profile,
isExpanded = profile.id == state.expandedId,
timeSlotB = state.timeSlotB,
onRowClick = viewModel::toggleEnabled,
onTimeSlotAClick = viewModel::enableForOneHour,
onTimeSlotBClick = viewModel::enableUntil,
onToggleClick = viewModel::setExpanded,
onViewSettingsClick = { navigateToSettings(it) }
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
}
customPref(
NotificationProfileSelection.New(
onClick = {
startActivity(AppSettingsActivity.createNotificationProfile(requireContext()))
dismissAllowingStateLoss()
}
)
)
space(DimensionUnit.DP.toPixels(20f).toInt())
}
}
private fun navigateToSettings(notificationProfile: NotificationProfile) {
startActivity(AppSettingsActivity.notificationProfileDetails(requireContext(), notificationProfile.id))
dismissAllowingStateLoss()
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
NotificationProfileSelectionFragment().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import java.time.LocalDateTime
data class NotificationProfileSelectionState(
val notificationProfiles: List<NotificationProfile> = listOf(),
val expandedId: Long = -1L,
val timeSlotB: LocalDateTime
)

View File

@@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.util.isBetween
import org.thoughtcrime.securesms.util.livedata.Store
import org.thoughtcrime.securesms.util.toMillis
import java.time.LocalDateTime
import java.util.concurrent.TimeUnit
class NotificationProfileSelectionViewModel(private val repository: NotificationProfilesRepository) : ViewModel() {
private val store = Store(NotificationProfileSelectionState(timeSlotB = getTimeSlotB()))
val state: LiveData<NotificationProfileSelectionState> = store.stateLiveData
val disposables = CompositeDisposable()
init {
disposables += repository.getProfiles().subscribeBy(onNext = { profiles -> store.update { it.copy(notificationProfiles = profiles) } })
disposables += Observable
.interval(0, 1, TimeUnit.MINUTES)
.map { getTimeSlotB() }
.distinctUntilChanged()
.subscribe { calendar ->
store.update { it.copy(timeSlotB = calendar) }
}
}
override fun onCleared() {
disposables.clear()
}
fun setExpanded(notificationProfile: NotificationProfile) {
store.update {
it.copy(expandedId = if (it.expandedId == notificationProfile.id) -1L else notificationProfile.id)
}
}
fun toggleEnabled(profile: NotificationProfile) {
disposables += repository.manuallyToggleProfile(profile)
.subscribe()
}
fun enableForOneHour(profile: NotificationProfile) {
disposables += repository.manuallyEnableProfileForDuration(profile.id, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
.subscribe()
}
fun enableUntil(profile: NotificationProfile, enableUntil: LocalDateTime) {
disposables += repository.manuallyEnableProfileForDuration(profile.id, enableUntil.toMillis())
.subscribe()
}
companion object {
@Suppress("CascadeIf")
private fun getTimeSlotB(): LocalDateTime {
val now = LocalDateTime.now()
val sixPm = now.withHour(18).withMinute(0).withSecond(0)
val eightAm = now.withHour(8).withMinute(0).withSecond(0)
return if (now.isBetween(eightAm, sixPm)) {
sixPm
} else if (now.isBefore(eightAm)) {
eightAm
} else {
eightAm.plusDays(1)
}
}
}
class Factory(private val notificationProfilesRepository: NotificationProfilesRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(NotificationProfileSelectionViewModel(notificationProfilesRepository))!!
}
}
}

View File

@@ -0,0 +1,134 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.manual.models
import android.view.View
import android.widget.TextView
import androidx.constraintlayout.widget.Group
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.visible
import java.time.LocalDateTime
import java.time.LocalTime
/**
* Notification Profile selection preference.
*/
object NotificationProfileSelection {
private const val TOGGLE_EXPANSION = 0
private const val UPDATE_TIMESLOT = 1
fun register(adapter: MappingAdapter) {
adapter.registerFactory(New::class.java, LayoutFactory(::NewViewHolder, R.layout.new_notification_profile_pref))
adapter.registerFactory(Entry::class.java, LayoutFactory(::EntryViewHolder, R.layout.notification_profile_entry_pref))
}
class Entry(
val isOn: Boolean,
override val summary: DSLSettingsText,
val notificationProfile: NotificationProfile,
val isExpanded: Boolean,
val timeSlotB: LocalDateTime,
val onRowClick: (NotificationProfile) -> Unit,
val onTimeSlotAClick: (NotificationProfile) -> Unit,
val onTimeSlotBClick: (NotificationProfile, LocalDateTime) -> Unit,
val onViewSettingsClick: (NotificationProfile) -> Unit,
val onToggleClick: (NotificationProfile) -> Unit
) : PreferenceModel<Entry>() {
override fun areItemsTheSame(newItem: Entry): Boolean {
return notificationProfile.id == newItem.notificationProfile.id
}
override fun areContentsTheSame(newItem: Entry): Boolean {
return super.areContentsTheSame(newItem) &&
isOn == newItem.isOn &&
notificationProfile == newItem.notificationProfile &&
isExpanded == newItem.isExpanded &&
timeSlotB == newItem.timeSlotB
}
override fun getChangePayload(newItem: Entry): Any? {
return if (notificationProfile == newItem.notificationProfile && isExpanded != newItem.isExpanded) {
TOGGLE_EXPANSION
} else if (notificationProfile == newItem.notificationProfile && timeSlotB != newItem.timeSlotB) {
UPDATE_TIMESLOT
} else {
null
}
}
}
class EntryViewHolder(itemView: View) : MappingViewHolder<Entry>(itemView) {
private val image: EmojiImageView = findViewById(R.id.notification_preference_image)
private val chevron: View = findViewById(R.id.notification_preference_chevron)
private val name: TextView = findViewById(R.id.notification_preference_name)
private val status: TextView = findViewById(R.id.notification_preference_status)
private val expansion: Group = findViewById(R.id.notification_preference_expanded)
private val timeSlotA: TextView = findViewById(R.id.notification_preference_1hr)
private val timeSlotB: TextView = findViewById(R.id.notification_preference_6pm)
private val viewSettings: View = findViewById(R.id.notification_preference_view_settings)
override fun bind(model: Entry) {
itemView.setOnClickListener { model.onRowClick(model.notificationProfile) }
chevron.setOnClickListener { model.onToggleClick(model.notificationProfile) }
chevron.rotation = if (model.isExpanded) 180f else 0f
timeSlotA.setOnClickListener { model.onTimeSlotAClick(model.notificationProfile) }
timeSlotB.setOnClickListener { model.onTimeSlotBClick(model.notificationProfile, model.timeSlotB) }
viewSettings.setOnClickListener { model.onViewSettingsClick(model.notificationProfile) }
expansion.visible = model.isExpanded
timeSlotB.text = context.getString(
R.string.NotificationProfileSelection__until_s,
LocalTime.from(model.timeSlotB).formatHours(context)
)
if (TOGGLE_EXPANSION in payload || UPDATE_TIMESLOT in payload) {
return
}
image.background.colorFilter = SimpleColorFilter(model.notificationProfile.color.colorInt())
if (model.notificationProfile.emoji.isNotEmpty()) {
image.setImageEmoji(model.notificationProfile.emoji)
} else {
image.setImageResource(R.drawable.ic_moon_24)
}
name.text = model.notificationProfile.name
presentStatus(model)
timeSlotB.text = context.getString(
R.string.NotificationProfileSelection__until_s,
LocalTime.from(model.timeSlotB).formatHours(context)
)
itemView.isSelected = model.isOn
}
private fun presentStatus(model: Entry) {
status.isEnabled = model.isOn
status.text = model.summary.resolve(context)
}
}
class New(val onClick: () -> Unit) : PreferenceModel<New>() {
override fun areItemsTheSame(newItem: New): Boolean {
return true
}
}
class NewViewHolder(itemView: View) : MappingViewHolder<New>(itemView) {
override fun bind(model: New) {
itemView.setOnClickListener { model.onClick() }
}
}
}

View File

@@ -0,0 +1,107 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.graphics.Color
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.dd.CircularProgressButton
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileAddMembers
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRecipient
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Show and allow addition of recipients to a profile during the create flow.
*/
class AddAllowedMembersFragment : DSLSettingsFragment(layoutId = R.layout.fragment_add_allowed_members) {
private val viewModel: AddAllowedMembersViewModel by viewModels(factoryProducer = { AddAllowedMembersViewModel.Factory(profileId) })
private val lifecycleDisposable = LifecycleDisposable()
private val profileId: Long by lazy { AddAllowedMembersFragmentArgs.fromBundle(requireArguments()).profileId }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
view.findViewById<CircularProgressButton>(R.id.add_allowed_members_profile_next).apply {
setOnClickListener {
findNavController().safeNavigate(AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToEditNotificationProfileScheduleFragment(profileId, true))
}
}
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfileAddMembers.register(adapter)
NotificationProfileRecipient.register(adapter)
lifecycleDisposable += viewModel.getProfile()
.subscribeBy(
onNext = { (profile, recipients) ->
adapter.submitList(getConfiguration(profile, recipients).toMappingModelList())
}
)
}
private fun getConfiguration(profile: NotificationProfile, recipients: List<Recipient>): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.AddAllowedMembers__allowed_notifications)
customPref(
NotificationProfileAddMembers.Model(
onClick = { id, currentSelection ->
findNavController().safeNavigate(
AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToSelectRecipientsFragment(id)
.setCurrentSelection(currentSelection.toTypedArray())
)
},
profileId = profile.id,
currentSelection = profile.allowedMembers
)
)
for (member in recipients) {
customPref(
NotificationProfileRecipient.Model(
recipientModel = RecipientPreference.Model(
recipient = member,
onClick = {}
),
onRemoveClick = { id ->
lifecycleDisposable += viewModel.removeMember(id)
.subscribeBy(
onSuccess = { removed ->
view?.let { view ->
Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG)
.setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) }
.setActionTextColor(ContextCompat.getColor(requireContext(), R.color.core_ultramarine_light))
.setTextColor(Color.WHITE)
.show()
}
}
)
}
)
)
}
}
}
private fun undoRemove(id: RecipientId) {
lifecycleDisposable += viewModel.addMember(id)
.subscribe()
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class AddAllowedMembersViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
fun getProfile(): Observable<NotificationProfileAndRecipients> {
return repository.getProfile(profileId)
.map { profile ->
NotificationProfileAndRecipients(profile, profile.allowedMembers.map { Recipient.resolved(it) })
}
.observeOn(AndroidSchedulers.mainThread())
}
fun addMember(id: RecipientId): Single<NotificationProfile> {
return repository.addMember(profileId, id)
.observeOn(AndroidSchedulers.mainThread())
}
fun removeMember(id: RecipientId): Single<Recipient> {
return repository.removeMember(profileId, id)
.map { Recipient.resolved(id) }
.observeOn(AndroidSchedulers.mainThread())
}
data class NotificationProfileAndRecipients(val profile: NotificationProfile, val recipients: List<Recipient>)
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(AddAllowedMembersViewModel(profileId, NotificationProfilesRepository()))!!
}
}
}

View File

@@ -0,0 +1,198 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.text.Editable
import android.text.TextUtils
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.dd.CircularProgressButton
import com.google.android.material.textfield.TextInputLayout
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.EditTextUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileViewModel.SaveNotificationProfileResult
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileNamePreset
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.text.AfterTextChanged
/**
* Dual use Edit/Create notification profile fragment. Use to create in the create profile flow,
* and then to edit from profile details. Responsible for naming and emoji.
*/
class EditNotificationProfileFragment : DSLSettingsFragment(layoutId = R.layout.fragment_edit_notification_profile), ReactWithAnyEmojiBottomSheetDialogFragment.Callback {
private val viewModel: EditNotificationProfileViewModel by viewModels(factoryProducer = this::createFactory)
private val lifecycleDisposable = LifecycleDisposable()
private var emojiView: ImageView? = null
private var nameView: EditText? = null
private fun createFactory(): ViewModelProvider.Factory {
val profileId = EditNotificationProfileFragmentArgs.fromBundle(requireArguments()).profileId
return EditNotificationProfileViewModel.Factory(profileId)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.NOTIFICATION_PROFILES)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener {
ViewUtil.hideKeyboard(requireContext(), requireView())
requireActivity().onBackPressed()
}
val title: TextView = view.findViewById(R.id.edit_notification_profile_title)
val countView: TextView = view.findViewById(R.id.edit_notification_profile_count)
val saveButton: CircularProgressButton = view.findViewById(R.id.edit_notification_profile_save)
val emojiView: ImageView = view.findViewById(R.id.edit_notification_profile_emoji)
val nameView: EditText = view.findViewById(R.id.edit_notification_profile_name)
val nameTextWrapper: TextInputLayout = view.findViewById(R.id.edit_notification_profile_name_wrapper)
EditTextUtil.addGraphemeClusterLimitFilter(nameView, NOTIFICATION_PROFILE_NAME_MAX_GLYPHS)
nameView.addTextChangedListener(
AfterTextChanged { editable: Editable ->
presentCount(countView, editable.toString())
nameTextWrapper.error = null
}
)
emojiView.setOnClickListener {
ReactWithAnyEmojiBottomSheetDialogFragment.createForAboutSelection()
.show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
view.findViewById<View>(R.id.edit_notification_profile_clear).setOnClickListener {
nameView.setText("")
onEmojiSelectedInternal("")
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
saveButton.setOnClickListener {
if (TextUtils.isEmpty(nameView.text)) {
nameTextWrapper.error = getString(R.string.EditNotificationProfileFragment__profile_must_have_a_name)
return@setOnClickListener
}
lifecycleDisposable += viewModel.save(nameView.text.toString())
.doOnSubscribe { CircularProgressButtonUtil.setSpinning(saveButton) }
.doAfterTerminate { CircularProgressButtonUtil.cancelSpinning(saveButton) }
.subscribeBy(
onSuccess = { saveResult ->
when (saveResult) {
is SaveNotificationProfileResult.Success -> {
ViewUtil.hideKeyboard(requireContext(), nameView)
if (saveResult.createMode) {
findNavController().safeNavigate(EditNotificationProfileFragmentDirections.actionEditNotificationProfileFragmentToAddAllowedMembersFragment(saveResult.profile.id))
} else {
findNavController().navigateUp()
}
}
SaveNotificationProfileResult.DuplicateNameFailure -> {
nameTextWrapper.error = getString(R.string.EditNotificationProfileFragment__a_profile_with_this_name_already_exists)
}
}
}
)
}
lifecycleDisposable += viewModel.getInitialState()
.subscribeBy(
onSuccess = { initial ->
if (initial.createMode) {
saveButton.text = getString(R.string.EditNotificationProfileFragment__create)
title.setText(R.string.EditNotificationProfileFragment__name_your_profile)
} else {
saveButton.text = getString(R.string.EditNotificationProfileFragment__save)
title.setText(R.string.EditNotificationProfileFragment__edit_this_profile)
}
nameView.setText(initial.name)
onEmojiSelectedInternal(initial.emoji)
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(nameView)
}
)
this.nameView = nameView
this.emojiView = emojiView
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfileNamePreset.register(adapter)
val onClick = { preset: NotificationProfileNamePreset.Model ->
nameView?.apply {
setText(preset.bodyResource)
setSelection(length(), length())
}
onEmojiSelectedInternal(preset.emoji)
}
adapter.submitList(
listOf(
NotificationProfileNamePreset.Model("\uD83D\uDCAA", R.string.EditNotificationProfileFragment__work, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE34", R.string.EditNotificationProfileFragment__sleep, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE97", R.string.EditNotificationProfileFragment__driving, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE0A", R.string.EditNotificationProfileFragment__downtime, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDCA1", R.string.EditNotificationProfileFragment__focus, onClick),
)
)
}
override fun onReactWithAnyEmojiSelected(emoji: String) {
onEmojiSelectedInternal(emoji)
}
override fun onReactWithAnyEmojiDialogDismissed() = Unit
private fun presentCount(countView: TextView, profileName: String) {
val breakIterator = BreakIteratorCompat.getInstance()
breakIterator.setText(profileName)
val glyphCount = breakIterator.countBreaks()
if (glyphCount >= NOTIFICATION_PROFILE_NAME_LIMIT_DISPLAY_THRESHOLD) {
countView.visibility = View.VISIBLE
countView.text = resources.getString(R.string.EditNotificationProfileFragment__count, glyphCount, NOTIFICATION_PROFILE_NAME_MAX_GLYPHS)
} else {
countView.visibility = View.GONE
}
}
private fun onEmojiSelectedInternal(emoji: String) {
val drawable = EmojiUtil.convertToDrawable(requireContext(), emoji)
if (drawable != null) {
emojiView?.setImageDrawable(drawable)
viewModel.onEmojiSelected(emoji)
} else {
emojiView?.setImageResource(R.drawable.ic_add_emoji)
viewModel.onEmojiSelected("")
}
}
companion object {
private const val NOTIFICATION_PROFILE_NAME_MAX_GLYPHS = 32
private const val NOTIFICATION_PROFILE_NAME_LIMIT_DISPLAY_THRESHOLD = 22
}
}

View File

@@ -0,0 +1,185 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.content.Context
import android.os.Bundle
import android.text.Spannable
import android.text.SpannableString
import android.text.format.DateFormat
import android.text.style.AbsoluteSizeSpan
import android.view.View
import android.widget.CheckedTextView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.dd.CircularProgressButton
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleViewModel.SaveScheduleResult
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.orderOfDaysInWeek
import org.thoughtcrime.securesms.util.visible
import java.time.DayOfWeek
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale
private val DAY_TO_STARTING_LETTER: Map<DayOfWeek, Int> = mapOf(
DayOfWeek.SUNDAY to R.string.EditNotificationProfileSchedule__sunday_first_letter,
DayOfWeek.MONDAY to R.string.EditNotificationProfileSchedule__monday_first_letter,
DayOfWeek.TUESDAY to R.string.EditNotificationProfileSchedule__tuesday_first_letter,
DayOfWeek.WEDNESDAY to R.string.EditNotificationProfileSchedule__wednesday_first_letter,
DayOfWeek.THURSDAY to R.string.EditNotificationProfileSchedule__thursday_first_letter,
DayOfWeek.FRIDAY to R.string.EditNotificationProfileSchedule__friday_first_letter,
DayOfWeek.SATURDAY to R.string.EditNotificationProfileSchedule__saturday_first_letter,
)
/**
* Can edit existing or use during create flow to setup a profile schedule.
*/
class EditNotificationProfileScheduleFragment : LoggingFragment(R.layout.fragment_edit_notification_profile_schedule) {
private val viewModel: EditNotificationProfileScheduleViewModel by viewModels(factoryProducer = { EditNotificationProfileScheduleViewModel.Factory(profileId, createMode) })
private val lifecycleDisposable = LifecycleDisposable()
private val profileId: Long by lazy { EditNotificationProfileScheduleFragmentArgs.fromBundle(requireArguments()).profileId }
private val createMode: Boolean by lazy { EditNotificationProfileScheduleFragmentArgs.fromBundle(requireArguments()).createMode }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
val title: View = view.findViewById(R.id.edit_notification_profile_schedule_title)
val description: View = view.findViewById(R.id.edit_notification_profile_schedule_description)
toolbar.title = if (!createMode) getString(R.string.EditNotificationProfileSchedule__schedule) else null
title.visible = createMode
description.visible = createMode
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
val enableToggle: SwitchMaterial = view.findViewById(R.id.edit_notification_profile_schedule_switch)
enableToggle.setOnClickListener { viewModel.setEnabled(enableToggle.isChecked) }
val startTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_start_time)
val endTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_end_time)
val next: CircularProgressButton = view.findViewById(R.id.edit_notification_profile_schedule__next)
next.setOnClickListener {
lifecycleDisposable += viewModel.save(createMode)
.subscribeBy(
onSuccess = { result ->
when (result) {
SaveScheduleResult.Success -> {
if (createMode) {
findNavController().safeNavigate(EditNotificationProfileScheduleFragmentDirections.actionEditNotificationProfileScheduleFragmentToNotificationProfileCreatedFragment(profileId))
} else {
findNavController().navigateUp()
}
}
SaveScheduleResult.NoDaysSelected -> {
Toast.makeText(requireContext(), R.string.EditNotificationProfileSchedule__schedule_must_have_at_least_one_day, Toast.LENGTH_LONG).show()
}
}
}
)
}
val day1: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_day_1)
val day2: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_day_2)
val day3: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_day_3)
val day4: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_day_4)
val day5: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_day_5)
val day6: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_day_6)
val day7: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_day_7)
val days: Map<CheckedTextView, DayOfWeek> = listOf(day1, day2, day3, day4, day5, day6, day7).zip(Locale.getDefault().orderOfDaysInWeek()).toMap()
days.forEach { (view, day) ->
DrawableCompat.setTintList(view.background, ContextCompat.getColorStateList(view.context, R.color.notification_profile_schedule_background_tint))
view.setOnClickListener { viewModel.toggleDay(day) }
view.setText(DAY_TO_STARTING_LETTER[day]!!)
}
lifecycleDisposable += viewModel.schedule()
.subscribeBy(
onNext = { schedule ->
enableToggle.isChecked = schedule.enabled
enableToggle.isEnabled = true
days.forEach { (view, day) ->
view.isChecked = schedule.daysEnabled.contains(day)
view.isEnabled = schedule.enabled
}
startTime.text = schedule.startTime().formatTime(view.context)
startTime.setOnClickListener { showTimeSelector(true, schedule.startTime()) }
startTime.isEnabled = schedule.enabled
endTime.text = schedule.endTime().formatTime(view.context)
endTime.setOnClickListener { showTimeSelector(false, schedule.endTime()) }
endTime.isEnabled = schedule.enabled
if (createMode) {
next.setText(if (schedule.enabled) R.string.EditNotificationProfileSchedule__next else R.string.EditNotificationProfileSchedule__skip)
} else {
next.setText(R.string.EditNotificationProfileSchedule__save)
}
next.isEnabled = true
}
)
}
private fun showTimeSelector(isStart: Boolean, time: LocalTime) {
val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
val timePickerFragment = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(time.hour)
.setMinute(time.minute)
.setTitleText(if (isStart) R.string.EditNotificationProfileSchedule__set_start_time else R.string.EditNotificationProfileSchedule__set_end_time)
.build()
timePickerFragment.addOnDismissListener {
timePickerFragment.clearOnDismissListeners()
timePickerFragment.clearOnPositiveButtonClickListeners()
}
timePickerFragment.addOnPositiveButtonClickListener {
val hour = timePickerFragment.hour
val minute = timePickerFragment.minute
if (isStart) {
viewModel.setStartTime(hour, minute)
} else {
viewModel.setEndTime(hour, minute)
}
}
timePickerFragment.show(childFragmentManager, "TIME_PICKER")
}
}
private fun LocalTime.formatTime(context: Context): SpannableString {
val amPm = DateTimeFormatter.ofPattern("a")
.format(this)
val formattedTime: String = this.formatHours(context)
return SpannableString(formattedTime).apply {
val amPmIndex = formattedTime.indexOf(string = amPm, ignoreCase = true)
if (amPmIndex != -1) {
setSpan(AbsoluteSizeSpan(ViewUtil.spToPx(20f)), amPmIndex, amPmIndex + amPm.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
}

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import java.time.DayOfWeek
/**
* ViewModel for driving edit schedule UI. UI starts in a disabled state until the first schedule is loaded
* from the database and into the [scheduleSubject] allowing the safe use of !! with [schedule].
*/
class EditNotificationProfileScheduleViewModel(
private val profileId: Long,
private val createMode: Boolean,
private val repository: NotificationProfilesRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
private val scheduleSubject: BehaviorSubject<NotificationProfileSchedule> = BehaviorSubject.create()
private val schedule: NotificationProfileSchedule
get() = scheduleSubject.value!!
init {
disposables += repository.getProfile(profileId)
.take(1)
.map { it.schedule }
.singleOrError()
.subscribeBy(onSuccess = { scheduleSubject.onNext(it) })
}
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun schedule(): Observable<NotificationProfileSchedule> {
return scheduleSubject.subscribeOn(AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleDay(day: DayOfWeek) {
val newDaysEnabled = schedule.daysEnabled.toMutableSet()
if (newDaysEnabled.contains(day)) {
newDaysEnabled.remove(day)
} else {
newDaysEnabled += day
}
scheduleSubject.onNext(schedule.copy(daysEnabled = newDaysEnabled))
}
fun setStartTime(hour: Int, minute: Int) {
scheduleSubject.onNext(schedule.copy(start = hour * 100 + minute))
}
fun setEndTime(hour: Int, minute: Int) {
val adjustedEndHour = if (hour == 0) 24 else hour
scheduleSubject.onNext(schedule.copy(end = adjustedEndHour * 100 + minute))
}
fun setEnabled(enabled: Boolean) {
scheduleSubject.onNext(schedule.copy(enabled = enabled))
}
fun save(createMode: Boolean): Single<SaveScheduleResult> {
val result: Single<SaveScheduleResult> = if (schedule.enabled && schedule.daysEnabled.isEmpty()) {
Single.just(SaveScheduleResult.NoDaysSelected)
} else if (createMode && !schedule.enabled) {
Single.just(SaveScheduleResult.Success)
} else {
repository.updateSchedule(schedule)
.toSingleDefault(SaveScheduleResult.Success)
.flatMap { r ->
if (schedule.enabled && schedule.coversTime(System.currentTimeMillis())) {
repository.manuallyEnableProfileForSchedule(profileId, schedule)
.toSingleDefault(r)
} else {
Single.just(r)
}
}
}
return result.observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val profileId: Long, private val createMode: Boolean) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(EditNotificationProfileScheduleViewModel(profileId, createMode, NotificationProfilesRepository()))!!
}
}
enum class SaveScheduleResult {
NoDaysSelected,
Success
}
}

View File

@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
class EditNotificationProfileViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
private val createMode: Boolean = profileId == -1L
private var selectedEmoji: String = ""
fun getInitialState(): Single<InitialState> {
val initialState = if (createMode) {
Single.just(InitialState(createMode))
} else {
repository.getProfile(profileId)
.take(1)
.map { InitialState(createMode, it.name, it.emoji) }
.singleOrError()
}
return initialState.observeOn(AndroidSchedulers.mainThread())
}
fun onEmojiSelected(emoji: String) {
selectedEmoji = emoji
}
fun save(name: String): Single<SaveNotificationProfileResult> {
val save = if (createMode) repository.createProfile(name, selectedEmoji) else repository.updateProfile(profileId, name, selectedEmoji)
return save.map { r ->
when (r) {
is NotificationProfileDatabase.NotificationProfileChangeResult.Success -> SaveNotificationProfileResult.Success(r.notificationProfile, createMode)
NotificationProfileDatabase.NotificationProfileChangeResult.DuplicateName -> SaveNotificationProfileResult.DuplicateNameFailure
}
}.observeOn(AndroidSchedulers.mainThread())
}
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(EditNotificationProfileViewModel(profileId, NotificationProfilesRepository()))!!
}
}
data class InitialState(
val createMode: Boolean,
val name: String = "",
val emoji: String = ""
)
sealed class SaveNotificationProfileResult {
data class Success(val profile: NotificationProfile, val createMode: Boolean) : SaveNotificationProfileResult()
object DuplicateNameFailure : SaveNotificationProfileResult()
}
}

View File

@@ -0,0 +1,58 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Shown at the end of the profile create flow.
*/
class NotificationProfileCreatedFragment : LoggingFragment(R.layout.fragment_notification_profile_created) {
private val lifecycleDisposable = LifecycleDisposable()
private val profileId: Long by lazy { NotificationProfileCreatedFragmentArgs.fromBundle(requireArguments()).profileId }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val topIcon: ImageView = view.findViewById(R.id.notification_profile_created_top_image)
val topText: TextView = view.findViewById(R.id.notification_profile_created_top_text)
val bottomIcon: ImageView = view.findViewById(R.id.notification_profile_created_bottom_image)
val bottomText: TextView = view.findViewById(R.id.notification_profile_created_bottom_text)
view.findViewById<View>(R.id.notification_profile_created_done).setOnClickListener {
findNavController().safeNavigate(NotificationProfileCreatedFragmentDirections.actionNotificationProfileCreatedFragmentToNotificationProfileDetailsFragment(profileId))
}
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
val repository = NotificationProfilesRepository()
lifecycleDisposable += repository.getProfile(profileId)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onNext = { profile ->
if (profile.schedule.enabled) {
topIcon.setImageResource(R.drawable.ic_recent_20)
topText.setText(R.string.NotificationProfileCreated__your_profile_will_turn_on_and_off_automatically_according_to_your_schedule)
bottomIcon.setImageResource(R.drawable.ic_more_vert_24)
bottomText.setText(R.string.NotificationProfileCreated__you_can_turn_your_profile_on_or_off_manually_via_the_menu_on_the_chat_list)
} else {
topIcon.setImageResource(R.drawable.ic_more_vert_24)
topText.setText(R.string.NotificationProfileCreated__you_can_turn_your_profile_on_or_off_manually_via_the_menu_on_the_chat_list)
bottomIcon.setImageResource(R.drawable.ic_recent_20)
bottomText.setText(R.string.NotificationProfileCreated__add_a_schedule_in_settings_to_automate_your_profile)
}
}
)
}
}

View File

@@ -0,0 +1,259 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.graphics.Color
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileAddMembers
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfilePreference
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRecipient
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.orderOfDaysInWeek
import java.time.DayOfWeek
import java.time.format.TextStyle
import java.util.Locale
private const val MEMBER_COUNT_TO_SHOW_EXPAND: Int = 5
class NotificationProfileDetailsFragment : DSLSettingsFragment() {
private val viewModel: NotificationProfileDetailsViewModel by viewModels(factoryProducer = this::createFactory)
private val lifecycleDisposable = LifecycleDisposable()
private var toolbar: Toolbar? = null
private fun createFactory(): ViewModelProvider.Factory {
return NotificationProfileDetailsViewModel.Factory(NotificationProfileDetailsFragmentArgs.fromBundle(requireArguments()).profileId)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar = view.findViewById(R.id.toolbar)
toolbar?.inflateMenu(R.menu.notification_profile_details)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
}
override fun onDestroyView() {
super.onDestroyView()
toolbar = null
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NotificationProfilePreference.register(adapter)
NotificationProfileAddMembers.register(adapter)
NotificationProfileRecipient.register(adapter)
LargeIconClickPreference.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
when (state) {
is NotificationProfileDetailsViewModel.State.Valid -> {
toolbar?.title = state.profile.name
toolbar?.setOnMenuItemClickListener { item ->
if (item.itemId == R.id.action_edit) {
findNavController().safeNavigate(NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToEditNotificationProfileFragment().setProfileId(state.profile.id))
true
} else {
false
}
}
adapter.submitList(getConfiguration(state).toMappingModelList())
}
NotificationProfileDetailsViewModel.State.NotLoaded -> Unit
NotificationProfileDetailsViewModel.State.Invalid -> requireActivity().onBackPressed()
}
}
}
private fun getConfiguration(state: NotificationProfileDetailsViewModel.State.Valid): DSLConfiguration {
val (profile: NotificationProfile, recipients: List<Recipient>, isOn: Boolean, expanded: Boolean) = state
return configure {
customPref(
NotificationProfilePreference.Model(
title = DSLSettingsText.from(profile.name),
summary = if (isOn) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null,
icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT),
color = profile.color,
isOn = isOn,
showSwitch = true,
onClick = {
lifecycleDisposable += viewModel.toggleEnabled(profile)
.subscribe()
}
)
)
dividerPref()
sectionHeaderPref(R.string.AddAllowedMembers__allowed_notifications)
customPref(
NotificationProfileAddMembers.Model(
onClick = { id, currentSelection ->
findNavController().safeNavigate(
NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToSelectRecipientsFragment(id)
.setCurrentSelection(currentSelection.toTypedArray())
)
},
profileId = profile.id,
currentSelection = profile.allowedMembers
)
)
val membersToShow = if (expanded || recipients.size <= MEMBER_COUNT_TO_SHOW_EXPAND) {
recipients
} else {
recipients.slice(0 until MEMBER_COUNT_TO_SHOW_EXPAND)
}
for (member in membersToShow) {
customPref(
NotificationProfileRecipient.Model(
recipientModel = RecipientPreference.Model(
recipient = member
),
onRemoveClick = { id ->
lifecycleDisposable += viewModel.removeMember(id)
.subscribeBy(
onSuccess = { removed ->
view?.let { view ->
Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG)
.setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) }
.setActionTextColor(ContextCompat.getColor(requireContext(), R.color.core_ultramarine_light))
.setTextColor(Color.WHITE)
.show()
}
}
)
}
)
)
}
if (!expanded && membersToShow != recipients) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.NotificationProfileDetails__see_all),
icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT),
onClick = viewModel::showAllMembers
)
)
}
dividerPref()
sectionHeaderPref(R.string.NotificationProfileDetails__schedule)
clickPref(
title = DSLSettingsText.from(profile.schedule.describe()),
summary = DSLSettingsText.from(if (profile.schedule.enabled) R.string.NotificationProfileDetails__on else R.string.NotificationProfileDetails__off),
icon = DSLSettingsIcon.from(R.drawable.ic_recent_20, NO_TINT),
onClick = {
findNavController().safeNavigate(NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToEditNotificationProfileScheduleFragment(profile.id, false))
}
)
dividerPref()
sectionHeaderPref(R.string.NotificationProfileDetails__exceptions)
switchPref(
title = DSLSettingsText.from(R.string.NotificationProfileDetails__allow_all_calls),
isChecked = profile.allowAllCalls,
icon = DSLSettingsIcon.from(R.drawable.ic_phone_right_24),
onClick = {
lifecycleDisposable += viewModel.toggleAllowAllCalls()
.subscribe()
}
)
switchPref(
title = DSLSettingsText.from(R.string.NotificationProfileDetails__notify_for_all_mentions),
icon = DSLSettingsIcon.from(R.drawable.ic_at_24),
isChecked = profile.allowAllMentions,
onClick = {
lifecycleDisposable += viewModel.toggleAllowAllMentions()
.subscribe()
}
)
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.NotificationProfileDetails__delete_profile, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
icon = DSLSettingsIcon.from(R.drawable.ic_delete_24, R.color.signal_alert_primary),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.NotificationProfileDetails__permanently_delete_profile)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(
SpanUtil.color(
ContextCompat.getColor(requireContext(), R.color.signal_alert_primary),
getString(R.string.NotificationProfileDetails__delete)
)
) { _, _ -> deleteProfile() }
.show()
}
)
}
}
private fun deleteProfile() {
lifecycleDisposable += viewModel.deleteProfile()
.subscribe()
}
private fun undoRemove(id: RecipientId) {
lifecycleDisposable += viewModel.addMember(id)
.subscribe()
}
private fun NotificationProfileSchedule.describe(): String {
if (!enabled) {
return getString(R.string.NotificationProfileDetails__schedule)
}
val startTime = startTime().formatHours(requireContext())
val endTime = endTime().formatHours(requireContext())
val days = StringBuilder()
if (daysEnabled.size == 7) {
days.append(getString(R.string.NotificationProfileDetails__everyday))
} else {
for (day: DayOfWeek in Locale.getDefault().orderOfDaysInWeek()) {
if (daysEnabled.contains(day)) {
if (days.isNotEmpty()) {
days.append(", ")
}
days.append(day.getDisplayName(TextStyle.SHORT, Locale.getDefault()))
}
}
}
return getString(R.string.NotificationProfileDetails__s_to_s, startTime, endTime).let { hours ->
if (days.isNotEmpty()) "$hours\n$days" else hours
}
}
}

View File

@@ -0,0 +1,130 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.livedata.Store
class NotificationProfileDetailsViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
private val store = Store<State>(State.NotLoaded)
private val disposables = CompositeDisposable()
val state: LiveData<State> = store.stateLiveData
init {
disposables += repository.getProfiles()
.map { profiles ->
val profile = profiles.firstOrNull { it.id == profileId }
if (profile == null) {
State.Invalid
} else {
State.Valid(
profile = profile,
recipients = profile.allowedMembers.map { Recipient.resolved(it) },
isOn = NotificationProfiles.getActiveProfile(profiles) == profile
)
}
}
.subscribeBy(
onNext = { newState ->
when (newState) {
State.NotLoaded -> Unit
State.Invalid -> store.update { newState }
is State.Valid -> updateWithValidState(newState)
}
}
)
}
private fun updateWithValidState(newState: State.Valid) {
store.update { oldState: State ->
if (oldState is State.Valid) {
oldState.copy(profile = newState.profile, recipients = newState.recipients, isOn = newState.isOn)
} else {
newState
}
}
}
override fun onCleared() {
disposables.clear()
}
fun addMember(id: RecipientId): Single<NotificationProfile> {
return repository.addMember(profileId, id)
.observeOn(AndroidSchedulers.mainThread())
}
fun removeMember(id: RecipientId): Single<Recipient> {
return repository.removeMember(profileId, id)
.map { Recipient.resolved(id) }
.observeOn(AndroidSchedulers.mainThread())
}
fun deleteProfile(): Completable {
return repository.deleteProfile(profileId)
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleEnabled(profile: NotificationProfile): Completable {
return repository.manuallyToggleProfile(profile)
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleAllowAllMentions(): Single<NotificationProfile> {
return repository.getProfile(profileId)
.take(1)
.singleOrError()
.flatMap { repository.updateProfile(it.copy(allowAllMentions = !it.allowAllMentions)) }
.map { (it as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile }
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleAllowAllCalls(): Single<NotificationProfile> {
return repository.getProfile(profileId)
.take(1)
.singleOrError()
.flatMap { repository.updateProfile(it.copy(allowAllCalls = !it.allowAllCalls)) }
.map { (it as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile }
.observeOn(AndroidSchedulers.mainThread())
}
fun showAllMembers() {
store.update { s ->
if (s is State.Valid) {
s.copy(expanded = true)
} else {
s
}
}
}
sealed class State {
data class Valid(
val profile: NotificationProfile,
val recipients: List<Recipient>,
val isOn: Boolean,
val expanded: Boolean = false
) : State()
object Invalid : State()
object NotLoaded : State()
}
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(NotificationProfileDetailsViewModel(profileId, NotificationProfilesRepository()))!!
}
}
}

View File

@@ -0,0 +1,110 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NoNotificationProfiles
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfilePreference
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Primary entry point for Notification Profiles. When user has no profiles, shows empty state, otherwise shows
* all current profiles.
*/
class NotificationProfilesFragment : DSLSettingsFragment() {
private val viewModel: NotificationProfilesViewModel by viewModels(
factoryProducer = { NotificationProfilesViewModel.Factory() }
)
private val lifecycleDisposable = LifecycleDisposable()
private var toolbar: Toolbar? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.NOTIFICATION_PROFILES)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
toolbar = view.findViewById(R.id.toolbar)
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
}
override fun onDestroyView() {
super.onDestroyView()
toolbar = null
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
NoNotificationProfiles.register(adapter)
LargeIconClickPreference.register(adapter)
NotificationProfilePreference.register(adapter)
lifecycleDisposable += viewModel.getProfiles()
.subscribe { profiles ->
if (profiles.isEmpty()) {
toolbar?.title = ""
} else {
toolbar?.setTitle(R.string.NotificationsSettingsFragment__notification_profiles)
}
adapter.submitList(getConfiguration(profiles).toMappingModelList())
}
}
private fun getConfiguration(profiles: List<NotificationProfile>): DSLConfiguration {
return configure {
if (profiles.isEmpty()) {
customPref(
NoNotificationProfiles.Model(
onClick = { findNavController().safeNavigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) }
)
)
} else {
sectionHeaderPref(R.string.NotificationProfilesFragment__profiles)
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.NotificationProfilesFragment__new_profile),
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
onClick = { findNavController().safeNavigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) }
)
)
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(profiles)
profiles.sortedDescending().forEach { profile ->
customPref(
NotificationProfilePreference.Model(
title = DSLSettingsText.from(profile.name),
summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null,
icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT),
color = profile.color,
onClick = {
findNavController().safeNavigate(NotificationProfilesFragmentDirections.actionNotificationProfilesFragmentToNotificationProfileDetailsFragment(profile.id))
}
)
)
}
}
}
}
}

View File

@@ -0,0 +1,148 @@
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.ObservableEmitter
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
/**
* One stop shop for all your Notification Profile data needs.
*/
class NotificationProfilesRepository {
private val database: NotificationProfileDatabase = SignalDatabase.notificationProfiles
fun getProfiles(): Observable<List<NotificationProfile>> {
return Observable.create { emitter: ObservableEmitter<List<NotificationProfile>> ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val profileObserver = DatabaseObserver.Observer { emitter.onNext(database.getProfiles()) }
databaseObserver.registerNotificationProfileObserver(profileObserver)
emitter.setCancellable { databaseObserver.unregisterObserver(profileObserver) }
emitter.onNext(database.getProfiles())
}.subscribeOn(Schedulers.io())
}
fun getProfile(profileId: Long): Observable<NotificationProfile> {
return Observable.create { emitter: ObservableEmitter<NotificationProfile> ->
val emitProfile: () -> Unit = {
val profile: NotificationProfile? = database.getProfile(profileId)
if (profile != null) {
emitter.onNext(profile)
} else {
emitter.onError(NotificationProfileNotFoundException())
}
}
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val profileObserver = DatabaseObserver.Observer { emitProfile() }
databaseObserver.registerNotificationProfileObserver(profileObserver)
emitter.setCancellable { databaseObserver.unregisterObserver(profileObserver) }
emitProfile()
}.subscribeOn(Schedulers.io())
}
fun createProfile(name: String, selectedEmoji: String): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) }
.subscribeOn(Schedulers.io())
}
fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) }
.subscribeOn(Schedulers.io())
}
fun updateProfile(profile: NotificationProfile): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
return Single.fromCallable { database.updateProfile(profile) }
.subscribeOn(Schedulers.io())
}
fun updateAllowedMembers(profileId: Long, recipients: Set<RecipientId>): Single<NotificationProfile> {
return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) }
.subscribeOn(Schedulers.io())
}
fun removeMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) }
.subscribeOn(Schedulers.io())
}
fun addMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) }
.subscribeOn(Schedulers.io())
}
fun deleteProfile(profileId: Long): Completable {
return Completable.fromCallable { database.deleteProfile(profileId) }
.subscribeOn(Schedulers.io())
}
fun updateSchedule(schedule: NotificationProfileSchedule): Completable {
return Completable.fromCallable { database.updateSchedule(schedule) }
.subscribeOn(Schedulers.io())
}
fun manuallyToggleProfile(profile: NotificationProfile, now: Long = System.currentTimeMillis()): Completable {
return manuallyToggleProfile(profile.id, profile.schedule, now)
}
fun manuallyToggleProfile(profileId: Long, schedule: NotificationProfileSchedule, now: Long = System.currentTimeMillis()): Completable {
return Completable.fromAction {
val profiles = database.getProfiles()
val activeProfile = NotificationProfiles.getActiveProfile(profiles, now)
if (profileId == activeProfile?.id) {
SignalStore.notificationProfileValues().manuallyEnabledProfile = 0
SignalStore.notificationProfileValues().manuallyEnabledUntil = 0
SignalStore.notificationProfileValues().manuallyDisabledAt = now
SignalStore.notificationProfileValues().lastProfilePopup = 0
SignalStore.notificationProfileValues().lastProfilePopupTime = 0
} else {
val inScheduledWindow = schedule.isCurrentlyActive(now)
SignalStore.notificationProfileValues().manuallyEnabledProfile = profileId
SignalStore.notificationProfileValues().manuallyEnabledUntil = if (inScheduledWindow) schedule.endDateTime(now.toLocalDateTime()).toMillis() else Long.MAX_VALUE
SignalStore.notificationProfileValues().manuallyDisabledAt = now
}
}
.doOnComplete { ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers() }
.subscribeOn(Schedulers.io())
}
fun manuallyEnableProfileForDuration(profileId: Long, enableUntil: Long, now: Long = System.currentTimeMillis()): Completable {
return Completable.fromAction {
SignalStore.notificationProfileValues().manuallyEnabledProfile = profileId
SignalStore.notificationProfileValues().manuallyEnabledUntil = enableUntil
SignalStore.notificationProfileValues().manuallyDisabledAt = now
}
.doOnComplete { ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers() }
.subscribeOn(Schedulers.io())
}
fun manuallyEnableProfileForSchedule(profileId: Long, schedule: NotificationProfileSchedule, now: Long = System.currentTimeMillis()): Completable {
return Completable.fromAction {
val inScheduledWindow = schedule.isCurrentlyActive(now)
SignalStore.notificationProfileValues().manuallyEnabledProfile = if (inScheduledWindow) profileId else 0
SignalStore.notificationProfileValues().manuallyEnabledUntil = if (inScheduledWindow) schedule.endDateTime(now.toLocalDateTime()).toMillis() else Long.MAX_VALUE
SignalStore.notificationProfileValues().manuallyDisabledAt = if (inScheduledWindow) now else 0
}
.doOnComplete { ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers() }
.subscribeOn(Schedulers.io())
}
class NotificationProfileNotFoundException : Throwable()
}

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